feat(19.3-02): opt-in /api-docs Swagger UI page with a pinned, SRI-checked swagger-ui-dist
- surf: APIDocsProvider (one plugin) supplies a YAML or JSON OpenAPI document, converted once at boot to compact JSON in source key order; zero or several providers or a spec that does not convert fail boot - http.api_docs.enabled/path/title mount GET /api-docs and /api-docs.json (summercms.apidocs, public, refusing to replace a plugin route) - the page loads swagger-ui-dist 5.33.1 from jsDelivr with sha384 SRI and crossorigin on all three assets, a per-request CSP nonce for its inline script, nosniff and no-referrer - docs: API documentation page section, README rows, ExampleAPIDocsProvider
This commit is contained in:
@@ -36,6 +36,7 @@ Use cases:
|
||||
- Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay.
|
||||
- `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale.
|
||||
- Opt-in uploads route: with `storage.uploads.serve` true, `surf.BuildRouter` mounts `GET <storage.uploads.public_path_prefix>/{path...}` (owned by `summercms.surf`) on `attach.StaticHandlerPublic`, so a file is served only when its `system_files` row is public. Every response carries `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`, and a file whose type is not a raster image (JPEG, PNG, GIF, WebP, AVIF) is sent with `Content-Disposition: attachment`, so a stored HTML or SVG file never renders on the app origin. The bucket and the database are looked up per request, so `route:list` lists the route without opening either; until `serve` publishes them it answers 404. The route owns the whole prefix: `surf.BuildRouter` fails when a plugin route sits at or under it, and when the prefix is not an absolute URL path (no leading slash, a scheme or host, a query, an empty, `.` or `..` segment, or `{`/`}`).
|
||||
- Opt-in API documentation page: with `http.api_docs.enabled` true, `surf.BuildRouter` mounts `GET /api-docs` (a Swagger UI page) and `GET /api-docs.json` (owned by `summercms.apidocs`, public, no middleware). The spec comes from the one plugin implementing `surf.APIDocsProvider`, as YAML or JSON, converted once at boot to compact JSON in source key order; zero or several providers, or a spec that does not convert, fail boot. The page loads swagger-ui-dist `surf.SwaggerUIVersion` from jsDelivr with Subresource Integrity hashes on all three assets and a nonce-based `Content-Security-Policy`.
|
||||
- Opt-in request log: with `http.access_log` true, every request writes one Info record `http request` after it is served, with `method`, `pattern` (the matched route, such as `GET /posts/{id}`), `status` and `duration_ms`. A request whose client went away adds `client_gone=true`; its 5xx is logged as 499 (client closed request) only when the client had already gone when the status was written and the handler did not panic, so a 5xx written before the cancellation and a recovered panic keep their real status. An unmatched request (a 404 or 405 from the mux, or a CORS preflight answered before routing) has an empty `pattern`. The URL path, the query string, headers and the client address are never logged, for an unmatched request too: a token route requested with another method answers 405 before any route handler runs. Records go to the app's published `*slog.Logger`, else `slog.Default`, so the [sunscreen](../sunscreen/README.md) redaction still applies.
|
||||
- A read-only route table (`surf.Router.Routes`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands.
|
||||
|
||||
@@ -136,6 +137,8 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `surf.TrustedProxies` | Parses `http.trusted_proxies` into CIDR prefixes. |
|
||||
| `surf.CORSConfig` | CORS settings; `surf.LoadCORSConfig` reads them from config. |
|
||||
| `surf.LoadCORSProfiles` | Reads and validates the `http.cors_profiles.<name>` blocks the `cors:<profile>` middleware uses. |
|
||||
| `surf.APIDocsProvider` | Implemented by the one plugin that supplies the OpenAPI document (`APIDocsSpec`, YAML or JSON) for the opt-in api-docs page. |
|
||||
| `surf.SwaggerUIVersion` | The exact swagger-ui-dist release the api-docs page loads, pinned so its assets carry SRI hashes. |
|
||||
| `surf.LocaleResolver` | Optional backpack-published request locale; `Resolve` returns a validated code and `Rewrite` strips an enabled prefix before routing. |
|
||||
| `surf.LocaleFromPrincipal` | Middleware that applies the signed-in user's preferred locale. |
|
||||
| `surf.ServeCommand` | The `serve` console command. |
|
||||
@@ -162,6 +165,9 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
|
||||
| `turnstile.secret` | empty | Cloudflare Turnstile secret for `turnstile:open` and `turnstile:closed`. Without it `turnstile:open` passes and `turnstile:closed` answers 503 in the production environment (passes elsewhere). A `*bouncer.Turnstile` published on the app replaces the verifier built from this key. Environment: `SUMMER_TURNSTILE__SECRET`. |
|
||||
| `turnstile.field` | `turnstile_token` | Request field (query, JSON body or form body) carrying the Turnstile token. |
|
||||
| `http.rate_limit.store` | `memory` | Where `throttle` counters live: `memory` (per instance) or `redis` (shared by every instance through [cooler](../cooler/README.md), which then needs `redis.url`). Only `serve` installs the Redis store; `route:list` keeps the memory store and never dials Redis. Any other value fails at boot. Environment: `SUMMER_HTTP__RATE_LIMIT__STORE`. |
|
||||
| `http.api_docs.enabled` | `false` | Mounts `GET <http.api_docs.path>` (Swagger UI page) and `GET <http.api_docs.path>.json` (the spec of the one `surf.APIDocsProvider` plugin). Both are public. Environment: `SUMMER_HTTP__API_DOCS__ENABLED`. |
|
||||
| `http.api_docs.path` | `/api-docs` | Page path; the JSON route adds `.json`. Must be an absolute URL path; a plugin route on either path fails boot. |
|
||||
| `http.api_docs.title` | `API documentation` | Page title. |
|
||||
| `http.access_log` | `false` | Logs one `http request` record per request (method, matched pattern, status, duration in milliseconds; never the path or query). Environment: `SUMMER_HTTP__ACCESS_LOG`. |
|
||||
| `storage.uploads.serve` | `false` | Mounts the uploads route `GET <storage.uploads.public_path_prefix>/{path...}` (prefix default `/storage/uploads`; it must then be an absolute URL path, or start-up fails) on `attach.StaticHandlerPublic` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Prefer a separate origin for uploads in production. Environment: `SUMMER_STORAGE__UPLOADS__SERVE`. |
|
||||
|
||||
@@ -191,8 +197,8 @@ The `serve` command also opens the database through [lagoon](../lagoon/README.md
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [cabana](../cabana/README.md), [compass](../compass/README.md), [conga](../conga/README.md) (the in-process job worker of `serve`), [cooler](../cooler/README.md) (the shared rate-limit store `serve` installs), [lagoon](../lagoon/README.md) (including `lagoon/attach`), [pact](../pact/README.md), [party](../party/README.md), [towel](../towel/README.md), [wire](../wire/README.md).
|
||||
- Third-party: `gocloud.dev/blob` (uploads bucket opened by `serve`).
|
||||
- Standard library: `bytes`, `context`, `fmt`, `math`, `net`, `net/http`, `net/netip`, `os`, `os/signal`, `regexp`, `strconv`, `strings`, `sync`, `syscall`, `time`.
|
||||
- Third-party: `gocloud.dev/blob` (uploads bucket opened by `serve`), `github.com/goccy/go-yaml` (api-docs spec conversion).
|
||||
- Standard library: `bytes`, `context`, `crypto/rand`, `encoding/base64`, `encoding/json`, `errors`, `fmt`, `html/template`, `log/slog`, `math`, `mime`, `net`, `net/http`, `net/netip`, `net/url`, `os`, `os/signal`, `regexp`, `sort`, `strconv`, `strings`, `sync`, `syscall`, `time`.
|
||||
- Tests additionally use `github.com/testcontainers/testcontainers-go` (Redis container).
|
||||
|
||||
## Testing
|
||||
|
||||
210
modules/surf/apidocs.go
Normal file
210
modules/surf/apidocs.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// APIDocsProvider is implemented by the one plugin that supplies the
|
||||
// application's OpenAPI document for the opt-in /api-docs page. APIDocsSpec
|
||||
// returns the document as YAML or JSON, typically an embedded file; surf
|
||||
// converts it to compact JSON once at boot.
|
||||
type APIDocsProvider interface {
|
||||
APIDocsSpec() ([]byte, error)
|
||||
}
|
||||
|
||||
// SwaggerUIVersion is the swagger-ui-dist release the /api-docs page loads
|
||||
// from jsDelivr. The version is exact so the assets can carry Subresource
|
||||
// Integrity hashes; a floating version could change under the page.
|
||||
const SwaggerUIVersion = "5.33.1"
|
||||
|
||||
// The pinned swagger-ui-dist assets and their integrity values (checked
|
||||
// against the npm tarball of the release).
|
||||
const (
|
||||
swaggerUICSS = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui.css"
|
||||
swaggerUICSSIntegrity = "sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW"
|
||||
swaggerUIBundle = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-bundle.js"
|
||||
swaggerUIBundleIntegrity = "sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw"
|
||||
swaggerUIPreset = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-standalone-preset.js"
|
||||
swaggerUIPresetIntegrity = "sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF"
|
||||
swaggerUICDN = "https://cdn.jsdelivr.net"
|
||||
)
|
||||
|
||||
const (
|
||||
// apiDocsPluginID owns the api-docs routes in the route table.
|
||||
apiDocsPluginID = "summercms.apidocs"
|
||||
defaultAPIDocsPath = "/api-docs"
|
||||
defaultAPIDocsName = "API documentation"
|
||||
)
|
||||
|
||||
// apiDocsPage is the Swagger UI shell. The asset URLs and integrity values
|
||||
// are constants written into the template text; the title, the nonce and the
|
||||
// spec URL go through html/template escaping.
|
||||
var apiDocsPage = template.Must(template.New("apidocs").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="` + swaggerUICSS + `" integrity="` + swaggerUICSSIntegrity + `" crossorigin="anonymous">
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="` + swaggerUIBundle + `" integrity="` + swaggerUIBundleIntegrity + `" crossorigin="anonymous"></script>
|
||||
<script src="` + swaggerUIPreset + `" integrity="` + swaggerUIPresetIntegrity + `" crossorigin="anonymous"></script>
|
||||
<script nonce="{{.Nonce}}">
|
||||
window.addEventListener('load', function () {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: {{.SpecURL}},
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
validatorUrl: null,
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||
layout: 'StandaloneLayout'
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
// mountAPIDocs registers GET <http.api_docs.path> (the Swagger UI page) and
|
||||
// GET <http.api_docs.path>.json (the spec) when http.api_docs.enabled is
|
||||
// true. Exactly one plugin must implement APIDocsProvider; its spec is
|
||||
// converted to compact JSON here, once, and a spec that does not convert
|
||||
// fails boot naming the plugin. Both routes are public (no middleware) and
|
||||
// may not replace a route a plugin declared.
|
||||
func mountAPIDocs(r *Router, app *backpack.App, plugins []party.Plugin) error {
|
||||
if r == nil || app == nil || app.Config == nil || !app.Config.Bool("http.api_docs.enabled") {
|
||||
return nil
|
||||
}
|
||||
path, err := apiDocsPath(app.Config.String("http.api_docs.path"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jsonPath := path + ".json"
|
||||
title := strings.TrimSpace(app.Config.String("http.api_docs.title"))
|
||||
if title == "" {
|
||||
title = defaultAPIDocsName
|
||||
}
|
||||
var ids []string
|
||||
var provider APIDocsProvider
|
||||
for _, p := range plugins {
|
||||
if dp, ok := p.(APIDocsProvider); ok {
|
||||
ids = append(ids, p.ID())
|
||||
provider = dp
|
||||
}
|
||||
}
|
||||
switch len(ids) {
|
||||
case 0:
|
||||
return errors.New("surf: http.api_docs.enabled is true but no plugin implements surf.APIDocsProvider")
|
||||
case 1:
|
||||
default:
|
||||
sort.Strings(ids)
|
||||
return fmt.Errorf("surf: http.api_docs.enabled is true and several plugins implement surf.APIDocsProvider (%s); exactly one may", strings.Join(ids, ", "))
|
||||
}
|
||||
raw, err := provider.APIDocsSpec()
|
||||
if err != nil {
|
||||
return fmt.Errorf("surf: api docs spec of plugin %q: %w", ids[0], err)
|
||||
}
|
||||
spec, err := apiDocsJSON(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("surf: api docs spec of plugin %q: %w", ids[0], err)
|
||||
}
|
||||
for _, p := range []string{path, jsonPath} {
|
||||
if owner, ok := r.seen[http.MethodGet+" "+p]; ok {
|
||||
return fmt.Errorf("surf: route GET %s (plugin %q) collides with the api docs page http.api_docs.path mounts", p, owner)
|
||||
}
|
||||
}
|
||||
r.BindPlugin(apiDocsPluginID)
|
||||
r.GroupRaw("", nil, func(g pact.Router) {
|
||||
g.Get(path, apiDocsPageHandler(title, jsonPath))
|
||||
g.Get(jsonPath, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
_, _ = w.Write(spec)
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// apiDocsPath validates http.api_docs.path with the uploads prefix rules: an
|
||||
// absolute URL path without scheme, host, query, wildcard or dot segments.
|
||||
func apiDocsPath(raw string) (string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return defaultAPIDocsPath, nil
|
||||
}
|
||||
path, err := uploadsPrefix(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("surf: http.api_docs.path must be an absolute URL path such as /api-docs (no scheme, host, query or wildcard), got %q", raw)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// apiDocsJSON converts a YAML or JSON OpenAPI document to compact JSON with
|
||||
// the source key order. A body starting with { must be valid JSON; anything
|
||||
// else is read as YAML (key order kept). The result must be a JSON object.
|
||||
func apiDocsJSON(raw []byte) ([]byte, error) {
|
||||
src := bytes.TrimSpace(raw)
|
||||
if len(src) == 0 {
|
||||
return nil, errors.New("the spec is empty")
|
||||
}
|
||||
var converted []byte
|
||||
if src[0] == '{' {
|
||||
if !json.Valid(src) {
|
||||
return nil, errors.New("the spec starts with { but is not valid JSON")
|
||||
}
|
||||
converted = src
|
||||
} else {
|
||||
out, err := yaml.YAMLToJSON(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the spec is not valid YAML: %w", err)
|
||||
}
|
||||
converted = out
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := json.Compact(&buf, converted); err != nil {
|
||||
return nil, fmt.Errorf("the spec does not convert to JSON: %w", err)
|
||||
}
|
||||
if buf.Len() == 0 || buf.Bytes()[0] != '{' {
|
||||
return nil, errors.New("the spec is not an object")
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// apiDocsPageHandler renders the Swagger UI page with a fresh CSP nonce for
|
||||
// its one inline script.
|
||||
func apiDocsPageHandler(title, specURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
nonceBytes := make([]byte, 18)
|
||||
if _, err := rand.Read(nonceBytes); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
nonce := base64.RawURLEncoding.EncodeToString(nonceBytes)
|
||||
var page bytes.Buffer
|
||||
if err := apiDocsPage.Execute(&page, struct{ Title, Nonce, SpecURL string }{title, nonce, specURL}); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Content-Security-Policy", "script-src 'nonce-"+nonce+"' "+swaggerUICDN+"; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
|
||||
_, _ = w.Write(page.Bytes())
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,24 @@ func (p *BlogPlugin) Buckets() map[string]surf.Bucket {
|
||||
}
|
||||
}
|
||||
|
||||
// openAPISpec is the plugin's hand-written OpenAPI document. A plugin
|
||||
// usually keeps it in a file and embeds it with //go:embed.
|
||||
const openAPISpec = `openapi: 3.0.3
|
||||
info:
|
||||
title: Blog API
|
||||
version: "1"
|
||||
paths:
|
||||
/api/blog/posts/{id}:
|
||||
get:
|
||||
summary: Show a post
|
||||
`
|
||||
|
||||
// APIDocsSpec supplies the document behind the opt-in /api-docs page
|
||||
// (surf.APIDocsProvider).
|
||||
func (p *BlogPlugin) APIDocsSpec() ([]byte, error) {
|
||||
return []byte(openAPISpec), nil
|
||||
}
|
||||
|
||||
// Routes is the Go form of the plugin's routes.php.
|
||||
func (p *BlogPlugin) Routes(r pact.Router) error {
|
||||
r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) {
|
||||
@@ -195,6 +213,27 @@ func ExampleClientIP() {
|
||||
// 203.0.113.7
|
||||
}
|
||||
|
||||
func ExampleAPIDocsProvider() {
|
||||
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
_ = cfg.Set("http.body_limits.default_bytes", 1048576)
|
||||
_ = cfg.Set("http.body_limits.upload_bytes", 1048576)
|
||||
_ = cfg.Set("http.api_docs.enabled", true)
|
||||
h, err := surf.Assemble(backpack.New(cfg), []party.Plugin{&BlogPlugin{}})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api-docs.json", nil))
|
||||
fmt.Println(rec.Code, rec.Body.String())
|
||||
// Output:
|
||||
// 200 {"openapi":"3.0.3","info":{"title":"Blog API","version":"1"},"paths":{"/api/blog/posts/{id}":{"get":{"summary":"Show a post"}}}}
|
||||
}
|
||||
|
||||
// TestDocsDeclarations checks the plugin declarations the Routing and Rate
|
||||
// limiting pages show.
|
||||
func TestDocsDeclarations(t *testing.T) {
|
||||
@@ -205,4 +244,7 @@ func TestDocsDeclarations(t *testing.T) {
|
||||
if b := p.Buckets()["blog.comments"]; b.Max != 1 || b.Decay != time.Minute {
|
||||
t.Fatalf("Buckets() = %+v", b)
|
||||
}
|
||||
if spec, err := p.APIDocsSpec(); err != nil || !strings.HasPrefix(string(spec), "openapi:") {
|
||||
t.Fatalf("APIDocsSpec() = %q, %v", spec, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +611,9 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
||||
if err := mountUploads(r, app); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mountAPIDocs(r, app, plugins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
admin, err := cabana.Activate(app, plugins)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -347,3 +347,66 @@ func TestTurnstileMiddlewareSmoke(t *testing.T) {
|
||||
t.Fatalf("turnstile:maybe error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// apiDocsPlugin supplies an OpenAPI document whose keys are not in
|
||||
// alphabetical order.
|
||||
type apiDocsPlugin struct{ id string }
|
||||
|
||||
func (p apiDocsPlugin) ID() string { return p.id }
|
||||
func (p apiDocsPlugin) Requires() []string { return nil }
|
||||
func (p apiDocsPlugin) Register(*backpack.App) error { return nil }
|
||||
func (p apiDocsPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p apiDocsPlugin) APIDocsSpec() ([]byte, error) {
|
||||
return []byte("openapi: 3.0.3\ninfo:\n title: Blog API\n version: \"1\"\npaths:\n /api/posts:\n get:\n summary: List posts\n"), nil
|
||||
}
|
||||
|
||||
func TestAPIDocsSmoke(t *testing.T) {
|
||||
get := func(h http.Handler, path string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
return rec
|
||||
}
|
||||
off, err := Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits}), []party.Plugin{apiDocsPlugin{id: "acme.blog"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := get(off, "/api-docs"); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("api docs off: GET /api-docs = %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
onFiles := func() map[string]string {
|
||||
return map[string]string{"http.yaml": transportBodyLimits + "api_docs:\n enabled: true\n"}
|
||||
}
|
||||
h, err := Assemble(transportApp(t, "development", onFiles()), []party.Plugin{apiDocsPlugin{id: "acme.blog"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := get(h, "/api-docs.json")
|
||||
want := `{"openapi":"3.0.3","info":{"title":"Blog API","version":"1"},"paths":{"/api/posts":{"get":{"summary":"List posts"}}}}`
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
||||
t.Fatalf("GET /api-docs.json = %d %s, want %s", rec.Code, rec.Body.String(), want)
|
||||
}
|
||||
rec = get(h, "/api-docs")
|
||||
page := rec.Body.String()
|
||||
if rec.Code != http.StatusOK || !strings.Contains(page, "swagger-ui-dist@5.33.1") {
|
||||
t.Fatalf("GET /api-docs = %d, page lacks the pinned version", rec.Code)
|
||||
}
|
||||
if n := strings.Count(page, `integrity="sha384-`); n != 3 {
|
||||
t.Errorf("page has %d integrity attributes, want 3", n)
|
||||
}
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
i := strings.Index(csp, "'nonce-")
|
||||
if i < 0 {
|
||||
t.Fatalf("CSP %q has no nonce", csp)
|
||||
}
|
||||
nonce := csp[i+len("'nonce-"):]
|
||||
nonce = nonce[:strings.IndexByte(nonce, '\'')]
|
||||
if nonce == "" || !strings.Contains(page, `nonce="`+nonce+`"`) {
|
||||
t.Errorf("page script nonce does not match CSP %q", csp)
|
||||
}
|
||||
|
||||
_, err = BuildRouter(transportApp(t, "development", onFiles()), []party.Plugin{apiDocsPlugin{id: "acme.blog"}, apiDocsPlugin{id: "acme.shop"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "acme.blog") || !strings.Contains(err.Error(), "acme.shop") {
|
||||
t.Fatalf("two providers error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user