diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/api.go | 67 | ||||
| -rw-r--r-- | internal/api/api_test.go | 55 |
2 files changed, 110 insertions, 12 deletions
diff --git a/internal/api/api.go b/internal/api/api.go index 2d4d80b5..daf3ffdb 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -12,6 +12,7 @@ import ( "net/http" "strconv" "strings" + "time" "golang.org/x/pkgsite/internal" "golang.org/x/pkgsite/internal/derrors" @@ -69,7 +70,7 @@ func ServePackage(w http.ResponseWriter, r *http.Request, ds internal.DataSource return err } - return serveJSON(w, http.StatusOK, resp) + return serveJSON(w, http.StatusOK, resp, versionCacheDur(params.Version)) } // ServeModule handles requests for the v1 module metadata endpoint. @@ -90,6 +91,8 @@ func ServeModule(w http.ResponseWriter, r *http.Request, ds internal.DataSource) 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, modulePath, requestedVersion) @@ -111,7 +114,7 @@ func ServeModule(w http.ResponseWriter, r *http.Request, ds internal.DataSource) } if !params.Readme && !params.Licenses { - return serveJSON(w, http.StatusOK, resp) + return serveJSON(w, http.StatusOK, resp, cacheDur) } fs := internal.MinimalFields @@ -123,7 +126,7 @@ func ServeModule(w http.ResponseWriter, r *http.Request, ds internal.DataSource) } unit, err := ds.GetUnit(r.Context(), um, fs, internal.BuildContext{}) if err != nil { - return serveJSON(w, http.StatusOK, resp) + return serveJSON(w, http.StatusOK, resp, cacheDur) } if params.Readme && unit.Readme != nil { @@ -142,7 +145,7 @@ func ServeModule(w http.ResponseWriter, r *http.Request, ds internal.DataSource) } } - return serveJSON(w, http.StatusOK, resp) + return serveJSON(w, http.StatusOK, resp, cacheDur) } // ServeModuleVersions handles requests for the v1 module versions endpoint. @@ -179,7 +182,8 @@ func ServeModuleVersions(w http.ResponseWriter, r *http.Request, ds internal.Dat return err } - return serveJSON(w, http.StatusOK, resp) + // 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 v1 module packages endpoint. @@ -225,7 +229,7 @@ func ServeModulePackages(w http.ResponseWriter, r *http.Request, ds internal.Dat return err } - return serveJSON(w, http.StatusOK, resp) + return serveJSON(w, http.StatusOK, resp, versionCacheDur(requestedVersion)) } // ServeSearch handles requests for the v1 search endpoint. @@ -270,7 +274,11 @@ func ServeSearch(w http.ResponseWriter, r *http.Request, ds internal.DataSource) return fmt.Errorf("%w: %s", derrors.InvalidArgument, err.Error()) } - return serveJSON(w, http.StatusOK, resp) + // 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 v1 package symbols endpoint. @@ -318,7 +326,7 @@ func ServePackageSymbols(w http.ResponseWriter, r *http.Request, ds internal.Dat return err } - return serveJSON(w, http.StatusOK, resp) + return serveJSON(w, http.StatusOK, resp, versionCacheDur(params.Version)) } // ServePackageImportedBy handles requests for the v1 package imported-by endpoint. @@ -381,7 +389,8 @@ func ServePackageImportedBy(w http.ResponseWriter, r *http.Request, ds internal. }, } - return serveJSON(w, http.StatusOK, resp) + // 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 v1 module vulnerabilities endpoint. @@ -428,7 +437,7 @@ func ServeVulnerabilities(vc *vuln.Client) func(w http.ResponseWriter, r *http.R return err } - return serveJSON(w, http.StatusOK, resp) + return serveJSON(w, http.StatusOK, resp, versionCacheDur(requestedVersion)) } } @@ -483,12 +492,36 @@ func resolveModulePath(r *http.Request, ds internal.DataSource, pkgPath, moduleP return um, nil } -func serveJSON(w http.ResponseWriter, status int, data any) error { +// 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 @@ -503,7 +536,7 @@ func ServeError(w http.ResponseWriter, err error) error { Message: err.Error(), } } - return serveJSON(w, aerr.Code, aerr) + return serveJSON(w, aerr.Code, aerr, noCache) } // paginate returns a paginated response for the given list of items and pagination parameters. @@ -632,3 +665,13 @@ func renderDocumentation(unit *internal.Unit, d *internal.Documentation, format } 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 +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 5d5c9c03..5de0ab6e 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1053,3 +1053,58 @@ func unmarshalResponse[T any](data []byte) (any, error) { } return nil, errors.Join(err1, err2) } + +func TestCacheControl(t *testing.T) { + ctx := context.Background() + ds := fakedatasource.New() + const modulePath = "example.com" + for _, v := range []string{"v1.0.0", "master"} { + ds.MustInsertModule(ctx, &internal.Module{ + ModuleInfo: internal.ModuleInfo{ + ModulePath: modulePath, + Version: v, + }, + Units: []*internal.Unit{{ + UnitMeta: internal.UnitMeta{ + Path: modulePath, + ModuleInfo: internal.ModuleInfo{ + ModulePath: modulePath, + Version: v, + }, + }, + }}, + }) + } + + for _, test := range []struct { + version string + want string + }{ + {"v1.0.0", "public, max-age=10800"}, + {"latest", "public, max-age=3600"}, + {"master", "public, max-age=3600"}, + {"", "public, max-age=3600"}, + } { + t.Run(test.version, func(t *testing.T) { + url := "/v1/module/" + modulePath + if test.version != "" { + url += "?version=" + test.version + } + r := httptest.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + + if err := ServeModule(w, r, ds); err != nil { + t.Fatal(err) + } + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + got := w.Header().Get("Cache-Control") + if got != test.want { + t.Errorf("Cache-Control = %q, want %q", got, test.want) + } + }) + } +} |
