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

@@ -267,3 +267,30 @@ This fragment belongs in `config/http.yaml`. A route group then lists `cors:spa`
A profile is checked like `http.cors`: credentials with a `*` origin, a pattern that does not compile and a PHP-delimited pattern fail the start-up, with errors naming `http.cors_profiles.<name>.<key>`. Profile names use lower-case letters, digits, underscore and dash, and a profile that sets `paths` fails too.
ServeMux answers an `OPTIONS` request on a path registered only for `GET` or `POST` with 405 before any route middleware runs, so the middleware alone would never see a browser's preflight. surf therefore registers one `OPTIONS <path>` route for every path whose routes name `cors:<profile>`, owned by `surf` in `route:list`. It answers the preflight from the profile, with the same headers as the global preflight, and runs no other middleware: a preflight carries no credentials, so no auth, throttle or body limit applies. The start-up fails when a route names an unknown profile, when two routes on one path name different profiles, when a plugin already declared an `OPTIONS` route on that path, and when the path also matches `http.cors.paths`, because the global wrapper answers every preflight on its paths before routing.
## API documentation page
An application can publish a Swagger UI page for its public API. The page is off by default; with `http.api_docs.enabled: true` surf mounts two public routes, without any middleware:
- `GET /api-docs`: the Swagger UI page.
- `GET /api-docs.json`: the OpenAPI document as compact JSON.
The document comes from the one plugin that implements `surf.APIDocsProvider`. It returns the document as YAML or JSON, usually a file it embeds:
```go src=modules/surf/example_test.go#BlogPlugin.APIDocsSpec
// APIDocsSpec supplies the document behind the opt-in /api-docs page
// (surf.APIDocsProvider).
func (p *BlogPlugin) APIDocsSpec() ([]byte, error) {
return []byte(openAPISpec), nil
}
```
surf converts the document once at start-up: a document starting with `{` must be valid JSON, anything else is read as YAML, and the result keeps the source's key order. A document that does not convert fails the start-up with the plugin's name, and so do zero or several providers while the page is enabled. Nothing is generated from the code: the document describes exactly the surface you choose to publish.
| Key | Default | Controls |
|-----|---------|----------|
| `http.api_docs.enabled` | `false` | Mounts the page and the JSON route. |
| `http.api_docs.path` | `/api-docs` | The page path; the JSON route is the same path plus `.json`. It must be an absolute URL path, and neither route may replace a plugin route. |
| `http.api_docs.title` | `API documentation` | The page title. |
The page loads `swagger-ui-dist` from jsDelivr at the exact version `surf.SwaggerUIVersion`, and every script and stylesheet carries a Subresource Integrity hash with `crossorigin="anonymous"`, so the browser refuses a file that changed on the CDN. A floating version such as `@5` cannot carry a hash, which is why the version is pinned and moves only with a framework release. The page sends a `Content-Security-Policy` that allows scripts only from jsDelivr and its own inline script (through a per-request nonce), plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`.