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:
Jakub Zych
2026-10-15 17:59:54 +02:00
parent 597c79bae7
commit 20bc1a0795
6 changed files with 353 additions and 2 deletions

View File

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