forked from security/hakurei
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:
@@ -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
183
cmd/pkgserver/api_test.go
Normal 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
82
cmd/pkgserver/main_test.go
Normal file
82
cmd/pkgserver/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user