From 887edcbe48b466451ae7ddbfbd1abfb8bcf6668a Mon Sep 17 00:00:00 2001 From: Ophestra Date: Wed, 11 Mar 2026 01:36:54 +0900 Subject: [PATCH] cmd/pkgserver: embed internal/rosa metadata This change also cleans up and reduces some unnecessary copies. Signed-off-by: Ophestra --- cmd/pkgserver/api.go | 76 +++++++-------- cmd/pkgserver/api_test.go | 183 +++++++++++++++++++++++++++++++++++++ cmd/pkgserver/index.go | 102 ++++++++++----------- cmd/pkgserver/main.go | 8 +- cmd/pkgserver/main_test.go | 82 +++++++++++++++++ 5 files changed, 350 insertions(+), 101 deletions(-) create mode 100644 cmd/pkgserver/api_test.go create mode 100644 cmd/pkgserver/main_test.go diff --git a/cmd/pkgserver/api.go b/cmd/pkgserver/api.go index a7c0442..810c99e 100644 --- a/cmd/pkgserver/api.go +++ b/cmd/pkgserver/api.go @@ -8,35 +8,36 @@ import ( "net/http" "path" "strconv" + "sync" "hakurei.app/internal/info" "hakurei.app/internal/pkg" "hakurei.app/internal/rosa" ) -type InfoPayload struct { - Count int `json:"count"` - HakureiVersion string `json:"hakurei_version"` -} - -func NewInfoPayload(index *PackageIndex) InfoPayload { - count := len(index.sorts[0]) - return InfoPayload{ - Count: count, - HakureiVersion: info.Version(), +// for lazy initialisation of serveInfo +var ( + infoPayload struct { + // Current package count. + Count int `json:"count"` + // Hakurei version, set at link time. + HakureiVersion string `json:"hakurei_version"` } + infoPayloadOnce sync.Once +) + +// serveInfo returns constant system information. +func serveInfo(w http.ResponseWriter, _ *http.Request) { + infoPayloadOnce.Do(func() { + infoPayload.Count = int(rosa.PresetUnexportedStart) + infoPayload.HakureiVersion = info.Version() + }) + w.WriteHeader(http.StatusOK) + // TODO(mae): cache entire response if no additional fields are planned + WritePayload(w, infoPayload) } -func serveInfo(index *PackageIndex) func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Header().Set("Content-Type", "text/json; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - WritePayload(w, NewInfoPayload(index)) - } -} - -func serveStatus(index *PackageIndex) func(w http.ResponseWriter, r *http.Request) { +func (index *packageIndex) serveStatus() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { download := path.Dir(r.URL.Path) == "/status" if index == nil { @@ -83,24 +84,7 @@ func serveStatus(index *PackageIndex) func(w http.ResponseWriter, r *http.Reques } } -type GetPayload struct { - Count int `json:"count"` - Values []PackageIndexEntry `json:"values"` -} - -func NewGetPayload(values []*PackageIndexEntry) GetPayload { - count := len(values) - v := make([]PackageIndexEntry, count) - for i, _ := range values { - v[i] = *values[i] - } - return GetPayload{ - Count: count, - Values: v, - } -} - -func serveGet(index *PackageIndex) func(http.ResponseWriter, *http.Request) { +func (index *packageIndex) serveGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit, err := strconv.Atoi(q.Get("limit")) @@ -119,17 +103,21 @@ func serveGet(index *PackageIndex) func(http.ResponseWriter, *http.Request) { return } values := index.sorts[sort][i:min(i+limit, len(index.sorts[sort]))] - WritePayload(w, NewGetPayload(values)) + // TODO(mae): remove count field + WritePayload(w, &struct { + Count int `json:"count"` + Values []*metadata `json:"values"` + }{len(values), values}) } } const ApiVersion = "v1" -func apiRoutes(mux *http.ServeMux, index *PackageIndex) { - mux.HandleFunc(fmt.Sprintf("GET /api/%s/info", ApiVersion), serveInfo(index)) - mux.HandleFunc(fmt.Sprintf("GET /api/%s/get", ApiVersion), serveGet(index)) - mux.HandleFunc(fmt.Sprintf("GET /api/%s/status/", ApiVersion), serveStatus(index)) - mux.HandleFunc("GET /status/", serveStatus(index)) +func apiRoutes(mux *http.ServeMux, index *packageIndex) { + mux.HandleFunc(fmt.Sprintf("GET /api/%s/info", ApiVersion), serveInfo) + mux.HandleFunc(fmt.Sprintf("GET /api/%s/get", ApiVersion), index.serveGet()) + mux.HandleFunc(fmt.Sprintf("GET /api/%s/status/", ApiVersion), index.serveStatus()) + mux.HandleFunc("GET /status/", index.serveStatus()) } func WritePayload(w http.ResponseWriter, payload any) { diff --git a/cmd/pkgserver/api_test.go b/cmd/pkgserver/api_test.go new file mode 100644 index 0000000..8728e17 --- /dev/null +++ b/cmd/pkgserver/api_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "slices" + "strconv" + "testing" + + "hakurei.app/internal/info" + "hakurei.app/internal/rosa" +) + +// prefix is prepended to every API path. +const prefix = "/api/" + ApiVersion + "/" + +func TestAPIInfo(t *testing.T) { + t.Parallel() + + w := httptest.NewRecorder() + serveInfo(w, httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + prefix+"info", + nil, + )) + + resp := w.Result() + checkStatus(t, resp, http.StatusOK) + checkAPIHeader(t, w.Header()) + + checkPayload(t, resp, struct { + Count int `json:"count"` + HakureiVersion string `json:"hakurei_version"` + }{int(rosa.PresetUnexportedStart), info.Version()}) +} + +func TestAPIGet(t *testing.T) { + t.Parallel() + const target = prefix + "get" + + index := newIndex(t) + newRequest := func(suffix string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + index.serveGet()(w, httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + target+suffix, + nil, + )) + return w + } + + checkValidate := func(t *testing.T, suffix string, vmin, vmax int, wantErr string) { + t.Run("invalid", func(t *testing.T) { + t.Parallel() + + w := newRequest("?" + suffix + "=invalid") + resp := w.Result() + checkError(t, resp, wantErr, http.StatusBadRequest) + }) + + t.Run("min", func(t *testing.T) { + t.Parallel() + + w := newRequest("?" + suffix + "=" + strconv.Itoa(vmin-1)) + resp := w.Result() + checkError(t, resp, wantErr, http.StatusBadRequest) + + w = newRequest("?" + suffix + "=" + strconv.Itoa(vmin)) + resp = w.Result() + checkStatus(t, resp, http.StatusOK) + }) + + t.Run("max", func(t *testing.T) { + t.Parallel() + + w := newRequest("?" + suffix + "=" + strconv.Itoa(vmax+1)) + resp := w.Result() + checkError(t, resp, wantErr, http.StatusBadRequest) + + w = newRequest("?" + suffix + "=" + strconv.Itoa(vmax)) + resp = w.Result() + checkStatus(t, resp, http.StatusOK) + }) + } + + t.Run("limit", func(t *testing.T) { + t.Parallel() + checkValidate( + t, "index=0&sort=0&limit", 1, 100, + "limit must be an integer between 1 and 100", + ) + }) + + t.Run("index", func(t *testing.T) { + t.Parallel() + checkValidate( + t, "limit=1&sort=0&index", 0, int(rosa.PresetUnexportedStart-1), + "index must be an integer between 0 and "+strconv.Itoa(int(rosa.PresetUnexportedStart-1)), + ) + }) + + t.Run("sort", func(t *testing.T) { + t.Parallel() + checkValidate( + t, "index=0&limit=1&sort", 0, int(sortOrderEnd), + "sort must be an integer between 0 and "+strconv.Itoa(int(sortOrderEnd)), + ) + }) + + checkWithSuffix := func(name, suffix string, want []*metadata) { + t.Run(name, func(t *testing.T) { + t.Parallel() + + w := newRequest(suffix) + resp := w.Result() + checkStatus(t, resp, http.StatusOK) + checkAPIHeader(t, w.Header()) + checkPayloadFunc(t, resp, func(got *struct { + Count int `json:"count"` + Values []*metadata `json:"values"` + }) bool { + return got.Count == len(want) && + slices.EqualFunc(got.Values, want, func(a, b *metadata) bool { + return (a.Version == b.Version || + a.Version == rosa.Unversioned || + b.Version == rosa.Unversioned) && + a.HasReport == b.HasReport && + a.Name == b.Name && + a.Description == b.Description && + a.Website == b.Website + }) + }) + + }) + } + + checkWithSuffix("declarationAscending", "?limit=2&index=0&sort=0", []*metadata{ + { + Metadata: rosa.GetMetadata(0), + Version: rosa.Std.Version(0), + }, + { + Metadata: rosa.GetMetadata(1), + Version: rosa.Std.Version(1), + }, + }) + checkWithSuffix("declarationAscending offset", "?limit=3&index=5&sort=0", []*metadata{ + { + Metadata: rosa.GetMetadata(5), + Version: rosa.Std.Version(5), + }, + { + Metadata: rosa.GetMetadata(6), + Version: rosa.Std.Version(6), + }, + { + Metadata: rosa.GetMetadata(7), + Version: rosa.Std.Version(7), + }, + }) + checkWithSuffix("declarationDescending", "?limit=3&index=0&sort=1", []*metadata{ + { + Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 1), + Version: rosa.Std.Version(rosa.PresetUnexportedStart - 1), + }, + { + Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 2), + Version: rosa.Std.Version(rosa.PresetUnexportedStart - 2), + }, + { + Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 3), + Version: rosa.Std.Version(rosa.PresetUnexportedStart - 3), + }, + }) + checkWithSuffix("declarationDescending offset", "?limit=1&index=37&sort=1", []*metadata{ + { + Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 38), + Version: rosa.Std.Version(rosa.PresetUnexportedStart - 38), + }, + }) +} diff --git a/cmd/pkgserver/index.go b/cmd/pkgserver/index.go index b24f3fa..9ba151f 100644 --- a/cmd/pkgserver/index.go +++ b/cmd/pkgserver/index.go @@ -1,86 +1,82 @@ package main import ( - "cmp" "slices" + "strings" "unique" "hakurei.app/internal/pkg" "hakurei.app/internal/rosa" ) -type SortOrders int - const ( - DeclarationAscending SortOrders = iota - DeclarationDescending - NameAscending - NameDescending - limitSortOrders + declarationAscending = iota + declarationDescending + nameAscending + nameDescending + + sortOrderEnd = iota - 1 ) -type PackageIndex struct { - sorts [limitSortOrders][rosa.PresetUnexportedStart]*PackageIndexEntry - names map[string]*PackageIndexEntry +// packageIndex refers to metadata by name and various sort orders. +type packageIndex struct { + sorts [sortOrderEnd + 1][rosa.PresetUnexportedStart]*metadata + names map[string]*metadata } -type PackageIndexEntry struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Website string `json:"website,omitempty"` - Version string `json:"version"` - HasReport bool `json:"report,omitempty"` - ident unique.Handle[pkg.ID] `json:"-"` - status []byte `json:"-"` +// metadata holds [rosa.Metadata] extended with additional information. +type metadata struct { + *rosa.Metadata + + // Populated via [rosa.Toolchain.Version], [rosa.Unversioned] is equivalent + // to the zero value. Otherwise, the zero value is invalid. + Version string `json:"version,omitempty"` + // Whether the underlying [pkg.Artifact] is present in the report. + HasReport bool `json:"report,omitempty"` + + // Ident resolved from underlying [pkg.Artifact]. + ident unique.Handle[pkg.ID] + // Backed by [rosa.Report], access must be prepared by HandleAccess. + status []byte } -func createPackageIndex(cache *pkg.Cache, report *rosa.Report) (index *PackageIndex, err error) { - index = new(PackageIndex) - index.names = make(map[string]*PackageIndexEntry, rosa.PresetUnexportedStart) - work := make([]PackageIndexEntry, rosa.PresetUnexportedStart) +// populate deterministically populates packageIndex, optionally with a report. +func (index *packageIndex) populate(cache *pkg.Cache, report *rosa.Report) (err error) { if report != nil { defer report.HandleAccess(&err)() } + var work [rosa.PresetUnexportedStart]*metadata + index.names = make(map[string]*metadata) for p := range rosa.PresetUnexportedStart { - m := rosa.GetMetadata(p) - v := rosa.Std.Version(p) - a := rosa.Std.Load(p) - entry := PackageIndexEntry{ - Name: m.Name, - Description: m.Description, - Website: m.Website, - Version: v, + m := metadata{ + Metadata: rosa.GetMetadata(p), + Version: rosa.Std.Version(p), } if cache != nil && report != nil { - entry.ident = cache.Ident(a) - status, n := report.ArtifactOf(entry.ident) + m.ident = cache.Ident(rosa.Std.Load(p)) + status, n := report.ArtifactOf(m.ident) if n >= 0 { - entry.HasReport = true - entry.status = status + m.HasReport = true + m.status = status } } - work[p] = entry - index.names[m.Name] = &entry + work[p] = &m + index.names[m.Name] = &m } - for i, p := range work { - index.sorts[DeclarationAscending][i] = &p - } - slices.Reverse(work) - for i, p := range work { - index.sorts[DeclarationDescending][i] = &p - } - slices.SortFunc(work, func(a PackageIndexEntry, b PackageIndexEntry) int { - return cmp.Compare(a.Name, b.Name) + + index.sorts[declarationAscending] = work + index.sorts[declarationDescending] = work + slices.Reverse(index.sorts[declarationDescending][:]) + + index.sorts[nameAscending] = work + slices.SortFunc(index.sorts[nameAscending][:], func(a, b *metadata) int { + return strings.Compare(a.Name, b.Name) }) - for i, p := range work { - index.sorts[NameAscending][i] = &p - } - slices.Reverse(work) - for i, p := range work { - index.sorts[NameDescending][i] = &p - } + index.sorts[nameDescending] = index.sorts[nameAscending] + slices.Reverse(index.sorts[nameDescending][:]) + return } diff --git a/cmd/pkgserver/main.go b/cmd/pkgserver/main.go index f95fb24..0c9faa7 100644 --- a/cmd/pkgserver/main.go +++ b/cmd/pkgserver/main.go @@ -37,20 +37,20 @@ func main() { return err } cache, err := pkg.Open(ctx, msg, 0, baseDir) - defer cache.Close() if err != nil { return err } + defer cache.Close() report, err := rosa.OpenReport(reportPath) if err != nil { return err } - index, err := createPackageIndex(cache, report) - if err != nil { + var index packageIndex + if err = index.populate(cache, report); err != nil { return err } uiRoutes(http.DefaultServeMux) - apiRoutes(http.DefaultServeMux, index) + apiRoutes(http.DefaultServeMux, &index) err = http.ListenAndServe(fmt.Sprintf(":%d", flagPort), nil) if err != nil { return err diff --git a/cmd/pkgserver/main_test.go b/cmd/pkgserver/main_test.go new file mode 100644 index 0000000..e4a6b4a --- /dev/null +++ b/cmd/pkgserver/main_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "testing" +) + +// newIndex returns the address of a newly populated packageIndex. +func newIndex(t *testing.T) *packageIndex { + var index packageIndex + if err := index.populate(nil, nil); err != nil { + t.Fatalf("populate: error = %v", err) + } + return &index +} + +// checkStatus checks response status code. +func checkStatus(t *testing.T, resp *http.Response, want int) { + if resp.StatusCode != want { + t.Errorf( + "StatusCode: %s, want %s", + http.StatusText(resp.StatusCode), + http.StatusText(want), + ) + } +} + +// checkHeader checks the value of a header entry. +func checkHeader(t *testing.T, h http.Header, key, want string) { + if got := h.Get(key); got != want { + t.Errorf("%s: %q, want %q", key, got, want) + } +} + +// checkAPIHeader checks common entries set for API endpoints. +func checkAPIHeader(t *testing.T, h http.Header) { + checkHeader(t, h, "Content-Type", "application/json; charset=utf-8") + checkHeader(t, h, "Cache-Control", "no-cache, no-store, must-revalidate") + checkHeader(t, h, "Pragma", "no-cache") + checkHeader(t, h, "Expires", "0") +} + +// checkPayloadFunc checks the JSON response of an API endpoint by passing it to f. +func checkPayloadFunc[T any]( + t *testing.T, + resp *http.Response, + f func(got *T) bool, +) { + var got T + r := io.Reader(resp.Body) + if testing.Verbose() { + var buf bytes.Buffer + r = io.TeeReader(r, &buf) + defer func() { t.Log(buf.String()) }() + } + if err := json.NewDecoder(r).Decode(&got); err != nil { + t.Fatalf("Decode: error = %v", err) + } + + if !f(&got) { + t.Errorf("Body: %#v", got) + } +} + +// checkPayload checks the JSON response of an API endpoint. +func checkPayload[T any](t *testing.T, resp *http.Response, want T) { + checkPayloadFunc(t, resp, func(got *T) bool { + return reflect.DeepEqual(got, &want) + }) +} + +func checkError(t *testing.T, resp *http.Response, error string, code int) { + checkStatus(t, resp, code) + if got, _ := io.ReadAll(resp.Body); string(got) != fmt.Sprintln(error) { + t.Errorf("Body: %q, want %q", string(got), error) + } +}