diff --git a/internal/api/api.go b/internal/api/api.go
index 0a6fe22..68c94ec 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -241,6 +241,60 @@
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
+ }
+
+ 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)
+}
+
func serveJSON(w http.ResponseWriter, status int, data any) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index d28b230..e873796 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -155,6 +155,62 @@
}
}
+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 len(got.Items) != test.wantCount {
+ t.Errorf("count = %d, want %d", len(got.Items), test.wantCount)
+ }
+ }
+ })
+ }
+}
+
func TestServeSearch(t *testing.T) {
ctx := context.Background()
ds := fakedatasource.New()
diff --git a/internal/datasource.go b/internal/datasource.go
index 912b54d..f6bf8c8 100644
--- a/internal/datasource.go
+++ b/internal/datasource.go
@@ -93,6 +93,8 @@
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/frontend/server.go b/internal/frontend/server.go
index 39e5aac..ad95335 100644
--- a/internal/frontend/server.go
+++ b/internal/frontend/server.go
@@ -239,6 +239,7 @@
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("GET /v1/search", s.errorHandler(api.ServeSearch))
handle("/opensearch.xml", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/testing/fakedatasource/fakedatasource.go b/internal/testing/fakedatasource/fakedatasource.go
index 677676d..f8cdba1 100644
--- a/internal/testing/fakedatasource/fakedatasource.go
+++ b/internal/testing/fakedatasource/fakedatasource.go
@@ -228,6 +228,34 @@
return infos, 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