cmd/pkgserver: embed internal/rosa metadata

This change also cleans up and reduces some unnecessary copies.

Signed-off-by: Ophestra <cat@gensokyo.uk>
This commit is contained in:
2026-03-11 01:36:54 +09:00
parent fa9bc70b39
commit 887edcbe48
5 changed files with 350 additions and 101 deletions

View File

@@ -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 {
// 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
)
func NewInfoPayload(index *PackageIndex) InfoPayload {
count := len(index.sorts[0])
return InfoPayload{
Count: count,
HakureiVersion: info.Version(),
}
}
func serveInfo(index *PackageIndex) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// 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)
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))
}
// TODO(mae): cache entire response if no additional fields are planned
WritePayload(w, infoPayload)
}
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) {

183
cmd/pkgserver/api_test.go Normal file
View File

@@ -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),
},
})
}

View File

@@ -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"`
// 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 unique.Handle[pkg.ID] `json:"-"`
status []byte `json:"-"`
// 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
}

View File

@@ -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

View File

@@ -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)
}
}