Generate the OpenAPI spec with Huma instead of swag
The spec is now built from the same calls that register the routes, so it
can't drift from the router like the swag comments did (all /api/v1
routes were missing).
- /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at
/openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs.
/openapi and /swagger/* redirect there
- Bearer token security is declared per operation; token creation and
the new GET /api/v1/health are public
- Legacy routes use the same handlers, are hidden from the spec and keep
the Deprecation header
- /sync/reset is hidden (debug only); the /sync/new, /sync/quick and
/sync/new/full aliases are removed (use /sync and /sync/full)
- Invalid parameters return 422 (validation); every error is
{"error": "..."}; empty lists are [] instead of null
- /sync/progress is documented as progress or result (oneOf)
- Tests validate the spec and check real responses of every documented
operation against it (kin-openapi)
- Removed swag annotations, cmd/docs, echo-swagger and the swag tool
This commit is contained in:
1 parent
0ec2425f78
commit
973ebf224b
37 files changed
+1245
-5967
No files matched your search
@@ -0,0 +1,260 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/config"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/getkin/kin-openapi/routers"
|
||||
"github.com/getkin/kin-openapi/routers/gorillamux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// loadSpec loads the OpenAPI 3.0 version of the spec the server publishes.
|
||||
func loadSpec(t *testing.T, e *testEnv) *openapi3.T {
|
||||
t.Helper()
|
||||
rec := e.do(http.MethodGet, "/openapi-3.0.json")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
spec, err := openapi3.NewLoader().LoadFromData(rec.Body.Bytes())
|
||||
require.NoError(t, err)
|
||||
return spec
|
||||
}
|
||||
|
||||
func TestOpenAPISpecIsValid(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
spec := loadSpec(t, e)
|
||||
require.NoError(t, spec.Validate(context.Background()))
|
||||
|
||||
// The 3.1 spec is valid JSON with the same paths.
|
||||
var spec31 struct {
|
||||
OpenAPI string `json:"openapi"`
|
||||
Paths map[string]any `json:"paths"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/openapi.json").Body.Bytes(), &spec31))
|
||||
assert.Equal(t, "3.1.0", spec31.OpenAPI)
|
||||
assert.Len(t, spec31.Paths, spec.Paths.Len())
|
||||
}
|
||||
|
||||
func TestOpenAPIDocumentsTheV1API(t *testing.T) {
|
||||
spec := loadSpec(t, newTestEnv(t))
|
||||
|
||||
var operations []string
|
||||
for path, item := range spec.Paths.Map() {
|
||||
assert.True(t, strings.HasPrefix(path, "/api/v1/"), "only /api/v1 is documented: %s", path)
|
||||
for method, op := range item.Operations() {
|
||||
operations = append(operations, method+" "+path)
|
||||
|
||||
public := path == "/api/v1/health" || (path == "/api/v1/token" && method == http.MethodPost)
|
||||
if public {
|
||||
assert.Nil(t, op.Security, "%s %s needs no token", method, path)
|
||||
} else {
|
||||
require.NotNil(t, op.Security, "%s %s needs a token", method, path)
|
||||
assert.Equal(t, openapi3.SecurityRequirements{{bearerAuthName: {}}}, *op.Security)
|
||||
assert.Contains(t, op.Responses.Map(), "401", "%s %s documents 401", method, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(operations)
|
||||
assert.Equal(t, []string{
|
||||
"DELETE /api/v1/token",
|
||||
"GET /api/v1/character",
|
||||
"GET /api/v1/character/list",
|
||||
"GET /api/v1/download",
|
||||
"GET /api/v1/download/linux",
|
||||
"GET /api/v1/download/list",
|
||||
"GET /api/v1/download/windows",
|
||||
"GET /api/v1/health",
|
||||
"GET /api/v1/music",
|
||||
"GET /api/v1/music/addPlayed",
|
||||
"GET /api/v1/music/addQue",
|
||||
"GET /api/v1/music/all",
|
||||
"GET /api/v1/music/all/order",
|
||||
"GET /api/v1/music/all/random",
|
||||
"GET /api/v1/music/info",
|
||||
"GET /api/v1/music/list",
|
||||
"GET /api/v1/music/next",
|
||||
"GET /api/v1/music/previous",
|
||||
"GET /api/v1/music/rand",
|
||||
"GET /api/v1/music/rand/classic",
|
||||
"GET /api/v1/music/rand/low",
|
||||
"GET /api/v1/music/reset",
|
||||
"GET /api/v1/music/soundTest",
|
||||
"GET /api/v1/songs/{uuid}",
|
||||
"GET /api/v1/songs/{uuid}/file",
|
||||
"GET /api/v1/soundtracks",
|
||||
"GET /api/v1/soundtracks/{uuid}",
|
||||
"GET /api/v1/statistics/songs/least-played",
|
||||
"GET /api/v1/statistics/songs/most-played",
|
||||
"GET /api/v1/statistics/soundtracks/last-played",
|
||||
"GET /api/v1/statistics/soundtracks/least-played",
|
||||
"GET /api/v1/statistics/soundtracks/most-played",
|
||||
"GET /api/v1/statistics/soundtracks/never-played",
|
||||
"GET /api/v1/statistics/soundtracks/oldest-played",
|
||||
"GET /api/v1/statistics/summary",
|
||||
"GET /api/v1/sync/progress",
|
||||
"GET /api/v1/version",
|
||||
"GET /api/v1/version/history",
|
||||
"POST /api/v1/sync",
|
||||
"POST /api/v1/sync/full",
|
||||
"POST /api/v1/token",
|
||||
"POST /api/v1/token/cleanup",
|
||||
"PUT /api/v1/music/played",
|
||||
}, operations, "/sync/reset is hidden and legacy routes are not documented")
|
||||
|
||||
assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type)
|
||||
assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme)
|
||||
}
|
||||
|
||||
// specValidator checks responses against the published spec.
|
||||
type specValidator struct {
|
||||
t *testing.T
|
||||
e *testEnv
|
||||
router routers.Router
|
||||
seen map[string]bool
|
||||
}
|
||||
|
||||
func newSpecValidator(t *testing.T, e *testEnv) *specValidator {
|
||||
spec := loadSpec(t, e)
|
||||
spec.Servers = openapi3.Servers{{URL: "http://example.com"}}
|
||||
router, err := gorillamux.NewRouter(spec)
|
||||
require.NoError(t, err)
|
||||
return &specValidator{t: t, e: e, router: router, seen: map[string]bool{}}
|
||||
}
|
||||
|
||||
// check sends a request and validates the response status, headers and body against the spec.
|
||||
func (v *specValidator) check(method, path, body string, wantStatus int, headers ...string) *httptest.ResponseRecorder {
|
||||
v.t.Helper()
|
||||
rec := v.e.doBody(method, path, body, headers...)
|
||||
require.Equal(v.t, wantStatus, rec.Code, "%s %s: %s", method, path, rec.Body.String())
|
||||
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
route, params, err := v.router.FindRoute(req)
|
||||
require.NoError(v.t, err, "%s %s is not in the spec", method, path)
|
||||
v.seen[method+" "+route.Path] = true
|
||||
|
||||
input := &openapi3filter.ResponseValidationInput{
|
||||
RequestValidationInput: &openapi3filter.RequestValidationInput{Request: req, PathParams: params, Route: route},
|
||||
Status: rec.Code,
|
||||
Header: rec.Header(),
|
||||
Options: &openapi3filter.Options{IncludeResponseStatus: true},
|
||||
}
|
||||
input.SetBodyBytes(rec.Body.Bytes())
|
||||
if ct := rec.Header().Get("Content-Type"); strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "image/") {
|
||||
input.Body = io.NopCloser(bytes.NewReader(nil))
|
||||
input.Options.ExcludeResponseBody = true
|
||||
}
|
||||
assert.NoError(v.t, openapi3filter.ValidateResponse(context.Background(), input), "%s %s", method, path)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestResponsesMatchOpenAPI(t *testing.T) {
|
||||
gitea := newGitea(t, http.StatusOK)
|
||||
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea })
|
||||
v := newSpecValidator(t, e)
|
||||
song := e.addSong("Game A", "Song", "0123456789")
|
||||
songURL := "/api/v1/songs/" + song.Uuid.String()
|
||||
|
||||
// Empty states and errors.
|
||||
v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music?song=x", "", http.StatusUnprocessableEntity)
|
||||
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
|
||||
|
||||
// Music.
|
||||
v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/addQue", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/addPlayed", "", http.StatusOK)
|
||||
v.check(http.MethodPut, "/api/v1/music/played?song=0", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music?song=0", "", http.StatusPartialContent, "Range", "bytes=0-1")
|
||||
v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/previous", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/rand/low", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/rand/classic", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/soundTest", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/all", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/all/random", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/all/order", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/reset", "", http.StatusOK)
|
||||
|
||||
// Library.
|
||||
v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, songURL, "", http.StatusOK)
|
||||
v.check(http.MethodGet, songURL+"/file", "", http.StatusOK)
|
||||
|
||||
// Characters, downloads, server info.
|
||||
v.check(http.MethodGet, "/api/v1/character/list", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/character?name=Aloy.jpg", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/character?name=../secret.png", "", http.StatusBadRequest)
|
||||
v.check(http.MethodGet, "/api/v1/character?name=Missing.png", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, "/api/v1/download", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/download/list", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/download/windows", "", http.StatusFound)
|
||||
v.check(http.MethodGet, "/api/v1/download/linux", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, "/api/v1/health", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/version", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/version/history", "", http.StatusOK)
|
||||
|
||||
// Statistics (empty database).
|
||||
for _, path := range []string{
|
||||
"/soundtracks/most-played", "/soundtracks/least-played", "/soundtracks/last-played",
|
||||
"/soundtracks/oldest-played", "/songs/most-played", "/songs/least-played",
|
||||
} {
|
||||
v.check(http.MethodGet, "/api/v1/statistics"+path+"?limit=5", "", http.StatusOK)
|
||||
}
|
||||
v.check(http.MethodGet, "/api/v1/statistics/soundtracks/never-played", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
|
||||
|
||||
// Sync, including the locked state and the result after it finishes.
|
||||
release := e.blockSync()
|
||||
v.check(http.MethodPost, "/api/v1/sync", "", http.StatusOK)
|
||||
v.check(http.MethodPost, "/api/v1/sync/full", "", http.StatusLocked)
|
||||
v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusLocked)
|
||||
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
|
||||
release()
|
||||
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
|
||||
|
||||
// Tokens and authentication.
|
||||
auth := newTestEnv(t, withAuth("secret"))
|
||||
va := newSpecValidator(t, auth)
|
||||
va.check(http.MethodPost, "/api/v1/token", `{"password":"wrong"}`, http.StatusUnauthorized)
|
||||
rec := va.check(http.MethodPost, "/api/v1/token", `{"password":"secret","client_type":"api"}`, http.StatusOK)
|
||||
var token TokenResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &token))
|
||||
bearer := []string{"Authorization", "Bearer " + token.Token}
|
||||
va.check(http.MethodGet, "/api/v1/version", "", http.StatusUnauthorized)
|
||||
va.check(http.MethodPost, "/api/v1/token/cleanup", "", http.StatusOK, bearer...)
|
||||
va.check(http.MethodDelete, "/api/v1/token", "", http.StatusOK, bearer...)
|
||||
|
||||
// Statistics with data, through the canned statistics store.
|
||||
stats := newTestEnv(t)
|
||||
stats.server.stats = statsService()
|
||||
stats.handler = stats.server.RegisterRoutes()
|
||||
vs := newSpecValidator(t, stats)
|
||||
vs.check(http.MethodGet, "/api/v1/statistics/soundtracks/most-played", "", http.StatusOK)
|
||||
vs.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
|
||||
|
||||
// Every documented operation was checked at least once (statistics and tokens use other environments).
|
||||
spec := loadSpec(t, e)
|
||||
for path, item := range spec.Paths.Map() {
|
||||
for method := range item.Operations() {
|
||||
key := method + " " + path
|
||||
assert.True(t, v.seen[key] || va.seen[key] || vs.seen[key], "no response check for %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user