From 6b0a41a6e7df54b4a771c83ed2636809a91d12c5 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Wed, 6 May 2026 12:13:26 +0200 Subject: [PATCH 1/6] refactor(middleware): extract docui handlers to a stdlib-only module Move SwaggerUI, Redoc, RapiDoc, SwaggerUIOAuth2Callback and Spec, along with their option helpers, out of the middleware package into a new github.com/go-openapi/runtime/server-middleware/docui package. The new module is stdlib-only, so any net/http application can mount a docs site without dragging in go-openapi/loads, analysis, spec or validate. Package middleware retains every old public name as a deprecated forwarder: type aliases for SwaggerUIOpts/RedocOpts/RapiDocOpts/SpecOption/ UIOption, and var aliases for the function values (SwaggerUI, Redoc, RapiDoc, SwaggerUIOAuth2Callback, Spec, WithUIBasePath, ...). User code keeps compiling unchanged. Spec forwards to docui.ServeSpec, which was renamed from Spec to avoid the awkward docui.Spec identifier. middleware/context.go now calls docui directly. A handful of helpers (UIOptions, UIOptionsWithDefaults, FromCommonToAnyOptions, ToCommonUIOptions) had to be exported because they cross the new module boundary in Context.uiOptionsForHandler. > NOTE: > The unification of Redoc/RapiDoc/SwaggerUI option structs that the > roadmap suggests for this step is intentionally deferred: keeping the > three structs distinct in docui is what allows the clean type aliases > above. Tracked for a follow-up. * fixes #257 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Frederic BIDON --- .claude/plans/track-b2-docui-extraction.md | 142 ++++++++++++++++++ go.mod | 3 + go.work | 1 + middleware/context.go | 31 ++-- middleware/context_test.go | 20 +-- middleware/seam.go | 119 +++++++++++++++ middleware/seam_test.go | 84 +++++++++++ server-middleware/doc.go | 4 + server-middleware/docui/doc.go | 12 ++ .../docui/options.go | 76 ++++------ .../docui/options_test.go | 20 +-- .../docui}/rapidoc.go | 6 +- .../docui}/rapidoc_test.go | 8 +- .../docui}/redoc.go | 6 +- .../docui}/redoc_test.go | 14 +- server-middleware/docui/render.go | 33 ++++ .../docui}/spec.go | 15 +- .../docui}/spec_test.go | 34 ++--- .../docui}/swaggerui.go | 6 +- .../docui}/swaggerui_oauth2.go | 2 +- .../docui}/swaggerui_oauth2_test.go | 8 +- .../docui}/swaggerui_test.go | 8 +- server-middleware/go.mod | 5 + server-middleware/go.sum | 2 + 24 files changed, 511 insertions(+), 148 deletions(-) create mode 100644 .claude/plans/track-b2-docui-extraction.md create mode 100644 middleware/seam.go create mode 100644 middleware/seam_test.go create mode 100644 server-middleware/doc.go create mode 100644 server-middleware/docui/doc.go rename middleware/ui_options.go => server-middleware/docui/options.go (61%) rename middleware/ui_options_test.go => server-middleware/docui/options_test.go (86%) rename {middleware => server-middleware/docui}/rapidoc.go (95%) rename {middleware => server-middleware/docui}/rapidoc_test.go (92%) rename {middleware => server-middleware/docui}/redoc.go (96%) rename {middleware => server-middleware/docui}/redoc_test.go (97%) create mode 100644 server-middleware/docui/render.go rename {middleware => server-middleware/docui}/spec.go (82%) rename {middleware => server-middleware/docui}/spec_test.go (68%) rename {middleware => server-middleware/docui}/swaggerui.go (98%) rename {middleware => server-middleware/docui}/swaggerui_oauth2.go (99%) rename {middleware => server-middleware/docui}/swaggerui_oauth2_test.go (92%) rename {middleware => server-middleware/docui}/swaggerui_test.go (95%) create mode 100644 server-middleware/go.mod create mode 100644 server-middleware/go.sum diff --git a/.claude/plans/track-b2-docui-extraction.md b/.claude/plans/track-b2-docui-extraction.md new file mode 100644 index 00000000..7b1cf9e4 --- /dev/null +++ b/.claude/plans/track-b2-docui-extraction.md @@ -0,0 +1,142 @@ +# Track B.2 — Extract `docui` (execution plan) + +Status: in progress. +Scope: move the doc-UI middlewares (`SwaggerUI`, `Redoc`, `RapiDoc`, +`SwaggerUIOAuth2Callback`, `Spec`) and their option helpers into a new +`server-middleware/docui` package, leaving deprecated forwarders behind. + +Parent: `roadmap-media-and-modularization.md` Track B.2. + +--- + +## Module layout + +Per fred's note in roadmap §B.4 ("most middlewares only depend on stdlib, +perhaps just 1 module is enough to hold them"), the umbrella is **one** Go +module: + +``` +server-middleware/ +├── go.mod // module: github.com/go-openapi/runtime/server-middleware +├── go.sum +└── docui/ // package: docui + ├── doc.go + ├── options.go // UIOptions, UIOption, WithXxx, EnsureDefaults helpers + ├── render.go // serveUI helper (private) + ├── swaggerui.go + ├── swaggerui_oauth2.go + ├── redoc.go + ├── rapidoc.go + ├── spec.go // ServeSpec, SpecOption, WithSpecPath, WithSpecDocument + └── *_test.go +``` + +`go.work` adds `./server-middleware` to its `use` clause. + +## Cross-module dependency + +`docui` is stdlib-only (production code). `runtime` will gain a runtime +dependency on `server-middleware/` for `middleware/context.go`. We follow +the established pattern from `client-middleware/opentracing`: + +```go +// runtime/go.mod +require github.com/go-openapi/runtime/server-middleware v0.30.0 +replace github.com/go-openapi/runtime/server-middleware => ./server-middleware +``` + +Both modules will release together at v0.30.0 (per fred's roadmap note on +versioning). + +## Exported surface of `docui` + +| Name (in docui) | Origin | Notes | +|-----------------|--------|-------| +| `UIOptions` | was unexported `uiOptions` | exported because `Context` needs to hold and convert it | +| `UIOption` | already exported | unchanged | +| `WithUIBasePath`, `WithUIPath`, `WithUISpecURL`, `WithUITitle`, `WithTemplate` | already exported | unchanged | +| `UIOptionsWithDefaults` | was unexported `uiOptionsWithDefaults` | exported | +| `FromCommonToAnyOptions` | was unexported `fromCommonToAnyOptions` | exported, same generic shape | +| `ToCommonUIOptions` | was unexported `toCommonUIOptions` | exported | +| `SwaggerUIOpts`, `SwaggerUI`, `SwaggerUIOAuth2Callback` | already exported | unchanged | +| `RedocOpts`, `Redoc` | already exported | unchanged | +| `RapiDocOpts`, `RapiDoc` | already exported | unchanged | +| `SpecOption`, `WithSpecPath`, `WithSpecDocument` | already exported | unchanged | +| `ServeSpec` | renamed from `Spec` | the package-name + `Spec` reads as `docui.Spec`, which is ambiguous in user code | + +Asset URL constants (`swaggerLatest`, `redocLatest`, ...), HTML templates, +and helper funcs (`serveUI`, `EnsureDefaults` flavor methods, +`defaultDocsPath/URL/Title`, `contentTypeHeader`, `applicationJSON`) stay +private to `docui`. + +## Backward-compat forwarders in `middleware/` + +For each moved file, we leave a thin shim that aliases the type and +forwards the func: + +```go +// Deprecated: moved to server-middleware/docui. Use docui.SwaggerUIOpts. +type SwaggerUIOpts = docui.SwaggerUIOpts + +// Deprecated: moved to server-middleware/docui. Use docui.SwaggerUI. +var SwaggerUI = docui.SwaggerUI +``` + +`Spec` keeps its name in `middleware/` but forwards to `docui.ServeSpec`: + +```go +// Deprecated: moved to server-middleware/docui. Use docui.ServeSpec. +var Spec = docui.ServeSpec +``` + +Because each old struct (`SwaggerUIOpts`, `RedocOpts`, `RapiDocOpts`) is a +plain Go type alias to its `docui` counterpart, fields and methods +(including `EnsureDefaults`) carry over identically — fully transparent +backward compat. + +`middleware/context.go` updates to call `docui.SwaggerUI`, `docui.Redoc`, +`docui.RapiDoc`, `docui.ServeSpec` directly. Its private +`uiOptionsForHandler` returns `docui.UIOptions` instead of the old +unexported `uiOptions`. + +## Tests + +- `swaggerui_test.go`, `swaggerui_oauth2_test.go`, `redoc_test.go`, + `rapidoc_test.go`, `ui_options_test.go` move verbatim to `docui/` (same + package, only stdlib + testify imports). +- `spec_test.go` is rewritten for `docui` to drop the petstore fixture and + the `runtime.HeaderContentType` literal — petstore lives in + `runtime/internal/testing/`, which is unreachable from a sibling module. + The new test uses raw spec bytes and the local `contentTypeHeader` + constant. +- A small smoke test stays in `middleware/` per file, asserting that the + deprecated alias still resolves and the forwarded handler returns 200 + on the documented path. This guards against accidental drift. + +## Out of scope (deliberately) + +- **Unification of `SwaggerUIOpts / RedocOpts / RapiDocOpts` into a single + `docui.Options`** — the roadmap proposed this. Doing it now would block + clean `type X = docui.X` aliases (the existing types differ in field + shape) and would force every external caller through a migration. + Defer to a follow-up issue once the move stabilises. +- **`upload`, `negotiate`, etc.** — separate Track B steps. + +## Step-by-step + +1. Scaffold `server-middleware/go.mod` (stdlib + testify deps for tests), + add to `go.work`. +2. Create `server-middleware/docui/` files by moving content out of + `middleware/{swaggerui,swaggerui_oauth2,redoc,rapidoc,spec,ui_options}.go`. +3. Export the names that need to cross the module boundary + (`UIOptions`, `UIOptionsWithDefaults`, `FromCommonToAnyOptions`, + `ToCommonUIOptions`) and rename `Spec` → `ServeSpec`. +4. Move tests; rewrite `spec_test.go` to drop the petstore fixture. +5. Replace each moved file in `middleware/` with a deprecation shim + (type alias + var alias for the function). +6. Update `middleware/context.go` to call `docui.*` directly and to use + `docui.UIOptions` in its private helper signature. +7. Update `runtime/go.mod` (require + replace) and run + `go test ./...` in both modules. +8. Add a minimal smoke test per shim in `middleware/`. +9. `golangci-lint run --new-from-rev master` clean. diff --git a/go.mod b/go.mod index eb648c6d..63135c55 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ require ( github.com/go-openapi/analysis v0.25.0 github.com/go-openapi/errors v0.22.7 github.com/go-openapi/loads v0.23.3 + github.com/go-openapi/runtime/server-middleware v0.30.0 github.com/go-openapi/spec v0.22.4 github.com/go-openapi/strfmt v0.26.2 github.com/go-openapi/swag/conv v0.26.0 @@ -22,6 +23,8 @@ require ( golang.org/x/sync v0.20.0 ) +replace github.com/go-openapi/runtime/server-middleware => ./server-middleware + require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-logr/logr v1.4.3 // indirect diff --git a/go.work b/go.work index efa29dca..bfc5db22 100644 --- a/go.work +++ b/go.work @@ -1,6 +1,7 @@ use ( . ./client-middleware/opentracing + ./server-middleware ) go 1.25.0 diff --git a/middleware/context.go b/middleware/context.go index 728c7330..aa4571a8 100644 --- a/middleware/context.go +++ b/middleware/context.go @@ -23,6 +23,7 @@ import ( "github.com/go-openapi/runtime/logger" "github.com/go-openapi/runtime/middleware/untyped" "github.com/go-openapi/runtime/security" + "github.com/go-openapi/runtime/server-middleware/docui" ) // Debug when true turns on verbose logging. @@ -619,7 +620,7 @@ func (c *Context) Respond(rw http.ResponseWriter, r *http.Request, produces []st // // This handler includes a swagger spec, router and the contract defined in the swagger spec. // -// A spec UI ([SwaggerUI]) is served at {API base path}/docs and the spec document at /swagger.json +// A spec UI ([docui.SwaggerUI]) is served at {API base path}/docs and the spec document at /swagger.json // (these can be modified with uiOptions). func (c *Context) APIHandlerSwaggerUI(builder Builder, opts ...UIOption) http.Handler { b := builder @@ -628,17 +629,17 @@ func (c *Context) APIHandlerSwaggerUI(builder Builder, opts ...UIOption) http.Ha } specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts) - var swaggerUIOpts SwaggerUIOpts - fromCommonToAnyOptions(uiOpts, &swaggerUIOpts) + var swaggerUIOpts docui.SwaggerUIOpts + docui.FromCommonToAnyOptions(uiOpts, &swaggerUIOpts) - return Spec(specPath, c.spec.Raw(), SwaggerUI(swaggerUIOpts, c.RoutesHandler(b)), specOpts...) + return docui.ServeSpec(specPath, c.spec.Raw(), docui.SwaggerUI(swaggerUIOpts, c.RoutesHandler(b)), specOpts...) } // APIHandlerRapiDoc returns a handler to serve the API. // // This handler includes a swagger spec, router and the contract defined in the swagger spec. // -// A spec UI ([RapiDoc]) is served at {API base path}/docs and the spec document at /swagger.json +// A spec UI ([docui.RapiDoc]) is served at {API base path}/docs and the spec document at /swagger.json // (these can be modified with uiOptions). func (c *Context) APIHandlerRapiDoc(builder Builder, opts ...UIOption) http.Handler { b := builder @@ -647,17 +648,17 @@ func (c *Context) APIHandlerRapiDoc(builder Builder, opts ...UIOption) http.Hand } specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts) - var rapidocUIOpts RapiDocOpts - fromCommonToAnyOptions(uiOpts, &rapidocUIOpts) + var rapidocUIOpts docui.RapiDocOpts + docui.FromCommonToAnyOptions(uiOpts, &rapidocUIOpts) - return Spec(specPath, c.spec.Raw(), RapiDoc(rapidocUIOpts, c.RoutesHandler(b)), specOpts...) + return docui.ServeSpec(specPath, c.spec.Raw(), docui.RapiDoc(rapidocUIOpts, c.RoutesHandler(b)), specOpts...) } // APIHandler returns a handler to serve the API. // // This handler includes a swagger spec, router and the contract defined in the swagger spec. // -// A spec UI ([Redoc]) is served at {API base path}/docs and the spec document at /swagger.json +// A spec UI ([docui.Redoc]) is served at {API base path}/docs and the spec document at /swagger.json // (these can be modified with uiOptions). func (c *Context) APIHandler(builder Builder, opts ...UIOption) http.Handler { b := builder @@ -666,10 +667,10 @@ func (c *Context) APIHandler(builder Builder, opts ...UIOption) http.Handler { } specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts) - var redocOpts RedocOpts - fromCommonToAnyOptions(uiOpts, &redocOpts) + var redocOpts docui.RedocOpts + docui.FromCommonToAnyOptions(uiOpts, &redocOpts) - return Spec(specPath, c.spec.Raw(), Redoc(redocOpts, c.RoutesHandler(b)), specOpts...) + return docui.ServeSpec(specPath, c.spec.Raw(), docui.Redoc(redocOpts, c.RoutesHandler(b)), specOpts...) } // RoutesHandler returns a handler to serve the API, just the routes and the contract defined in the swagger spec. @@ -681,7 +682,7 @@ func (c *Context) RoutesHandler(builder Builder) http.Handler { return NewRouter(c, b(NewOperationExecutor(c))) } -func (c Context) uiOptionsForHandler(opts []UIOption) (string, uiOptions, []SpecOption) { +func (c Context) uiOptionsForHandler(opts []UIOption) (string, docui.UIOptions, []SpecOption) { var title string sp := c.spec.Spec() if sp != nil && sp.Info != nil && sp.Info.Title != "" { @@ -696,7 +697,7 @@ func (c Context) uiOptionsForHandler(opts []UIOption) (string, uiOptions, []Spec WithUITitle(title), ) optsForContext = append(optsForContext, opts...) - uiOpts := uiOptionsWithDefaults(optsForContext) + uiOpts := docui.UIOptionsWithDefaults(optsForContext) // If spec URL is provided, there is a non-default path to serve the spec. // This makes sure that the UI middleware is aligned with the Spec middleware. @@ -711,7 +712,7 @@ func (c Context) uiOptionsForHandler(opts []UIOption) (string, uiOptions, []Spec pth = "" } - return pth, uiOpts, []SpecOption{WithSpecDocument(doc)} + return pth, uiOpts, []SpecOption{docui.WithSpecDocument(doc)} } func cantFindProducer(format string) string { diff --git a/middleware/context_test.go b/middleware/context_test.go index ca70f94c..92ac1ef0 100644 --- a/middleware/context_test.go +++ b/middleware/context_test.go @@ -63,8 +63,8 @@ func TestContentType_Issue264(t *testing.T) { require.NoError(t, err) api := untyped.NewAPI(swspec) - api.RegisterConsumer(applicationJSON, runtime.JSONConsumer()) - api.RegisterProducer(applicationJSON, runtime.JSONProducer()) + api.RegisterConsumer(runtime.JSONMime, runtime.JSONConsumer()) + api.RegisterProducer(runtime.JSONMime, runtime.JSONProducer()) api.RegisterOperation("delete", "/key/{id}", new(stubOperationHandler)) handler := Serve(swspec, api) @@ -95,7 +95,7 @@ func TestContentType_Issue172(t *testing.T) { assert.EqualT(t, http.StatusNotAcceptable, recorder.Code) // acceptable as defined as default by the API (not explicit in the spec) - request.Header.Add("Accept", applicationJSON) + request.Header.Add("Accept", runtime.JSONMime) recorder = httptest.NewRecorder() handler.ServeHTTP(recorder, request) assert.EqualT(t, http.StatusOK, recorder.Code) @@ -106,8 +106,8 @@ func TestContentType_Issue174(t *testing.T) { require.NoError(t, err) api := untyped.NewAPI(swspec) - api.RegisterConsumer(applicationJSON, runtime.JSONConsumer()) - api.RegisterProducer(applicationJSON, runtime.JSONProducer()) + api.RegisterConsumer(runtime.JSONMime, runtime.JSONConsumer()) + api.RegisterProducer(runtime.JSONMime, runtime.JSONProducer()) api.RegisterOperation("get", "/pets", new(stubOperationHandler)) handler := Serve(swspec, api) @@ -475,7 +475,7 @@ func TestContextBindValidRequest(t *testing.T) { request, err = http.NewRequestWithContext(stdcontext.Background(), http.MethodPost, "/api/pets", nil) require.NoError(t, err) request.Header.Add("Accept", "application/vnd.cia.v1+json") - request.Header.Add("Content-Type", applicationJSON) + request.Header.Add("Content-Type", runtime.JSONMime) ri, request, _ = ctx.RouteInfo(request) assertAPIError(t, http.StatusNotAcceptable, ctx.BindValidRequest(request, ri, new(stubBindRequester))) @@ -486,8 +486,8 @@ func TestContextBindValidRequest_Issue174(t *testing.T) { require.NoError(t, err) api := untyped.NewAPI(spec) - api.RegisterConsumer(applicationJSON, runtime.JSONConsumer()) - api.RegisterProducer(applicationJSON, runtime.JSONProducer()) + api.RegisterConsumer(runtime.JSONMime, runtime.JSONConsumer()) + api.RegisterProducer(runtime.JSONMime, runtime.JSONProducer()) api.RegisterOperation("get", "/pets", new(stubOperationHandler)) ctx := NewContext(spec, api, nil) @@ -588,7 +588,7 @@ func TestContextRender(t *testing.T) { } func TestContextValidResponseFormat(t *testing.T) { - const ct = applicationJSON + const ct = runtime.JSONMime spec, api := petstore.NewAPI(t) ctx := NewContext(spec, api, nil) ctx.router = DefaultRouter(spec, ctx.api) @@ -705,7 +705,7 @@ func TestContextInvalidRoute(t *testing.T) { } func TestContextValidContentType(t *testing.T) { - ct := applicationJSON + ct := runtime.JSONMime ctx := NewContext(nil, nil, nil) request, err := http.NewRequestWithContext(stdcontext.Background(), http.MethodGet, "http://localhost:8080", nil) diff --git a/middleware/seam.go b/middleware/seam.go new file mode 100644 index 00000000..0406f796 --- /dev/null +++ b/middleware/seam.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package middleware + +import ( + "github.com/go-openapi/runtime/server-middleware/docui" +) + +// RapiDocOpts configures the [RapiDoc] middlewares. +// +// Deprecated: moved to the [docui] package. Use [docui.RapiDocOpts] instead. +type RapiDocOpts = docui.RapiDocOpts + +// RapiDoc creates a [middleware] to serve a documentation site for a swagger spec. +// +// This allows for altering the spec before starting the [http] listener. +// +// Deprecated: moved to the [docui] package. Use [docui.RapiDoc] instead. +var RapiDoc = docui.RapiDoc + +// RedocOpts configures the [Redoc] middlewares. +// +// Deprecated: moved to the [docui] package. Use [docui.RedocOpts] instead. +type RedocOpts = docui.RedocOpts + +// Redoc creates a [middleware] to serve a documentation site for a swagger spec. +// +// This allows for altering the spec before starting the [http] listener. +// +// Deprecated: moved to the [docui] package. Use [docui.Redoc] instead. +var Redoc = docui.Redoc + +// SwaggerUIOpts configures the [SwaggerUI] [middleware]. +// +// Deprecated: moved to the [docui] package. Use [docui.SwaggerUIOpts] instead. +type SwaggerUIOpts = docui.SwaggerUIOpts + +// SwaggerUI creates a [middleware] to serve a documentation site for a swagger spec. +// +// This allows for altering the spec before starting the [http] listener. +// +// Deprecated: moved to the [docui] package. Use [docui.SwaggerUI] instead. +var SwaggerUI = docui.SwaggerUI + +// SwaggerUIOAuth2Callback creates a middleware that serves the OAuth2 callback page used by Swagger UI. +// +// Deprecated: moved to the [docui] package. Use [docui.SwaggerUIOAuth2Callback] instead. +var SwaggerUIOAuth2Callback = docui.SwaggerUIOAuth2Callback + +// SpecOption can be applied to the [Spec] serving [middleware]. +// +// Deprecated: moved to the [docui] package. Use [docui.SpecOption] instead. +type SpecOption = docui.SpecOption + +// Spec creates a [middleware] to serve a swagger spec as a JSON document. +// +// This allows for altering the spec before starting the [http] listener. +// +// The basePath argument indicates the path of the spec document (defaults to "/"). +// Additional [SpecOption] can be used to change the name of the document (defaults to "swagger.json"). +// +// Deprecated: moved to the [docui] package as [docui.ServeSpec]. +var Spec = docui.ServeSpec + +// WithSpecPath sets the path to be joined to the base path of the [Spec] [middleware]. +// +// This is empty by default. +// +// Deprecated: moved to the [docui] package. Use [docui.WithSpecPath] instead. +var WithSpecPath = docui.WithSpecPath + +// WithSpecDocument sets the name of the JSON document served as a spec. +// +// By default, this is "swagger.json". +// +// Deprecated: moved to the [docui] package. Use [docui.WithSpecDocument] instead. +var WithSpecDocument = docui.WithSpecDocument + +// UIOption can be applied to UI serving [middleware], such as Context.[APIHandler] or +// Context.[APIHandlerSwaggerUI] to alter the default behavior. +// +// Deprecated: moved to the [docui] package. Use [docui.UIOption] instead. +type UIOption = docui.UIOption + +// WithUIBasePath sets the base path from where to serve the UI assets. +// +// By default, Context [middleware] sets this value to the API base path. +// +// Deprecated: moved to the [docui] package. Use [docui.WithUIBasePath] instead. +var WithUIBasePath = docui.WithUIBasePath + +// WithUIPath sets the path from where to serve the UI assets (i.e. /{basepath}/{path}. +// +// Deprecated: moved to the [docui] package. Use [docui.WithUIPath] instead. +var WithUIPath = docui.WithUIPath + +// WithUISpecURL sets the path from where to serve swagger spec document. +// +// This may be specified as a full URL or a path. +// +// By default, this is "/swagger.json". +// +// Deprecated: moved to the [docui] package. Use [docui.WithUISpecURL] instead. +var WithUISpecURL = docui.WithUISpecURL + +// WithUITitle sets the title of the UI. +// +// By default, Context [middleware] sets this value to the title found in the API spec. +// +// Deprecated: moved to the [docui] package. Use [docui.WithUITitle] instead. +var WithUITitle = docui.WithUITitle + +// WithTemplate allows to set a custom template for the UI. +// +// UI [middleware] will panic if the template does not parse or execute properly. +// +// Deprecated: moved to the [docui] package. Use [docui.WithTemplate] instead. +var WithTemplate = docui.WithTemplate diff --git a/middleware/seam_test.go b/middleware/seam_test.go new file mode 100644 index 00000000..26c692db --- /dev/null +++ b/middleware/seam_test.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package middleware_test + +// Smoke tests for the deprecated middleware aliases that forward to the +// docui package. These verify that: +// +// - the type aliases still resolve so user code keeps compiling, +// - the function-value aliases still serve the documented payload. +// +// The exhaustive coverage lives in the docui package itself. + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/runtime/server-middleware/docui" +) + +// Compile-time assertions that the deprecated middleware names alias the +// docui types — type identity is required for these assignments to type-check. +var ( + _ = func(o docui.SwaggerUIOpts) middleware.SwaggerUIOpts { return o } + _ = func(o docui.RedocOpts) middleware.RedocOpts { return o } + _ = func(o docui.RapiDocOpts) middleware.RapiDocOpts { return o } + _ = func(o docui.UIOption) middleware.UIOption { return o } + _ = func(o docui.SpecOption) middleware.SpecOption { return o } +) + +func TestDeprecatedDocUIForwarders(t *testing.T) { + t.Run("middleware.SwaggerUI still serves the docs page", func(t *testing.T) { + h := middleware.SwaggerUI(middleware.SwaggerUIOpts{}, nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) + require.NoError(t, err) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + assert.EqualT(t, http.StatusOK, rec.Code) + }) + + t.Run("middleware.Redoc still serves the docs page", func(t *testing.T) { + h := middleware.Redoc(middleware.RedocOpts{}, nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) + require.NoError(t, err) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + assert.EqualT(t, http.StatusOK, rec.Code) + }) + + t.Run("middleware.RapiDoc still serves the docs page", func(t *testing.T) { + h := middleware.RapiDoc(middleware.RapiDocOpts{}, nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) + require.NoError(t, err) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + assert.EqualT(t, http.StatusOK, rec.Code) + }) + + t.Run("middleware.SwaggerUIOAuth2Callback still serves the callback page", func(t *testing.T) { + h := middleware.SwaggerUIOAuth2Callback(middleware.SwaggerUIOpts{}, nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs/oauth2-callback", nil) + require.NoError(t, err) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + assert.EqualT(t, http.StatusOK, rec.Code) + }) + + t.Run("middleware.Spec still serves the spec document", func(t *testing.T) { + body := []byte(`{"swagger":"2.0"}`) + h := middleware.Spec("", body, nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/swagger.json", nil) + require.NoError(t, err) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + assert.EqualT(t, http.StatusOK, rec.Code) + assert.EqualT(t, string(body), rec.Body.String()) + }) +} diff --git a/server-middleware/doc.go b/server-middleware/doc.go new file mode 100644 index 00000000..12b48303 --- /dev/null +++ b/server-middleware/doc.go @@ -0,0 +1,4 @@ +// Package middleware exposes middleware utilities for OpenAPI. +// +// This is a standalone module, with minimal dependencies to the rest of the go-openapi libraries. +package middleware diff --git a/server-middleware/docui/doc.go b/server-middleware/docui/doc.go new file mode 100644 index 00000000..809296d5 --- /dev/null +++ b/server-middleware/docui/doc.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package docui provides standalone HTTP middlewares that serve OpenAPI +// documentation UIs (Swagger UI, ReDoc, RapiDoc) and the spec document +// itself. +// +// The package is stdlib-only and has no transitive dependency on any +// OpenAPI spec, loading or validation library, so it may be imported by +// any net/http application that simply wants to mount a documentation +// site. +package docui diff --git a/middleware/ui_options.go b/server-middleware/docui/options.go similarity index 61% rename from middleware/ui_options.go rename to server-middleware/docui/options.go index ed255426..bd272c17 100644 --- a/middleware/ui_options.go +++ b/server-middleware/docui/options.go @@ -1,14 +1,11 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "bytes" "encoding/gob" - "fmt" - "net/http" - "path" "strings" ) @@ -17,10 +14,13 @@ const ( defaultDocsPath = "docs" defaultDocsURL = "/swagger.json" defaultDocsTitle = "API Documentation" + + contentTypeHeader = "Content-Type" + applicationJSON = "application/json" ) -// uiOptions defines common options for UI serving middlewares. -type uiOptions struct { +// UIOptions defines common options for UI serving middlewares. +type UIOptions struct { // BasePath for the UI, defaults to: / BasePath string @@ -39,14 +39,14 @@ type uiOptions struct { Template string } -// toCommonUIOptions converts any UI option type to retain the common options. +// ToCommonUIOptions converts any UI option type to retain the common options. // // This uses gob encoding/decoding to convert common fields from one struct to another. -func toCommonUIOptions(opts any) uiOptions { +func ToCommonUIOptions(opts any) UIOptions { var buf bytes.Buffer enc := gob.NewEncoder(&buf) dec := gob.NewDecoder(&buf) - var o uiOptions + var o UIOptions err := enc.Encode(opts) if err != nil { panic(err) @@ -60,7 +60,10 @@ func toCommonUIOptions(opts any) uiOptions { return o } -func fromCommonToAnyOptions[T any](source uiOptions, target *T) { +// FromCommonToAnyOptions copies the common UI options held in source into the +// flavor-specific target struct (one of [SwaggerUIOpts], [RedocOpts] or +// [RapiDocOpts]). +func FromCommonToAnyOptions[T any](source UIOptions, target *T) { var buf bytes.Buffer enc := gob.NewEncoder(&buf) dec := gob.NewDecoder(&buf) @@ -75,12 +78,16 @@ func fromCommonToAnyOptions[T any](source uiOptions, target *T) { } } -// UIOption can be applied to UI serving [middleware], such as Context.[APIHandler] or -// Context.[APIHandlerSwaggerUI] to alter the default behavior. -type UIOption func(*uiOptions) +// UIOption can be applied to UI serving [middleware] to alter the default +// behavior. +type UIOption func(*UIOptions) -func uiOptionsWithDefaults(opts []UIOption) uiOptions { - var o uiOptions +// UIOptionsWithDefaults applies the given options on top of an empty +// [UIOptions]. Per-flavor handlers ([SwaggerUI], [Redoc], [RapiDoc]) +// fill in the remaining defaults via [UIOptions.EnsureDefaults] when +// the option struct is used. +func UIOptionsWithDefaults(opts []UIOption) UIOptions { + var o UIOptions for _, apply := range opts { apply(&o) } @@ -89,10 +96,8 @@ func uiOptionsWithDefaults(opts []UIOption) uiOptions { } // WithUIBasePath sets the base path from where to serve the UI assets. -// -// By default, Context [middleware] sets this value to the API base path. func WithUIBasePath(base string) UIOption { - return func(o *uiOptions) { + return func(o *UIOptions) { if !strings.HasPrefix(base, "/") { base = "/" + base } @@ -102,7 +107,7 @@ func WithUIBasePath(base string) UIOption { // WithUIPath sets the path from where to serve the UI assets (i.e. /{basepath}/{path}. func WithUIPath(pth string) UIOption { - return func(o *uiOptions) { + return func(o *UIOptions) { o.Path = pth } } @@ -113,16 +118,14 @@ func WithUIPath(pth string) UIOption { // // By default, this is "/swagger.json". func WithUISpecURL(specURL string) UIOption { - return func(o *uiOptions) { + return func(o *UIOptions) { o.SpecURL = specURL } } // WithUITitle sets the title of the UI. -// -// By default, Context [middleware] sets this value to the title found in the API spec. func WithUITitle(title string) UIOption { - return func(o *uiOptions) { + return func(o *UIOptions) { o.Title = title } } @@ -131,13 +134,13 @@ func WithUITitle(title string) UIOption { // // UI [middleware] will panic if the template does not parse or execute properly. func WithTemplate(tpl string) UIOption { - return func(o *uiOptions) { + return func(o *UIOptions) { o.Template = tpl } } // EnsureDefaults in case some options are missing. -func (r *uiOptions) EnsureDefaults() { +func (r *UIOptions) EnsureDefaults() { if r.BasePath == "" { r.BasePath = "/" } @@ -151,26 +154,3 @@ func (r *uiOptions) EnsureDefaults() { r.Title = defaultDocsTitle } } - -// serveUI creates a middleware that serves a templated asset as text/html. -func serveUI(pth string, assets []byte, next http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - if path.Clean(r.URL.Path) == pth { - rw.Header().Set(contentTypeHeader, "text/html; charset=utf-8") - rw.WriteHeader(http.StatusOK) - _, _ = rw.Write(assets) - - return - } - - if next != nil { - next.ServeHTTP(rw, r) - - return - } - - rw.Header().Set(contentTypeHeader, "text/plain") - rw.WriteHeader(http.StatusNotFound) - _, _ = fmt.Fprintf(rw, "%q not found", pth) - }) -} diff --git a/middleware/ui_options_test.go b/server-middleware/docui/options_test.go similarity index 86% rename from middleware/ui_options_test.go rename to server-middleware/docui/options_test.go index c0d07b56..d7fe7bf0 100644 --- a/middleware/ui_options_test.go +++ b/server-middleware/docui/options_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "testing" @@ -10,7 +10,7 @@ import ( ) func TestConvertOptions(t *testing.T) { - t.Run("from any UI options to uiOptions", func(t *testing.T) { + t.Run("from any UI options to UIOptions", func(t *testing.T) { t.Run("from RedocOpts", func(t *testing.T) { in := RedocOpts{ BasePath: "a", @@ -20,7 +20,7 @@ func TestConvertOptions(t *testing.T) { Title: "e", RedocURL: "f", } - out := toCommonUIOptions(in) + out := ToCommonUIOptions(in) require.EqualT(t, "a", out.BasePath) require.EqualT(t, "b", out.Path) @@ -38,7 +38,7 @@ func TestConvertOptions(t *testing.T) { Title: "e", RapiDocURL: "f", } - out := toCommonUIOptions(in) + out := ToCommonUIOptions(in) require.EqualT(t, "a", out.BasePath) require.EqualT(t, "b", out.Path) @@ -56,7 +56,7 @@ func TestConvertOptions(t *testing.T) { Title: "e", SwaggerURL: "f", } - out := toCommonUIOptions(in) + out := ToCommonUIOptions(in) require.EqualT(t, "a", out.BasePath) require.EqualT(t, "b", out.Path) @@ -66,8 +66,8 @@ func TestConvertOptions(t *testing.T) { }) }) - t.Run("from uiOptions to any UI options", func(t *testing.T) { - in := uiOptions{ + t.Run("from UIOptions to any UI options", func(t *testing.T) { + in := UIOptions{ BasePath: "a", Path: "b", SpecURL: "c", @@ -77,7 +77,7 @@ func TestConvertOptions(t *testing.T) { t.Run("to RedocOpts", func(t *testing.T) { var out RedocOpts - fromCommonToAnyOptions(in, &out) + FromCommonToAnyOptions(in, &out) require.EqualT(t, "a", out.BasePath) require.EqualT(t, "b", out.Path) require.EqualT(t, "c", out.SpecURL) @@ -87,7 +87,7 @@ func TestConvertOptions(t *testing.T) { t.Run("to RapiDocOpts", func(t *testing.T) { var out RapiDocOpts - fromCommonToAnyOptions(in, &out) + FromCommonToAnyOptions(in, &out) require.EqualT(t, "a", out.BasePath) require.EqualT(t, "b", out.Path) require.EqualT(t, "c", out.SpecURL) @@ -97,7 +97,7 @@ func TestConvertOptions(t *testing.T) { t.Run("to SwaggerUIOpts", func(t *testing.T) { var out SwaggerUIOpts - fromCommonToAnyOptions(in, &out) + FromCommonToAnyOptions(in, &out) require.EqualT(t, "a", out.BasePath) require.EqualT(t, "b", out.Path) require.EqualT(t, "c", out.SpecURL) diff --git a/middleware/rapidoc.go b/server-middleware/docui/rapidoc.go similarity index 95% rename from middleware/rapidoc.go rename to server-middleware/docui/rapidoc.go index 1574defb..745874f0 100644 --- a/middleware/rapidoc.go +++ b/server-middleware/docui/rapidoc.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "bytes" @@ -37,9 +37,9 @@ type RapiDocOpts struct { } func (r *RapiDocOpts) EnsureDefaults() { - common := toCommonUIOptions(r) + common := ToCommonUIOptions(r) common.EnsureDefaults() - fromCommonToAnyOptions(common, r) + FromCommonToAnyOptions(common, r) // rapidoc-specifics if r.RapiDocURL == "" { diff --git a/middleware/rapidoc_test.go b/server-middleware/docui/rapidoc_test.go similarity index 92% rename from middleware/rapidoc_test.go rename to server-middleware/docui/rapidoc_test.go index 4ffc09b4..a3b75bd3 100644 --- a/middleware/rapidoc_test.go +++ b/server-middleware/docui/rapidoc_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "context" @@ -35,11 +35,7 @@ func TestRapiDocMiddleware(t *testing.T) { t.Run("with custom template that fails to execute", func(t *testing.T) { assert.Panics(t, func() { RapiDoc(RapiDocOpts{ - Template: ` - - spec-url='{{ .Unknown }}' - -`, + Template: badTemplate, }, nil) }) }) diff --git a/middleware/redoc.go b/server-middleware/docui/redoc.go similarity index 96% rename from middleware/redoc.go rename to server-middleware/docui/redoc.go index 1007409a..b760b3be 100644 --- a/middleware/redoc.go +++ b/server-middleware/docui/redoc.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "bytes" @@ -38,9 +38,9 @@ type RedocOpts struct { // EnsureDefaults in case some options are missing. func (r *RedocOpts) EnsureDefaults() { - common := toCommonUIOptions(r) + common := ToCommonUIOptions(r) common.EnsureDefaults() - fromCommonToAnyOptions(common, r) + FromCommonToAnyOptions(common, r) // redoc-specifics if r.RedocURL == "" { diff --git a/middleware/redoc_test.go b/server-middleware/docui/redoc_test.go similarity index 97% rename from middleware/redoc_test.go rename to server-middleware/docui/redoc_test.go index 9c57ae43..9354e4d2 100644 --- a/middleware/redoc_test.go +++ b/server-middleware/docui/redoc_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "context" @@ -14,6 +14,12 @@ import ( "github.com/go-openapi/testify/v2/require" ) +const badTemplate = ` + + spec-url='{{ .Unknown }}' + +` + func TestRedocMiddleware(t *testing.T) { t.Run("with defaults", func(t *testing.T) { redoc := Redoc(RedocOpts{}, nil) @@ -107,11 +113,7 @@ func TestRedocMiddleware(t *testing.T) { t.Run("with custom template that fails to execute", func(t *testing.T) { assert.Panics(t, func() { Redoc(RedocOpts{ - Template: ` - - spec-url='{{ .Unknown }}' - -`, + Template: badTemplate, }, nil) }) }) diff --git a/server-middleware/docui/render.go b/server-middleware/docui/render.go new file mode 100644 index 00000000..d8823703 --- /dev/null +++ b/server-middleware/docui/render.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package docui + +import ( + "fmt" + "net/http" + "path" +) + +// serveUI creates a middleware that serves a templated asset as text/html. +func serveUI(pth string, assets []byte, next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if path.Clean(r.URL.Path) == pth { + rw.Header().Set(contentTypeHeader, "text/html; charset=utf-8") + rw.WriteHeader(http.StatusOK) + _, _ = rw.Write(assets) + + return + } + + if next != nil { + next.ServeHTTP(rw, r) + + return + } + + rw.Header().Set(contentTypeHeader, "text/plain") + rw.WriteHeader(http.StatusNotFound) + _, _ = fmt.Fprintf(rw, "%q not found", pth) + }) +} diff --git a/middleware/spec.go b/server-middleware/docui/spec.go similarity index 82% rename from middleware/spec.go rename to server-middleware/docui/spec.go index 0a64a957..7f4b75a8 100644 --- a/middleware/spec.go +++ b/server-middleware/docui/spec.go @@ -1,19 +1,14 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "net/http" "path" ) -const ( - contentTypeHeader = "Content-Type" - applicationJSON = "application/json" -) - -// SpecOption can be applied to the Spec serving [middleware]. +// SpecOption can be applied to the [ServeSpec] [middleware]. type SpecOption func(*specOptions) var defaultSpecOptions = specOptions{ @@ -35,13 +30,13 @@ func specOptionsWithDefaults(opts []SpecOption) specOptions { return o } -// Spec creates a [middleware] to serve a swagger spec as a JSON document. +// ServeSpec creates a [middleware] to serve a swagger spec as a JSON document. // // This allows for altering the spec before starting the [http] listener. // // The basePath argument indicates the path of the spec document (defaults to "/"). // Additional [SpecOption] can be used to change the name of the document (defaults to "swagger.json"). -func Spec(basePath string, b []byte, next http.Handler, opts ...SpecOption) http.Handler { +func ServeSpec(basePath string, b []byte, next http.Handler, opts ...SpecOption) http.Handler { if basePath == "" { basePath = "/" } @@ -68,7 +63,7 @@ func Spec(basePath string, b []byte, next http.Handler, opts ...SpecOption) http }) } -// WithSpecPath sets the path to be joined to the base path of the Spec [middleware]. +// WithSpecPath sets the path to be joined to the base path of the [ServeSpec] [middleware]. // // This is empty by default. func WithSpecPath(pth string) SpecOption { diff --git a/middleware/spec_test.go b/server-middleware/docui/spec_test.go similarity index 68% rename from middleware/spec_test.go rename to server-middleware/docui/spec_test.go index 953dbe21..074bab6e 100644 --- a/middleware/spec_test.go +++ b/server-middleware/docui/spec_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "context" @@ -11,40 +11,35 @@ import ( "github.com/go-openapi/testify/v2/assert" "github.com/go-openapi/testify/v2/require" - - "github.com/go-openapi/runtime" - "github.com/go-openapi/runtime/internal/testing/petstore" ) -func TestServeSpecMiddleware(t *testing.T) { - spec, api := petstore.NewAPI(t) - ctx := NewContext(spec, api, nil) +// minimal valid swagger 2.0 document, sufficient for round-tripping bytes +// without dragging in the petstore fixture (which lives in an internal/ +// package of the parent runtime module and is not importable from here). +var testSpec = []byte(`{"swagger":"2.0","info":{"title":"Test","version":"1.0.0"},"paths":{}}`) - t.Run("Spec handler", func(t *testing.T) { - handler := Spec("", ctx.spec.Raw(), nil) +func TestServeSpecMiddleware(t *testing.T) { + t.Run("ServeSpec handler", func(t *testing.T) { + handler := ServeSpec("", testSpec, nil) t.Run("serves spec", func(t *testing.T) { request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/swagger.json", nil) require.NoError(t, err) - request.Header.Add(runtime.HeaderContentType, runtime.JSONMime) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) assert.EqualT(t, http.StatusOK, recorder.Code) responseHeaders := recorder.Result().Header - responseContentType := responseHeaders.Get("Content-Type") + responseContentType := responseHeaders.Get(contentTypeHeader) assert.EqualT(t, applicationJSON, responseContentType) - responseBody := recorder.Body - require.NotNil(t, responseBody) - require.JSONEqT(t, string(spec.Raw()), responseBody.String()) + require.JSONEqT(t, string(testSpec), recorder.Body.String()) }) t.Run("returns 404 when no next handler", func(t *testing.T) { request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/api/pets", nil) require.NoError(t, err) - request.Header.Add(runtime.HeaderContentType, runtime.JSONMime) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) @@ -52,12 +47,11 @@ func TestServeSpecMiddleware(t *testing.T) { }) t.Run("forwards to next handler for other url", func(t *testing.T) { - handler = Spec("", ctx.spec.Raw(), http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + handler = ServeSpec("", testSpec, http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusOK) })) request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/api/pets", nil) require.NoError(t, err) - request.Header.Add(runtime.HeaderContentType, runtime.JSONMime) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) @@ -65,8 +59,8 @@ func TestServeSpecMiddleware(t *testing.T) { }) }) - t.Run("Spec handler with options", func(t *testing.T) { - handler := Spec("/swagger", ctx.spec.Raw(), nil, + t.Run("ServeSpec handler with options", func(t *testing.T) { + handler := ServeSpec("/swagger", testSpec, nil, WithSpecPath("spec"), WithSpecDocument("myapi-swagger.json"), ) @@ -74,7 +68,6 @@ func TestServeSpecMiddleware(t *testing.T) { t.Run("serves spec", func(t *testing.T) { request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/swagger/spec/myapi-swagger.json", nil) require.NoError(t, err) - request.Header.Add(runtime.HeaderContentType, runtime.JSONMime) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) @@ -84,7 +77,6 @@ func TestServeSpecMiddleware(t *testing.T) { t.Run("should not find spec there", func(t *testing.T) { request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/swagger.json", nil) require.NoError(t, err) - request.Header.Add(runtime.HeaderContentType, runtime.JSONMime) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) diff --git a/middleware/swaggerui.go b/server-middleware/docui/swaggerui.go similarity index 98% rename from middleware/swaggerui.go rename to server-middleware/docui/swaggerui.go index 14ed37ce..984faf85 100644 --- a/middleware/swaggerui.go +++ b/server-middleware/docui/swaggerui.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "bytes" @@ -65,9 +65,9 @@ func (r *SwaggerUIOpts) EnsureDefaultsOauth2() { } func (r *SwaggerUIOpts) ensureDefaults() { - common := toCommonUIOptions(r) + common := ToCommonUIOptions(r) common.EnsureDefaults() - fromCommonToAnyOptions(common, r) + FromCommonToAnyOptions(common, r) // swaggerui-specifics if r.OAuthCallbackURL == "" { diff --git a/middleware/swaggerui_oauth2.go b/server-middleware/docui/swaggerui_oauth2.go similarity index 99% rename from middleware/swaggerui_oauth2.go rename to server-middleware/docui/swaggerui_oauth2.go index 879bdbaa..8adb96a0 100644 --- a/middleware/swaggerui_oauth2.go +++ b/server-middleware/docui/swaggerui_oauth2.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "bytes" diff --git a/middleware/swaggerui_oauth2_test.go b/server-middleware/docui/swaggerui_oauth2_test.go similarity index 92% rename from middleware/swaggerui_oauth2_test.go rename to server-middleware/docui/swaggerui_oauth2_test.go index 33cafd7c..4d73f489 100644 --- a/middleware/swaggerui_oauth2_test.go +++ b/server-middleware/docui/swaggerui_oauth2_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "context" @@ -37,11 +37,7 @@ func TestSwaggerUIOAuth2CallbackMiddleware(t *testing.T) { t.Run("with custom template that fails to execute", func(t *testing.T) { assert.Panics(t, func() { SwaggerUIOAuth2Callback(SwaggerUIOpts{ - Template: ` - - spec-url='{{ .Unknown }}' - -`, + Template: badTemplate, }, nil) }) }) diff --git a/middleware/swaggerui_test.go b/server-middleware/docui/swaggerui_test.go similarity index 95% rename from middleware/swaggerui_test.go rename to server-middleware/docui/swaggerui_test.go index d1b9a180..f0c5f57a 100644 --- a/middleware/swaggerui_test.go +++ b/server-middleware/docui/swaggerui_test.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "context" @@ -60,11 +60,7 @@ func TestSwaggerUIMiddleware(t *testing.T) { t.Run("with custom template that fails to execute", func(t *testing.T) { assert.Panics(t, func() { SwaggerUI(SwaggerUIOpts{ - Template: ` - - spec-url='{{ .Unknown }}' - -`, + Template: badTemplate, }, nil) }) }) diff --git a/server-middleware/go.mod b/server-middleware/go.mod new file mode 100644 index 00000000..a3824642 --- /dev/null +++ b/server-middleware/go.mod @@ -0,0 +1,5 @@ +module github.com/go-openapi/runtime/server-middleware + +go 1.25.0 + +require github.com/go-openapi/testify/v2 v2.5.0 diff --git a/server-middleware/go.sum b/server-middleware/go.sum new file mode 100644 index 00000000..3fecac05 --- /dev/null +++ b/server-middleware/go.sum @@ -0,0 +1,2 @@ +github.com/go-openapi/testify/v2 v2.5.0 h1:UOCr63aAsMIDydZbZGqo5Ev01D4eydItRbekDuZMJLw= +github.com/go-openapi/testify/v2 v2.5.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= From e8f4ba62d08ea26e9f9c02767046c376a6ddd2b8 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Wed, 6 May 2026 15:43:11 +0200 Subject: [PATCH 2/6] feat(mediatype): typed media-type and symmetric Accept negotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a new server-middleware/mediatype package with a typed MediaType value, a Set type, and a single asymmetric Matches rule used by both server-side validation and Accept-header negotiation. The rule faithfully reproduces the parameter-aware match from #136 — bare types must agree (with wildcards), and if the receiver carries parameters the constraint's parameters must be a subset (case-insensitive values). Both call sites are rewired on top of mediatype: - middleware.validateContentType uses MediaType.Matches; the mediaTypeMatches helper is removed. - middleware.NegotiateContentType uses Set.BestMatch; normalizeOffer is no longer applied to negotiation candidates. Encoding negotiation (NegotiateContentEncoding) is unchanged — encoding tokens carry no parameters. The default behavior of NegotiateContentType therefore changes: an Accept entry like "text/plain;charset=ascii" no longer silently matches a "text/plain;charset=utf-8" offer. For applications that relied on the loose pre-v0.30 match, an explicit opt-out is provided: - WithIgnoreParameters(bool) NegotiateOption — a per-call option that strips parameters from both sides before matching. - Context.SetIgnoreParameters(bool) — server-wide toggle, threaded into the internal Respond / ResponseFormat negotiation calls. The opt-out is documented with a runnable godoc Example (ExampleWithIgnoreParameters) showing both modes side-by-side. Tests: - mediatype/* exhaustive table-driven coverage: parsing, wildcard handling, parameter subset rule, q-value clamping, BestMatch against the full historic negotiate_test.go matrix. - middleware/negotiate_test split into TestNegotiateContentTypeDefault (param-honouring) and TestNegotiateContentTypeIgnoreParameters (legacy mode), plus a multi-Accept-header guard (RFC 7230 §3.2.2) and the godoc Example. * fixes #386 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Frederic BIDON --- middleware/context.go | 38 +- middleware/negotiate.go | 175 ++++++--- middleware/negotiate_test.go | 230 +++++++++--- middleware/validation.go | 66 ++-- server-middleware/mediatype/doc.go | 28 ++ server-middleware/mediatype/mediatype.go | 160 ++++++++ server-middleware/mediatype/mediatype_test.go | 343 ++++++++++++++++++ server-middleware/mediatype/set.go | 120 ++++++ 8 files changed, 1020 insertions(+), 140 deletions(-) create mode 100644 server-middleware/mediatype/doc.go create mode 100644 server-middleware/mediatype/mediatype.go create mode 100644 server-middleware/mediatype/mediatype_test.go create mode 100644 server-middleware/mediatype/set.go diff --git a/middleware/context.go b/middleware/context.go index aa4571a8..6fad84d3 100644 --- a/middleware/context.go +++ b/middleware/context.go @@ -77,11 +77,27 @@ func (fn ResponderFunc) WriteResponse(rw http.ResponseWriter, pr runtime.Produce // used throughout to store request context with the standard context attached // to the [http.Request]. type Context struct { - spec *loads.Document - analyzer *analysis.Spec - api RoutableAPI - router Router - debugLogf func(string, ...any) // a logging function to debug context and all components using it + spec *loads.Document + analyzer *analysis.Spec + api RoutableAPI + router Router + debugLogf func(string, ...any) // a logging function to debug context and all components using it + ignoreParameters bool // see SetIgnoreParameters / WithIgnoreParameters +} + +// SetIgnoreParameters toggles the legacy parameter-stripping behaviour for +// Accept negotiation server-wide. When set, every internal call to +// [NegotiateContentType] from this Context applies [WithIgnoreParameters]. +// +// Returns the receiver for fluent configuration: +// +// ctx := middleware.NewContext(spec, api, nil).SetIgnoreParameters(true) +// +// See [WithIgnoreParameters] for the rationale and an example. +func (c *Context) SetIgnoreParameters(ignore bool) *Context { + c.ignoreParameters = ignore + + return c } type routableUntypedAPI struct { @@ -354,7 +370,7 @@ func (c *Context) BindValidRequest(request *http.Request, route *MatchedRoute, b requestContentType = "*/*" } - if str := NegotiateContentType(request, route.Produces, requestContentType); str == "" { + if str := NegotiateContentType(request, route.Produces, requestContentType, c.negotiateOpts()...); str == "" { res = append(res, errors.InvalidResponseFormat(request.Header.Get(runtime.HeaderAccept), route.Produces)) } } @@ -433,7 +449,7 @@ func (c *Context) ResponseFormat(r *http.Request, offers []string) (string, *htt return v, r } - format := NegotiateContentType(r, offers, "") + format := NegotiateContentType(r, offers, "", c.negotiateOpts()...) if format != "" { c.debugLogf("[%s %s] set response format %q in context", r.Method, r.URL.Path, format) r = r.WithContext(stdContext.WithValue(rCtx, ctxResponseFormat, format)) @@ -715,6 +731,14 @@ func (c Context) uiOptionsForHandler(opts []UIOption) (string, docui.UIOptions, return pth, uiOpts, []SpecOption{docui.WithSpecDocument(doc)} } +func (c *Context) negotiateOpts() []NegotiateOption { + if !c.ignoreParameters { + return nil + } + + return []NegotiateOption{WithIgnoreParameters(true)} +} + func cantFindProducer(format string) string { return "can't find a producer for " + format } diff --git a/middleware/negotiate.go b/middleware/negotiate.go index cb0a8528..3e5d79d8 100644 --- a/middleware/negotiate.go +++ b/middleware/negotiate.go @@ -7,7 +7,7 @@ // license that can be found in the LICENSE file or at // https://developers.google.com/open-source/licenses/bsd. -// this file was taken from the github.com/golang/gddo repository +// this file was originally based on github.com/golang/gddo package middleware @@ -16,12 +16,56 @@ import ( "strings" "github.com/go-openapi/runtime/middleware/header" + "github.com/go-openapi/runtime/server-middleware/mediatype" ) +// NegotiateOption configures [NegotiateContentType] behaviour. +type NegotiateOption func(*negotiateOptions) + +type negotiateOptions struct { + ignoreParameters bool +} + +func negotiateOptionsWithDefaults(opts []NegotiateOption) negotiateOptions { + var o negotiateOptions + for _, apply := range opts { + apply(&o) + } + + return o +} + +// WithIgnoreParameters returns a [NegotiateOption] that strips MIME-type +// parameters from both Accept entries and offers before matching, restoring +// the behaviour the runtime had before v0.30. +// +// New code should leave parameters honoured (the default). This option +// exists for applications that depend on the looser pre-v0.30 match — +// most often because their producers and Accept clients use mismatched +// charset or version params that they treat as informational. +// +// Example — per-call opt-out: +// +// chosen := middleware.NegotiateContentType(r, offers, "", +// middleware.WithIgnoreParameters(true), +// ) +// +// Example — server-wide opt-out: +// +// ctx := middleware.NewContext(spec, api, nil).SetIgnoreParameters(true) +func WithIgnoreParameters(ignore bool) NegotiateOption { + return func(o *negotiateOptions) { + o.ignoreParameters = ignore + } +} + // NegotiateContentEncoding returns the best offered content encoding for the // request's Accept-Encoding header. If two offers match with equal weight and // then the offer earlier in the list is preferred. If no offers are // acceptable, then "" is returned. +// +// Encoding tokens have no parameters, so this function is unaffected by +// the v0.30 parameter-honouring change to [NegotiateContentType]. func NegotiateContentEncoding(r *http.Request, offers []string) string { bestOffer := "identity" bestQ := -1.0 @@ -38,55 +82,98 @@ func NegotiateContentEncoding(r *http.Request, offers []string) string { if bestQ == 0 { bestOffer = "" } + return bestOffer } -// NegotiateContentType returns the best offered content type for the request's -// Accept header. If two offers match with equal weight, then the more specific -// offer is preferred. For example, text/* trumps */*. If two offers match -// with equal weight and specificity, then the offer earlier in the list is -// preferred. If no offers match, then defaultOffer is returned. -func NegotiateContentType(r *http.Request, offers []string, defaultOffer string) string { - bestOffer := defaultOffer - bestQ := -1.0 - bestWild := 3 - specs := header.ParseAccept(r.Header, "Accept") - for _, rawOffer := range offers { - offer := normalizeOffer(rawOffer) - // No Accept header: just return the first offer. - if len(specs) == 0 { - return rawOffer +// NegotiateContentType returns the best offered content type for the +// request's Accept header. If two offers match with equal weight, then +// the more specific offer is preferred (text/* trumps */*; type/subtype +// trumps type/*). If two offers match with equal weight and specificity, +// then the offer earlier in the list is preferred. If no offers match, +// then defaultOffer is returned. +// +// As of v0.30 the matching rule honours MIME-type parameters: an Accept +// entry of "text/plain;charset=utf-8" matches an offer of bare +// "text/plain" (offer carries no constraint), but it does NOT match an +// offer of "text/plain;charset=ascii" (charset values disagree). Pass +// [WithIgnoreParameters](true) to restore the pre-v0.30 behaviour where +// parameters were stripped before matching — see [WithIgnoreParameters] +// for details and an example. +// +// When the Accept header is absent, the first offer is returned +// unchanged (param-stripping is irrelevant in that case). +func NegotiateContentType(r *http.Request, offers []string, defaultOffer string, opts ...NegotiateOption) string { + if len(offers) == 0 { + return defaultOffer + } + o := negotiateOptionsWithDefaults(opts) + + // Per RFC 7230 §3.2.2, multiple Accept headers are equivalent to a + // single comma-joined value. Join before parsing so we don't drop + // later entries. + acceptValues := r.Header.Values("Accept") + if len(acceptValues) == 0 { + return offers[0] + } + acceptSet := mediatype.ParseAccept(strings.Join(acceptValues, ", ")) + if len(acceptSet) == 0 { + return defaultOffer + } + + offerSet := make(mediatype.Set, 0, len(offers)) + rawByIdx := make([]string, 0, len(offers)) + for _, raw := range offers { + mt, err := mediatype.Parse(raw) + if err != nil { + continue } - for _, spec := range specs { - switch { - case spec.Q == 0.0: - // ignore - case spec.Q < bestQ: - // better match found - case spec.Value == "*/*": - if spec.Q > bestQ || bestWild > 2 { - bestQ = spec.Q - bestWild = 2 - bestOffer = rawOffer - } - case strings.HasSuffix(spec.Value, "/*"): - if strings.HasPrefix(offer, spec.Value[:len(spec.Value)-1]) && - (spec.Q > bestQ || bestWild > 1) { - bestQ = spec.Q - bestWild = 1 - bestOffer = rawOffer - } - default: - if spec.Value == offer && - (spec.Q > bestQ || bestWild > 0) { - bestQ = spec.Q - bestWild = 0 - bestOffer = rawOffer - } - } + offerSet = append(offerSet, mt) + rawByIdx = append(rawByIdx, raw) + } + if len(offerSet) == 0 { + return defaultOffer + } + + if o.ignoreParameters { + acceptSet = stripSet(acceptSet) + offerSet = stripSet(offerSet) + } + + best, ok := acceptSet.BestMatch(offerSet) + if !ok { + return defaultOffer + } + // Return the original raw offer string so callers receive the value + // they declared, with its parameters preserved. + for i, mt := range offerSet { + if mt.Type == best.Type && mt.Subtype == best.Subtype && sameParams(mt.Params, best.Params) { + return rawByIdx[i] } } - return bestOffer + return best.String() +} + +func stripSet(s mediatype.Set) mediatype.Set { + out := make(mediatype.Set, len(s)) + for i, m := range s { + out[i] = m.StripParams() + } + + return out +} + +func sameParams(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + + return true } func normalizeOffers(orig []string) (norm []string) { diff --git a/middleware/negotiate_test.go b/middleware/negotiate_test.go index 7530c858..e0e50856 100644 --- a/middleware/negotiate_test.go +++ b/middleware/negotiate_test.go @@ -7,11 +7,31 @@ // license that can be found in the LICENSE file or at // https://developers.google.com/open-source/licenses/bsd. -package middleware +package middleware_test import ( + "fmt" "net/http" "testing" + + "github.com/go-openapi/runtime/middleware" +) + +// Test fixtures: extracted to dedup goconst hits across the table-driven cases. +const ( + headerAccept = "Accept" + imagePNG = "image/png" + imageJPG = "image/jpg" + jsonMime = "application/json" + jsonUTF8 = "application/json; charset=utf-8" + textPlainASCII = "text/plain;charset=ascii" + + textPlainUTF8 = "text/plain;charset=utf-8" + jsonV1 = "application/json;version=1" + htmlPNGq05 = "text/html, image/png; q=0.5" + xy = "x/y" + + textHTML = "text/html" ) var negotiateContentEncodingTests = []struct { @@ -24,66 +44,182 @@ var negotiateContentEncodingTests = []struct { {"gzip", []string{"identity", "gzip"}, "gzip"}, } -func TestNegotiateContentEnoding(t *testing.T) { +func TestNegotiateContentEncoding(t *testing.T) { for _, tt := range negotiateContentEncodingTests { r := &http.Request{Header: http.Header{"Accept-Encoding": {tt.s}}} - actual := NegotiateContentEncoding(r, tt.offers) + actual := middleware.NegotiateContentEncoding(r, tt.offers) if actual != tt.expect { t.Errorf("NegotiateContentEncoding(%q, %#v)=%q, want %q", tt.s, tt.offers, actual, tt.expect) } } } -var negotiateContentTypeTests = []struct { - s string - offers []string - defaultOffer string - expect string -}{ - {"text/html, */*;q=0", []string{"x/y"}, "", ""}, - {"text/html, */*", []string{"x/y"}, "", "x/y"}, - {"text/html, image/png", []string{"text/html", "image/png"}, "", "text/html"}, - {"text/html, image/png", []string{"image/png", "text/html"}, "", "image/png"}, - {"text/html, image/png; q=0.5", []string{"image/png"}, "", "image/png"}, - {"text/html, image/png; q=0.5", []string{"text/html"}, "", "text/html"}, - {"text/html, image/png; q=0.5", []string{"foo/bar"}, "", ""}, - {"text/html, image/png; q=0.5", []string{"image/png", "text/html"}, "", "text/html"}, - {"text/html, image/png; q=0.5", []string{"text/html", "image/png"}, "", "text/html"}, - {"text/html;q=0.5, image/png", []string{"image/png"}, "", "image/png"}, - {"text/html;q=0.5, image/png", []string{"text/html"}, "", "text/html"}, - {"text/html;q=0.5, image/png", []string{"image/png", "text/html"}, "", "image/png"}, - {"text/html;q=0.5, image/png", []string{"text/html", "image/png"}, "", "image/png"}, - {"text/html;q=0.5, image/png", []string{"text/html", "image/png"}, "", "image/png"}, - {"image/png, image/*;q=0.5", []string{"image/jpg", "image/png"}, "", "image/png"}, - {"image/png, image/*;q=0.5", []string{"image/jpg"}, "", "image/jpg"}, - {"image/png, image/*;q=0.5", []string{"image/jpg", "image/gif"}, "", "image/jpg"}, - {"image/png, image/*", []string{"image/jpg", "image/gif"}, "", "image/jpg"}, - {"image/png, image/*", []string{"image/gif", "image/jpg"}, "", "image/gif"}, - {"image/png, image/*", []string{"image/gif", "image/png"}, "", "image/png"}, - {"image/png, image/*", []string{"image/png", "image/gif"}, "", "image/png"}, - {"application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily;encoding=delimited;q=0.7,text/plain;version=0.0.4;q=0.3", []string{"text/plain"}, "", "text/plain"}, - {"application/json", []string{"application/json; charset=utf-8", "image/png"}, "", "application/json; charset=utf-8"}, - {"application/json; charset=utf-8", []string{"application/json; charset=utf-8", "image/png"}, "", "application/json; charset=utf-8"}, - {"application/json", []string{"application/vnd.cia.v1+json"}, "", ""}, - // Default header of java clients - {"text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2", []string{"application/json"}, "", "application/json"}, +// TestNegotiateContentTypeDefault asserts the v0.30+ default behaviour: +// MIME parameters are honoured by both sides of the match. +// +// Cases inherited from the legacy test suite (which predate the +// parameter-honouring change) keep their original outcomes — they all use +// either bare types or matching params, so honouring vs stripping is a +// no-op for them. +func TestNegotiateContentTypeDefault(t *testing.T) { + cases := []struct { + name string + acceptHeader string + offers []string + defaultOffer string + expect string + }{ + // --- legacy cases (parameters not in conflict) --- + {"reject-all via q=0", "text/html, */*;q=0", []string{xy}, "", ""}, + {"wildcard catches anything", "text/html, */*", []string{xy}, "", xy}, + {"first offer wins on tie", "text/html, image/png", []string{textHTML, imagePNG}, "", textHTML}, + {"first offer wins on tie (rev)", "text/html, image/png", []string{imagePNG, textHTML}, "", imagePNG}, + {"non-default match", htmlPNGq05, []string{imagePNG}, "", imagePNG}, + {"q wins over position", htmlPNGq05, []string{textHTML}, "", textHTML}, + {"no match returns default", htmlPNGq05, []string{"foo/bar"}, "", ""}, + {"image/png beats image/* on specificity", "image/png, image/*;q=0.5", []string{imageJPG, imagePNG}, "", imagePNG}, + {"image/* matches jpg", "image/png, image/*;q=0.5", []string{imageJPG}, "", imageJPG}, + {"vendor MIME unmatched (no structural match)", jsonMime, []string{"application/vnd.cia.v1+json"}, "", ""}, + {"java client default", "text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2", []string{jsonMime}, "", jsonMime}, + + // --- parameter-honouring matches (offer can satisfy a parametered Accept) --- + { + "bare client accept matches param-bearing offer (offer's params satisfy)", + jsonMime, []string{jsonUTF8, imagePNG}, "", + jsonUTF8, + }, + { + "exact param match", + jsonUTF8, []string{jsonUTF8, imagePNG}, "", + jsonUTF8, + }, + + // --- parameter-honouring rejects (the A.4 fix) --- + { + // Pre-v0.30 this matched (params stripped). Now: charset values + // disagree, so the offer no longer satisfies the Accept entry. + "client-asks-ascii vs offer-utf-8 → no match", + textPlainASCII, []string{textPlainUTF8}, "", + "", + }, + { + "version mismatch on vendor type → no match", + "application/json;version=2", []string{jsonV1}, "", + "", + }, + + // --- parameter case-insensitivity preserved --- + { + "value compare case-insensitive (UTF-8 vs utf-8)", + "text/plain;charset=UTF-8", []string{textPlainUTF8}, "", + textPlainUTF8, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := &http.Request{Header: http.Header{headerAccept: {c.acceptHeader}}} + got := middleware.NegotiateContentType(r, c.offers, c.defaultOffer) + if got != c.expect { + t.Errorf("NegotiateContentType(%q, %#v, %q) = %q, want %q", + c.acceptHeader, c.offers, c.defaultOffer, got, c.expect) + } + }) + } } -func TestNegotiateContentType(t *testing.T) { - for _, tt := range negotiateContentTypeTests { - r := &http.Request{Header: http.Header{"Accept": {tt.s}}} - actual := NegotiateContentType(r, tt.offers, tt.defaultOffer) - if actual != tt.expect { - t.Errorf("NegotiateContentType(%q, %#v, %q)=%q, want %q", tt.s, tt.offers, tt.defaultOffer, actual, tt.expect) - } +// TestNegotiateContentTypeIgnoreParameters covers the explicit opt-out: +// parameters are stripped before matching, restoring the pre-v0.30 +// behaviour. Notably, the cases that fail in default mode now succeed. +func TestNegotiateContentTypeIgnoreParameters(t *testing.T) { + cases := []struct { + name string + acceptHeader string + offers []string + defaultOffer string + expect string + }{ + { + "client-asks-ascii vs offer-utf-8 (legacy: matches bare)", + textPlainASCII, []string{textPlainUTF8}, "", + textPlainUTF8, + }, + { + "version mismatch (legacy: matches bare)", + "application/json;version=2", []string{jsonV1}, "", + jsonV1, + }, + // Outcomes that are identical in both modes — sanity checks that + // IgnoreParameters didn't break the easy cases. + { + "bare client accept matches param offer", + jsonMime, []string{jsonUTF8}, "", + jsonUTF8, + }, + { + "no match returns default (params don't help)", + imagePNG, []string{textPlainUTF8}, "", + "", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := &http.Request{Header: http.Header{headerAccept: {c.acceptHeader}}} + got := middleware.NegotiateContentType(r, c.offers, c.defaultOffer, + middleware.WithIgnoreParameters(true), + ) + if got != c.expect { + t.Errorf("NegotiateContentType(%q, %#v, %q, WithIgnoreParameters(true)) = %q, want %q", + c.acceptHeader, c.offers, c.defaultOffer, got, c.expect) + } + }) } } +// TestNegotiateContentTypeNoAcceptHeader: when Accept is absent the first +// offer is returned regardless of mode. Legacy guarantee, preserved. func TestNegotiateContentTypeNoAcceptHeader(t *testing.T) { r := &http.Request{Header: http.Header{}} - offers := []string{"application/json", "text/xml"} - actual := NegotiateContentType(r, offers, "") - if actual != "application/json" { - t.Errorf("NegotiateContentType(empty, %#v, empty)=%q, want %q", offers, actual, "application/json") + offers := []string{jsonMime, "text/xml"} + if got := middleware.NegotiateContentType(r, offers, ""); got != jsonMime { + t.Errorf("default mode: got %q, want %q", got, jsonMime) } + if got := middleware.NegotiateContentType(r, offers, "", middleware.WithIgnoreParameters(true)); got != jsonMime { + t.Errorf("ignore mode: got %q, want %q", got, jsonMime) + } +} + +// TestNegotiateContentTypeMultiHeader: multiple Accept header values are +// equivalent to a single comma-joined value (RFC 7230 §3.2.2). The legacy +// test suite's TestContentType_Issue172 relied on this — making the same +// guarantee explicit here. +func TestNegotiateContentTypeMultiHeader(t *testing.T) { + r := &http.Request{Header: http.Header{headerAccept: {"application/xml", jsonMime}}} + offers := []string{jsonMime} + if got := middleware.NegotiateContentType(r, offers, ""); got != jsonMime { + t.Errorf("got %q, want %q (later Accept value should still match)", got, jsonMime) + } +} + +// ExampleWithIgnoreParameters shows the per-call opt-out for legacy +// parameter-stripping behaviour. +func ExampleWithIgnoreParameters() { + r := &http.Request{Header: http.Header{headerAccept: {textPlainASCII}}} + offers := []string{textPlainUTF8} + + // Default: parameters are honoured. The charset values disagree, so + // no offer matches and we fall back to the default. + strict := middleware.NegotiateContentType(r, offers, "fallback/default") + + // Opt-out: strip parameters before matching. The bare types agree, so + // the offer is selected. + loose := middleware.NegotiateContentType(r, offers, "fallback/default", + middleware.WithIgnoreParameters(true), + ) + + fmt.Printf("strict=%q\nloose=%q\n", strict, loose) + // Output: + // strict="fallback/default" + // loose="text/plain;charset=utf-8" } diff --git a/middleware/validation.go b/middleware/validation.go index 8dca105b..2783de44 100644 --- a/middleware/validation.go +++ b/middleware/validation.go @@ -4,12 +4,13 @@ package middleware import ( - "mime" "net/http" "strings" "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/server-middleware/mediatype" ) type validation struct { @@ -20,61 +21,42 @@ type validation struct { bound map[string]any } -// ContentType validates the content type of a request. +// validateContentType validates a request's Content-Type against the route's +// declared consumes list, applying the parameter-aware matching rule from +// [mediatype.MediaType.Matches]: // -// An allowed entry may carry MIME type parameters (e.g. "text/plain;charset=utf-8"). -// In that case every parameter the client sends must be present on the allowed entry -// with the same value; the allowed entry may carry additional parameters the client -// omits. An allowed entry without parameters accepts any client parameters. -// "*/*" and "type/*" wildcards are matched on the bare type only. +// - bare types must agree (with "*/*" and "type/*" wildcards on the +// allowed side); +// - an allowed entry without parameters accepts any client parameters; +// - an allowed entry with parameters constrains the client — every +// parameter the client sends must be present on the allowed entry +// with the same value (case-insensitive). The allowed entry may +// carry additional parameters the client omits. func validateContentType(allowed []string, actual string) error { if len(allowed) == 0 { return nil } - actualType, actualParams, err := mime.ParseMediaType(actual) + actualMT, err := mediatype.Parse(actual) if err != nil { return errors.InvalidContentType(actual, allowed) } - typeWildcard := "" - if slash := strings.IndexByte(actualType, '/'); slash > 0 { - typeWildcard = actualType[:slash] + "/*" - } for _, a := range allowed { - if strings.EqualFold(a, "*/*") { - return nil - } - if typeWildcard != "" && strings.EqualFold(a, typeWildcard) { - return nil + allowedMT, perr := mediatype.Parse(a) + if perr != nil { + // Configured value isn't a valid media type — fall back to + // a case-insensitive bare comparison, preserving the + // pre-mediatype behaviour. + if strings.EqualFold(a, actual) { + return nil + } + continue } - if mediaTypeMatches(a, actualType, actualParams) { + if allowedMT.Matches(actualMT) { return nil } } - return errors.InvalidContentType(actual, allowed) -} -// mediaTypeMatches reports whether the actual client media type satisfies the -// server-side allowed media type, with parameter-aware comparison. -func mediaTypeMatches(allowed, actualType string, actualParams map[string]string) bool { - allowedType, allowedParams, err := mime.ParseMediaType(allowed) - if err != nil { - // Fall back to a case-insensitive bare match if the configured value - // can't be parsed as a media type. - return strings.EqualFold(allowed, actualType) - } - if !strings.EqualFold(allowedType, actualType) { - return false - } - if len(allowedParams) == 0 { - return true - } - for k, v := range actualParams { - sv, ok := allowedParams[k] - if !ok || !strings.EqualFold(sv, v) { - return false - } - } - return true + return errors.InvalidContentType(actual, allowed) } func validateRequest(ctx *Context, request *http.Request, route *MatchedRoute) *validation { diff --git a/server-middleware/mediatype/doc.go b/server-middleware/mediatype/doc.go new file mode 100644 index 00000000..5d27bf19 --- /dev/null +++ b/server-middleware/mediatype/doc.go @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package mediatype provides a typed value for RFC 7231 / RFC 2045 media +// types and the matching/selection primitives used by both server-side +// validation and Accept-header negotiation. +// +// The package is stdlib-only. +// +// # The matching rule +// +// [MediaType.Matches] is asymmetric. The receiver acts as the "bound" +// (an allowed entry on the server side, or a candidate offer when +// matching against an Accept entry); the argument is the constraint +// (the actual incoming request, or the Accept entry being satisfied). +// +// - bare type/subtype must agree, with wildcard handling on either +// side ("*/*" matches anything; "type/*" matches any subtype); +// - if the receiver carries no parameters, any constraint is +// accepted regardless of its parameters; +// - otherwise every (key,value) pair on the constraint must be +// present on the receiver, with case-insensitive value +// comparison. The receiver may carry additional parameters the +// constraint does not list. +// +// q-values are NOT considered by [MediaType.Matches] — they are the +// negotiator's concern, handled inside [Set.BestMatch]. +package mediatype diff --git a/server-middleware/mediatype/mediatype.go b/server-middleware/mediatype/mediatype.go new file mode 100644 index 00000000..69232294 --- /dev/null +++ b/server-middleware/mediatype/mediatype.go @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package mediatype + +import ( + "errors" + "fmt" + "mime" + "strconv" + "strings" +) + +const wildcard = "*" + +// Specificity scores returned by [MediaType.Specificity], ordered from +// least to most specific. +const ( + SpecificityAny = iota // "*/*" + SpecificityType // "type/*" + SpecificityExact // "type/subtype" (no params) + SpecificityExactWithParams // "type/subtype;k=v" +) + +// MediaType is a parsed RFC 7231 media type with optional parameters and +// an optional q-value (used by Accept negotiation). +// +// Type, Subtype and the keys of Params are lowercased. Parameter values +// are preserved verbatim; comparisons are case-insensitive (matching the +// pre-v0.30 behaviour and the common convention for charset, version, etc.). +type MediaType struct { + Type string + Subtype string + Params map[string]string + Q float64 +} + +// Parse parses a single media type. The input may carry parameters and a +// q-value; the q-value is extracted into [MediaType.Q] and removed from +// [MediaType.Params]. +// +// An empty input returns an error. +func Parse(s string) (MediaType, error) { + s = strings.TrimSpace(s) + if s == "" { + return MediaType{}, errors.New("mediatype: empty value") + } + full, params, err := mime.ParseMediaType(s) + if err != nil { + return MediaType{}, fmt.Errorf("mediatype: %w", err) + } + slash := strings.IndexByte(full, '/') + if slash <= 0 || slash == len(full)-1 { + return MediaType{}, fmt.Errorf("mediatype: %q has no subtype", s) + } + mt := MediaType{ + Type: full[:slash], + Subtype: full[slash+1:], + Q: 1.0, + } + if q, ok := params["q"]; ok { + if qf, perr := strconv.ParseFloat(q, 64); perr == nil { + if qf < 0 { + qf = 0 + } + if qf > 1 { + qf = 1 + } + mt.Q = qf + } + delete(params, "q") + } + if len(params) > 0 { + mt.Params = params + } + + return mt, nil +} + +// String renders the canonical "type/subtype;k=v;k=v" form. Parameters are +// emitted in lexicographic key order (the standard library guarantees this) +// so the result is stable. The q-value is NOT emitted — it is meta, not +// part of the media type identity. +func (m MediaType) String() string { + if m.Type == "" && m.Subtype == "" { + return "" + } + + return mime.FormatMediaType(m.Type+"/"+m.Subtype, m.Params) +} + +// Matches reports whether the receiver accepts other, per the package +// documentation: the receiver is the bound, other is the constraint. +func (m MediaType) Matches(other MediaType) bool { + if !typeAgrees(m.Type, other.Type) { + return false + } + if !subtypeAgrees(m.Type, m.Subtype, other.Type, other.Subtype) { + return false + } + if len(m.Params) == 0 { + return true + } + for k, v := range other.Params { + sv, ok := m.Params[k] + if !ok || !strings.EqualFold(sv, v) { + return false + } + } + + return true +} + +// Specificity returns a numeric score for ordering matches. Higher is more +// specific. The returned value is one of [SpecificityAny], +// [SpecificityType], [SpecificityExact] or [SpecificityExactWithParams]. +func (m MediaType) Specificity() int { + if m.Type == wildcard && m.Subtype == wildcard { + return SpecificityAny + } + if m.Subtype == wildcard { + return SpecificityType + } + if len(m.Params) == 0 { + return SpecificityExact + } + + return SpecificityExactWithParams +} + +// typeAgrees reports whether two top-level types match, allowing "*" on +// either side. A type of "*" without a "*" subtype is rejected per RFC +// 7231 §5.3.2 ("*/sub" is not valid), but Parse never produces such a +// shape — it goes through mime.ParseMediaType. +func typeAgrees(a, b string) bool { + return a == wildcard || b == wildcard || a == b +} + +// subtypeAgrees handles the "type/*" wildcard: the bare type must match +// (a "*/*" pair has already been accepted by typeAgrees above). +func subtypeAgrees(at, asub, bt, bsub string) bool { + if at == wildcard || bt == wildcard { + // at least one side is "*/*" or "*/sub". With typeAgrees having + // returned true, we accept. + return true + } + if asub == wildcard || bsub == wildcard { + return true + } + + return asub == bsub +} + +// StripParams returns a copy of m with no parameters. Q is preserved +// because it drives negotiation ordering, not media-type identity. +// +// Useful for the legacy "ignore parameters" negotiation mode. +func (m MediaType) StripParams() MediaType { + return MediaType{Type: m.Type, Subtype: m.Subtype, Q: m.Q} +} diff --git a/server-middleware/mediatype/mediatype_test.go b/server-middleware/mediatype/mediatype_test.go new file mode 100644 index 00000000..4184eb5c --- /dev/null +++ b/server-middleware/mediatype/mediatype_test.go @@ -0,0 +1,343 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package mediatype + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// Test fixtures: extracted to dedup goconst hits in the table-driven cases. +const ( + textPlain = "text/plain" + textPlainUTF8 = "text/plain;charset=utf-8" + textHTML = "text/html" + textWild = "text/*" + starStar = "*/*" + imagePNG = "image/png" + imageJPG = "image/jpg" + imageGIF = "image/gif" + imageWild = "image/*" + jsonMime = "application/json" + xy = "x/y" + htmlPNGq05 = "text/html, image/png; q=0.5" + html05PNG = "text/html;q=0.5, image/png" + pngWildq05 = "image/png, image/*;q=0.5" + pngWild = "image/png, image/*" + + // Component fragments referenced as expected values. + tApp = "application" + tText = "text" + tHTML = "html" + tJSON = "json" + tPlain = "plain" + pCharset = "charset" + vUTF8 = "utf-8" +) + +func TestParse(t *testing.T) { + t.Run("happy paths", func(t *testing.T) { + cases := []struct { + in string + wantType string + wantSub string + wantQ float64 + wantPars map[string]string + }{ + {jsonMime, tApp, tJSON, 1.0, nil}, + {" Application/JSON ", tApp, tJSON, 1.0, nil}, + {textPlainUTF8, tText, tPlain, 1.0, map[string]string{pCharset: vUTF8}}, + {"text/plain; charset=utf-8", tText, tPlain, 1.0, map[string]string{pCharset: vUTF8}}, + {"text/html;q=0.5", tText, tHTML, 0.5, nil}, + {"text/html;q=0", tText, tHTML, 0.0, nil}, + {"text/html;q=1", tText, tHTML, 1.0, nil}, + {"text/html;q=0.7;version=2", tText, tHTML, 0.7, map[string]string{"version": "2"}}, + {textWild, tText, "*", 1.0, nil}, + {starStar, "*", "*", 1.0, nil}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + got, err := Parse(c.in) + require.NoError(t, err) + assert.EqualT(t, c.wantType, got.Type) + assert.EqualT(t, c.wantSub, got.Subtype) + assert.EqualT(t, c.wantQ, got.Q) + if c.wantPars == nil { + assert.Nil(t, got.Params) + } else { + assert.EqualValues(t, c.wantPars, got.Params) + } + }) + } + }) + + t.Run("invalid", func(t *testing.T) { + invalid := []string{ + "", + " ", + "application", + "application/", + "/json", + "application(", + "application/json;char*", + } + for _, s := range invalid { + t.Run(s, func(t *testing.T) { + _, err := Parse(s) + assert.Error(t, err) + }) + } + }) + + t.Run("q clamped to [0,1]", func(t *testing.T) { + got, err := Parse("text/plain;q=2") + require.NoError(t, err) + assert.EqualT(t, 1.0, got.Q) + + got, err = Parse("text/plain;q=-0.5") + require.NoError(t, err) + assert.EqualT(t, 0.0, got.Q) + }) + + t.Run("malformed q ignored, default 1.0", func(t *testing.T) { + got, err := Parse("text/plain;q=garbage") + require.NoError(t, err) + assert.EqualT(t, 1.0, got.Q) + }) +} + +func TestString(t *testing.T) { + cases := []struct { + in MediaType + want string + }{ + {MediaType{Type: "application", Subtype: "json"}, jsonMime}, + {MediaType{Type: "text", Subtype: "plain", Params: map[string]string{pCharset: vUTF8}}, "text/plain; charset=utf-8"}, + {MediaType{Type: "*", Subtype: "*"}, starStar}, + {MediaType{}, ""}, + } + for _, c := range cases { + t.Run(c.want, func(t *testing.T) { + assert.EqualT(t, c.want, c.in.String()) + }) + } +} + +func TestRoundtrip(t *testing.T) { + // String() must be re-parseable to an equivalent value. + inputs := []string{ + jsonMime, + textPlainUTF8, + "application/vnd.api+json", + "multipart/form-data;boundary=xyz", + } + for _, s := range inputs { + t.Run(s, func(t *testing.T) { + m1, err := Parse(s) + require.NoError(t, err) + s2 := m1.String() + m2, err := Parse(s2) + require.NoError(t, err) + assert.EqualT(t, m1.Type, m2.Type) + assert.EqualT(t, m1.Subtype, m2.Subtype) + assert.EqualValues(t, m1.Params, m2.Params) + }) + } +} + +func TestSpecificity(t *testing.T) { + cases := []struct { + s string + want int + }{ + {starStar, 0}, + {textWild, 1}, + {textPlain, 2}, + {textPlainUTF8, 3}, + } + for _, c := range cases { + t.Run(c.s, func(t *testing.T) { + m, err := Parse(c.s) + require.NoError(t, err) + assert.EqualT(t, c.want, m.Specificity()) + }) + } +} + +func TestMatches(t *testing.T) { + cases := []struct { + name string + bound string // receiver + other string // argument + wantMatch bool + }{ + // type-level + {"identical bare", textPlain, textPlain, true}, + {"different bare", textPlain, textHTML, false}, + {"different top-level type", textPlain, jsonMime, false}, + + // wildcards on the bound + {"bound */*", starStar, "anything/whatever", true}, + {"bound type/*", textWild, textPlain, true}, + {"bound type/* mismatched type", textWild, imagePNG, false}, + + // wildcards on the constraint + {"other */*", textPlain, starStar, true}, + {"other type/*", textPlain, textWild, true}, + {"other type/* mismatched type", textPlain, imageWild, false}, + + // param subset rule (#136) + {"bare bound, params other → accept", textPlain, textPlainUTF8, true}, + {"bound has params, bare other → accept (no constraint)", textPlainUTF8, textPlain, true}, + {"exact param match", textPlainUTF8, textPlainUTF8, true}, + {"value differs → reject", textPlainUTF8, "text/plain;charset=ascii", false}, + {"key not in bound → reject", textPlainUTF8, "text/plain;version=2", false}, + {"value compare case-insensitive", textPlainUTF8, "text/plain;charset=UTF-8", true}, + {"key compare lowercased at parse", "text/plain;CHARSET=utf-8", textPlainUTF8, true}, + {"bound has extra param, other subset → accept", + "text/plain;charset=utf-8;boundary=xyz", + textPlainUTF8, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + b, err := Parse(c.bound) + require.NoError(t, err) + o, err := Parse(c.other) + require.NoError(t, err) + assert.EqualT(t, c.wantMatch, b.Matches(o), "%q.Matches(%q)", c.bound, c.other) + }) + } +} + +func TestParseAccept(t *testing.T) { + t.Run("empty", func(t *testing.T) { + got := ParseAccept("") + assert.Nil(t, got) + }) + + t.Run("single", func(t *testing.T) { + got := ParseAccept(jsonMime) + require.Len(t, got, 1) + assert.EqualT(t, tApp, got[0].Type) + assert.EqualT(t, tJSON, got[0].Subtype) + }) + + t.Run("multiple with q-values", func(t *testing.T) { + got := ParseAccept("text/html;q=0.8, application/json, */*;q=0.1") + require.Len(t, got, 3) + assert.EqualT(t, 0.8, got[0].Q) + assert.EqualT(t, 1.0, got[1].Q) + assert.EqualT(t, 0.1, got[2].Q) + }) + + t.Run("malformed entries skipped", func(t *testing.T) { + got := ParseAccept("application/json, garbage(, text/plain") + require.Len(t, got, 2) + assert.EqualT(t, tJSON, got[0].Subtype) + assert.EqualT(t, tPlain, got[1].Subtype) + }) + + t.Run("quoted comma not split", func(t *testing.T) { + got := ParseAccept(`text/plain;foo="a,b", text/html`) + require.Len(t, got, 2) + }) +} + +func TestBestMatch(t *testing.T) { + type row struct { + name string + accept string + offered []string + wantBest string // empty means no match + } + + // All rows below are reproductions of the legacy negotiate_test.go + // cases (see middleware/negotiate_test.go), confirming the new + // algorithm yields identical answers under default behaviour for + // inputs that have no parameters or have parameters where both + // sides agree. Cases where parameters disagree (the A.4 fix) are + // covered separately in the negotiate_test.go matrix. + rows := []row{ + {"reject all via q=0", "text/html, */*;q=0", []string{xy}, ""}, + {"wildcard catches anything", "text/html, */*", []string{xy}, xy}, + {"first offer wins on tie", "text/html, image/png", []string{textHTML, imagePNG}, textHTML}, + {"first offer wins on tie (reversed)", "text/html, image/png", []string{imagePNG, textHTML}, imagePNG}, + {"accept earlier specific beats generic", htmlPNGq05, []string{imagePNG}, imagePNG}, + {"q wins over position", htmlPNGq05, []string{textHTML}, textHTML}, + {"no offer matches", htmlPNGq05, []string{"foo/bar"}, ""}, + {"higher q wins", htmlPNGq05, []string{imagePNG, textHTML}, textHTML}, + {"higher q wins even when offer lists png first", htmlPNGq05, []string{textHTML, imagePNG}, textHTML}, + {"higher q overrides offer order", html05PNG, []string{imagePNG}, imagePNG}, + {"higher q overrides offer order 2", html05PNG, []string{textHTML}, textHTML}, + {"higher q image/png beats text/html;q=0.5", html05PNG, []string{imagePNG, textHTML}, imagePNG}, + {"text/html;q=0.5, image/png with both offers", html05PNG, []string{textHTML, imagePNG}, imagePNG}, + {"image/png beats image/* on specificity", pngWildq05, []string{imageJPG, imagePNG}, imagePNG}, + {"image/* matches jpg", pngWildq05, []string{imageJPG}, imageJPG}, + {"image/* matches first jpg, jpg before gif", pngWildq05, []string{imageJPG, imageGIF}, imageJPG}, + {"image/* matches both, first wins", pngWild, []string{imageJPG, imageGIF}, imageJPG}, + {"image/* matches gif first", pngWild, []string{imageGIF, imageJPG}, imageGIF}, + {"image/png beats image/* on specificity (2)", pngWild, []string{imageGIF, imagePNG}, imagePNG}, + {"image/png beats image/* (offer order doesn't override)", pngWild, []string{imagePNG, imageGIF}, imagePNG}, + {"vendor params don't break match", "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily;encoding=delimited;q=0.7,text/plain;version=0.0.4;q=0.3", []string{textPlain}, textPlain}, + // vendor MIME types are NOT structurally matched against + // "+json" — text/json doesn't match application/vnd.cia.v1+json. + {"vendor MIME unmatched", jsonMime, []string{"application/vnd.cia.v1+json"}, ""}, + // java client default + {"java default", "text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2", []string{jsonMime}, jsonMime}, + } + + for _, r := range rows { + t.Run(r.name, func(t *testing.T) { + accept := ParseAccept(r.accept) + offers := make(Set, 0, len(r.offered)) + for _, o := range r.offered { + m, err := Parse(o) + require.NoErrorf(t, err, "offer %q", o) + offers = append(offers, m) + } + best, ok := accept.BestMatch(offers) + if r.wantBest == "" { + assert.FalseT(t, ok, "want no match, got %q", best.String()) + return + } + require.TrueT(t, ok) + assert.EqualT(t, r.wantBest, best.String()) + }) + } +} + +func TestBestMatchEmptyInputs(t *testing.T) { + t.Run("empty accept set", func(t *testing.T) { + offers := Set{mustParse(t, textPlain)} + _, ok := Set(nil).BestMatch(offers) + assert.FalseT(t, ok) + }) + + t.Run("empty offered set", func(t *testing.T) { + accept := ParseAccept(textPlain) + _, ok := accept.BestMatch(nil) + assert.FalseT(t, ok) + }) +} + +func TestStripParams(t *testing.T) { + m := mustParse(t, "text/plain;charset=utf-8;q=0.5") + stripped := m.StripParams() + assert.Nil(t, stripped.Params) + assert.EqualT(t, "text", stripped.Type) + assert.EqualT(t, "plain", stripped.Subtype) + // q is preserved — it is meta and still drives negotiation order. + assert.EqualT(t, 0.5, stripped.Q) + // original is untouched + require.NotNil(t, m.Params) +} + +func mustParse(t *testing.T, s string) MediaType { + t.Helper() + m, err := Parse(s) + require.NoError(t, err) + return m +} diff --git a/server-middleware/mediatype/set.go b/server-middleware/mediatype/set.go new file mode 100644 index 00000000..dcbdd709 --- /dev/null +++ b/server-middleware/mediatype/set.go @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package mediatype + +import ( + "strings" +) + +// Set is a list of media types — typically the parsed value of an Accept +// header, or a list of server-side offers. +type Set []MediaType + +// ParseAccept parses a comma-separated list of media types, as found in +// the Accept, Accept-Charset (etc.) HTTP headers. Malformed entries are +// skipped silently — be liberal in what you accept. +// +// An empty input returns nil. +func ParseAccept(s string) Set { + parts := splitTopLevel(s) + if len(parts) == 0 { + return nil + } + out := make(Set, 0, len(parts)) + for _, p := range parts { + mt, err := Parse(p) + if err != nil { + continue + } + out = append(out, mt) + } + + return out +} + +// BestMatch picks the offer most acceptable to the receiver's Accept +// entries. Selection follows RFC 7231 §5.3.2: +// +// - highest q-value wins; +// - ties on q broken by the highest [MediaType.Specificity] of the +// matching Accept entry; +// - ties on specificity broken by earliest position in offered. +// +// Accept entries with q=0 are treated as exclusions and never match. +// Returns ok=false if no offer matched any non-zero-q entry. +func (s Set) BestMatch(offered Set) (best MediaType, ok bool) { + if len(s) == 0 || len(offered) == 0 { + return MediaType{}, false + } + bestQ := -1.0 + bestSpec := -1 + bestIdx := -1 + for i, offer := range offered { + for _, entry := range s { + if entry.Q == 0 { + continue + } + if !offer.Matches(entry) { + continue + } + spec := entry.Specificity() + switch { + case entry.Q > bestQ: + best, ok = offer, true + bestQ = entry.Q + bestSpec = spec + bestIdx = i + case entry.Q < bestQ: + // not better + case spec > bestSpec: + best, ok = offer, true + bestSpec = spec + bestIdx = i + case spec < bestSpec: + // not better + case bestIdx < 0 || i < bestIdx: + best, ok = offer, true + bestIdx = i + } + } + } + + return best, ok +} + +// splitTopLevel splits s on top-level commas, respecting double-quoted +// strings (RFC 7230 §3.2.6 — quoted-string). +func splitTopLevel(s string) []string { + if strings.IndexByte(s, ',') < 0 { + if t := strings.TrimSpace(s); t != "" { + return []string{t} + } + return nil + } + var out []string + start := 0 + inQuote := false + escape := false + for i := range len(s) { + c := s[i] + switch { + case escape: + escape = false + case inQuote && c == '\\': + escape = true + case c == '"': + inQuote = !inQuote + case c == ',' && !inQuote: + if t := strings.TrimSpace(s[start:i]); t != "" { + out = append(out, t) + } + start = i + 1 + } + } + if t := strings.TrimSpace(s[start:]); t != "" { + out = append(out, t) + } + + return out +} From d98be1a1befde283710afa579908098b9da7ee31 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Wed, 6 May 2026 16:39:19 +0200 Subject: [PATCH 3/6] refactor(negotiate): extract negotiate package to server-middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the content-negotiation helpers out of middleware/ into a new server-middleware/negotiate package. The header parsing utilities they depend on travel along as a nested server-middleware/negotiate/header package — they were already stdlib-only and used nowhere else. Naming in the new package drops the redundant Negotiate prefix: - NegotiateContentType -> negotiate.ContentType - NegotiateContentEncoding -> negotiate.ContentEncoding - NegotiateOption -> negotiate.Option - WithIgnoreParameters unchanged The old middleware names remain as deprecated forwarders in middleware/seam.go (type alias for Option, var aliases for the function values), so user code keeps compiling unchanged. middleware/context.go now calls negotiate.ContentType directly and constructs []negotiate.Option in its negotiateOpts helper. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Frederic BIDON --- middleware/context.go | 11 ++- middleware/seam.go | 25 +++++++ middleware/seam_test.go | 41 +++++++++- middleware/typeutils.go | 29 +++++++ server-middleware/mediatype/doc.go | 6 +- server-middleware/negotiate/doc.go | 13 ++++ .../negotiate}/header/header.go | 0 .../negotiate}/header/header_test.go | 0 .../negotiate}/negotiate.go | 75 +++++++------------ .../negotiate}/negotiate_test.go | 33 ++++---- 10 files changed, 159 insertions(+), 74 deletions(-) create mode 100644 middleware/typeutils.go create mode 100644 server-middleware/negotiate/doc.go rename {middleware => server-middleware/negotiate}/header/header.go (100%) rename {middleware => server-middleware/negotiate}/header/header_test.go (100%) rename {middleware => server-middleware/negotiate}/negotiate.go (62%) rename {middleware => server-middleware/negotiate}/negotiate_test.go (88%) diff --git a/middleware/context.go b/middleware/context.go index 6fad84d3..de57e393 100644 --- a/middleware/context.go +++ b/middleware/context.go @@ -24,14 +24,13 @@ import ( "github.com/go-openapi/runtime/middleware/untyped" "github.com/go-openapi/runtime/security" "github.com/go-openapi/runtime/server-middleware/docui" + "github.com/go-openapi/runtime/server-middleware/negotiate" ) // Debug when true turns on verbose logging. var Debug = logger.DebugEnabled() // Logger is the standard library logger used for printing debug messages. -// -// (Note: The correct spelling is "library", not "libra". "Libra" is a zodiac sign/constellation and wouldn't make sense in this context.) var Logger logger.Logger = logger.StandardLogger{} func debugLogfFunc(lg logger.Logger) func(string, ...any) { @@ -370,7 +369,7 @@ func (c *Context) BindValidRequest(request *http.Request, route *MatchedRoute, b requestContentType = "*/*" } - if str := NegotiateContentType(request, route.Produces, requestContentType, c.negotiateOpts()...); str == "" { + if str := negotiate.ContentType(request, route.Produces, requestContentType, c.negotiateOpts()...); str == "" { res = append(res, errors.InvalidResponseFormat(request.Header.Get(runtime.HeaderAccept), route.Produces)) } } @@ -449,7 +448,7 @@ func (c *Context) ResponseFormat(r *http.Request, offers []string) (string, *htt return v, r } - format := NegotiateContentType(r, offers, "", c.negotiateOpts()...) + format := negotiate.ContentType(r, offers, "", c.negotiateOpts()...) if format != "" { c.debugLogf("[%s %s] set response format %q in context", r.Method, r.URL.Path, format) r = r.WithContext(stdContext.WithValue(rCtx, ctxResponseFormat, format)) @@ -731,12 +730,12 @@ func (c Context) uiOptionsForHandler(opts []UIOption) (string, docui.UIOptions, return pth, uiOpts, []SpecOption{docui.WithSpecDocument(doc)} } -func (c *Context) negotiateOpts() []NegotiateOption { +func (c *Context) negotiateOpts() []negotiate.Option { if !c.ignoreParameters { return nil } - return []NegotiateOption{WithIgnoreParameters(true)} + return []negotiate.Option{negotiate.WithIgnoreParameters(true)} } func cantFindProducer(format string) string { diff --git a/middleware/seam.go b/middleware/seam.go index 0406f796..93d4083e 100644 --- a/middleware/seam.go +++ b/middleware/seam.go @@ -5,8 +5,33 @@ package middleware import ( "github.com/go-openapi/runtime/server-middleware/docui" + "github.com/go-openapi/runtime/server-middleware/negotiate" ) +// NegotiateOption configures [NegotiateContentType] behaviour. +// +// Deprecated: moved to the [negotiate] package. Use [negotiate.Option] instead. +type NegotiateOption = negotiate.Option + +// NegotiateContentType returns the best offered content type for the +// request's Accept header. +// +// Deprecated: moved to the [negotiate] package. Use [negotiate.ContentType] instead. +var NegotiateContentType = negotiate.ContentType + +// NegotiateContentEncoding returns the best offered content encoding for +// the request's Accept-Encoding header. +// +// Deprecated: moved to the [negotiate] package. Use [negotiate.ContentEncoding] instead. +var NegotiateContentEncoding = negotiate.ContentEncoding + +// WithIgnoreParameters returns a [NegotiateOption] that strips MIME-type +// parameters from both Accept entries and offers before matching, +// restoring the pre-v0.30 behaviour. +// +// Deprecated: moved to the [negotiate] package. Use [negotiate.WithIgnoreParameters] instead. +var WithIgnoreParameters = negotiate.WithIgnoreParameters + // RapiDocOpts configures the [RapiDoc] middlewares. // // Deprecated: moved to the [docui] package. Use [docui.RapiDocOpts] instead. diff --git a/middleware/seam_test.go b/middleware/seam_test.go index 26c692db..92c0cdb6 100644 --- a/middleware/seam_test.go +++ b/middleware/seam_test.go @@ -4,12 +4,12 @@ package middleware_test // Smoke tests for the deprecated middleware aliases that forward to the -// docui package. These verify that: +// docui and negotiate packages. These verify that: // // - the type aliases still resolve so user code keeps compiling, -// - the function-value aliases still serve the documented payload. +// - the function-value aliases still produce the documented behaviour. // -// The exhaustive coverage lives in the docui package itself. +// The exhaustive coverage lives in the destination packages themselves. import ( "context" @@ -22,16 +22,19 @@ import ( "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/runtime/server-middleware/docui" + "github.com/go-openapi/runtime/server-middleware/negotiate" ) // Compile-time assertions that the deprecated middleware names alias the -// docui types — type identity is required for these assignments to type-check. +// destination types — type identity is required for these assignments to +// type-check. var ( _ = func(o docui.SwaggerUIOpts) middleware.SwaggerUIOpts { return o } _ = func(o docui.RedocOpts) middleware.RedocOpts { return o } _ = func(o docui.RapiDocOpts) middleware.RapiDocOpts { return o } _ = func(o docui.UIOption) middleware.UIOption { return o } _ = func(o docui.SpecOption) middleware.SpecOption { return o } + _ = func(o negotiate.Option) middleware.NegotiateOption { return o } ) func TestDeprecatedDocUIForwarders(t *testing.T) { @@ -81,4 +84,34 @@ func TestDeprecatedDocUIForwarders(t *testing.T) { assert.EqualT(t, http.StatusOK, rec.Code) assert.EqualT(t, string(body), rec.Body.String()) }) + + t.Run("middleware.NegotiateContentType still selects the offered type", func(t *testing.T) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) + require.NoError(t, err) + req.Header.Set("Accept", "application/json") + got := middleware.NegotiateContentType(req, []string{"application/json"}, "") + assert.EqualT(t, "application/json", got) + }) + + t.Run("middleware.NegotiateContentEncoding still selects gzip", func(t *testing.T) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) + require.NoError(t, err) + req.Header.Set("Accept-Encoding", "gzip") + got := middleware.NegotiateContentEncoding(req, []string{"identity", "gzip"}) + assert.EqualT(t, "gzip", got) + }) + + t.Run("middleware.WithIgnoreParameters still strips params", func(t *testing.T) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) + require.NoError(t, err) + req.Header.Set("Accept", "text/plain;charset=ascii") + // Strict (default): no match because charset disagrees. + strict := middleware.NegotiateContentType(req, []string{"text/plain;charset=utf-8"}, "fallback") + assert.EqualT(t, "fallback", strict) + // Loose (legacy mode): bare types agree, offer picked. + loose := middleware.NegotiateContentType(req, []string{"text/plain;charset=utf-8"}, "fallback", + middleware.WithIgnoreParameters(true), + ) + assert.EqualT(t, "text/plain;charset=utf-8", loose) + }) } diff --git a/middleware/typeutils.go b/middleware/typeutils.go new file mode 100644 index 00000000..10919392 --- /dev/null +++ b/middleware/typeutils.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package middleware + +import "strings" + +// normalizeOffer strips the parameter section (";...") from a media-type +// string. Despite its name (kept for historical reasons), this helper is +// not about Accept negotiation — it is used to derive the bare type that +// keys the producer/consumer maps registered on a [RoutableAPI]. Those +// maps are looked up by the bare media type, so an entry registered as +// "application/json" satisfies a route that declares "application/json; +// charset=utf-8" and vice-versa. +func normalizeOffer(orig string) string { + const maxParts = 2 + + return strings.SplitN(orig, ";", maxParts)[0] +} + +// normalizeOffers is the slice form of [normalizeOffer]. +func normalizeOffers(orig []string) []string { + norm := make([]string, 0, len(orig)) + for _, o := range orig { + norm = append(norm, normalizeOffer(o)) + } + + return norm +} diff --git a/server-middleware/mediatype/doc.go b/server-middleware/mediatype/doc.go index 5d27bf19..6f8aa313 100644 --- a/server-middleware/mediatype/doc.go +++ b/server-middleware/mediatype/doc.go @@ -1,8 +1,10 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Package mediatype provides a typed value for RFC 7231 / RFC 2045 media -// types and the matching/selection primitives used by both server-side +// Package mediatype provides a typed value for media types +// defined by RFC 7231 and RFC 2045. +// +// The matching/selection primitives used by both server-side // validation and Accept-header negotiation. // // The package is stdlib-only. diff --git a/server-middleware/negotiate/doc.go b/server-middleware/negotiate/doc.go new file mode 100644 index 00000000..a9f278c3 --- /dev/null +++ b/server-middleware/negotiate/doc.go @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package negotiate provides server-side HTTP content negotiation +// helpers — selecting the response Content-Type from an Accept header +// and the response Content-Encoding from an Accept-Encoding header. +// +// The package is stdlib-only (modulo the typed [mediatype.MediaType] +// values it consumes). +// +// The exported [ContentType] honours MIME-type parameters by default; +// use [WithIgnoreParameters] to restore the pre-v0.30 looser match. +package negotiate diff --git a/middleware/header/header.go b/server-middleware/negotiate/header/header.go similarity index 100% rename from middleware/header/header.go rename to server-middleware/negotiate/header/header.go diff --git a/middleware/header/header_test.go b/server-middleware/negotiate/header/header_test.go similarity index 100% rename from middleware/header/header_test.go rename to server-middleware/negotiate/header/header_test.go diff --git a/middleware/negotiate.go b/server-middleware/negotiate/negotiate.go similarity index 62% rename from middleware/negotiate.go rename to server-middleware/negotiate/negotiate.go index 3e5d79d8..594c5ee8 100644 --- a/middleware/negotiate.go +++ b/server-middleware/negotiate/negotiate.go @@ -1,33 +1,25 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Copyright 2013 The Go Authors. All rights reserved. -// -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file or at -// https://developers.google.com/open-source/licenses/bsd. - -// this file was originally based on github.com/golang/gddo - -package middleware +package negotiate import ( "net/http" "strings" - "github.com/go-openapi/runtime/middleware/header" "github.com/go-openapi/runtime/server-middleware/mediatype" + "github.com/go-openapi/runtime/server-middleware/negotiate/header" ) -// NegotiateOption configures [NegotiateContentType] behaviour. -type NegotiateOption func(*negotiateOptions) +// Option configures [ContentType] behaviour. +type Option func(*options) -type negotiateOptions struct { +type options struct { ignoreParameters bool } -func negotiateOptionsWithDefaults(opts []NegotiateOption) negotiateOptions { - var o negotiateOptions +func optionsWithDefaults(opts []Option) options { + var o options for _, apply := range opts { apply(&o) } @@ -35,7 +27,7 @@ func negotiateOptionsWithDefaults(opts []NegotiateOption) negotiateOptions { return o } -// WithIgnoreParameters returns a [NegotiateOption] that strips MIME-type +// WithIgnoreParameters returns an [Option] that strips MIME-type // parameters from both Accept entries and offers before matching, restoring // the behaviour the runtime had before v0.30. // @@ -46,27 +38,27 @@ func negotiateOptionsWithDefaults(opts []NegotiateOption) negotiateOptions { // // Example — per-call opt-out: // -// chosen := middleware.NegotiateContentType(r, offers, "", -// middleware.WithIgnoreParameters(true), +// chosen := negotiate.ContentType(r, offers, "", +// negotiate.WithIgnoreParameters(true), // ) // -// Example — server-wide opt-out: +// Example — server-wide opt-out (via [middleware.Context]): // // ctx := middleware.NewContext(spec, api, nil).SetIgnoreParameters(true) -func WithIgnoreParameters(ignore bool) NegotiateOption { - return func(o *negotiateOptions) { +func WithIgnoreParameters(ignore bool) Option { + return func(o *options) { o.ignoreParameters = ignore } } -// NegotiateContentEncoding returns the best offered content encoding for the -// request's Accept-Encoding header. If two offers match with equal weight and -// then the offer earlier in the list is preferred. If no offers are -// acceptable, then "" is returned. +// ContentEncoding returns the best offered content encoding for the +// request's Accept-Encoding header. If two offers match with equal +// weight then the offer earlier in the list is preferred. If no offers +// are acceptable, then "" is returned. // // Encoding tokens have no parameters, so this function is unaffected by -// the v0.30 parameter-honouring change to [NegotiateContentType]. -func NegotiateContentEncoding(r *http.Request, offers []string) string { +// the v0.30 parameter-honouring change to [ContentType]. +func ContentEncoding(r *http.Request, offers []string) string { bestOffer := "identity" bestQ := -1.0 specs := header.ParseAccept(r.Header, "Accept-Encoding") @@ -86,12 +78,12 @@ func NegotiateContentEncoding(r *http.Request, offers []string) string { return bestOffer } -// NegotiateContentType returns the best offered content type for the -// request's Accept header. If two offers match with equal weight, then -// the more specific offer is preferred (text/* trumps */*; type/subtype -// trumps type/*). If two offers match with equal weight and specificity, -// then the offer earlier in the list is preferred. If no offers match, -// then defaultOffer is returned. +// ContentType returns the best offered content type for the request's +// Accept header. If two offers match with equal weight, then the more +// specific offer is preferred (text/* trumps */*; type/subtype trumps +// type/*). If two offers match with equal weight and specificity, then +// the offer earlier in the list is preferred. If no offers match, then +// defaultOffer is returned. // // As of v0.30 the matching rule honours MIME-type parameters: an Accept // entry of "text/plain;charset=utf-8" matches an offer of bare @@ -103,11 +95,11 @@ func NegotiateContentEncoding(r *http.Request, offers []string) string { // // When the Accept header is absent, the first offer is returned // unchanged (param-stripping is irrelevant in that case). -func NegotiateContentType(r *http.Request, offers []string, defaultOffer string, opts ...NegotiateOption) string { +func ContentType(r *http.Request, offers []string, defaultOffer string, opts ...Option) string { if len(offers) == 0 { return defaultOffer } - o := negotiateOptionsWithDefaults(opts) + o := optionsWithDefaults(opts) // Per RFC 7230 §3.2.2, multiple Accept headers are equivalent to a // single comma-joined value. Join before parsing so we don't drop @@ -151,6 +143,7 @@ func NegotiateContentType(r *http.Request, offers []string, defaultOffer string, return rawByIdx[i] } } + return best.String() } @@ -175,15 +168,3 @@ func sameParams(a, b map[string]string) bool { return true } - -func normalizeOffers(orig []string) (norm []string) { - for _, o := range orig { - norm = append(norm, normalizeOffer(o)) - } - return -} - -func normalizeOffer(orig string) string { - const maxParts = 2 - return strings.SplitN(orig, ";", maxParts)[0] -} diff --git a/middleware/negotiate_test.go b/server-middleware/negotiate/negotiate_test.go similarity index 88% rename from middleware/negotiate_test.go rename to server-middleware/negotiate/negotiate_test.go index e0e50856..935c2731 100644 --- a/middleware/negotiate_test.go +++ b/server-middleware/negotiate/negotiate_test.go @@ -7,14 +7,14 @@ // license that can be found in the LICENSE file or at // https://developers.google.com/open-source/licenses/bsd. -package middleware_test +package negotiate_test import ( "fmt" "net/http" "testing" - "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/runtime/server-middleware/negotiate" ) // Test fixtures: extracted to dedup goconst hits across the table-driven cases. @@ -32,6 +32,9 @@ const ( xy = "x/y" textHTML = "text/html" + + encGzip = "gzip" + encIdentity = "identity" ) var negotiateContentEncodingTests = []struct { @@ -39,15 +42,15 @@ var negotiateContentEncodingTests = []struct { offers []string expect string }{ - {"", []string{"identity", "gzip"}, "identity"}, - {"*;q=0", []string{"identity", "gzip"}, ""}, - {"gzip", []string{"identity", "gzip"}, "gzip"}, + {"", []string{encIdentity, encGzip}, encIdentity}, + {"*;q=0", []string{encIdentity, encGzip}, ""}, + {encGzip, []string{encIdentity, encGzip}, encGzip}, } func TestNegotiateContentEncoding(t *testing.T) { for _, tt := range negotiateContentEncodingTests { r := &http.Request{Header: http.Header{"Accept-Encoding": {tt.s}}} - actual := middleware.NegotiateContentEncoding(r, tt.offers) + actual := negotiate.ContentEncoding(r, tt.offers) if actual != tt.expect { t.Errorf("NegotiateContentEncoding(%q, %#v)=%q, want %q", tt.s, tt.offers, actual, tt.expect) } @@ -119,7 +122,7 @@ func TestNegotiateContentTypeDefault(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { r := &http.Request{Header: http.Header{headerAccept: {c.acceptHeader}}} - got := middleware.NegotiateContentType(r, c.offers, c.defaultOffer) + got := negotiate.ContentType(r, c.offers, c.defaultOffer) if got != c.expect { t.Errorf("NegotiateContentType(%q, %#v, %q) = %q, want %q", c.acceptHeader, c.offers, c.defaultOffer, got, c.expect) @@ -166,8 +169,8 @@ func TestNegotiateContentTypeIgnoreParameters(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { r := &http.Request{Header: http.Header{headerAccept: {c.acceptHeader}}} - got := middleware.NegotiateContentType(r, c.offers, c.defaultOffer, - middleware.WithIgnoreParameters(true), + got := negotiate.ContentType(r, c.offers, c.defaultOffer, + negotiate.WithIgnoreParameters(true), ) if got != c.expect { t.Errorf("NegotiateContentType(%q, %#v, %q, WithIgnoreParameters(true)) = %q, want %q", @@ -182,10 +185,10 @@ func TestNegotiateContentTypeIgnoreParameters(t *testing.T) { func TestNegotiateContentTypeNoAcceptHeader(t *testing.T) { r := &http.Request{Header: http.Header{}} offers := []string{jsonMime, "text/xml"} - if got := middleware.NegotiateContentType(r, offers, ""); got != jsonMime { + if got := negotiate.ContentType(r, offers, ""); got != jsonMime { t.Errorf("default mode: got %q, want %q", got, jsonMime) } - if got := middleware.NegotiateContentType(r, offers, "", middleware.WithIgnoreParameters(true)); got != jsonMime { + if got := negotiate.ContentType(r, offers, "", negotiate.WithIgnoreParameters(true)); got != jsonMime { t.Errorf("ignore mode: got %q, want %q", got, jsonMime) } } @@ -197,7 +200,7 @@ func TestNegotiateContentTypeNoAcceptHeader(t *testing.T) { func TestNegotiateContentTypeMultiHeader(t *testing.T) { r := &http.Request{Header: http.Header{headerAccept: {"application/xml", jsonMime}}} offers := []string{jsonMime} - if got := middleware.NegotiateContentType(r, offers, ""); got != jsonMime { + if got := negotiate.ContentType(r, offers, ""); got != jsonMime { t.Errorf("got %q, want %q (later Accept value should still match)", got, jsonMime) } } @@ -210,12 +213,12 @@ func ExampleWithIgnoreParameters() { // Default: parameters are honoured. The charset values disagree, so // no offer matches and we fall back to the default. - strict := middleware.NegotiateContentType(r, offers, "fallback/default") + strict := negotiate.ContentType(r, offers, "fallback/default") // Opt-out: strip parameters before matching. The bare types agree, so // the offer is selected. - loose := middleware.NegotiateContentType(r, offers, "fallback/default", - middleware.WithIgnoreParameters(true), + loose := negotiate.ContentType(r, offers, "fallback/default", + negotiate.WithIgnoreParameters(true), ) fmt.Printf("strict=%q\nloose=%q\n", strict, loose) From ecb95a98ee238e180ac82b566037ed35c6f1be79 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Thu, 7 May 2026 20:00:37 +0200 Subject: [PATCH 4/6] refactor(docui): functional options API and seam rework The docui package now exposes a functional-options API (Redoc, RapiDoc, SwaggerUI, SwaggerUIOAuth2Callback take a next handler and variadic Option) along with companion Use* variants returning func(http.Handler) http.Handler. The middleware package keeps its v0.29 struct-based handlers as deprecated forwarders in seam.go: each toFuncOptions() bridges the struct to the new option set, omitting zero-valued fields so empty structs don't clobber filled-in defaults. APIHandler{,SwaggerUI,RapiDoc} delegate to APIHandlerWithUI, with default basepath/title injection now in a single place. SwaggerUI defaults (preset, styles, favicons) apply after user opts in swaggeruiSetup, fixing a regression where WithSwaggerUIOptions(empty) would erase them. Tests: all UI handlers covered by httptest-based unit tests including the WithSwaggerUIOptions clobber regression. Shared serveUI moved to render.go; shared test fixtures (testSpec, badTemplate, malformedTemplate) live in render_test.go. * fixes #257 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Frederic BIDON --- .claude/plans/track-b2-docui-extraction.md | 7 + .gitignore | 1 + middleware/context.go | 111 +++-- middleware/seam.go | 422 ++++++++++++++++-- middleware/seam_test.go | 14 - middleware/typeutils.go | 13 +- server-middleware/docui/options.go | 276 ++++++++---- server-middleware/docui/options_test.go | 108 ----- server-middleware/docui/rapidoc.go | 70 ++- server-middleware/docui/rapidoc_test.go | 87 +++- server-middleware/docui/redoc.go | 73 ++- server-middleware/docui/redoc_test.go | 153 ++++--- server-middleware/docui/render.go | 2 +- server-middleware/docui/render_test.go | 26 ++ server-middleware/docui/spec.go | 62 +-- server-middleware/docui/spec_test.go | 14 +- server-middleware/docui/swaggerui.go | 124 ++--- server-middleware/docui/swaggerui_oauth2.go | 45 +- .../docui/swaggerui_oauth2_test.go | 89 +++- server-middleware/docui/swaggerui_test.go | 136 +++++- 20 files changed, 1151 insertions(+), 682 deletions(-) delete mode 100644 server-middleware/docui/options_test.go create mode 100644 server-middleware/docui/render_test.go diff --git a/.claude/plans/track-b2-docui-extraction.md b/.claude/plans/track-b2-docui-extraction.md index 7b1cf9e4..374128b7 100644 --- a/.claude/plans/track-b2-docui-extraction.md +++ b/.claude/plans/track-b2-docui-extraction.md @@ -140,3 +140,10 @@ unexported `uiOptions`. `go test ./...` in both modules. 8. Add a minimal smoke test per shim in `middleware/`. 9. `golangci-lint run --new-from-rev master` clean. + +## Track B.5 - Refactor midleware options for UI + +Objective: in the new package, replace the xxxOption struct argument by the more +modern function options pattern. + +The xxxOption structs remain part of the deprecated "seam.go" and are not reconducted in the new module. diff --git a/.gitignore b/.gitignore index bbdffea7..c0bc15be 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .env .mcp.json go.work.sum +.worktrees/ diff --git a/middleware/context.go b/middleware/context.go index de57e393..7b8b8771 100644 --- a/middleware/context.go +++ b/middleware/context.go @@ -7,8 +7,6 @@ import ( stdContext "context" "fmt" "net/http" - "net/url" - "path" "strings" "sync" @@ -267,7 +265,7 @@ func Serve(spec *loads.Document, api *untyped.API) http.Handler { // by the Builder. func ServeWithBuilder(spec *loads.Document, api *untyped.API, builder Builder) http.Handler { context := NewContext(spec, api, nil) - return context.APIHandler(builder) + return context.APIHandler(builder) // TODO: use new method } type contextKey int8 @@ -636,18 +634,11 @@ func (c *Context) Respond(rw http.ResponseWriter, r *http.Request, produces []st // This handler includes a swagger spec, router and the contract defined in the swagger spec. // // A spec UI ([docui.SwaggerUI]) is served at {API base path}/docs and the spec document at /swagger.json -// (these can be modified with uiOptions). +// (these can be modified with combined [UIOption]). +// +// Deprecated: use [Context.APIHandlerWithUI] with [docui.SwaggerUI] middleware instead. func (c *Context) APIHandlerSwaggerUI(builder Builder, opts ...UIOption) http.Handler { - b := builder - if b == nil { - b = PassthroughBuilder - } - - specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts) - var swaggerUIOpts docui.SwaggerUIOpts - docui.FromCommonToAnyOptions(uiOpts, &swaggerUIOpts) - - return docui.ServeSpec(specPath, c.spec.Raw(), docui.SwaggerUI(swaggerUIOpts, c.RoutesHandler(b)), specOpts...) + return c.APIHandlerWithUI(builder, docui.UseSwaggerUI, c.uiOptionsForHandler(opts)...) } // APIHandlerRapiDoc returns a handler to serve the API. @@ -655,18 +646,11 @@ func (c *Context) APIHandlerSwaggerUI(builder Builder, opts ...UIOption) http.Ha // This handler includes a swagger spec, router and the contract defined in the swagger spec. // // A spec UI ([docui.RapiDoc]) is served at {API base path}/docs and the spec document at /swagger.json -// (these can be modified with uiOptions). +// (these can be modified with combined [UIOption]). +// +// Deprecated: use [Context.APIHandlerWithUI] with [docui.UseRapiDoc] middleware instead. func (c *Context) APIHandlerRapiDoc(builder Builder, opts ...UIOption) http.Handler { - b := builder - if b == nil { - b = PassthroughBuilder - } - - specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts) - var rapidocUIOpts docui.RapiDocOpts - docui.FromCommonToAnyOptions(uiOpts, &rapidocUIOpts) - - return docui.ServeSpec(specPath, c.spec.Raw(), docui.RapiDoc(rapidocUIOpts, c.RoutesHandler(b)), specOpts...) + return c.APIHandlerWithUI(builder, docui.UseRapiDoc, c.uiOptionsForHandler(opts)...) } // APIHandler returns a handler to serve the API. @@ -674,60 +658,67 @@ func (c *Context) APIHandlerRapiDoc(builder Builder, opts ...UIOption) http.Hand // This handler includes a swagger spec, router and the contract defined in the swagger spec. // // A spec UI ([docui.Redoc]) is served at {API base path}/docs and the spec document at /swagger.json -// (these can be modified with uiOptions). +// (these can be modified with combined [UIOption]). +// +// Notice that you may use [Context.APIHandlerWithUI] to use an alternate UI-serving middleware. func (c *Context) APIHandler(builder Builder, opts ...UIOption) http.Handler { - b := builder - if b == nil { - b = PassthroughBuilder - } - - specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts) - var redocOpts docui.RedocOpts - docui.FromCommonToAnyOptions(uiOpts, &redocOpts) - - return docui.ServeSpec(specPath, c.spec.Raw(), docui.Redoc(redocOpts, c.RoutesHandler(b)), specOpts...) + return c.APIHandlerWithUI(builder, docui.UseRedoc, c.uiOptionsForHandler(opts)...) } -// RoutesHandler returns a handler to serve the API, just the routes and the contract defined in the swagger spec. -func (c *Context) RoutesHandler(builder Builder) http.Handler { +// APIHandlerWithUI returns a handler to serve the API with a swagger spec and a UI. +// +// This handler includes a swagger spec, router and the contract defined in the swagger spec. +// +// A spec UI is served at {API base path}/docs and the spec document at /swagger.json +// (these can be modified with combined [UIOption]). +// +// Notice that any function that accepts the [docui.Option] set and returns a valid middleware may be injected here. +// +// [Context.APIHandlerWithUI] extends [Context.APIHandler], and supersedes [Context.APIHandlerRapiDoc] and [Context.APIHandlerSwaggerUI]. +func (c *Context) APIHandlerWithUI(builder Builder, uiMiddleware docui.UIMiddleware, opts ...docui.Option) http.Handler { b := builder if b == nil { b = PassthroughBuilder } - return NewRouter(c, b(NewOperationExecutor(c))) -} -func (c Context) uiOptionsForHandler(opts []UIOption) (string, docui.UIOptions, []SpecOption) { + // the UI titles defaults to the title in the spec + const extraOptions = 2 + prepend := make([]docui.Option, 0, len(opts)+extraOptions) var title string + sp := c.spec.Spec() if sp != nil && sp.Info != nil && sp.Info.Title != "" { title = sp.Info.Title } + if title != "" { + prepend = append(prepend, docui.WithUITitle(title)) + } - // default options (may be overridden) - const baseOptions = 2 - optsForContext := make([]UIOption, 0, len(opts)+baseOptions) - optsForContext = append(optsForContext, - WithUIBasePath(c.BasePath()), - WithUITitle(title), + prepend = append(prepend, docui.WithUIBasePath(c.BasePath())) + prepend = append(prepend, opts...) + + // aligns spec serve path with UI setting to fetch spec document. + return docui.UseSpec(c.spec.Raw(), docui.WithSpecPathFromOptions(prepend...))( + uiMiddleware(prepend...)( + c.RoutesHandler(b), + ), ) - optsForContext = append(optsForContext, opts...) - uiOpts := docui.UIOptionsWithDefaults(optsForContext) +} - // If spec URL is provided, there is a non-default path to serve the spec. - // This makes sure that the UI middleware is aligned with the Spec middleware. - u, _ := url.Parse(uiOpts.SpecURL) - var specPath string - if u != nil { - specPath = u.Path +// RoutesHandler returns a handler to serve the API, just the routes and the contract defined in the swagger spec. +func (c *Context) RoutesHandler(builder Builder) http.Handler { + b := builder + if b == nil { + b = PassthroughBuilder } + return NewRouter(c, b(NewOperationExecutor(c))) +} - pth, doc := path.Split(specPath) - if pth == "." { - pth = "" - } +// uiOptionsForHandler bridges the deprecated [UIOption] set to the new [docui.Option] set. +func (c Context) uiOptionsForHandler(opts []UIOption) []docui.Option { + uiOpts := uiOptionsWithDefaults(opts) - return pth, uiOpts, []SpecOption{docui.WithSpecDocument(doc)} + return uiOpts.toFuncOptions() } func (c *Context) negotiateOpts() []negotiate.Option { diff --git a/middleware/seam.go b/middleware/seam.go index 93d4083e..a1f8accc 100644 --- a/middleware/seam.go +++ b/middleware/seam.go @@ -4,10 +4,18 @@ package middleware import ( + "net/http" + "path" + "strings" + "github.com/go-openapi/runtime/server-middleware/docui" "github.com/go-openapi/runtime/server-middleware/negotiate" ) +/////////////////////////////////////////////////////////: +// Seam to the negotiate options introduced in v0.29.5 +/////////////////////////////////////////////////////////: + // NegotiateOption configures [NegotiateContentType] behaviour. // // Deprecated: moved to the [negotiate] package. Use [negotiate.Option] instead. @@ -32,51 +40,79 @@ var NegotiateContentEncoding = negotiate.ContentEncoding // Deprecated: moved to the [negotiate] package. Use [negotiate.WithIgnoreParameters] instead. var WithIgnoreParameters = negotiate.WithIgnoreParameters -// RapiDocOpts configures the [RapiDoc] middlewares. -// -// Deprecated: moved to the [docui] package. Use [docui.RapiDocOpts] instead. -type RapiDocOpts = docui.RapiDocOpts +/////////////////////////////////////////////////////////: +// Seam to the UI options +/////////////////////////////////////////////////////////: -// RapiDoc creates a [middleware] to serve a documentation site for a swagger spec. +// RapiDoc creates a [http.Handler] to serve a documentation site for a swagger spec. // // This allows for altering the spec before starting the [http] listener. // // Deprecated: moved to the [docui] package. Use [docui.RapiDoc] instead. -var RapiDoc = docui.RapiDoc - -// RedocOpts configures the [Redoc] middlewares. -// -// Deprecated: moved to the [docui] package. Use [docui.RedocOpts] instead. -type RedocOpts = docui.RedocOpts +func RapiDoc(opts RapiDocOpts, next http.Handler) http.Handler { + return docui.RapiDoc(next, opts.toFuncOptions()...) +} -// Redoc creates a [middleware] to serve a documentation site for a swagger spec. +// Redoc creates a [http.Handler] to serve a documentation site for a swagger spec. // // This allows for altering the spec before starting the [http] listener. // // Deprecated: moved to the [docui] package. Use [docui.Redoc] instead. -var Redoc = docui.Redoc - -// SwaggerUIOpts configures the [SwaggerUI] [middleware]. -// -// Deprecated: moved to the [docui] package. Use [docui.SwaggerUIOpts] instead. -type SwaggerUIOpts = docui.SwaggerUIOpts +func Redoc(opts RedocOpts, next http.Handler) http.Handler { + return docui.Redoc(next, opts.toFuncOptions()...) +} -// SwaggerUI creates a [middleware] to serve a documentation site for a swagger spec. +// SwaggerUI creates a [http.Handler] to serve a documentation site for a swagger spec. // // This allows for altering the spec before starting the [http] listener. // // Deprecated: moved to the [docui] package. Use [docui.SwaggerUI] instead. -var SwaggerUI = docui.SwaggerUI +func SwaggerUI(opts SwaggerUIOpts, next http.Handler) http.Handler { + return docui.SwaggerUI(next, opts.toFuncOptions()...) +} // SwaggerUIOAuth2Callback creates a middleware that serves the OAuth2 callback page used by Swagger UI. // // Deprecated: moved to the [docui] package. Use [docui.SwaggerUIOAuth2Callback] instead. -var SwaggerUIOAuth2Callback = docui.SwaggerUIOAuth2Callback +func SwaggerUIOAuth2Callback(opts SwaggerUIOpts, next http.Handler) http.Handler { + return docui.SwaggerUIOAuth2Callback(next, opts.toFuncOptions()...) +} + +/////////////////////////////////////////////////////////: +// Seam to the spec middleware options +/////////////////////////////////////////////////////////: // SpecOption can be applied to the [Spec] serving [middleware]. // // Deprecated: moved to the [docui] package. Use [docui.SpecOption] instead. -type SpecOption = docui.SpecOption +type SpecOption func(*specOptions) + +type specOptions struct { + BasePath string + Path string + Document string +} + +func (o specOptions) fullPath() string { + return path.Join(o.BasePath, o.Path, o.Document) +} + +func specOptionsWithDefaults(basePath string, opts []SpecOption) specOptions { + o := specOptions{ + BasePath: "/", + Path: "", + Document: "swagger.json", + } + + for _, apply := range opts { + apply(&o) + } + if basePath != "" { + o.BasePath = basePath + } + + return o +} // Spec creates a [middleware] to serve a swagger spec as a JSON document. // @@ -86,39 +122,321 @@ type SpecOption = docui.SpecOption // Additional [SpecOption] can be used to change the name of the document (defaults to "swagger.json"). // // Deprecated: moved to the [docui] package as [docui.ServeSpec]. -var Spec = docui.ServeSpec +func Spec(basePath string, spec []byte, next http.Handler, opts ...SpecOption) http.Handler { + o := specOptionsWithDefaults(basePath, opts) + + return docui.ServeSpec(spec, next, docui.WithSpecPath(o.fullPath())) + +} -// WithSpecPath sets the path to be joined to the base path of the [Spec] [middleware]. +// WithSpecPath sets the path to be joined to the base path of the [ServeSpec] [middleware]. // // This is empty by default. -// -// Deprecated: moved to the [docui] package. Use [docui.WithSpecPath] instead. -var WithSpecPath = docui.WithSpecPath +func WithSpecPath(pth string) SpecOption { + return func(o *specOptions) { + o.Path = pth + } +} // WithSpecDocument sets the name of the JSON document served as a spec. // // By default, this is "swagger.json". +func WithSpecDocument(doc string) SpecOption { + return func(o *specOptions) { + if doc == "" { + return + } + + o.Document = doc + } +} + +// UIOptions defines common options for UI serving middlewares. // -// Deprecated: moved to the [docui] package. Use [docui.WithSpecDocument] instead. -var WithSpecDocument = docui.WithSpecDocument +// Deprecated: use instead the function options provided by [docui]. +type UIOptions struct { + // BasePath for the UI, defaults to: / + BasePath string + + // Path combines with BasePath to construct the path to the UI, defaults to: "docs". + Path string + + // SpecURL is the URL of the spec document. + // + // Defaults to: /swagger.json + SpecURL string -// UIOption can be applied to UI serving [middleware], such as Context.[APIHandler] or -// Context.[APIHandlerSwaggerUI] to alter the default behavior. + // Title for the documentation site, default to: API documentation + Title string + + // Template specifies a custom template to serve the UI + Template string +} + +// toFuncOptions bridges the deprecated options struct with the newer function options in [docui]. +func (o UIOptions) toFuncOptions() []docui.Option { + const structMembers = 5 + opts := make([]docui.Option, 0, structMembers) + + if o.BasePath != "" { + opts = append(opts, docui.WithUIBasePath(o.BasePath)) + } + + if o.Path != "" { + opts = append(opts, docui.WithUIPath(o.Path)) + } + + if o.SpecURL != "" { + opts = append(opts, docui.WithSpecURL(o.SpecURL)) + } + + if o.Title != "" { + opts = append(opts, docui.WithUITitle(o.Title)) + } + + if o.Template != "" { + opts = append(opts, docui.WithUITemplate(o.Template)) + } + + return opts +} + +// RapiDocOpts configures the [RapiDoc] middlewares. // -// Deprecated: moved to the [docui] package. Use [docui.UIOption] instead. -type UIOption = docui.UIOption +// Deprecated: use instead the function options provided by [docui]. +type RapiDocOpts struct { + // BasePath for the UI, defaults to: / + BasePath string -// WithUIBasePath sets the base path from where to serve the UI assets. + // Path combines with BasePath to construct the path to the UI, defaults to: "docs". + Path string + + // SpecURL is the URL of the spec document. + // + // Defaults to: /swagger.json + SpecURL string + + // Title for the documentation site, default to: API documentation + Title string + + // Template specifies a custom template to serve the UI + Template string + + // RapiDocURL points to the js asset that generates the rapidoc site. + // + // Defaults to https://unpkg.com/rapidoc/dist/rapidoc-min.js + RapiDocURL string +} + +func (o RapiDocOpts) toFuncOptions() []docui.Option { + const structMembers = 6 + opts := make([]docui.Option, 0, structMembers) + + if o.BasePath != "" { + opts = append(opts, docui.WithUIBasePath(o.BasePath)) + } + + if o.Path != "" { + opts = append(opts, docui.WithUIPath(o.Path)) + } + + if o.SpecURL != "" { + opts = append(opts, docui.WithSpecURL(o.SpecURL)) + } + + if o.Title != "" { + opts = append(opts, docui.WithUITitle(o.Title)) + } + + if o.Template != "" { + opts = append(opts, docui.WithUITemplate(o.Template)) + } + + if o.RapiDocURL != "" { + opts = append(opts, docui.WithUIAssetsURL(o.RapiDocURL)) + } + + return opts +} + +// RedocOpts configures the [Redoc] middlewares. +// +// Deprecated: use instead the function options provided by [docui]. +type RedocOpts struct { + // BasePath for the UI, defaults to: / + BasePath string + + // Path combines with BasePath to construct the path to the UI, defaults to: "docs". + Path string + + // SpecURL is the URL of the spec document. + // + // Defaults to: /swagger.json + SpecURL string + + // Title for the documentation site, default to: API documentation + Title string + + // Template specifies a custom template to serve the UI + Template string + + // RedocURL points to the js that generates the redoc site. + // + // Defaults to: https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js + RedocURL string +} + +func (o RedocOpts) toFuncOptions() []docui.Option { + const structMembers = 6 + opts := make([]docui.Option, 0, structMembers) + + if o.BasePath != "" { + opts = append(opts, docui.WithUIBasePath(o.BasePath)) + } + + if o.Path != "" { + opts = append(opts, docui.WithUIPath(o.Path)) + } + + if o.SpecURL != "" { + opts = append(opts, docui.WithSpecURL(o.SpecURL)) + } + + if o.Title != "" { + opts = append(opts, docui.WithUITitle(o.Title)) + } + + if o.Template != "" { + opts = append(opts, docui.WithUITemplate(o.Template)) + } + + if o.RedocURL != "" { + opts = append(opts, docui.WithUIAssetsURL(o.RedocURL)) + } + + return opts +} + +// SwaggerUIOpts configures the [SwaggerUI] [middleware]. +// +// Deprecated: use instead the function options provided by [docui]. +type SwaggerUIOpts struct { + // BasePath for the API, defaults to: / + BasePath string + + // Path combines with BasePath to construct the path to the UI, defaults to: "docs". + Path string + + // SpecURL is the URL of the spec document. + // + // Defaults to: /swagger.json + SpecURL string + + // Title for the documentation site, default to: API documentation + Title string + + // Template specifies a custom template to serve the UI + Template string + + // OAuth2CallbackURL the url called after OAuth2 login + OAuth2CallbackURL string + + // The three components needed to embed swagger-ui + + // SwaggerURL points to the js that generates the SwaggerUI site. + // + // Defaults to: https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js + SwaggerURL string + + SwaggerPresetURL string + SwaggerStylesURL string + + Favicon32 string + Favicon16 string +} + +func (o SwaggerUIOpts) toFuncOptions() []docui.Option { + const structMembers = 6 + opts := make([]docui.Option, 0, structMembers) + + if o.BasePath != "" { + opts = append(opts, docui.WithUIBasePath(o.BasePath)) + } + + if o.Path != "" { + opts = append(opts, docui.WithUIPath(o.Path)) + } + + if o.SpecURL != "" { + opts = append(opts, docui.WithSpecURL(o.SpecURL)) + } + + if o.Title != "" { + opts = append(opts, docui.WithUITitle(o.Title)) + } + + if o.Template != "" { + opts = append(opts, docui.WithUITemplate(o.Template)) + } + + if o.SwaggerURL != "" { + opts = append(opts, docui.WithUIAssetsURL(o.SwaggerURL)) + } + + var empty SwaggerUIOpts + if o != empty { + swaggeruiOpts := docui.SwaggerUIOptions{ + OAuth2CallbackURL: o.OAuth2CallbackURL, + SwaggerPresetURL: o.SwaggerPresetURL, + SwaggerStylesURL: o.SwaggerStylesURL, + Favicon32: o.Favicon32, + Favicon16: o.Favicon16, + } + opts = append(opts, docui.WithSwaggerUIOptions(swaggeruiOpts)) + } + + return opts +} + +// UIOption can be applied to UI serving [middleware] to alter the default +// behavior. // -// By default, Context [middleware] sets this value to the API base path. +// Deprecated: use instead the function options provided by [docui]. +type UIOption func(*UIOptions) + +// uiOptionsWithDefaults applies the given options on top of an empty +// [UIOptions]. Per-flavor handlers ([SwaggerUI], [Redoc], [RapiDoc]) +// fill in the remaining defaults via [UIOptions.EnsureDefaults] when +// the option struct is used. +func uiOptionsWithDefaults(opts []UIOption) UIOptions { + var o UIOptions + for _, apply := range opts { + apply(&o) + } + + return o +} + +// WithUIBasePath sets the base path from where to serve the UI assets. // -// Deprecated: moved to the [docui] package. Use [docui.WithUIBasePath] instead. -var WithUIBasePath = docui.WithUIBasePath +// Deprecated: use instead the function options provided by [docui]. +func WithUIBasePath(base string) UIOption { + return func(o *UIOptions) { + if !strings.HasPrefix(base, "/") { + base = "/" + base + } + o.BasePath = base + } +} // WithUIPath sets the path from where to serve the UI assets (i.e. /{basepath}/{path}. // -// Deprecated: moved to the [docui] package. Use [docui.WithUIPath] instead. -var WithUIPath = docui.WithUIPath +// Deprecated: use instead the function options provided by [docui]. +func WithUIPath(pth string) UIOption { + return func(o *UIOptions) { + o.Path = pth + } +} // WithUISpecURL sets the path from where to serve swagger spec document. // @@ -126,19 +444,29 @@ var WithUIPath = docui.WithUIPath // // By default, this is "/swagger.json". // -// Deprecated: moved to the [docui] package. Use [docui.WithUISpecURL] instead. -var WithUISpecURL = docui.WithUISpecURL +// Deprecated: use instead the function options provided by [docui]. +func WithUISpecURL(specURL string) UIOption { + return func(o *UIOptions) { + o.SpecURL = specURL + } +} // WithUITitle sets the title of the UI. // -// By default, Context [middleware] sets this value to the title found in the API spec. -// -// Deprecated: moved to the [docui] package. Use [docui.WithUITitle] instead. -var WithUITitle = docui.WithUITitle +// Deprecated: use instead the function options provided by [docui]. +func WithUITitle(title string) UIOption { + return func(o *UIOptions) { + o.Title = title + } +} // WithTemplate allows to set a custom template for the UI. // // UI [middleware] will panic if the template does not parse or execute properly. // -// Deprecated: moved to the [docui] package. Use [docui.WithTemplate] instead. -var WithTemplate = docui.WithTemplate +// Deprecated: use instead the function options provided by [docui]. +func WithTemplate(tpl string) UIOption { + return func(o *UIOptions) { + o.Template = tpl + } +} diff --git a/middleware/seam_test.go b/middleware/seam_test.go index 92c0cdb6..481cff76 100644 --- a/middleware/seam_test.go +++ b/middleware/seam_test.go @@ -21,20 +21,6 @@ import ( "github.com/go-openapi/testify/v2/require" "github.com/go-openapi/runtime/middleware" - "github.com/go-openapi/runtime/server-middleware/docui" - "github.com/go-openapi/runtime/server-middleware/negotiate" -) - -// Compile-time assertions that the deprecated middleware names alias the -// destination types — type identity is required for these assignments to -// type-check. -var ( - _ = func(o docui.SwaggerUIOpts) middleware.SwaggerUIOpts { return o } - _ = func(o docui.RedocOpts) middleware.RedocOpts { return o } - _ = func(o docui.RapiDocOpts) middleware.RapiDocOpts { return o } - _ = func(o docui.UIOption) middleware.UIOption { return o } - _ = func(o docui.SpecOption) middleware.SpecOption { return o } - _ = func(o negotiate.Option) middleware.NegotiateOption { return o } ) func TestDeprecatedDocUIForwarders(t *testing.T) { diff --git a/middleware/typeutils.go b/middleware/typeutils.go index 10919392..3f7d7976 100644 --- a/middleware/typeutils.go +++ b/middleware/typeutils.go @@ -6,13 +6,14 @@ package middleware import "strings" // normalizeOffer strips the parameter section (";...") from a media-type -// string. Despite its name (kept for historical reasons), this helper is -// not about Accept negotiation — it is used to derive the bare type that -// keys the producer/consumer maps registered on a [RoutableAPI]. Those -// maps are looked up by the bare media type, so an entry registered as -// "application/json" satisfies a route that declares "application/json; -// charset=utf-8" and vice-versa. +// string. func normalizeOffer(orig string) string { + // NOTE(maintainers): Despite its name (kept for historical reasons), this helper is + // not about Accept negotiation — it is used to derive the bare type that + // keys the producer/consumer maps registered on a [RoutableAPI]. + // Those maps are looked up by the bare media type, so an entry registered as + // "application/json" satisfies a route that declares "application/json; + // charset=utf-8" and vice-versa. const maxParts = 2 return strings.SplitN(orig, ";", maxParts)[0] diff --git a/server-middleware/docui/options.go b/server-middleware/docui/options.go index bd272c17..438b53f8 100644 --- a/server-middleware/docui/options.go +++ b/server-middleware/docui/options.go @@ -4,8 +4,8 @@ package docui import ( - "bytes" - "encoding/gob" + "net/http" + "net/url" "strings" ) @@ -19,85 +19,96 @@ const ( applicationJSON = "application/json" ) -// UIOptions defines common options for UI serving middlewares. -type UIOptions struct { - // BasePath for the UI, defaults to: / - BasePath string +// UIMiddleware is a function returning a http middleware which accepts UI [Option]. +type UIMiddleware func(...Option) func(http.Handler) http.Handler - // Path combines with BasePath to construct the path to the UI, defaults to: "docs". - Path string +// Option to tune your swagger documentation UI middleware. +// +// Options may be combined to alter the route at which the UI asset is served, +// the URL of the spec document, the source URL of the UI asset and the title of the UI page. +// +// The embedded js scriptlet served may be modified using `WithTemplate`. +type Option func(*options) + +// SpecOption can be applied to the [ServeSpec] [middleware]. +type SpecOption func(*specOptions) - // SpecURL is the URL of the spec document. +// SwaggerUIOptions define a group of extra options specific to the SwaggerUI component. +type SwaggerUIOptions struct { + // OAuth2CallbackURL sets the url called after OAuth2 login + OAuth2CallbackURL string + + // Defines the url of the swagger UI assets with presets. // - // Defaults to: /swagger.json - SpecURL string + // Default: https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js + SwaggerPresetURL string - // Title for the documentation site, default to: API documentation - Title string + // Defines style sheet URL. + // + // Default: https://unpkg.com/swagger-ui-dist/swagger-ui.css + SwaggerStylesURL string - // Template specifies a custom template to serve the UI - Template string + // Define the favicons URLs. + // + // Defaults: + // + // - 16x16: "https://unpkg.com/swagger-ui-dist/favicon-16x16.png" + // - 32x32: "https://unpkg.com/swagger-ui-dist/favicon-32x32.png" + Favicon32 string + Favicon16 string } -// ToCommonUIOptions converts any UI option type to retain the common options. -// -// This uses gob encoding/decoding to convert common fields from one struct to another. -func ToCommonUIOptions(opts any) UIOptions { - var buf bytes.Buffer - enc := gob.NewEncoder(&buf) - dec := gob.NewDecoder(&buf) - var o UIOptions - err := enc.Encode(opts) - if err != nil { - panic(err) +func (o *SwaggerUIOptions) applySwaggerUIDefaults() { + if o.SwaggerPresetURL == "" { + o.SwaggerPresetURL = swaggerPresetLatest } - - err = dec.Decode(&o) - if err != nil { - panic(err) + if o.SwaggerStylesURL == "" { + o.SwaggerStylesURL = swaggerStylesLatest + } + if o.Favicon16 == "" || o.Favicon32 == "" { + o.Favicon16 = swaggerFavicon16Latest + o.Favicon32 = swaggerFavicon32Latest } - - return o } -// FromCommonToAnyOptions copies the common UI options held in source into the -// flavor-specific target struct (one of [SwaggerUIOpts], [RedocOpts] or -// [RapiDocOpts]). -func FromCommonToAnyOptions[T any](source UIOptions, target *T) { - var buf bytes.Buffer - enc := gob.NewEncoder(&buf) - dec := gob.NewDecoder(&buf) - err := enc.Encode(source) - if err != nil { - panic(err) - } +type ( + options struct { + SwaggerUIOptions - err = dec.Decode(target) - if err != nil { - panic(err) - } -} + // BasePath for the UI, defaults to: / + BasePath string -// UIOption can be applied to UI serving [middleware] to alter the default -// behavior. -type UIOption func(*UIOptions) + // Path combines with BasePath to construct the path to the UI, defaults to: "docs". + Path string -// UIOptionsWithDefaults applies the given options on top of an empty -// [UIOptions]. Per-flavor handlers ([SwaggerUI], [Redoc], [RapiDoc]) -// fill in the remaining defaults via [UIOptions.EnsureDefaults] when -// the option struct is used. -func UIOptionsWithDefaults(opts []UIOption) UIOptions { - var o UIOptions - for _, apply := range opts { - apply(&o) + // SpecURL is the URL of the spec document. + SpecURL string + + // Title for the documentation site, default to: API documentation + Title string + + // Template specifies a custom template to serve the UI + Template string + + // AssetsURL points to the js asset that generates the documentation page. + AssetsURL string } - return o -} + specOptions struct { + Path string + Document string + } +) + +//////////////////////////////////////////////////////////// +// Common UI options +//////////////////////////////////////////////////////////// // WithUIBasePath sets the base path from where to serve the UI assets. -func WithUIBasePath(base string) UIOption { - return func(o *UIOptions) { +// +// Default: "/" +func WithUIBasePath(base string) Option { + return func(o *options) { if !strings.HasPrefix(base, "/") { base = "/" + base } @@ -106,51 +117,136 @@ func WithUIBasePath(base string) UIOption { } // WithUIPath sets the path from where to serve the UI assets (i.e. /{basepath}/{path}. -func WithUIPath(pth string) UIOption { - return func(o *UIOptions) { +// +// Default: "docs" +func WithUIPath(pth string) Option { + return func(o *options) { o.Path = pth } } -// WithUISpecURL sets the path from where to serve swagger spec document. +// WithUITitle sets the title of the UI. +// +// Default: "API documentation" +func WithUITitle(title string) Option { + return func(o *options) { + o.Title = title + } +} + +// WithUIAssetsURL sets the URL from where to fetch the js assets. // -// This may be specified as a full URL or a path. +// Defaults: // -// By default, this is "/swagger.json". -func WithUISpecURL(specURL string) UIOption { - return func(o *UIOptions) { - o.SpecURL = specURL +// - for Redoc: https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js +// - for RapiDoc, this defaults to: https://unpkg.com/rapidoc/dist/rapidoc-min.js +// - for SwaggerUI: https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js +func WithUIAssetsURL(assets string) Option { + return func(o *options) { + o.AssetsURL = assets } } -// WithUITitle sets the title of the UI. -func WithUITitle(title string) UIOption { - return func(o *UIOptions) { - o.Title = title +// WithUITemplate allows to set a custom template for the UI. +// +// This allows the caller to fully customize the rendered UI, using the advanced options +// provided by any UI. +// +// The UI [middleware] will panic if the template does not parse or execute properly. +// +// Reference documentations to customize your js scriptlet: +// +// - for Redoc: https://github.com/Redocly/redoc/blob/main/docs/deployment/html.md +// - for RapiDoc: +// - for SwaggerUI: +func WithUITemplate[StringOrBytes ~string | ~[]byte](tpl StringOrBytes) Option { + return func(o *options) { + o.Template = string(tpl) + } +} + +// WithSpecURL sets the URL of the spec document. +// Defaults to: /swagger.json +func WithSpecURL(u string) Option { + return func(o *options) { + o.SpecURL = u + } +} + +//////////////////////////////////////////////////////////// +// SwaggerUI UI options +//////////////////////////////////////////////////////////// + +func WithSwaggerUIOptions(opts SwaggerUIOptions) Option { + return func(o *options) { + o.SwaggerUIOptions = opts } } -// WithTemplate allows to set a custom template for the UI. +//////////////////////////////////////////////////////////// +// Spec options +//////////////////////////////////////////////////////////// + +// WithSpecPath sets the path of the spec document. // -// UI [middleware] will panic if the template does not parse or execute properly. -func WithTemplate(tpl string) UIOption { - return func(o *UIOptions) { - o.Template = tpl +// This is "/swagger.json" by default. +func WithSpecPath(pth string) SpecOption { + return func(o *specOptions) { + if pth == "" { + return + } + + o.Path = pth } } -// EnsureDefaults in case some options are missing. -func (r *UIOptions) EnsureDefaults() { - if r.BasePath == "" { - r.BasePath = "/" +// WithSpecPathFromOptions reuses the same SpecPath as the one specified in +// a set of UI [Option] (extract the value provided by [WithSpecURL]). +func WithSpecPathFromOptions(opts ...Option) SpecOption { + return func(o *specOptions) { + uiOpts := optionsWithDefaults(opts) + + // If the spec URL is provided, there is a non-default path to serve the spec. + // + // This makes sure that the UI middleware is aligned with the Spec middleware. + u, _ := url.Parse(uiOpts.SpecURL) + + if u.Path == "" { + return + } + + o.Path = u.Path } - if r.Path == "" { - r.Path = defaultDocsPath +} + +func optionsWithDefaults(opts []Option, prepend ...Option) options { + o := options{ + BasePath: "/", + Path: defaultDocsPath, + SpecURL: defaultDocsURL, + Title: defaultDocsTitle, } - if r.SpecURL == "" { - r.SpecURL = defaultDocsURL + + prepend = append(prepend, opts...) + for _, apply := range prepend { + apply(&o) } - if r.Title == "" { - r.Title = defaultDocsTitle + + return o +} + +func specOptionsWithDefaults(opts []SpecOption) specOptions { + o := specOptions{ + Path: defaultDocsURL, } + + for _, apply := range opts { + apply(&o) + } + + if !strings.HasPrefix(o.Path, "/") { + o.Path = "/" + o.Path + } + + return o } diff --git a/server-middleware/docui/options_test.go b/server-middleware/docui/options_test.go deleted file mode 100644 index d7fe7bf0..00000000 --- a/server-middleware/docui/options_test.go +++ /dev/null @@ -1,108 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package docui - -import ( - "testing" - - "github.com/go-openapi/testify/v2/require" -) - -func TestConvertOptions(t *testing.T) { - t.Run("from any UI options to UIOptions", func(t *testing.T) { - t.Run("from RedocOpts", func(t *testing.T) { - in := RedocOpts{ - BasePath: "a", - Path: "b", - SpecURL: "c", - Template: "d", - Title: "e", - RedocURL: "f", - } - out := ToCommonUIOptions(in) - - require.EqualT(t, "a", out.BasePath) - require.EqualT(t, "b", out.Path) - require.EqualT(t, "c", out.SpecURL) - require.EqualT(t, "d", out.Template) - require.EqualT(t, "e", out.Title) - }) - - t.Run("from RapiDocOpts", func(t *testing.T) { - in := RapiDocOpts{ - BasePath: "a", - Path: "b", - SpecURL: "c", - Template: "d", - Title: "e", - RapiDocURL: "f", - } - out := ToCommonUIOptions(in) - - require.EqualT(t, "a", out.BasePath) - require.EqualT(t, "b", out.Path) - require.EqualT(t, "c", out.SpecURL) - require.EqualT(t, "d", out.Template) - require.EqualT(t, "e", out.Title) - }) - - t.Run("from SwaggerUIOpts", func(t *testing.T) { - in := SwaggerUIOpts{ - BasePath: "a", - Path: "b", - SpecURL: "c", - Template: "d", - Title: "e", - SwaggerURL: "f", - } - out := ToCommonUIOptions(in) - - require.EqualT(t, "a", out.BasePath) - require.EqualT(t, "b", out.Path) - require.EqualT(t, "c", out.SpecURL) - require.EqualT(t, "d", out.Template) - require.EqualT(t, "e", out.Title) - }) - }) - - t.Run("from UIOptions to any UI options", func(t *testing.T) { - in := UIOptions{ - BasePath: "a", - Path: "b", - SpecURL: "c", - Template: "d", - Title: "e", - } - - t.Run("to RedocOpts", func(t *testing.T) { - var out RedocOpts - FromCommonToAnyOptions(in, &out) - require.EqualT(t, "a", out.BasePath) - require.EqualT(t, "b", out.Path) - require.EqualT(t, "c", out.SpecURL) - require.EqualT(t, "d", out.Template) - require.EqualT(t, "e", out.Title) - }) - - t.Run("to RapiDocOpts", func(t *testing.T) { - var out RapiDocOpts - FromCommonToAnyOptions(in, &out) - require.EqualT(t, "a", out.BasePath) - require.EqualT(t, "b", out.Path) - require.EqualT(t, "c", out.SpecURL) - require.EqualT(t, "d", out.Template) - require.EqualT(t, "e", out.Title) - }) - - t.Run("to SwaggerUIOpts", func(t *testing.T) { - var out SwaggerUIOpts - FromCommonToAnyOptions(in, &out) - require.EqualT(t, "a", out.BasePath) - require.EqualT(t, "b", out.Path) - require.EqualT(t, "c", out.SpecURL) - require.EqualT(t, "d", out.Template) - require.EqualT(t, "e", out.Title) - }) - }) -} diff --git a/server-middleware/docui/rapidoc.go b/server-middleware/docui/rapidoc.go index 745874f0..673bc73d 100644 --- a/server-middleware/docui/rapidoc.go +++ b/server-middleware/docui/rapidoc.go @@ -11,59 +11,39 @@ import ( "path" ) -// RapiDocOpts configures the [RapiDoc] middlewares. -type RapiDocOpts struct { - // BasePath for the UI, defaults to: / - BasePath string - - // Path combines with BasePath to construct the path to the UI, defaults to: "docs". - Path string - - // SpecURL is the URL of the spec document. - // - // Defaults to: /swagger.json - SpecURL string - - // Title for the documentation site, default to: API documentation - Title string - - // Template specifies a custom template to serve the UI - Template string - - // RapiDocURL points to the js asset that generates the rapidoc site. - // - // Defaults to https://unpkg.com/rapidoc/dist/rapidoc-min.js - RapiDocURL string -} - -func (r *RapiDocOpts) EnsureDefaults() { - common := ToCommonUIOptions(r) - common.EnsureDefaults() - FromCommonToAnyOptions(common, r) - - // rapidoc-specifics - if r.RapiDocURL == "" { - r.RapiDocURL = rapidocLatest - } - if r.Template == "" { - r.Template = rapidocTemplate +// UseRapiDoc creates a [middleware] to serve a documentation site for a swagger spec. +func UseRapiDoc(opts ...Option) func(next http.Handler) http.Handler { + pth, assets := rapiDocSetup(opts) + return func(next http.Handler) http.Handler { + return serveUI(pth, assets, next) } } -// RapiDoc creates a [middleware] to serve a documentation site for a swagger spec. +// RapiDoc creates a [http.Handler] to serve a documentation site for a swagger spec. +// +// By default, the UI is served at route "/docs" // // This allows for altering the spec before starting the [http] listener. -func RapiDoc(opts RapiDocOpts, next http.Handler) http.Handler { - opts.EnsureDefaults() +func RapiDoc(next http.Handler, opts ...Option) http.Handler { + pth, assets := rapiDocSetup(opts) + + return serveUI(pth, assets, next) +} - pth := path.Join(opts.BasePath, opts.Path) - tmpl := template.Must(template.New("rapidoc").Parse(opts.Template)) - assets := bytes.NewBuffer(nil) - if err := tmpl.Execute(assets, opts); err != nil { +func rapiDocSetup(opts []Option) (pth string, assets []byte) { + o := optionsWithDefaults(opts, + // defaults for rapiDoc + WithUITemplate(rapidocTemplate), + WithUIAssetsURL(rapidocLatest), + ) + pth = path.Join(o.BasePath, o.Path) + tmpl := template.Must(template.New("rapidoc").Parse(o.Template)) + buf := bytes.NewBuffer(nil) + if err := tmpl.Execute(buf, o); err != nil { panic(fmt.Errorf("cannot execute template: %w", err)) } - return serveUI(pth, assets.Bytes(), next) + return pth, buf.Bytes() } const ( @@ -73,7 +53,7 @@ const ( {{ .Title }} - + diff --git a/server-middleware/docui/rapidoc_test.go b/server-middleware/docui/rapidoc_test.go index a3b75bd3..de283bde 100644 --- a/server-middleware/docui/rapidoc_test.go +++ b/server-middleware/docui/rapidoc_test.go @@ -16,28 +16,91 @@ import ( func TestRapiDocMiddleware(t *testing.T) { t.Run("with defaults", func(t *testing.T) { - rapidoc := RapiDoc(RapiDocOpts{}, nil) - + h := RapiDoc(nil) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) require.NoError(t, err) recorder := httptest.NewRecorder() - rapidoc.ServeHTTP(recorder, req) + + h.ServeHTTP(recorder, req) assert.EqualT(t, http.StatusOK, recorder.Code) assert.EqualT(t, "text/html; charset=utf-8", recorder.Header().Get(contentTypeHeader)) - var o RapiDocOpts - o.EnsureDefaults() - assert.StringContainsT(t, recorder.Body.String(), fmt.Sprintf("%s", o.Title)) - assert.StringContainsT(t, recorder.Body.String(), fmt.Sprintf("", o.SpecURL)) - assert.StringContainsT(t, recorder.Body.String(), rapidocLatest) + + body := recorder.Body.String() + assert.StringContainsT(t, body, fmt.Sprintf("%s", defaultDocsTitle)) + assert.StringContainsT(t, body, fmt.Sprintf(``, defaultDocsURL)) + assert.StringContainsT(t, body, rapidocLatest) + }) + + t.Run("with alternate path and spec URL", func(t *testing.T) { + h := RapiDoc(nil, + WithUIBasePath("/base"), + WithUIPath("ui"), + WithSpecURL("/ui/swagger.json"), + ) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/base/ui", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusOK, recorder.Code) + assert.StringContainsT(t, recorder.Body.String(), ``) + }) + + t.Run("with custom assets URL", func(t *testing.T) { + h := RapiDoc(nil, WithUIAssetsURL("https://example.com/rapidoc.js")) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusOK, recorder.Code) + assert.StringContainsT(t, recorder.Body.String(), `src="https://example.com/rapidoc.js"`) + }) + + t.Run("falls through to next handler", func(t *testing.T) { + next := http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusTeapot) + }) + h := RapiDoc(next) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/elsewhere", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusTeapot, recorder.Code) + }) + + t.Run("returns 404 when no next handler", func(t *testing.T) { + h := RapiDoc(nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/elsewhere", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusNotFound, recorder.Code) }) t.Run("edge cases", func(t *testing.T) { - t.Run("with custom template that fails to execute", func(t *testing.T) { + t.Run("with template that fails to execute", func(t *testing.T) { assert.Panics(t, func() { - RapiDoc(RapiDocOpts{ - Template: badTemplate, - }, nil) + RapiDoc(nil, WithUITemplate(badTemplate)) }) }) }) } + +func TestUseRapiDoc(t *testing.T) { + t.Run("composes as a func(http.Handler) http.Handler", func(t *testing.T) { + next := http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusTeapot) + }) + h := UseRapiDoc()(next) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusOK, recorder.Code) + }) +} diff --git a/server-middleware/docui/redoc.go b/server-middleware/docui/redoc.go index b760b3be..de70ed80 100644 --- a/server-middleware/docui/redoc.go +++ b/server-middleware/docui/redoc.go @@ -11,64 +11,45 @@ import ( "path" ) -// RedocOpts configures the [Redoc] middlewares. -type RedocOpts struct { - // BasePath for the UI, defaults to: / - BasePath string +// UseRedoc creates a [middleware] to serve a documentation site for a swagger spec. +func UseRedoc(opts ...Option) func(next http.Handler) http.Handler { + pth, assets := redocSetup(opts) - // Path combines with BasePath to construct the path to the UI, defaults to: "docs". - Path string - - // SpecURL is the URL of the spec document. - // - // Defaults to: /swagger.json - SpecURL string - - // Title for the documentation site, default to: API documentation - Title string - - // Template specifies a custom template to serve the UI - Template string - - // RedocURL points to the js that generates the redoc site. - // - // Defaults to: https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js - RedocURL string -} - -// EnsureDefaults in case some options are missing. -func (r *RedocOpts) EnsureDefaults() { - common := ToCommonUIOptions(r) - common.EnsureDefaults() - FromCommonToAnyOptions(common, r) - - // redoc-specifics - if r.RedocURL == "" { - r.RedocURL = redocLatest - } - if r.Template == "" { - r.Template = redocTemplate + return func(next http.Handler) http.Handler { + return serveUI(pth, assets, next) } } -// Redoc creates a [middleware] to serve a documentation site for a swagger spec. +// Redoc creates a [http.Handler] to serve a documentation site for a swagger spec. +// +// By default, the UI is served at route "/docs" // // This allows for altering the spec before starting the [http] listener. -func Redoc(opts RedocOpts, next http.Handler) http.Handler { - opts.EnsureDefaults() +func Redoc(next http.Handler, opts ...Option) http.Handler { + pth, assets := redocSetup(opts) + + return serveUI(pth, assets, next) +} + +func redocSetup(opts []Option) (pth string, assets []byte) { + o := optionsWithDefaults(opts, + // defaults for redoc + WithUITemplate(redocTemplate), + WithUIAssetsURL(redocLatest), + ) - pth := path.Join(opts.BasePath, opts.Path) - tmpl := template.Must(template.New("redoc").Parse(opts.Template)) - assets := bytes.NewBuffer(nil) - if err := tmpl.Execute(assets, opts); err != nil { + pth = path.Join(o.BasePath, o.Path) + tmpl := template.Must(template.New("redoc").Parse(o.Template)) + buf := bytes.NewBuffer(nil) + if err := tmpl.Execute(buf, o); err != nil { panic(fmt.Errorf("cannot execute template: %w", err)) } - return serveUI(pth, assets.Bytes(), next) + return pth, buf.Bytes() } const ( - redocLatest = "https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js" + redocLatest = "https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js" // "https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js" redocTemplate = ` @@ -90,7 +71,7 @@ const ( - + ` diff --git a/server-middleware/docui/redoc_test.go b/server-middleware/docui/redoc_test.go index 9354e4d2..65ab489b 100644 --- a/server-middleware/docui/redoc_test.go +++ b/server-middleware/docui/redoc_test.go @@ -14,108 +14,129 @@ import ( "github.com/go-openapi/testify/v2/require" ) -const badTemplate = ` - - spec-url='{{ .Unknown }}' - -` - func TestRedocMiddleware(t *testing.T) { t.Run("with defaults", func(t *testing.T) { - redoc := Redoc(RedocOpts{}, nil) - + h := Redoc(nil) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) require.NoError(t, err) recorder := httptest.NewRecorder() - redoc.ServeHTTP(recorder, req) + + h.ServeHTTP(recorder, req) assert.EqualT(t, http.StatusOK, recorder.Code) assert.EqualT(t, "text/html; charset=utf-8", recorder.Header().Get(contentTypeHeader)) - var o RedocOpts - o.EnsureDefaults() - assert.StringContainsT(t, recorder.Body.String(), fmt.Sprintf("%s", o.Title)) - assert.StringContainsT(t, recorder.Body.String(), fmt.Sprintf("", o.SpecURL)) - assert.StringContainsT(t, recorder.Body.String(), redocLatest) + + body := recorder.Body.String() + assert.StringContainsT(t, body, fmt.Sprintf("%s", defaultDocsTitle)) + assert.StringContainsT(t, body, fmt.Sprintf("", defaultDocsURL)) + assert.StringContainsT(t, body, redocLatest) }) t.Run("with alternate path and spec URL", func(t *testing.T) { - redoc := Redoc(RedocOpts{ - BasePath: "/base", - Path: "ui", - SpecURL: "/ui/swagger.json", - }, nil) - + h := Redoc(nil, + WithUIBasePath("/base"), + WithUIPath("ui"), + WithSpecURL("/ui/swagger.json"), + ) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/base/ui", nil) require.NoError(t, err) recorder := httptest.NewRecorder() - redoc.ServeHTTP(recorder, req) + + h.ServeHTTP(recorder, req) assert.EqualT(t, http.StatusOK, recorder.Code) assert.StringContainsT(t, recorder.Body.String(), "") }) + t.Run("with custom assets URL", func(t *testing.T) { + h := Redoc(nil, WithUIAssetsURL("https://example.com/redoc.js")) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusOK, recorder.Code) + assert.StringContainsT(t, recorder.Body.String(), ` + + -`, - }, nil) - +` + h := Redoc(nil, WithUITemplate(tpl)) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) require.NoError(t, err) recorder := httptest.NewRecorder() - redoc.ServeHTTP(recorder, req) + + h.ServeHTTP(recorder, req) assert.EqualT(t, http.StatusOK, recorder.Code) assert.StringContainsT(t, recorder.Body.String(), "required-props-first=true") }) + t.Run("falls through to next handler", func(t *testing.T) { + next := http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusTeapot) + }) + h := Redoc(next) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/elsewhere", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusTeapot, recorder.Code) + }) + + t.Run("returns 404 when no next handler", func(t *testing.T) { + h := Redoc(nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/elsewhere", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusNotFound, recorder.Code) + }) + t.Run("edge cases", func(t *testing.T) { - t.Run("with invalid custom template", func(t *testing.T) { + t.Run("with malformed template", func(t *testing.T) { assert.Panics(t, func() { - Redoc(RedocOpts{ - Template: ` - - - spec-url='{{ .Spec - -`, - }, nil) + Redoc(nil, WithUITemplate(malformedTemplate)) }) }) - t.Run("with custom template that fails to execute", func(t *testing.T) { + t.Run("with template that fails to execute", func(t *testing.T) { assert.Panics(t, func() { - Redoc(RedocOpts{ - Template: badTemplate, - }, nil) + Redoc(nil, WithUITemplate(badTemplate)) }) }) }) } + +func TestUseRedoc(t *testing.T) { + t.Run("composes as a func(http.Handler) http.Handler", func(t *testing.T) { + next := http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusTeapot) + }) + h := UseRedoc()(next) + + t.Run("serves the docs page", func(t *testing.T) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusOK, recorder.Code) + }) + + t.Run("forwards everything else", func(t *testing.T) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/elsewhere", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusTeapot, recorder.Code) + }) + }) +} diff --git a/server-middleware/docui/render.go b/server-middleware/docui/render.go index d8823703..1fb744fd 100644 --- a/server-middleware/docui/render.go +++ b/server-middleware/docui/render.go @@ -9,7 +9,7 @@ import ( "path" ) -// serveUI creates a middleware that serves a templated asset as text/html. +// serveUI creates a [http.Handler] that serves a templated asset as text/html. func serveUI(pth string, assets []byte, next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { if path.Clean(r.URL.Path) == pth { diff --git a/server-middleware/docui/render_test.go b/server-middleware/docui/render_test.go new file mode 100644 index 00000000..21e3234c --- /dev/null +++ b/server-middleware/docui/render_test.go @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package docui + +// minimal valid swagger 2.0 document, sufficient for round-tripping bytes +// without dragging in the petstore fixture (which lives in an internal/ +// package of the parent runtime module and is not importable from here). +var testSpec = []byte(`{"swagger":"2.0","info":{"title":"Test","version":"1.0.0"},"paths":{}}`) + +// badTemplate references a field that does not exist on the [options] +// struct. Parsing succeeds but execution fails — exercising the +// template.Execute panic branch in every UI handler. +const badTemplate = ` + + spec-url='{{ .Unknown }}' + +` + +// malformedTemplate fails at parse time (open action with no close). +const malformedTemplate = ` + + + spec-url='{{ .Spec + +` diff --git a/server-middleware/docui/spec.go b/server-middleware/docui/spec.go index 7f4b75a8..59780199 100644 --- a/server-middleware/docui/spec.go +++ b/server-middleware/docui/spec.go @@ -8,46 +8,32 @@ import ( "path" ) -// SpecOption can be applied to the [ServeSpec] [middleware]. -type SpecOption func(*specOptions) - -var defaultSpecOptions = specOptions{ - Path: "", - Document: "swagger.json", -} - -type specOptions struct { - Path string - Document string -} +// UseSpec creates a middleware to serve a swagger spec as a JSON document. +func UseSpec(spec []byte, opts ...SpecOption) func(next http.Handler) http.Handler { + o := specOptionsWithDefaults(opts) -func specOptionsWithDefaults(opts []SpecOption) specOptions { - o := defaultSpecOptions - for _, apply := range opts { - apply(&o) + return func(next http.Handler) http.Handler { + return handleSpec(o.Path, spec, next) } - - return o } -// ServeSpec creates a [middleware] to serve a swagger spec as a JSON document. +// ServeSpec creates a [http.Handler] to serve a swagger spec as a JSON document. // // This allows for altering the spec before starting the [http] listener. // -// The basePath argument indicates the path of the spec document (defaults to "/"). -// Additional [SpecOption] can be used to change the name of the document (defaults to "swagger.json"). -func ServeSpec(basePath string, b []byte, next http.Handler, opts ...SpecOption) http.Handler { - if basePath == "" { - basePath = "/" - } +// Additional [SpecOption] can be used to change the path and the name of the document (defaults to "/swagger.json"). +func ServeSpec(spec []byte, next http.Handler, opts ...SpecOption) http.Handler { o := specOptionsWithDefaults(opts) - pth := path.Join(basePath, o.Path, o.Document) + return handleSpec(o.Path, spec, next) +} + +func handleSpec(pth string, spec []byte, next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { if path.Clean(r.URL.Path) == pth { rw.Header().Set(contentTypeHeader, applicationJSON) rw.WriteHeader(http.StatusOK) - _, _ = rw.Write(b) + _, _ = rw.Write(spec) return } @@ -62,25 +48,3 @@ func ServeSpec(basePath string, b []byte, next http.Handler, opts ...SpecOption) rw.WriteHeader(http.StatusNotFound) }) } - -// WithSpecPath sets the path to be joined to the base path of the [ServeSpec] [middleware]. -// -// This is empty by default. -func WithSpecPath(pth string) SpecOption { - return func(o *specOptions) { - o.Path = pth - } -} - -// WithSpecDocument sets the name of the JSON document served as a spec. -// -// By default, this is "swagger.json". -func WithSpecDocument(doc string) SpecOption { - return func(o *specOptions) { - if doc == "" { - return - } - - o.Document = doc - } -} diff --git a/server-middleware/docui/spec_test.go b/server-middleware/docui/spec_test.go index 074bab6e..08b693a6 100644 --- a/server-middleware/docui/spec_test.go +++ b/server-middleware/docui/spec_test.go @@ -13,14 +13,9 @@ import ( "github.com/go-openapi/testify/v2/require" ) -// minimal valid swagger 2.0 document, sufficient for round-tripping bytes -// without dragging in the petstore fixture (which lives in an internal/ -// package of the parent runtime module and is not importable from here). -var testSpec = []byte(`{"swagger":"2.0","info":{"title":"Test","version":"1.0.0"},"paths":{}}`) - func TestServeSpecMiddleware(t *testing.T) { t.Run("ServeSpec handler", func(t *testing.T) { - handler := ServeSpec("", testSpec, nil) + handler := ServeSpec(testSpec, nil) t.Run("serves spec", func(t *testing.T) { request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/swagger.json", nil) @@ -47,7 +42,7 @@ func TestServeSpecMiddleware(t *testing.T) { }) t.Run("forwards to next handler for other url", func(t *testing.T) { - handler = ServeSpec("", testSpec, http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + handler = ServeSpec(testSpec, http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusOK) })) request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/api/pets", nil) @@ -60,9 +55,8 @@ func TestServeSpecMiddleware(t *testing.T) { }) t.Run("ServeSpec handler with options", func(t *testing.T) { - handler := ServeSpec("/swagger", testSpec, nil, - WithSpecPath("spec"), - WithSpecDocument("myapi-swagger.json"), + handler := ServeSpec(testSpec, nil, + WithSpecPath("/swagger/spec/myapi-swagger.json"), ) t.Run("serves spec", func(t *testing.T) { diff --git a/server-middleware/docui/swaggerui.go b/server-middleware/docui/swaggerui.go index 984faf85..ff376461 100644 --- a/server-middleware/docui/swaggerui.go +++ b/server-middleware/docui/swaggerui.go @@ -11,99 +11,45 @@ import ( "path" ) -// SwaggerUIOpts configures the [SwaggerUI] [middleware]. -type SwaggerUIOpts struct { - // BasePath for the API, defaults to: / - BasePath string +// UseSwaggerUI creates a [middleware] to serve a documentation site for a swagger spec. +func UseSwaggerUI(opts ...Option) func(next http.Handler) http.Handler { + pth, assets := swaggeruiSetup(opts) - // Path combines with BasePath to construct the path to the UI, defaults to: "docs". - Path string - - // SpecURL is the URL of the spec document. - // - // Defaults to: /swagger.json - SpecURL string - - // Title for the documentation site, default to: API documentation - Title string - - // Template specifies a custom template to serve the UI - Template string - - // OAuthCallbackURL the url called after OAuth2 login - OAuthCallbackURL string - - // The three components needed to embed swagger-ui - - // SwaggerURL points to the js that generates the SwaggerUI site. - // - // Defaults to: https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js - SwaggerURL string - - SwaggerPresetURL string - SwaggerStylesURL string - - Favicon32 string - Favicon16 string -} - -// EnsureDefaults in case some options are missing. -func (r *SwaggerUIOpts) EnsureDefaults() { - r.ensureDefaults() - - if r.Template == "" { - r.Template = swaggeruiTemplate + return func(next http.Handler) http.Handler { + return serveUI(pth, assets, next) } } -func (r *SwaggerUIOpts) EnsureDefaultsOauth2() { - r.ensureDefaults() +// SwaggerUI creates a [http.Handler] to serve a documentation site for a swagger spec. +// +// By default, the UI is served at route "/docs" +// +// This allows for altering the spec before starting the [http] listener. +func SwaggerUI(next http.Handler, opts ...Option) http.Handler { + pth, assets := swaggeruiSetup(opts) - if r.Template == "" { - r.Template = swaggerOAuthTemplate - } + return serveUI(pth, assets, next) } -func (r *SwaggerUIOpts) ensureDefaults() { - common := ToCommonUIOptions(r) - common.EnsureDefaults() - FromCommonToAnyOptions(common, r) - - // swaggerui-specifics - if r.OAuthCallbackURL == "" { - r.OAuthCallbackURL = path.Join(r.BasePath, r.Path, "oauth2-callback") - } - if r.SwaggerURL == "" { - r.SwaggerURL = swaggerLatest - } - if r.SwaggerPresetURL == "" { - r.SwaggerPresetURL = swaggerPresetLatest - } - if r.SwaggerStylesURL == "" { - r.SwaggerStylesURL = swaggerStylesLatest +func swaggeruiSetup(opts []Option) (pth string, assets []byte) { + o := optionsWithDefaults(opts, + // defaults for SwaggerUI + WithUITemplate(swaggeruiTemplate), + WithUIAssetsURL(swaggerLatest), + ) + o.applySwaggerUIDefaults() + if o.OAuth2CallbackURL == "" { + o.OAuth2CallbackURL = path.Join(o.BasePath, o.Path, "oauth2-callback") } - if r.Favicon16 == "" { - r.Favicon16 = swaggerFavicon16Latest - } - if r.Favicon32 == "" { - r.Favicon32 = swaggerFavicon32Latest - } -} - -// SwaggerUI creates a [middleware] to serve a documentation site for a swagger spec. -// -// This allows for altering the spec before starting the [http] listener. -func SwaggerUI(opts SwaggerUIOpts, next http.Handler) http.Handler { - opts.EnsureDefaults() - pth := path.Join(opts.BasePath, opts.Path) - tmpl := template.Must(template.New("swaggerui").Parse(opts.Template)) - assets := bytes.NewBuffer(nil) - if err := tmpl.Execute(assets, opts); err != nil { + pth = path.Join(o.BasePath, o.Path) + tmpl := template.Must(template.New("swaggerui").Parse(o.Template)) + buf := bytes.NewBuffer(nil) + if err := tmpl.Execute(buf, o); err != nil { panic(fmt.Errorf("cannot execute template: %w", err)) } - return serveUI(pth, assets.Bytes(), next) + return pth, buf.Bytes() } const ( @@ -119,9 +65,15 @@ const ( {{ .Title }} - + {{- if .SwaggerStylesURL }} + + {{- end }} + {{- if .Favicon32 }} + {{- end }} + {{- if .Favicon16 }} + {{- end }}