diff options
| author | Ethan Lee <ethanalee@google.com> | 2026-03-11 19:18:53 +0000 |
|---|---|---|
| committer | Jonathan Amsterdam <jba@google.com> | 2026-03-19 11:12:22 -0700 |
| commit | 6d0faac5e074a8a4e101da820e7e21a8a6c2ff2d (patch) | |
| tree | 82fdb2e4634d0d12f4583f1e1a94463b1cb49667 /internal/api | |
| parent | 71f5bef85c18c92a4652faea046caaadbb0d4969 (diff) | |
| download | go-x-pkgsite-6d0faac5e074a8a4e101da820e7e21a8a6c2ff2d.tar.xz | |
internal: instantiate v1/package/{path} endpoint
- Create handler for serving v1 package endpoint.
- Create tests to verify endpoint behavior.
Change-Id: I72701cb15d83baf4e31ed918c198adf347605a4a
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/754420
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
kokoro-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
Auto-Submit: Ethan Lee <ethanalee@google.com>
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/api.go | 183 | ||||
| -rw-r--r-- | internal/api/api_test.go | 168 | ||||
| -rw-r--r-- | internal/api/params.go | 3 | ||||
| -rw-r--r-- | internal/api/params_test.go | 22 |
4 files changed, 363 insertions, 13 deletions
diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 00000000..d27e49b0 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,183 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "strings" + + "golang.org/x/pkgsite/internal" + "golang.org/x/pkgsite/internal/derrors" + "golang.org/x/pkgsite/internal/stdlib" + "golang.org/x/pkgsite/internal/version" +) + +// ServePackage handles requests for the v1 package metadata endpoint. +func ServePackage(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) { + defer derrors.Wrap(&err, "ServePackage") + + // The path is expected to be /v1/package/{path} + pkgPath := strings.TrimPrefix(r.URL.Path, "/v1/package/") + pkgPath = strings.Trim(pkgPath, "/") + if pkgPath == "" { + return serveErrorJSON(w, http.StatusBadRequest, "missing package path", nil) + } + + var params PackageParams + 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 + } + + var um *internal.UnitMeta + modulePath := params.Module + if modulePath == "" { + // Handle potential ambiguity if module is not specified. + candidates := internal.CandidateModulePaths(pkgPath) + var validCandidates []Candidate + for _, mp := range candidates { + // Check if this module actually exists and contains the package at the requested version. + if m, err := ds.GetUnitMeta(r.Context(), pkgPath, mp, requestedVersion); err == nil { + um = m + validCandidates = append(validCandidates, Candidate{ + ModulePath: mp, + PackagePath: pkgPath, + }) + } else if !errors.Is(err, derrors.NotFound) { + return serveErrorJSON(w, http.StatusInternalServerError, err.Error(), nil) + } + } + + if len(validCandidates) > 1 { + return serveErrorJSON(w, http.StatusBadRequest, "ambiguous package path", validCandidates) + } + if len(validCandidates) == 0 { + return serveErrorJSON(w, http.StatusNotFound, "package not found", nil) + } + modulePath = validCandidates[0].ModulePath + } + + // Use GetUnit to get the requested data. + fs := internal.WithMain + if params.Licenses { + fs |= internal.WithLicenses + } + if params.Imports { + fs |= internal.WithImports + } + + bc := internal.BuildContext{GOOS: params.GOOS, GOARCH: params.GOARCH} + var unit *internal.Unit + if um != nil { + var err error + unit, err = ds.GetUnit(r.Context(), um, fs, bc) + if err != nil { + return serveErrorJSON(w, http.StatusInternalServerError, err.Error(), nil) + } + } else if modulePath != "" && modulePath != internal.UnknownModulePath && !needsResolution(requestedVersion) { + // This block is reachable if the user explicitly provided a module path and a + // concrete version in the query parameters, skipping the candidate search. + um = &internal.UnitMeta{ + Path: pkgPath, + ModuleInfo: internal.ModuleInfo{ + ModulePath: modulePath, + Version: requestedVersion, + }, + } + var err error + unit, err = ds.GetUnit(r.Context(), um, fs, bc) + if err != nil && !errors.Is(err, derrors.NotFound) { + return serveErrorJSON(w, http.StatusInternalServerError, err.Error(), nil) + } + } + + if unit == nil { + // Fallback: Resolve the version or find the module using GetUnitMeta. + var err error + um, err = ds.GetUnitMeta(r.Context(), pkgPath, modulePath, requestedVersion) + if err != nil { + if errors.Is(err, derrors.NotFound) { + return serveErrorJSON(w, http.StatusNotFound, err.Error(), nil) + } + return serveErrorJSON(w, http.StatusInternalServerError, err.Error(), nil) + } + unit, err = ds.GetUnit(r.Context(), um, fs, bc) + if err != nil { + return serveErrorJSON(w, http.StatusInternalServerError, err.Error(), nil) + } + } + + synopsis := "" + var docs string + goos := params.GOOS + goarch := params.GOARCH + if len(unit.Documentation) > 0 { + d := unit.Documentation[0] + synopsis = d.Synopsis + if goos == "" { + goos = d.GOOS + } + if goarch == "" { + goarch = d.GOARCH + } + // TODO(jba): Add support for docs. + } + + imports := unit.Imports + var licenses []License + for _, l := range unit.LicenseContents { + licenses = append(licenses, License{ + Types: l.Metadata.Types, + FilePath: l.Metadata.FilePath, + Contents: string(l.Contents), + }) + } + + resp := Package{ + Path: unit.Path, + ModulePath: unit.ModulePath, + ModuleVersion: unit.Version, + Synopsis: synopsis, + IsStandardLibrary: stdlib.Contains(unit.ModulePath), + GOOS: goos, + GOARCH: goarch, + Docs: docs, + Imports: imports, + Licenses: licenses, + } + + 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 +} + +func serveJSON(w http.ResponseWriter, status int, data any) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(data); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, err := w.Write(buf.Bytes()) + return err +} + +func serveErrorJSON(w http.ResponseWriter, status int, message string, candidates []Candidate) error { + return serveJSON(w, status, Error{ + Code: status, + Message: message, + Candidates: candidates, + }) +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 00000000..e087b13e --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,168 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" + "golang.org/x/pkgsite/internal" + "golang.org/x/pkgsite/internal/testing/fakedatasource" +) + +func TestServePackage(t *testing.T) { + ctx := context.Background() + ds := fakedatasource.New() + + const ( + pkgPath = "example.com/a/b" + modulePath1 = "example.com/a" + modulePath2 = "example.com/a/b" + version = "v1.2.3" + ) + + ds.MustInsertModule(ctx, &internal.Module{ + ModuleInfo: internal.ModuleInfo{ModulePath: "example.com", Version: version}, + Units: []*internal.Unit{{ + UnitMeta: internal.UnitMeta{ + Path: "example.com/pkg", + ModuleInfo: internal.ModuleInfo{ModulePath: "example.com", Version: version}, + Name: "pkg", + }, + Documentation: []*internal.Documentation{{ + GOOS: "linux", + GOARCH: "amd64", + Synopsis: "Basic synopsis", + }}, + }}, + }) + + for _, mp := range []string{modulePath1, modulePath2} { + u := &internal.Unit{ + UnitMeta: internal.UnitMeta{ + Path: pkgPath, + ModuleInfo: internal.ModuleInfo{ + ModulePath: mp, + Version: version, + }, + Name: "b", + }, + Documentation: []*internal.Documentation{ + { + GOOS: "linux", + GOARCH: "amd64", + Synopsis: "Synopsis for " + mp, + }, + }, + } + ds.MustInsertModule(ctx, &internal.Module{ + ModuleInfo: internal.ModuleInfo{ + ModulePath: mp, + Version: version, + }, + Units: []*internal.Unit{u}, + }) + } + + for _, test := range []struct { + name string + url string + wantStatus int + want any // Can be *Package or *Error + }{ + { + name: "basic metadata", + url: "/v1/package/example.com/pkg?version=v1.2.3", + wantStatus: http.StatusOK, + want: &Package{ + Path: "example.com/pkg", + ModulePath: "example.com", + ModuleVersion: version, + Synopsis: "Basic synopsis", + GOOS: "linux", + GOARCH: "amd64", + }, + }, + { + name: "ambiguous path", + url: "/v1/package/example.com/a/b?version=v1.2.3", + wantStatus: http.StatusBadRequest, + want: &Error{ + Code: http.StatusBadRequest, + Message: "ambiguous package path", + Candidates: []Candidate{ + {ModulePath: "example.com/a/b", PackagePath: "example.com/a/b"}, + {ModulePath: "example.com/a", PackagePath: "example.com/a/b"}, + }, + }, + }, + { + name: "disambiguated path", + url: "/v1/package/example.com/a/b?version=v1.2.3&module=example.com/a", + wantStatus: http.StatusOK, + want: &Package{ + Path: pkgPath, + ModulePath: modulePath1, + ModuleVersion: version, + Synopsis: "Synopsis for " + modulePath1, + GOOS: "linux", + GOARCH: "amd64", + }, + }, + { + name: "default build context", + url: "/v1/package/example.com/pkg?version=v1.2.3", + wantStatus: http.StatusOK, + want: &Package{ + Path: "example.com/pkg", + ModulePath: "example.com", + ModuleVersion: version, + Synopsis: "Basic synopsis", + GOOS: "linux", + GOARCH: "amd64", + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + r := httptest.NewRequest("GET", test.url, nil) + w := httptest.NewRecorder() + + err := ServePackage(w, r, ds) + if err != nil { + t.Fatalf("ServePackage returned error: %v", err) + } + + if w.Code != test.wantStatus { + t.Errorf("status = %d, want %d. Body: %s", w.Code, test.wantStatus, w.Body.String()) + } + + if test.want != nil { + switch want := test.want.(type) { + case *Package: + var got Package + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal Package: %v", err) + } + got.IsLatest = false + if diff := cmp.Diff(want, &got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + case *Error: + var got Error + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal Error: %v. Body: %s", err, w.Body.String()) + } + if diff := cmp.Diff(want, &got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + } + } + }) + } +} diff --git a/internal/api/params.go b/internal/api/params.go index 9b01e7be..e126fb2a 100644 --- a/internal/api/params.go +++ b/internal/api/params.go @@ -25,7 +25,7 @@ type PackageParams struct { GOOS string `form:"goos"` GOARCH string `form:"goarch"` Doc string `form:"doc"` - Examples bool `form:"examples"` + Imports bool `form:"imports"` Licenses bool `form:"licenses"` } @@ -36,7 +36,6 @@ type SymbolsParams struct { GOOS string `form:"goos"` GOARCH string `form:"goarch"` ListParams - Examples bool `form:"examples"` } // ImportedByParams are query parameters for /v1/imported-by/{path}. diff --git a/internal/api/params_test.go b/internal/api/params_test.go index 6d325247..b630413b 100644 --- a/internal/api/params_test.go +++ b/internal/api/params_test.go @@ -45,21 +45,21 @@ func TestParseParams(t *testing.T) { }{ { name: "PackageParams", - values: url.Values{"module": {"m"}, "version": {"v1.0.0"}, "goos": {"linux"}, "examples": {"true"}}, + values: url.Values{"module": {"m"}, "version": {"v1.0.0"}, "goos": {"linux"}, "imports": {"true"}}, dst: &PackageParams{}, want: &PackageParams{ - Module: "m", - Version: "v1.0.0", - GOOS: "linux", - Examples: true, + Module: "m", + Version: "v1.0.0", + GOOS: "linux", + Imports: true, }, }, { name: "Boolean presence", - values: url.Values{"examples": {"true"}, "licenses": {"1"}}, + values: url.Values{"imports": {"true"}, "licenses": {"1"}}, dst: &PackageParams{}, want: &PackageParams{ - Examples: true, + Imports: true, Licenses: true, }, }, @@ -74,15 +74,15 @@ func TestParseParams(t *testing.T) { }, { name: "Empty bool", - values: url.Values{"examples": {""}}, + values: url.Values{"imports": {""}}, dst: &PackageParams{}, want: &PackageParams{ - Examples: false, + Imports: false, }, }, { name: "Invalid bool (on)", - values: url.Values{"examples": {"on"}}, + values: url.Values{"imports": {"on"}}, dst: &PackageParams{}, wantErr: true, }, @@ -124,7 +124,7 @@ func TestParseParams(t *testing.T) { }, { name: "Malformed bool", - values: url.Values{"examples": {"maybe"}}, + values: url.Values{"imports": {"maybe"}}, dst: &PackageParams{}, wantErr: true, }, |
