From 9da8f15f1c5721ddc1866bda9091a7f49c63fa10 Mon Sep 17 00:00:00 2001 From: Ethan Lee Date: Wed, 11 Mar 2026 21:08:19 +0000 Subject: internal/api: implement module packages endpoint - Implement the ServeModulePackages handler and introduce GetModulePackages to return the list of packages for a given module version. Change-Id: I20c1618e2fdbf0126cb913665a1d7457b8951177 Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/754863 LUCI-TryBot-Result: Go LUCI kokoro-CI: kokoro Auto-Submit: Ethan Lee Reviewed-by: Jonathan Amsterdam --- internal/api/api.go | 56 ++++++++++++++++++++++ internal/api/api_test.go | 58 +++++++++++++++++++++-- internal/datasource.go | 2 + internal/fetchdatasource/fetchdatasource.go | 29 ++++++++++++ internal/frontend/server.go | 1 + internal/postgres/unit.go | 5 ++ internal/postgres/unit_test.go | 24 ++++++++++ internal/testing/fakedatasource/fakedatasource.go | 28 +++++++++++ 8 files changed, 199 insertions(+), 4 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index d94098e3..1d21ba7e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -283,6 +283,62 @@ func ServeModuleVersions(w http.ResponseWriter, r *http.Request, ds internal.Dat return serveJSON(w, http.StatusOK, resp) } +// ServeModulePackages handles requests for the v1 module packages endpoint. +func ServeModulePackages(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) { + defer derrors.Wrap(&err, "ServeModulePackages") + + modulePath := strings.TrimPrefix(r.URL.Path, "/v1/packages/") + if modulePath == "" { + return serveErrorJSON(w, http.StatusBadRequest, "missing module path", nil) + } + + var params PackagesParams + if err := ParseParams(r.URL.Query(), ¶ms); err != nil { + return serveErrorJSON(w, http.StatusBadRequest, err.Error(), nil) + } + + requestedVersion := params.Version + if requestedVersion == "" { + requestedVersion = version.Latest + } + + metas, err := ds.GetModulePackages(r.Context(), modulePath, requestedVersion) + if err != nil { + if errors.Is(err, derrors.NotFound) { + return serveErrorJSON(w, http.StatusNotFound, err.Error(), nil) + } + return err + } + + // TODO: Handle params.Token and params.Filter. + // For now, we just use params.Limit to limit the number of packages returned. + limit := params.Limit + if limit <= 0 { + limit = 100 + } + if limit > len(metas) { + limit = len(metas) + } + + var items []Package + for _, m := range metas[:limit] { + items = append(items, Package{ + Path: m.Path, + ModulePath: modulePath, + ModuleVersion: requestedVersion, + Synopsis: m.Synopsis, + IsStandardLibrary: stdlib.Contains(modulePath), + }) + } + + resp := PaginatedResponse[Package]{ + Items: items, + Total: len(metas), + } + + return serveJSON(w, http.StatusOK, resp) +} + // needsResolution reports whether the version string is a sentinel like "latest" or "master". func needsResolution(v string) bool { return v == version.Latest || v == version.Master || v == version.Main diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 9983a8cf..a0b63904 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -322,11 +322,61 @@ func TestServeModuleVersions(t *testing.T) { if len(got.Items) != test.wantCount { t.Errorf("count = %d, want %d", len(got.Items), test.wantCount) } - if test.name == "with limit" && got.NextPageToken != "1" { - t.Errorf("nextPageToken = %q, want %q", got.NextPageToken, "1") + } + }) + } +} + +func TestServeModulePackages(t *testing.T) { + ctx := context.Background() + ds := fakedatasource.New() + + const ( + modulePath = "example.com" + version = "v1.0.0" + ) + + ds.MustInsertModule(ctx, &internal.Module{ + ModuleInfo: internal.ModuleInfo{ModulePath: modulePath, Version: version}, + Units: []*internal.Unit{ + {UnitMeta: internal.UnitMeta{Path: modulePath, Name: "pkg1"}}, + {UnitMeta: internal.UnitMeta{Path: modulePath + "/sub", Name: "pkg2"}}, + }, + }) + + for _, test := range []struct { + name string + url string + wantStatus int + wantCount int + }{ + { + name: "all packages", + url: "/v1/packages/example.com?version=v1.0.0", + wantStatus: http.StatusOK, + wantCount: 2, + }, + } { + t.Run(test.name, func(t *testing.T) { + r := httptest.NewRequest("GET", test.url, nil) + w := httptest.NewRecorder() + + err := ServeModulePackages(w, r, ds) + if err != nil { + t.Fatalf("ServeModulePackages returned error: %v", err) + } + + if w.Code != test.wantStatus { + t.Errorf("status = %d, want %d", w.Code, test.wantStatus) + } + + if test.wantStatus == http.StatusOK { + var got PaginatedResponse[Package] + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal: %v", err) } - if test.name == "pagination" && got.NextPageToken != "2" { - t.Errorf("nextPageToken = %q, want %q", got.NextPageToken, "2") + if len(got.Items) != test.wantCount { + t.Errorf("count = %d, want %d", len(got.Items), test.wantCount) } } }) diff --git a/internal/datasource.go b/internal/datasource.go index 912b54de..f6bf8c84 100644 --- a/internal/datasource.go +++ b/internal/datasource.go @@ -93,6 +93,8 @@ type DataSource interface { GetLatestInfo(ctx context.Context, unitPath, modulePath string, latestUnitMeta *UnitMeta) (LatestInfo, error) // GetVersionsForPath returns a list of versions for the given path. GetVersionsForPath(ctx context.Context, path string) ([]*ModuleInfo, error) + // GetModulePackages returns a list of packages in the given module version. + GetModulePackages(ctx context.Context, modulePath, version string) ([]*PackageMeta, error) // SearchSupport reports the search types supported by this datasource. SearchSupport() SearchSupport diff --git a/internal/fetchdatasource/fetchdatasource.go b/internal/fetchdatasource/fetchdatasource.go index 41573ae5..1643d009 100644 --- a/internal/fetchdatasource/fetchdatasource.go +++ b/internal/fetchdatasource/fetchdatasource.go @@ -404,3 +404,32 @@ func (ds *FetchDataSource) Search(ctx context.Context, q string, opts internal.S return results, nil } + +// GetModulePackages returns a list of packages in the given module version. +func (ds *FetchDataSource) GetModulePackages(ctx context.Context, modulePath, version string) ([]*internal.PackageMeta, error) { + m, err := ds.getModule(ctx, modulePath, version) + if err != nil { + return nil, err + } + var metas []*internal.PackageMeta + for _, um := range m.UnitMetas { + if um.IsPackage() { + u, err := ds.findUnit(ctx, m, um.Path) + if err != nil { + return nil, err + } + var synopsis string + if len(u.Documentation) > 0 { + synopsis = u.Documentation[0].Synopsis + } + metas = append(metas, &internal.PackageMeta{ + Path: u.Path, + Name: u.Name, + Synopsis: synopsis, + IsRedistributable: u.IsRedistributable, + Licenses: u.Licenses, + }) + } + } + return metas, nil +} diff --git a/internal/frontend/server.go b/internal/frontend/server.go index b229af0d..acd9c909 100644 --- a/internal/frontend/server.go +++ b/internal/frontend/server.go @@ -239,6 +239,7 @@ func (s *Server) Install(handle func(string, http.Handler), cacher Cacher, authV handle("GET /v1/package/", s.errorHandler(api.ServePackage)) handle("GET /v1/module/", s.errorHandler(api.ServeModule)) handle("GET /v1/versions/", s.errorHandler(api.ServeModuleVersions)) + handle("GET /v1/packages/", s.errorHandler(api.ServeModulePackages)) handle("/opensearch.xml", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serveFileFS(w, r, s.staticFS, "shared/opensearch.xml") })) diff --git a/internal/postgres/unit.go b/internal/postgres/unit.go index 765d541e..fee52439 100644 --- a/internal/postgres/unit.go +++ b/internal/postgres/unit.go @@ -277,6 +277,11 @@ func (db *DB) GetUnit(ctx context.Context, um *internal.UnitMeta, fields interna return u, nil } +// GetModulePackages returns a list of packages in the given module version. +func (db *DB) GetModulePackages(ctx context.Context, modulePath, version string) ([]*internal.PackageMeta, error) { + return getPackagesInUnit(ctx, db.db, modulePath, modulePath, version, -1, db.bypassLicenseCheck) +} + func (db *DB) getUnitID(ctx context.Context, fullPath, modulePath, resolvedVersion string) (_ int, _ bool, err error) { defer derrors.WrapStack(&err, "getUnitID(ctx, %q, %q, %q)", fullPath, modulePath, resolvedVersion) defer stats.Elapsed(ctx, "getUnitID")() diff --git a/internal/postgres/unit_test.go b/internal/postgres/unit_test.go index 82dbd9d9..a35d5658 100644 --- a/internal/postgres/unit_test.go +++ b/internal/postgres/unit_test.go @@ -917,3 +917,27 @@ func newUnitMeta(path, modulePath, version string) *internal.UnitMeta { }, } } + +func TestGetModulePackages(t *testing.T) { + t.Parallel() + testDB, release := acquire(t) + defer release() + ctx := context.Background() + + m := sample.Module("m.com", "v1.2.3", "a", "a/b", "c") + MustInsertModule(ctx, t, testDB, m) + + got, err := testDB.GetModulePackages(ctx, "m.com", "v1.2.3") + if err != nil { + t.Fatal(err) + } + + var gotPaths []string + for _, p := range got { + gotPaths = append(gotPaths, p.Path) + } + wantPaths := []string{"m.com/a", "m.com/a/b", "m.com/c"} + if diff := cmp.Diff(wantPaths, gotPaths); diff != "" { + t.Errorf("GetModulePackages mismatch (-want +got):\n%s", diff) + } +} diff --git a/internal/testing/fakedatasource/fakedatasource.go b/internal/testing/fakedatasource/fakedatasource.go index 46de2a8d..82212163 100644 --- a/internal/testing/fakedatasource/fakedatasource.go +++ b/internal/testing/fakedatasource/fakedatasource.go @@ -309,6 +309,34 @@ func (ds *FakeDataSource) GetLatestInfo(ctx context.Context, unitPath, modulePat }, nil } +// GetModulePackages returns a list of packages in the given module version. +func (ds *FakeDataSource) GetModulePackages(ctx context.Context, modulePath, version string) ([]*internal.PackageMeta, error) { + m := ds.getModule(modulePath, version) + if m == nil { + return nil, derrors.NotFound + } + var pkgs []*internal.PackageMeta + for _, u := range m.Units { + if u.IsPackage() { + var syn string + if len(u.Documentation) > 0 { + syn = u.Documentation[0].Synopsis + } + pkgs = append(pkgs, &internal.PackageMeta{ + Path: u.Path, + Name: u.Name, + Synopsis: syn, + IsRedistributable: u.IsRedistributable, + Licenses: u.Licenses, + }) + } + } + sort.Slice(pkgs, func(i, j int) bool { + return pkgs[i].Path < pkgs[j].Path + }) + return pkgs, nil +} + // SearchSupport reports the search types supported by this datasource. func (ds *FakeDataSource) SearchSupport() internal.SearchSupport { // internal/frontend.TestDetermineSearchAction depends on us returning FullSearch -- cgit v1.3