| // 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. |
| |
| // Comments beginning with "api:" are read by RouteInfos. |
| // They should not be removed. |
| // If a new route is added, provide all the "api:" comments for it. |
| |
| package api |
| |
| import ( |
| "bytes" |
| _ "embed" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "go/parser" |
| "maps" |
| "net/http" |
| "reflect" |
| "slices" |
| "strings" |
| "time" |
| |
| "golang.org/x/pkgsite/internal" |
| "golang.org/x/pkgsite/internal/derrors" |
| "golang.org/x/pkgsite/internal/godoc" |
| "golang.org/x/pkgsite/internal/log" |
| "golang.org/x/pkgsite/internal/stdlib" |
| "golang.org/x/pkgsite/internal/version" |
| "golang.org/x/pkgsite/internal/vuln" |
| ) |
| |
| const ( |
| // maxSearchResults is the maximum number of search results to return for a search query. |
| maxSearchResults = 1000 |
| // defaultSearchLimit is the default number of results to return per page for search. |
| defaultSearchLimit = 25 |
| // maxLimit is the maximum allowed limit for paginated results. |
| maxLimit = 1000 |
| // defaultLimit is the default number of results to return per page for paginated results. |
| defaultLimit = 100 |
| ) |
| |
| // OpenAPISpec contains the raw bytes of the OpenAPI 3.0 specification for the API. |
| // |
| //go:embed openapi.yaml |
| var OpenAPISpec []byte |
| |
| // ServePackage handles requests for the v1beta package metadata endpoint. |
| // api:route /v1beta/package/{path} |
| // api:desc Information about the package at {path}. |
| // api:example /v1beta/package/golang.org/x/time/rate |
| func ServePackage(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) { |
| defer derrors.Wrap(&err, "ServePackage") |
| |
| pkgPath := trimPath(r, "/v1beta/package/") |
| if pkgPath == "" { |
| return BadRequest("missing package path", |
| "the package path must be provided after '/package/'") |
| } |
| |
| // api:params PackageParams |
| var params PackageParams |
| if err := ParseParams(r.URL.Query(), ¶ms); err != nil { |
| return err |
| } |
| |
| if params.Examples && params.Doc == "" { |
| return BadRequest("examples require doc format to be specified") |
| } |
| switch params.Doc { |
| // renderDocumentation needs to be updated when the doc set changes. |
| case "", "text", "md", "markdown", "html": |
| default: |
| return BadRequest("bad doc format: need one of 'text', 'md', 'markdown' or 'html'") |
| } |
| |
| um, err := resolveModulePath(r, ds, pkgPath, params.Module, params.Version) |
| if err != nil { |
| return err |
| } |
| |
| fs := internal.WithMain |
| if params.Licenses { |
| fs |= internal.WithLicenses |
| } |
| if params.Imports { |
| fs |= internal.WithImports |
| } |
| if params.Doc != "" || params.Examples { |
| fs |= internal.WithDocsSource |
| } |
| |
| bc := internal.BuildContext{GOOS: params.GOOS, GOARCH: params.GOARCH} |
| unit, err := ds.GetUnit(r.Context(), um, fs, bc) |
| if err != nil { |
| return err |
| } |
| |
| // api:response Package |
| resp, err := unitToPackage(unit, params) |
| if err != nil { |
| return err |
| } |
| |
| return serveJSON(w, http.StatusOK, resp, versionCacheDur(params.Version)) |
| } |
| |
| // ServeModule handles requests for the v1beta module metadata endpoint. |
| // api:route /v1beta/module/{path} |
| // api:desc Information about the module at {path}. |
| // api:example /v1beta/module/golang.org/x/time |
| func ServeModule(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) { |
| defer derrors.Wrap(&err, "ServeModule") |
| |
| modulePath := trimPath(r, "/v1beta/module/") |
| if modulePath == "" { |
| return BadRequest("missing module path", |
| "the module path must be provided after '/module/'") |
| } |
| |
| // api:params ModuleParams |
| var params ModuleParams |
| if err := ParseParams(r.URL.Query(), ¶ms); err != nil { |
| return err |
| } |
| |
| requestedVersion := params.Version |
| if requestedVersion == "" { |
| requestedVersion = version.Latest |
| } |
| // The served response is cacheDur if and only if the version is. |
| cacheDur := versionCacheDur(requestedVersion) |
| |
| // For modules, we can use GetUnitMeta on the module path. |
| um, err := ds.GetUnitMeta(r.Context(), modulePath, internal.UnknownModulePath, requestedVersion) |
| if err != nil { |
| return err |
| } |
| |
| if err := checkModulePath(modulePath, um.ModulePath); err != nil { |
| return err |
| } |
| |
| // api:response Module |
| resp := Module{ |
| Path: um.ModulePath, |
| Version: um.Version, |
| CommitTime: um.CommitTime, |
| IsLatest: um.Version == um.LatestVersion, |
| IsStandardLibrary: stdlib.Contains(um.ModulePath), |
| IsRedistributable: um.IsRedistributable, |
| HasGoMod: um.HasGoMod, |
| } |
| // RepoURL needs to be extracted from source info if available |
| if um.SourceInfo != nil { |
| resp.RepoURL = um.SourceInfo.RepoURL() |
| } |
| |
| if !params.Readme && !params.Licenses { |
| return serveJSON(w, http.StatusOK, resp, cacheDur) |
| } |
| |
| fs := internal.MinimalFields |
| if params.Readme { |
| fs |= internal.WithMain // WithMain includes Readme in GetUnit |
| } |
| if params.Licenses { |
| fs |= internal.WithLicenses |
| } |
| unit, err := ds.GetUnit(r.Context(), um, fs, internal.BuildContext{}) |
| if err != nil { |
| return serveJSON(w, http.StatusOK, resp, cacheDur) |
| } |
| |
| if params.Readme && unit.Readme != nil { |
| resp.Readme = &Readme{ |
| Filepath: unit.Readme.Filepath, |
| Contents: unit.Readme.Contents, |
| } |
| } |
| if params.Licenses { |
| for _, l := range unit.LicenseContents { |
| resp.Licenses = append(resp.Licenses, License{ |
| Types: l.Metadata.Types, |
| FilePath: l.Metadata.FilePath, |
| Contents: string(l.Contents), |
| }) |
| } |
| } |
| |
| return serveJSON(w, http.StatusOK, resp, cacheDur) |
| } |
| |
| // ServeModuleVersions handles requests for the v1beta module versions endpoint. |
| // api:route /v1beta/versions/{path} |
| // api:desc Versions of the module at {path}. |
| // api:desc If there are tagged versions, they are returned. |
| // api:desc Otherwise, the 10 most recent pseudo-versions are returned. |
| // api:desc The versions are in descending order. |
| // api:desc Only results that match the filter query parameter are returned. |
| // api:example /v1beta/versions/golang.org/x/time?limit=3 |
| func ServeModuleVersions(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) { |
| defer derrors.Wrap(&err, "ServeModuleVersions") |
| |
| path := trimPath(r, "/v1beta/versions/") |
| if path == "" { |
| return BadRequest("missing module path", |
| "the module path must be provided after '/versions/'") |
| } |
| |
| // api:params VersionsParams |
| var params VersionsParams |
| if err := ParseParams(r.URL.Query(), ¶ms); err != nil { |
| return err |
| } |
| um, err := ds.GetUnitMeta(r.Context(), path, internal.UnknownModulePath, version.Latest) |
| if err != nil { |
| return fmt.Errorf("module %q: %w", path, err) |
| } |
| if err := checkModulePath(path, um.ModulePath); err != nil { |
| return err |
| } |
| |
| infos, err := ds.GetVersionsForPath(r.Context(), path) |
| if err != nil { |
| return err |
| } |
| var mvs []ModuleVersion |
| for _, in := range infos { |
| mvs = append(mvs, ModuleVersion{ |
| ModulePath: in.ModulePath, |
| Version: in.Version, |
| CommitTime: in.CommitTime, |
| IsRedistributable: in.IsRedistributable, |
| HasGoMod: in.HasGoMod, |
| LatestVersion: in.LatestVersion, |
| Deprecated: in.Deprecated, |
| DeprecationReason: in.DeprecationComment, |
| Retracted: in.Retracted, |
| RetractionReason: in.RetractionRationale, |
| }) |
| } |
| mvs, err = filterStruct(mvs, params.Filter) |
| if err != nil { |
| return err |
| } |
| |
| // api:response PaginatedResponse[ModuleVersion] |
| resp, err := paginate(mvs, params.ListParams, defaultLimit) |
| if err != nil { |
| return err |
| } |
| |
| // The response is never immutable, because a new version can arrive at any time. |
| return serveJSON(w, http.StatusOK, resp, shortCacheDur) |
| } |
| |
| // ServeModulePackages handles requests for the v1beta module packages endpoint. |
| // api:route /v1beta/packages/{path} |
| // api:desc Information about packages of the module at {path}. |
| // api:desc Filtering is applied to the list of packages in the response. |
| // api:desc Only packages that match the filter query parameter are returned. |
| // api:example /v1beta/packages/golang.org/x/time/rate |
| func ServeModulePackages(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) { |
| defer derrors.Wrap(&err, "ServeModulePackages") |
| |
| modulePath := trimPath(r, "/v1beta/packages/") |
| if modulePath == "" { |
| return BadRequest("missing module path", |
| "the module path must be provided after '/packages/'") |
| } |
| |
| // api:params PackagesParams |
| var params PackagesParams |
| if err := ParseParams(r.URL.Query(), ¶ms); err != nil { |
| return err |
| } |
| |
| requestedVersion := params.Version |
| if requestedVersion == "" { |
| requestedVersion = version.Latest |
| } |
| |
| // Resolve latest version, and check if specific version exists. |
| um, err := ds.GetUnitMeta(r.Context(), modulePath, internal.UnknownModulePath, requestedVersion) |
| if err != nil { |
| return err |
| } |
| |
| if err := checkModulePath(modulePath, um.ModulePath); err != nil { |
| return err |
| } |
| |
| metas, err := ds.GetModulePackages(r.Context(), um.ModulePath, um.Version) |
| if err != nil { |
| return err |
| } |
| |
| var pinfos []PackageInfo |
| for _, m := range metas { |
| pinfos = append(pinfos, PackageInfo{ |
| Path: m.Path, |
| Name: m.Name, |
| Synopsis: m.Synopsis, |
| IsRedistributable: m.IsRedistributable, |
| }) |
| } |
| pinfos, err = filterStruct(pinfos, params.Filter) |
| if err != nil { |
| return err |
| } |
| |
| // api:response PackagesResponse |
| resp := PackagesResponse{ |
| ModulePath: um.ModulePath, |
| Version: um.Version, |
| IsStandardLibrary: stdlib.Contains(modulePath), |
| } |
| |
| resp.Packages, err = paginate(pinfos, params.ListParams, defaultLimit) |
| if err != nil { |
| return err |
| } |
| |
| return serveJSON(w, http.StatusOK, resp, versionCacheDur(requestedVersion)) |
| } |
| |
| // ServeSearch handles requests for the v1 search endpoint. |
| // api:route /v1beta/search |
| // api:desc Search results. Only results that match the filter query parameter are returned. |
| // api:desc Results are sorted by how well the match the query, with the best match first. |
| // api:example /v1beta/search?q=xyzzy |
| func ServeSearch(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) { |
| defer derrors.Wrap(&err, "ServeSearch") |
| |
| // api:params SearchParams |
| var params SearchParams |
| if err := ParseParams(r.URL.Query(), ¶ms); err != nil { |
| return err |
| } |
| |
| if params.Query == "" { |
| return BadRequest("missing query", "provide the query by using the query parameter 'q'") |
| } |
| |
| dbLimit := maxSearchResults |
| // We can only optimize the DB limit when no filter is present. |
| // Filtering is done in memory after fetching results, so we need a large |
| // candidate set to avoid returning empty pages when matches exist further down. |
| if params.Filter == "" { |
| limit, offset, err := params.ListParams.pageParams(defaultSearchLimit) |
| if err != nil { |
| return fmt.Errorf("%w: %s", derrors.InvalidArgument, err.Error()) |
| } |
| dbLimit = min(offset+limit+1, maxSearchResults) |
| } |
| |
| dbresults, err := ds.Search(r.Context(), params.Query, internal.SearchOptions{ |
| MaxResults: dbLimit, |
| MaxResultCount: maxSearchResults, |
| SearchSymbols: params.Symbol != "", |
| SymbolFilter: params.Symbol, |
| // Don't group search results: packages in the same module and |
| // in modules with different major versions will all appear in |
| // the same flat list, sorted by score. |
| GroupResults: false, |
| }) |
| if err != nil { |
| return err |
| } |
| |
| var results []SearchResult |
| for _, r := range dbresults { |
| results = append(results, SearchResult{ |
| PackagePath: r.PackagePath, |
| ModulePath: r.ModulePath, |
| Version: r.Version, |
| Synopsis: r.Synopsis, |
| }) |
| } |
| |
| results, err = filterStruct(results, params.Filter) |
| if err != nil { |
| return err |
| } |
| |
| // api:response PaginatedResponse[SearchResult] |
| resp, err := paginate(results, params.ListParams, defaultLimit) |
| if err != nil { |
| return fmt.Errorf("%w: %s", derrors.InvalidArgument, err.Error()) |
| } |
| if params.Filter == "" && len(dbresults) > 0 { |
| resp.Total = int(dbresults[0].NumResults) |
| } |
| |
| // Search results are never immutable, because new modules are always being added. |
| // NOTE: the default cache freshness is set to 1 hour (see serveJSON). This seems |
| // like a reasonable time to cache a search, but be aware of complaints |
| // about stale search results. |
| return serveJSON(w, http.StatusOK, resp, shortCacheDur) |
| } |
| |
| // ServePackageSymbols handles requests for the v1beta package symbols endpoint. |
| // api:route /v1beta/symbols/{path} |
| // api:desc List of symbols for the package at {path}. |
| // api:desc Filtering is applied to the list of symbols in the response. |
| // api:desc Only symbols that match the filter query parameter are returned. |
| // api:example /v1beta/symbols/golang.org/x/time/rate |
| func ServePackageSymbols(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) { |
| defer derrors.Wrap(&err, "ServePackageSymbols") |
| |
| pkgPath := trimPath(r, "/v1beta/symbols/") |
| if pkgPath == "" { |
| return BadRequest("missing package path", |
| "the package path must be provided after '/symbols/'") |
| } |
| |
| // api:params SymbolsParams |
| var params SymbolsParams |
| if err := ParseParams(r.URL.Query(), ¶ms); err != nil { |
| return err |
| } |
| |
| um, err := resolveModulePath(r, ds, pkgPath, params.Module, params.Version) |
| if err != nil { |
| return err |
| } |
| |
| bc := internal.BuildContext{GOOS: params.GOOS, GOARCH: params.GOARCH} |
| dbSyms, err := ds.GetSymbols(r.Context(), pkgPath, um.ModulePath, um.Version, bc) |
| if err != nil { |
| return fmt.Errorf("symbols for package %s: %w", pkgPath, err) |
| } |
| |
| var syms []*internal.Symbol |
| for _, s := range dbSyms { |
| syms = append(syms, s) |
| for _, child := range s.Children { |
| syms = append(syms, &internal.Symbol{ |
| SymbolMeta: *child, |
| GOOS: s.GOOS, |
| GOARCH: s.GOARCH, |
| }) |
| } |
| s.Children = nil |
| } |
| |
| syms = slices.Clone(syms) |
| |
| // TODO(jba): combine this loop with the one above, if possible. |
| var items []Symbol |
| for _, s := range syms { |
| items = append(items, Symbol{ |
| Name: s.Name, |
| Kind: string(s.Kind), |
| Synopsis: s.Synopsis, |
| Parent: s.ParentName, |
| }) |
| } |
| |
| items, err = filterStruct(items, params.Filter) |
| if err != nil { |
| return err |
| } |
| |
| paged, err := paginate(items, params.ListParams, defaultLimit) |
| if err != nil { |
| return err |
| } |
| // api:response PackageSymbols |
| resp := PackageSymbols{ |
| ModulePath: um.ModulePath, |
| Version: um.Version, |
| Symbols: paged, |
| } |
| |
| return serveJSON(w, http.StatusOK, resp, versionCacheDur(params.Version)) |
| } |
| |
| // ServePackageImportedBy handles requests for the v1beta package imported-by endpoint. |
| // api:route /v1beta/imported-by/{path} |
| // api:desc Paths of packages importing the package at {path}, |
| // api:desc not including packages in the same module. |
| // api:desc Filtering is applied to the list of paths in the response. |
| // api:desc Only paths that match the filter query parameter are returned. |
| // api:desc Within a filter, the variable `path` is set to the import path. |
| // api:example /v1beta/imported-by/golang.org/x/time/rate?limit=10&filter=%5E.%2A%5C.io%2F |
| func ServePackageImportedBy(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) { |
| defer derrors.Wrap(&err, "ServePackageImportedBy") |
| |
| pkgPath := trimPath(r, "/v1beta/imported-by/") |
| if pkgPath == "" { |
| return BadRequest("missing package path", |
| "the package path must be provided after '/imported-by/'") |
| } |
| |
| // api:params ImportedByParams |
| var params ImportedByParams |
| if err := ParseParams(r.URL.Query(), ¶ms); err != nil { |
| return err |
| } |
| |
| requestedVersion := params.Version |
| if requestedVersion == "" { |
| requestedVersion = version.Latest |
| } |
| |
| um, err := resolveModulePath(r, ds, pkgPath, params.Module, requestedVersion) |
| if err != nil { |
| return err |
| } |
| modulePath := um.ModulePath |
| |
| limit := params.Limit |
| // If the user doesn't provide a limit, use a default. |
| if limit <= 0 { |
| limit = defaultLimit |
| } |
| // Cap the user-supplied limit so we don't do too much work. |
| if limit > maxLimit { |
| limit = maxLimit |
| } |
| |
| // Resolve start path from token |
| start := "" |
| if params.Token != "" { |
| var err error |
| start, err = decodeStringPageToken(params.Token) |
| if err != nil { |
| return BadRequest(fmt.Sprintf("invalid next-page token: %v", err), "try again from the beginning, with no token") |
| } |
| } |
| |
| // Fetch an extra item so we can tell if we're done. |
| importedBy, err := ds.GetImportedBy(r.Context(), pkgPath, modulePath, start, limit+1) |
| if err != nil { |
| return err |
| } |
| |
| nextToken := "" |
| if len(importedBy) > limit { |
| if len(importedBy) != limit+1 { |
| return InternalServerError("len(importedBy)=%d, expected %d", len(importedBy), limit+1) |
| } |
| |
| nextToken, err = encodeStringPageToken(importedBy[limit]) |
| if err != nil { |
| return err |
| } |
| importedBy = importedBy[:limit] |
| } |
| |
| count, err := ds.GetImportedByCount(r.Context(), pkgPath, modulePath) |
| if err != nil { |
| return err |
| } |
| |
| filtered, err := filterString(importedBy, params.Filter, "path") |
| if err != nil { |
| return err |
| } |
| // len(filtered) may be 0. That's fine: we document that zero-length |
| // pages are OK. |
| // The alternative is to fetch rows indefinitely, which means unbounded |
| // work. |
| |
| // api:response PackageImportedBy |
| resp := PackageImportedBy{ |
| ModulePath: modulePath, |
| Version: requestedVersion, |
| ImportedBy: PaginatedResponse[string]{ |
| Items: filtered, |
| Total: count, |
| NextPageToken: nextToken, |
| }, |
| } |
| |
| // The imported-by list is not immutable, because new modules are always being added. |
| return serveJSON(w, http.StatusOK, resp, shortCacheDur) |
| } |
| |
| // ServeVulnerabilities handles requests for the v1beta vulnerabilities endpoint. |
| // api:route /v1beta/vulns/{path} |
| // api:desc Vulnerabilities of the module or package at {path}, from |
| // api:desc the Go vulnerability database (https://vuln.go.dev). |
| // api:desc Only results that match the filter query parameter are returned. |
| // api:example /v1beta/vulns/golang.org/x/image |
| func ServeVulnerabilities(vc *vuln.Client) func(w http.ResponseWriter, r *http.Request, _ internal.DataSource) error { |
| return func(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) { |
| defer derrors.Wrap(&err, "ServeVulnerabilities") |
| |
| path := trimPath(r, "/v1beta/vulns/") |
| if path == "" { |
| return BadRequest("missing path", |
| "the package or module path must be provided after '/vulns/'") |
| } |
| |
| // api:params VulnParams |
| var params VulnParams |
| if err := ParseParams(r.URL.Query(), ¶ms); err != nil { |
| return err |
| } |
| |
| if vc == nil { |
| return InternalServerError("vulnerability client is nil") |
| } |
| |
| requestedVersion := params.Version |
| if requestedVersion == "" { |
| requestedVersion = version.Latest |
| } |
| |
| // Verify package or module existence and resolve containing module. |
| um, err := resolveModulePath(r, ds, path, params.Module, requestedVersion) |
| if err != nil { |
| return err |
| } |
| |
| var pkgPath string |
| if path != um.ModulePath { |
| pkgPath = path |
| } |
| |
| // Use VulnsForPackage from internal/vuln to get vulnerabilities. |
| // If pkgPath is non-empty, it filters vulnerabilities to only that package. |
| vulns := vuln.VulnsForPackage(r.Context(), um.ModulePath, um.Version, pkgPath, vc) |
| |
| vulns, err = filterStruct(vulns, params.Filter) |
| if err != nil { |
| return err |
| } |
| |
| var items []Vulnerability |
| for _, v := range vulns { |
| items = append(items, Vulnerability{ |
| ID: v.ID, |
| Details: v.Details, |
| }) |
| } |
| |
| // api:response PaginatedResponse[Vulnerability] |
| resp, err := paginate(items, params.ListParams, defaultLimit) |
| if err != nil { |
| return err |
| } |
| |
| return serveJSON(w, http.StatusOK, resp, versionCacheDur(requestedVersion)) |
| } |
| } |
| |
| func trimPath(r *http.Request, prefix string) string { |
| path := strings.TrimPrefix(r.URL.Path, prefix) |
| return strings.Trim(path, "/") |
| } |
| |
| // resolveModulePath determines the correct module path for a given package path and version. |
| // If the module path is not provided, it searches through potential candidate module paths |
| // derived from the package path. |
| // |
| // Resolution logic: |
| // 1. Use internal.CandidateModulePaths(pkgPath) to get potential candidates (ordered longest first). |
| // 2. Fetch UnitMeta for each candidate that exists in the data source. |
| // 3. Check if um.ModulePath == mp (where mp is the candidate module path). If not, ignore it |
| // (this handles the case where GetUnitMeta falls back to another module when the requested |
| // module does not exist). |
| // 4. Filter candidates by eliminating those that are deprecated or retracted. |
| // 5. If exactly one candidate remains after filtering, return it (HTTP 200). |
| // 6. If multiple candidates remain, return HTTP 400 with the list of candidates (ambiguity). |
| // 7. If all candidates are eliminated (e.g., all are deprecated or retracted), fall back to |
| // the longest matching candidate among those that exist (HTTP 200). |
| func resolveModulePath(r *http.Request, ds internal.DataSource, pkgPath, modulePath, requestedVersion string) (*internal.UnitMeta, error) { |
| if requestedVersion == "" { |
| requestedVersion = version.Latest |
| } |
| if modulePath != "" { |
| um, err := ds.GetUnitMeta(r.Context(), pkgPath, modulePath, requestedVersion) |
| if err != nil { |
| return nil, err |
| } |
| return um, nil |
| } |
| |
| candidates := internal.CandidateModulePaths(pkgPath) |
| var validCandidates []*internal.UnitMeta |
| for _, mp := range candidates { |
| if um, err := ds.GetUnitMeta(r.Context(), pkgPath, mp, requestedVersion); err == nil { |
| // Critical check: ensure the DB actually found the candidate module we requested. |
| // GetUnitMeta falls back to the best match if the requested module doesn't exist, |
| // which could lead to false positives (e.g. google.golang.org matching because it |
| // falls back to google.golang.org/adk/agent). |
| if um.ModulePath == mp { |
| validCandidates = append(validCandidates, um) |
| } |
| } else if !errors.Is(err, derrors.NotFound) { |
| return nil, err |
| } |
| } |
| |
| if len(validCandidates) == 0 { |
| return nil, derrors.NotFound |
| } |
| |
| // Filter candidates based on signals (deprecation, retraction). |
| goodCandidates := slices.Clone(validCandidates) |
| goodCandidates = slices.DeleteFunc(goodCandidates, func(um *internal.UnitMeta) bool { |
| return um.Deprecated || um.Retracted |
| }) |
| |
| switch len(goodCandidates) { |
| case 1: |
| return goodCandidates[0], nil |
| case 0: |
| // If all candidates are deprecated or retracted, fall back to the longest match. |
| // Since candidates are ordered longest first, validCandidates[0] is the longest match. |
| return validCandidates[0], nil |
| default: |
| return nil, &Error{ |
| Code: http.StatusBadRequest, |
| Message: "ambiguous package path", |
| Fixes: []string{"retry the call with a 'module' query parameter specifying the desired module"}, |
| Candidates: makeCandidates(goodCandidates), |
| } |
| } |
| } |
| |
| func makeCandidates(ums []*internal.UnitMeta) []Candidate { |
| var r []Candidate |
| for _, um := range ums { |
| r = append(r, Candidate{ |
| ModulePath: um.ModulePath, |
| PackagePath: um.Path, |
| }) |
| } |
| return r |
| } |
| |
| // Values for the Cache-Control header. |
| // Compare with the TTLs for pkgsite's own cache, in internal/frontend/server.go |
| // (look for symbols ending in "TTL"). |
| // Those values are shorter to manage our cache's memory, but the job of |
| // Cache-Control is to reduce network traffic; downstream caches can manage |
| // their own memory. |
| const ( |
| // Immutable pages can theoretically, be cached indefinitely, |
| // but have them time out so that excluded modules don't |
| // live in caches forever. |
| longCacheDur = 3 * time.Hour |
| // The information on some pages can change relatively quickly. |
| shortCacheDur = 1 * time.Hour |
| // Errors should not be cached. |
| noCache = time.Duration(0) |
| ) |
| |
| func serveJSON(w http.ResponseWriter, status int, data any, cacheDur time.Duration) error { |
| var buf bytes.Buffer |
| if err := json.NewEncoder(&buf).Encode(data); err != nil { |
| return err |
| } |
| w.Header().Set("Content-Type", "application/json") |
| var ccHeader string |
| if cacheDur == 0 { |
| ccHeader = "no-store" |
| } else { |
| ccHeader = fmt.Sprintf("public, max-age=%d", int(cacheDur.Seconds())) |
| } |
| w.Header().Set("Cache-Control", ccHeader) |
| w.WriteHeader(status) |
| _, err := w.Write(buf.Bytes()) |
| return err |
| } |
| |
| func ServeError(w http.ResponseWriter, r *http.Request, err error) error { |
| var aerr *Error |
| if !errors.As(err, &aerr) { |
| status := derrors.ToStatus(err) |
| aerr = &Error{ |
| Code: status, |
| Message: strings.ToLower(http.StatusText(status)), |
| err: err, |
| } |
| } |
| log.Errorf(r.Context(), "API error %d: %v", aerr.Code, aerr) |
| return serveJSON(w, aerr.Code, aerr, noCache) |
| } |
| |
| // paginate returns a paginated response for the given list of items and pagination parameters. |
| // It uses offset-based pagination with a token that encodes the offset. |
| // The default limit is used if the provided limit is non-positive. |
| func paginate[T any](all []T, lp ListParams, defaultLimit int) (PaginatedResponse[T], error) { |
| limit, offset, err := lp.pageParams(defaultLimit) |
| if err != nil { |
| return PaginatedResponse[T]{}, fmt.Errorf("%w: %s", derrors.InvalidArgument, err) |
| } |
| |
| offset = min(offset, len(all)) |
| end := min(offset+limit, len(all)) |
| |
| var nextToken string |
| if end < len(all) { |
| var err error |
| nextToken, err = encodePageToken(end) |
| if err != nil { |
| return PaginatedResponse[T]{}, fmt.Errorf("encoding token: %w", err) |
| } |
| } |
| |
| return PaginatedResponse[T]{ |
| Items: all[offset:end], |
| Total: len(all), |
| NextPageToken: nextToken, |
| }, nil |
| } |
| |
| // unitToPackage processes unit documentation into a Package struct. |
| func unitToPackage(unit *internal.Unit, params PackageParams) (*Package, error) { |
| // Although unit.Documentation is a slice, it will |
| // have at most one item, the documentation matching |
| // the build context. |
| synopsis := "" |
| var docs string |
| goos := params.GOOS |
| goarch := params.GOARCH |
| if len(unit.Documentation) > 0 { |
| d := unit.Documentation[0] |
| synopsis = d.Synopsis |
| // Return the more precise GOOS/GOARCH. |
| // If the user didn't provide them, use the unit's. |
| // If the user did, assume what they provided is at |
| // least as specific as the unit's, and use it. |
| if goos == "" { |
| goos = d.GOOS |
| } |
| if goarch == "" { |
| goarch = d.GOARCH |
| } |
| if params.Doc != "" { |
| var err error |
| docs, err = renderDocumentation(unit, d, params.Doc, params.Examples) |
| if err != nil { |
| return nil, err |
| } |
| } |
| } |
| |
| var licenses []License |
| for _, l := range unit.LicenseContents { |
| licenses = append(licenses, License{ |
| Types: l.Metadata.Types, |
| FilePath: l.Metadata.FilePath, |
| Contents: string(l.Contents), |
| }) |
| } |
| |
| return &Package{ |
| ModulePath: unit.ModulePath, |
| Version: unit.Version, |
| IsStandardLibrary: stdlib.Contains(unit.ModulePath), |
| IsLatest: unit.Version == unit.LatestVersion, |
| GOOS: goos, |
| GOARCH: goarch, |
| Docs: docs, |
| Imports: unit.Imports, |
| Licenses: licenses, |
| PackageInfo: PackageInfo{ |
| Path: unit.Path, |
| Name: unit.Name, |
| IsRedistributable: unit.IsRedistributable, |
| Synopsis: synopsis, |
| }, |
| }, nil |
| } |
| |
| // renderDocumentation renders the provided unit into the specified format. |
| func renderDocumentation(unit *internal.Unit, d *internal.Documentation, format string, examples bool) (string, error) { |
| // d.Source is an encoded AST. Decode it, then use |
| // go/doc (not pkgsite's renderer) to generate the |
| // result. |
| gpkg, err := godoc.DecodePackage(d.Source) |
| if err != nil { |
| return "", fmt.Errorf("renderDocumentation: %w", err) |
| } |
| innerPath := internal.Suffix(unit.Path, unit.ModulePath) |
| modInfo := &godoc.ModuleInfo{ModulePath: unit.ModulePath, ResolvedVersion: unit.Version} |
| dpkg, err := gpkg.DocPackage(innerPath, modInfo) |
| if err != nil { |
| return "", err |
| } |
| var r renderer |
| var sb strings.Builder |
| switch format { |
| case "text": |
| r = newTextRenderer(gpkg.Fset, &sb) |
| case "md", "markdown": |
| r = newMarkdownRenderer(gpkg.Fset, &sb) |
| case "html": |
| r = newHTMLRenderer(gpkg.Fset, &sb) |
| default: |
| // ServePackage needs to be updated when the doc set changes. |
| return "", BadRequest("bad doc format: need one of 'text', 'md', 'markdown' or 'html'") |
| } |
| if err := renderDoc(dpkg, r, examples); err != nil { |
| return "", fmt.Errorf("renderDoc: %w", err) |
| } |
| return sb.String(), nil |
| } |
| |
| // versionCacheDur returns the duration used in the Cache-Control header |
| // appropriate for the given module version. |
| func versionCacheDur(v string) time.Duration { |
| immutable := !(v == "" || v == version.Latest || internal.DefaultBranches[v] || stdlib.SupportedBranches[v]) |
| if immutable { |
| return longCacheDur |
| } |
| return shortCacheDur |
| } |
| |
| // filter returns a new slice containing all elements in list which match |
| // the expression denoted by filter. |
| // It sets each element to varName before evaluating the filter. |
| func filterString(list []string, filter, varName string) ([]string, error) { |
| if varName == "" { |
| return nil, errors.New("string filter must have varName") |
| } |
| if filter == "" { |
| return list, nil |
| } |
| return filterInternal(list, filter, nil, varName) |
| } |
| |
| // filter returns a new slice containing all elements in list which match |
| // the expression denoted by filter. |
| func filterStruct[T any](list []T, filter string) ([]T, error) { |
| if filter == "" { |
| return list, nil |
| } |
| t := reflect.TypeFor[T]() |
| for t.Kind() == reflect.Pointer { |
| t = t.Elem() |
| } |
| if t.Kind() != reflect.Struct { |
| return nil, fmt.Errorf("bad type %s for filter: need struct or pointer to struct", t) |
| } |
| return filterInternal(list, filter, jsonFields(t), "") |
| } |
| |
| func filterInternal[T any](list []T, filter string, jfields fieldMap, varName string) ([]T, error) { |
| expr, err := parser.ParseExpr(filter) |
| if err != nil { |
| return nil, BadRequest(fmt.Sprintf(`parsing filter "%s": %v`, |
| filter, err), |
| "the 'filter' query parameter must be a valid Go expression; see the documentation at /v1beta/api", |
| ) |
| } |
| var out []T |
| for _, e := range list { |
| env := maps.Clone(defaultEnv) |
| if jfields == nil { |
| env[varName] = e |
| } else { |
| tv := reflect.ValueOf(e) |
| if !tv.IsValid() { |
| continue |
| } |
| for tv.Kind() == reflect.Pointer { |
| tv = tv.Elem() |
| } |
| for name, field := range jfields { |
| env[name] = tv.FieldByIndex(field.Index).Interface() |
| } |
| } |
| res, err := evaluate(expr, env) |
| if err != nil { |
| return nil, BadRequest(fmt.Sprintf(`evaluating filter "%s": %v`, filter, err), |
| "the filter must be a Go expression; see the documentation at /v1beta/api") |
| } |
| b, ok := res.(bool) |
| if !ok { |
| return nil, BadRequest(fmt.Sprintf(`filter "%s" did not evaluate to bool`, filter), |
| "the filter must be a boolean Go expression; see the documentation at /v1beta/api") |
| } |
| if b { |
| out = append(out, e) |
| } |
| } |
| return out, nil |
| } |
| |
| // checkModulePath verifies that the requested module path exactly matches the resolved |
| // module path. If it is a package path instead, it returns a BadRequest error with |
| // containing module suggestions. |
| func checkModulePath(requested, resolved string) error { |
| if requested != resolved { |
| return &Error{ |
| Code: http.StatusBadRequest, |
| Message: fmt.Sprintf("%s is a package, not a module", requested), |
| Fixes: []string{fmt.Sprintf("retry the call with the containing module: %q", resolved)}, |
| } |
| } |
| return nil |
| } |