Skip to content

Commit 82b3b38

Browse files
committed
Add /api, /files, and /assets routes
1 parent 9266003 commit 82b3b38

File tree

11 files changed

+1959
-24
lines changed

11 files changed

+1959
-24
lines changed

api/api.go

Lines changed: 167 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,70 @@
11
package api
22

33
import (
4+
"context"
5+
"encoding/json"
46
"net/http"
7+
"os"
58

69
"github.com/go-chi/chi/v5"
710
"github.com/go-chi/chi/v5/middleware"
811
"github.com/go-chi/cors"
912

1013
"cdr.dev/slog"
14+
"github.com/coder/code-marketplace/api/httpapi"
1115
"github.com/coder/code-marketplace/api/httpmw"
16+
"github.com/coder/code-marketplace/database"
1217
)
1318

19+
// QueryRequest implements an untyped object. It is the data sent to the API to
20+
// query for extensions.
21+
// https://github.com/microsoft/vscode/blob/a69f95fdf3dc27511517eef5ff62b21c7a418015/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L338-L342
22+
type QueryRequest struct {
23+
Filters []database.Filter `json:"filters"`
24+
Flags database.Flag `json:"flags"`
25+
}
26+
27+
// QueryResponse implements IRawGalleryQueryResult. This is the response sent
28+
// to extension queries.
29+
// https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L81-L92
30+
type QueryResponse struct {
31+
Results []QueryResult `json:"results"`
32+
}
33+
34+
// QueryResult implements IRawGalleryQueryResult.results.
35+
// https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L82-L91
36+
type QueryResult struct {
37+
Extensions []*database.Extension `json:"extensions"`
38+
Metadata []ResultMetadata `json:"resultMetadata"`
39+
}
40+
41+
// ResultMetadata implements IRawGalleryQueryResult.resultMetadata.
42+
// https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L84-L90
43+
type ResultMetadata struct {
44+
Type string `json:"metadataType"`
45+
Items []ResultMetadataItem `json:"metadataItems"`
46+
}
47+
48+
// ResultMetadataItem implements IRawGalleryQueryResult.metadataItems.
49+
// https://github.com/microsoft/vscode/blob/29234f0219bdbf649d6107b18651a1038d6357ac/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L86-L89
50+
type ResultMetadataItem struct {
51+
Name string `json:"name"`
52+
Count int `json:"count"`
53+
}
54+
1455
type Options struct {
56+
Database database.Database
57+
// TODO: Abstract file storage for use with storage services like jFrog.
1558
ExtDir string
1659
Logger slog.Logger
1760
// Set to <0 to disable.
1861
RateLimit int
1962
}
2063

2164
type API struct {
22-
Handler http.Handler
65+
Database database.Database
66+
Handler http.Handler
67+
Logger slog.Logger
2368
}
2469

2570
// New creates a new API server.
@@ -48,6 +93,12 @@ func New(options *Options) *API {
4893
httpmw.Logger(options.Logger),
4994
)
5095

96+
api := &API{
97+
Database: options.Database,
98+
Handler: r,
99+
Logger: options.Logger,
100+
}
101+
51102
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
52103
httpapi.WriteBytes(rw, http.StatusOK, []byte("Marketplace is running"))
53104
})
@@ -56,7 +107,120 @@ func New(options *Options) *API {
56107
httpapi.WriteBytes(rw, http.StatusOK, []byte("API server running"))
57108
})
58109

59-
return &API{
60-
Handler: r,
110+
// TODO: Read API version header and output a warning if it has changed since
111+
// that could indicate something needs to be updated.
112+
r.Post("/api/extensionquery", api.extensionQuery)
113+
114+
// Endpoint for getting an extension's files or the extension zip.
115+
options.Logger.Info(context.Background(), "Serving files", slog.F("dir", options.ExtDir))
116+
r.Mount("/files", http.StripPrefix("/files", http.FileServer(http.Dir(options.ExtDir))))
117+
118+
// VS Code can use the files in the response to get file paths but it will
119+
// sometimes ignore that and use use requests to /assets with hardcoded
120+
// types to get files.
121+
r.Get("/assets/{publisher}/{extension}/{version}/{type}", api.assetRedirect)
122+
123+
return api
124+
}
125+
126+
func (api *API) extensionQuery(rw http.ResponseWriter, r *http.Request) {
127+
ctx := r.Context()
128+
129+
var query QueryRequest
130+
if r.ContentLength <= 0 {
131+
query = QueryRequest{}
132+
} else {
133+
err := json.NewDecoder(r.Body).Decode(&query)
134+
if err != nil {
135+
httpapi.Write(rw, http.StatusBadRequest, httpapi.ErrorResponse{
136+
Message: "Unable to read query",
137+
Detail: "Check that the posted data is valid JSON",
138+
RequestID: httpmw.RequestID(r),
139+
})
140+
return
141+
}
142+
}
143+
144+
// Validate query sizes.
145+
if len(query.Filters) == 0 {
146+
query.Filters = append(query.Filters, database.Filter{})
147+
} else if len(query.Filters) > 1 {
148+
// VS Code always seems to use one filter.
149+
httpapi.Write(rw, http.StatusBadRequest, httpapi.ErrorResponse{
150+
Message: "Too many filters",
151+
Detail: "Check that you only have one filter",
152+
RequestID: httpmw.RequestID(r),
153+
})
61154
}
155+
for _, filter := range query.Filters {
156+
if filter.PageSize < 0 || filter.PageSize > 50 {
157+
httpapi.Write(rw, http.StatusBadRequest, httpapi.ErrorResponse{
158+
Message: "Invalid page size",
159+
Detail: "Check that the page size is between zero and fifty",
160+
RequestID: httpmw.RequestID(r),
161+
})
162+
}
163+
}
164+
165+
baseURL := httpapi.RequestBaseURL(r, "/")
166+
167+
// Each filter gets its own entry in the results.
168+
results := []QueryResult{}
169+
for _, filter := range query.Filters {
170+
extensions, count, err := api.Database.GetExtensions(ctx, filter, query.Flags, baseURL)
171+
if err != nil {
172+
api.Logger.Error(ctx, "Unable to execute query", slog.Error(err))
173+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.ErrorResponse{
174+
Message: "Internal server error while executing query",
175+
Detail: "Contact an administrator with the request ID",
176+
RequestID: httpmw.RequestID(r),
177+
})
178+
return
179+
}
180+
181+
api.Logger.Debug(ctx, "Got extensions for filter",
182+
slog.F("filter", filter),
183+
slog.F("count", count))
184+
185+
results = append(results, QueryResult{
186+
Extensions: extensions,
187+
Metadata: []ResultMetadata{{
188+
Type: "ResultCount",
189+
Items: []ResultMetadataItem{{
190+
Count: count,
191+
Name: "TotalCount",
192+
}},
193+
}},
194+
})
195+
}
196+
197+
httpapi.Write(rw, http.StatusOK, QueryResponse{Results: results})
198+
}
199+
200+
func (api *API) assetRedirect(rw http.ResponseWriter, r *http.Request) {
201+
// TODO: Asset URIs can contain a targetPlatform query variable.
202+
baseURL := httpapi.RequestBaseURL(r, "/")
203+
url, err := api.Database.GetExtensionAssetPath(r.Context(), &database.Asset{
204+
Extension: chi.URLParam(r, "extension"),
205+
Publisher: chi.URLParam(r, "publisher"),
206+
Type: chi.URLParam(r, "type"),
207+
Version: chi.URLParam(r, "version"),
208+
}, baseURL)
209+
if err != nil && os.IsNotExist(err) {
210+
httpapi.Write(rw, http.StatusNotFound, httpapi.ErrorResponse{
211+
Message: "Extension asset does not exist",
212+
Detail: "Please check the asset path",
213+
RequestID: httpmw.RequestID(r),
214+
})
215+
return
216+
} else if err != nil {
217+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.ErrorResponse{
218+
Message: "Unable to read extension",
219+
Detail: "Contact an administrator with the request ID",
220+
RequestID: httpmw.RequestID(r),
221+
})
222+
return
223+
}
224+
225+
http.Redirect(rw, r, url, http.StatusMovedPermanently)
62226
}

0 commit comments

Comments
 (0)