diff --git a/.claude/plans/track-b2-docui-extraction.md b/.claude/plans/track-b2-docui-extraction.md new file mode 100644 index 00000000..374128b7 --- /dev/null +++ b/.claude/plans/track-b2-docui-extraction.md @@ -0,0 +1,149 @@ +# 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. + +## 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/README.md b/README.md index befd3d64..2d501183 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,44 @@ A runtime for go OpenAPI toolkit. The runtime component for use in code generation or as untyped usage. - + +**Changes to the API surface in `v0.30.0`**: + +* utility package `header` has now moved to `github.com/go-openapi/runtime/server-middleware/negotiate/header` + +> A shim is provided to support existing programs, with a deprecation notice. + +**Changes in semantics in `v0.30.0`**: + +Function `negotiate.NegotiateContentType` (available as an alias for backward compatibility as `middleware.NegotiateContentType` +now performs a full match considering MIME parameters. + +The previous behavior (matching in order of appearance after stripping parameters) may be enabled explicitly with +option `negotiate.WithIgnoreParameters`. + +* **2026-05-05** : exposed content negotiation methods as a separate, dependency-free module + +> Users may reuse these utilities to support content-negotiation without extra dependencies. +> +> Newly available module: `github.com/go-openapi/runtime/server-middleware` +> +> Newly available packages: `github.com/go-openapi/runtime/server-middleware/negotiate` and +> `github.com/go-openapi/runtime/server-middleware/mediatype`. + +* **2026-05-07** : exposed UI and Spec middleware as a separate, dependency-free module. + +> Newly available package: `github.com/go-openapi/runtime/server-middleware/docui` that now holds our +> UI and spec serve middleware. +> +> A shim is available in `github.com/go-openapi/runtime/middleware` to bridge the older UI options to the new ones, +> with a deprecation notice. +> +> Methods that were unduly exported and purely used to manipulate options (e.g. `SwaggerUIOpts.EnsureDefaults`) have been +> removed. New options in `docui` should be used instead. + +> Users may reuse this middleware to serve a Redoc, Rapidoc or SwaggerUI documentation without +> importing the complete go-openapi scaffolding. ## Status @@ -34,18 +69,21 @@ go get github.com/go-openapi/runtime See -For pre-v0.30.0 releases see [release notes](docs/NOTES.md). +For v0.29.0 release see [release notes](docs/NOTES.md). +From that release onwards, changes are tracked in the github release notes. **What coming next?** Moving forward, we want to : -* [ ] continue narrowing down the scope of dependencies: - * yaml support in an independent module +* [x] fix a few known issues with some file upload requests (e.g. #286) +* [] continue narrowing down the scope of dependencies: + * [x] split middleware and other useful utilities as a separate dependency-free module + * yaml support in an independent module (v2) * introduce more up-to-date support for opentelemetry as a separate module that evolves independently from the main package (to avoid breaking changes, the existing API - will remain maintained, but evolve at a slower pace than opentelemetry). -* [ ] fix a few known issues with some file upload requests (e.g. #286) + will remain maintained, but evolve at a slower pace than opentelemetry). (v2) +* [] publish proper documentation and examples ## Licensing 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..812b72a7 100644 --- a/middleware/context.go +++ b/middleware/context.go @@ -7,8 +7,6 @@ import ( stdContext "context" "fmt" "net/http" - "net/url" - "path" "strings" "sync" @@ -23,14 +21,14 @@ 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" + "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) { @@ -76,11 +74,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 { @@ -353,7 +367,7 @@ func (c *Context) BindValidRequest(request *http.Request, route *MatchedRoute, b requestContentType = "*/*" } - if str := NegotiateContentType(request, route.Produces, requestContentType); str == "" { + if str := negotiate.ContentType(request, route.Produces, requestContentType, c.negotiateOpts()...); str == "" { res = append(res, errors.InvalidResponseFormat(request.Header.Get(runtime.HeaderAccept), route.Produces)) } } @@ -432,7 +446,7 @@ func (c *Context) ResponseFormat(r *http.Request, offers []string) (string, *htt return v, r } - format := NegotiateContentType(r, offers, "") + 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)) @@ -619,57 +633,76 @@ 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 -// (these can be modified with uiOptions). +// A spec UI ([docui.SwaggerUI]) is served at {API base path}/docs and the spec document at /swagger.json +// (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 SwaggerUIOpts - fromCommonToAnyOptions(uiOpts, &swaggerUIOpts) - - return Spec(specPath, c.spec.Raw(), SwaggerUI(swaggerUIOpts, c.RoutesHandler(b)), specOpts...) + return c.APIHandlerWithUI(builder, docui.UseSwaggerUI, c.uiOptionsForHandler(opts)...) } // 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 -// (these can be modified with uiOptions). +// A spec UI ([docui.RapiDoc]) is served at {API base path}/docs and the spec document at /swagger.json +// (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 RapiDocOpts - fromCommonToAnyOptions(uiOpts, &rapidocUIOpts) - - return Spec(specPath, c.spec.Raw(), RapiDoc(rapidocUIOpts, c.RoutesHandler(b)), specOpts...) + return c.APIHandlerWithUI(builder, docui.UseRapiDoc, c.uiOptionsForHandler(opts)...) } // 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 -// (these can be modified with uiOptions). +// A spec UI ([docui.Redoc]) is served at {API base path}/docs and the spec document at /swagger.json +// (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 { + return c.APIHandlerWithUI(builder, docui.UseRedoc, c.uiOptionsForHandler(opts)...) +} + +// 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 } - specPath, uiOpts, specOpts := c.uiOptionsForHandler(opts) - var redocOpts RedocOpts - fromCommonToAnyOptions(uiOpts, &redocOpts) + // 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)) + } + + prepend = append(prepend, docui.WithUIBasePath(c.BasePath())) + prepend = append(prepend, opts...) - return Spec(specPath, c.spec.Raw(), Redoc(redocOpts, c.RoutesHandler(b)), specOpts...) + // 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), + ), + ) } // RoutesHandler returns a handler to serve the API, just the routes and the contract defined in the swagger spec. @@ -681,37 +714,19 @@ func (c *Context) RoutesHandler(builder Builder) http.Handler { return NewRouter(c, b(NewOperationExecutor(c))) } -func (c Context) uiOptionsForHandler(opts []UIOption) (string, uiOptions, []SpecOption) { - var title string - sp := c.spec.Spec() - if sp != nil && sp.Info != nil && sp.Info.Title != "" { - title = sp.Info.Title - } - - // default options (may be overridden) - const baseOptions = 2 - optsForContext := make([]UIOption, 0, len(opts)+baseOptions) - optsForContext = append(optsForContext, - WithUIBasePath(c.BasePath()), - WithUITitle(title), - ) - optsForContext = append(optsForContext, opts...) - uiOpts := uiOptionsWithDefaults(optsForContext) +// uiOptionsForHandler bridges the deprecated [UIOption] set to the new [docui.Option] set. +func (c Context) uiOptionsForHandler(opts []UIOption) []docui.Option { + uiOpts := uiOptionsWithDefaults(opts) - // 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 - } + return uiOpts.toFuncOptions() +} - pth, doc := path.Split(specPath) - if pth == "." { - pth = "" +func (c *Context) negotiateOpts() []negotiate.Option { + if !c.ignoreParameters { + return nil } - return pth, uiOpts, []SpecOption{WithSpecDocument(doc)} + return []negotiate.Option{negotiate.WithIgnoreParameters(true)} } 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/header/header.go b/middleware/header/header.go index 6ce870d8..7fde102a 100644 --- a/middleware/header/header.go +++ b/middleware/header/header.go @@ -1,339 +1,75 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Copyright 2013 The Go Authors. All rights reserved. +// Package header forwards to the relocated implementation at +// [github.com/go-openapi/runtime/server-middleware/negotiate/header]. // -// 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 taken from the github.com/golang/gddo repository - -// Package header provides functions for parsing HTTP headers. +// Deprecated: this package was unintentionally exposed and has moved to +// [github.com/go-openapi/runtime/server-middleware/negotiate/header]. +// +// The shim preserves the public surface so existing imports keep +// compiling against v0.30.x; new code should target the new path. package header import ( - "maps" "net/http" - "strings" "time" -) - -// Octet types from RFC 2616. -var octetTypes [256]octetType - -type octetType byte -const ( - isToken octetType = 1 << iota - isSpace + upstream "github.com/go-openapi/runtime/server-middleware/negotiate/header" ) -const ( - asciiMaxControlChar = 31 - asciiMaxChar = 127 -) - -func init() { - // OCTET = - // CHAR = - // CTL = - // CR = - // LF = - // SP = - // HT = - // <"> = - // CRLF = CR LF - // LWS = [CRLF] 1*( SP | HT ) - // TEXT = - // separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <"> - // | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT - // token = 1* - // qdtext = > - - for c := range 256 { - var t octetType - isCtl := c <= asciiMaxControlChar || c == asciiMaxChar - isChar := 0 <= c && c <= asciiMaxChar - isSeparator := strings.ContainsRune(" \t\"(),/:;<=>?@[]\\{}", rune(c)) - if strings.ContainsRune(" \t\r\n", rune(c)) { - t |= isSpace - } - if isChar && !isCtl && !isSeparator { - t |= isToken - } - octetTypes[c] = t - } -} +// AcceptSpec describes an entry parsed from an Accept-style header. +// +// Deprecated: see package documentation. +type AcceptSpec = upstream.AcceptSpec // Copy returns a shallow copy of the header. +// +// Deprecated: see package documentation. func Copy(header http.Header) http.Header { - h := make(http.Header) - maps.Copy(h, header) - return h + return upstream.Copy(header) } -var timeLayouts = []string{"Mon, 02 Jan 2006 15:04:05 GMT", time.RFC850, time.ANSIC} - -// ParseTime parses the header as time. The zero value is returned if the -// header is not present or there is an error parsing the -// header. -func ParseTime(header http.Header, key string) time.Time { - if s := header.Get(key); s != "" { - for _, layout := range timeLayouts { - if t, err := time.Parse(layout, s); err == nil { - return t.UTC() - } - } - } - return time.Time{} +// ParseList parses a comma separated list of values. +// +// Commas are ignored in quoted strings. Quoted values are not unescaped or +// unquoted. Whitespace is trimmed. +// +// Deprecated: see package documentation. +func ParseList(header http.Header, key string) []string { + return upstream.ParseList(header, key) } -// ParseList parses a comma separated list of values. Commas are ignored in -// quoted strings. Quoted values are not unescaped or unquoted. Whitespace is -// trimmed. -func ParseList(header http.Header, key string) []string { - var result []string - for _, s := range header[http.CanonicalHeaderKey(key)] { - begin := 0 - end := 0 - escape := false - quote := false - for i := range len(s) { - b := s[i] - switch { - case escape: - escape = false - end = i + 1 - case quote: - switch b { - case '\\': - escape = true - case '"': - quote = false - } - end = i + 1 - case b == '"': - quote = true - end = i + 1 - case octetTypes[b]&isSpace != 0: - if begin == end { - begin = i + 1 - end = begin - } - case b == ',': - if begin < end { - result = append(result, s[begin:end]) - } - begin = i + 1 - end = begin - default: - end = i + 1 - } - } - if begin < end { - result = append(result, s[begin:end]) - } - } - return result +// ParseTime parses the header as time. +// +// The zero value is returned if the header is not present or there is an +// error parsing the header. +// +// Deprecated: see package documentation. +func ParseTime(header http.Header, key string) time.Time { + return upstream.ParseTime(header, key) } // ParseValueAndParams parses a comma separated list of values with optional -// semicolon separated name-value pairs. Content-Type and Content-Disposition -// headers are in this format. +// semicolon separated name-value pairs. +// +// Content-Type and Content-Disposition headers are in this format. +// +// Deprecated: see package documentation. func ParseValueAndParams(header http.Header, key string) (string, map[string]string) { - return parseValueAndParams(header.Get(key)) -} - -func parseValueAndParams(s string) (value string, params map[string]string) { - params = make(map[string]string) - value, s = expectTokenSlash(s) - if value == "" { - return - } - value = strings.ToLower(value) - s = skipSpace(s) - for strings.HasPrefix(s, ";") { - var pkey string - pkey, s = expectToken(skipSpace(s[1:])) - if pkey == "" { - return - } - if !strings.HasPrefix(s, "=") { - return - } - var pvalue string - pvalue, s = expectTokenOrQuoted(s[1:]) - if pvalue == "" { - return - } - pkey = strings.ToLower(pkey) - params[pkey] = pvalue - s = skipSpace(s) - } - return -} - -// AcceptSpec ... -type AcceptSpec struct { - Value string - Q float64 -} - -// ParseAccept2 ... -func ParseAccept2(header http.Header, key string) (specs []AcceptSpec) { - for _, en := range ParseList(header, key) { - v, p := parseValueAndParams(en) - var spec AcceptSpec - spec.Value = v - spec.Q = 1.0 - if p != nil { - if q, ok := p["q"]; ok { - spec.Q, _ = expectQuality(q) - } - } - if spec.Q < 0.0 { - continue - } - specs = append(specs, spec) - } - - return + return upstream.ParseValueAndParams(header, key) } // ParseAccept parses Accept* headers. +// +// Deprecated: see package documentation. func ParseAccept(header http.Header, key string) []AcceptSpec { - var specs []AcceptSpec -loop: - for _, s := range header[key] { - for { - var spec AcceptSpec - spec.Value, s = expectTokenSlash(s) - if spec.Value == "" { - continue loop - } - spec.Q = 1.0 - s = skipSpace(s) - if strings.HasPrefix(s, ";") { - s = skipSpace(s[1:]) - for !strings.HasPrefix(s, "q=") && s != "" && !strings.HasPrefix(s, ",") { - s = skipSpace(s[1:]) - } - if strings.HasPrefix(s, "q=") { - spec.Q, s = expectQuality(s[2:]) - if spec.Q < 0.0 { - continue loop - } - } - } - - specs = append(specs, spec) - s = skipSpace(s) - if !strings.HasPrefix(s, ",") { - continue loop - } - s = skipSpace(s[1:]) - } - } - - return specs -} - -func skipSpace(s string) (rest string) { - i := 0 - for ; i < len(s); i++ { - if octetTypes[s[i]]&isSpace == 0 { - break - } - } - return s[i:] + return upstream.ParseAccept(header, key) } -func expectToken(s string) (token, rest string) { - i := 0 - for ; i < len(s); i++ { - if octetTypes[s[i]]&isToken == 0 { - break - } - } - return s[:i], s[i:] -} - -func expectTokenSlash(s string) (token, rest string) { - i := 0 - for ; i < len(s); i++ { - b := s[i] - if (octetTypes[b]&isToken == 0) && b != '/' { - break - } - } - return s[:i], s[i:] -} - -func expectQuality(s string) (q float64, rest string) { - switch { - case len(s) == 0: - return -1, "" - case s[0] == '0': - // q is already 0 - s = s[1:] - case s[0] == '1': - s = s[1:] - q = 1 - case s[0] == '.': - // q is already 0 - default: - return -1, "" - } - if !strings.HasPrefix(s, ".") { - return q, s - } - s = s[1:] - i := 0 - n := 0 - d := 1 - for ; i < len(s); i++ { - b := s[i] - if b < '0' || b > '9' { - break - } - n = n*10 + int(b) - '0' - d *= 10 - } - return q + float64(n)/float64(d), s[i:] -} - -func expectTokenOrQuoted(s string) (value string, rest string) { - if !strings.HasPrefix(s, "\"") { - return expectToken(s) - } - s = s[1:] - for i := 0; i < len(s); i++ { - switch s[i] { - case '"': - return s[:i], s[i+1:] - case '\\': - p := make([]byte, len(s)-1) - j := copy(p, s[:i]) - escape := true - for i++; i < len(s); i++ { - b := s[i] - switch { - case escape: - escape = false - p[j] = b - j++ - case b == '\\': - escape = true - case b == '"': - return string(p[:j]), s[i+1:] - default: - p[j] = b - j++ - } - } - return "", "" - } - } - return "", "" +// ParseAccept2 parses Accept* headers (alternate parser). +// +// Deprecated: see package documentation. +func ParseAccept2(header http.Header, key string) (specs []AcceptSpec) { + return upstream.ParseAccept2(header, key) } diff --git a/middleware/header/header_test.go b/middleware/header/header_test.go index f39bed98..2082de35 100644 --- a/middleware/header/header_test.go +++ b/middleware/header/header_test.go @@ -1,19 +1,78 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package header +package header_test import ( "net/http" "testing" + "github.com/go-openapi/testify/v2/assert" "github.com/go-openapi/testify/v2/require" + + // shim under test + header "github.com/go-openapi/runtime/middleware/header" + upstream "github.com/go-openapi/runtime/server-middleware/negotiate/header" ) -func TestHeader(t *testing.T) { - hdr := http.Header{ - "x-test": []string{"value"}, - } - clone := Copy(hdr) - require.Len(t, clone, len(hdr)) +// TestShimWiring is a smoke test: it asserts that every exported symbol +// re-exported from middleware/header forwards to the relocated package. +// Edge-case behaviour is exhaustively covered in the upstream package +// (server-middleware/negotiate/header) — the assertions here only need +// to be specific enough to prove the call landed there. +func TestShimWiring(t *testing.T) { + t.Run("AcceptSpec is a type alias for upstream.AcceptSpec", func(t *testing.T) { + // Type alias means a value of one is assignable to the other + // without conversion. If the shim re-declared the struct we'd + // need an explicit cast and this would not compile. The explicit + // type annotations are the assertion — do not let inference + // erase them. + //nolint:staticcheck // ST1023: explicit annotations prove the alias + var s header.AcceptSpec = upstream.AcceptSpec{Value: "x", Q: 1.0} + //nolint:staticcheck // ST1023: explicit annotations prove the alias + var u upstream.AcceptSpec = s + assert.EqualT(t, "x", u.Value) + assert.InDeltaT(t, 1.0, u.Q, 0) + }) + + t.Run("Copy forwards", func(t *testing.T) { + in := http.Header{"X-Test": []string{"v"}} + got := header.Copy(in) + require.Len(t, got, 1) + assert.EqualT(t, "v", got.Get("X-Test")) + }) + + t.Run("ParseList forwards", func(t *testing.T) { + got := header.ParseList(http.Header{"X-Test": []string{"a, b"}}, "X-Test") + assert.Equal(t, []string{"a", "b"}, got) + }) + + t.Run("ParseTime forwards", func(t *testing.T) { + h := http.Header{} + h.Set("Date", "Sun, 06 Nov 1994 08:49:37 GMT") + got := header.ParseTime(h, "Date") + assert.EqualT(t, 1994, got.Year()) + }) + + t.Run("ParseValueAndParams forwards", func(t *testing.T) { + h := http.Header{} + h.Set("Content-Type", "text/plain; charset=utf-8") + value, params := header.ParseValueAndParams(h, "Content-Type") + assert.EqualT(t, "text/plain", value) + assert.EqualT(t, "utf-8", params["charset"]) + }) + + t.Run("ParseAccept forwards", func(t *testing.T) { + got := header.ParseAccept(http.Header{"Accept": []string{"text/html;q=0.5"}}, "Accept") + require.Len(t, got, 1) + assert.EqualT(t, "text/html", got[0].Value) + assert.InDeltaT(t, 0.5, got[0].Q, 1e-9) + }) + + t.Run("ParseAccept2 forwards", func(t *testing.T) { + got := header.ParseAccept2(http.Header{"Accept": []string{"text/html;q=0.5"}}, "Accept") + require.Len(t, got, 1) + assert.EqualT(t, "text/html", got[0].Value) + assert.InDeltaT(t, 0.5, got[0].Q, 1e-9) + }) } diff --git a/middleware/negotiate.go b/middleware/negotiate.go deleted file mode 100644 index cb0a8528..00000000 --- a/middleware/negotiate.go +++ /dev/null @@ -1,102 +0,0 @@ -// 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 taken from the github.com/golang/gddo repository - -package middleware - -import ( - "net/http" - "strings" - - "github.com/go-openapi/runtime/middleware/header" -) - -// 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. -func NegotiateContentEncoding(r *http.Request, offers []string) string { - bestOffer := "identity" - bestQ := -1.0 - specs := header.ParseAccept(r.Header, "Accept-Encoding") - for _, offer := range offers { - for _, spec := range specs { - if spec.Q > bestQ && - (spec.Value == "*" || spec.Value == offer) { - bestQ = spec.Q - bestOffer = offer - } - } - } - 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 - } - 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 - } - } - } - } - return bestOffer -} - -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/middleware/negotiate_test.go deleted file mode 100644 index 7530c858..00000000 --- a/middleware/negotiate_test.go +++ /dev/null @@ -1,89 +0,0 @@ -// 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. - -package middleware - -import ( - "net/http" - "testing" -) - -var negotiateContentEncodingTests = []struct { - s string - offers []string - expect string -}{ - {"", []string{"identity", "gzip"}, "identity"}, - {"*;q=0", []string{"identity", "gzip"}, ""}, - {"gzip", []string{"identity", "gzip"}, "gzip"}, -} - -func TestNegotiateContentEnoding(t *testing.T) { - for _, tt := range negotiateContentEncodingTests { - r := &http.Request{Header: http.Header{"Accept-Encoding": {tt.s}}} - actual := 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"}, -} - -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) - } - } -} - -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") - } -} diff --git a/middleware/rapidoc.go b/middleware/rapidoc.go deleted file mode 100644 index 1574defb..00000000 --- a/middleware/rapidoc.go +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package middleware - -import ( - "bytes" - "fmt" - "html/template" - "net/http" - "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 - } -} - -// RapiDoc creates a [middleware] to serve a documentation site for a swagger spec. -// -// This allows for altering the spec before starting the [http] listener. -func RapiDoc(opts RapiDocOpts, next http.Handler) http.Handler { - opts.EnsureDefaults() - - 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 { - panic(fmt.Errorf("cannot execute template: %w", err)) - } - - return serveUI(pth, assets.Bytes(), next) -} - -const ( - rapidocLatest = "https://unpkg.com/rapidoc/dist/rapidoc-min.js" - rapidocTemplate = ` - - - {{ .Title }} - - - - - - - -` -) diff --git a/middleware/rapidoc_test.go b/middleware/rapidoc_test.go deleted file mode 100644 index 4ffc09b4..00000000 --- a/middleware/rapidoc_test.go +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package middleware - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" -) - -func TestRapiDocMiddleware(t *testing.T) { - t.Run("with defaults", func(t *testing.T) { - rapidoc := RapiDoc(RapiDocOpts{}, nil) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) - require.NoError(t, err) - recorder := httptest.NewRecorder() - rapidoc.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) - }) - - t.Run("edge cases", func(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 }}' - -`, - }, nil) - }) - }) - }) -} diff --git a/middleware/redoc.go b/middleware/redoc.go deleted file mode 100644 index 1007409a..00000000 --- a/middleware/redoc.go +++ /dev/null @@ -1,97 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package middleware - -import ( - "bytes" - "fmt" - "html/template" - "net/http" - "path" -) - -// RedocOpts configures the [Redoc] middlewares. -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 -} - -// 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 - } -} - -// Redoc creates a [middleware] to serve a documentation site for a swagger spec. -// -// This allows for altering the spec before starting the [http] listener. -func Redoc(opts RedocOpts, next http.Handler) http.Handler { - opts.EnsureDefaults() - - 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 { - panic(fmt.Errorf("cannot execute template: %w", err)) - } - - return serveUI(pth, assets.Bytes(), next) -} - -const ( - redocLatest = "https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js" - redocTemplate = ` - - - {{ .Title }} - - - - - - - - - - - - - -` -) diff --git a/middleware/redoc_test.go b/middleware/redoc_test.go deleted file mode 100644 index 9c57ae43..00000000 --- a/middleware/redoc_test.go +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package middleware - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" -) - -func TestRedocMiddleware(t *testing.T) { - t.Run("with defaults", func(t *testing.T) { - redoc := Redoc(RedocOpts{}, nil) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) - require.NoError(t, err) - recorder := httptest.NewRecorder() - redoc.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) - }) - - t.Run("with alternate path and spec URL", func(t *testing.T) { - redoc := Redoc(RedocOpts{ - BasePath: "/base", - Path: "ui", - SpecURL: "/ui/swagger.json", - }, nil) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/base/ui", nil) - require.NoError(t, err) - recorder := httptest.NewRecorder() - redoc.ServeHTTP(recorder, req) - assert.EqualT(t, http.StatusOK, recorder.Code) - assert.StringContainsT(t, recorder.Body.String(), "") - }) - - t.Run("with custom template", func(t *testing.T) { - redoc := Redoc(RedocOpts{ - Template: ` - - - {{ .Title }} - - - - - - - - - - - - -`, - }, nil) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) - require.NoError(t, err) - recorder := httptest.NewRecorder() - redoc.ServeHTTP(recorder, req) - assert.EqualT(t, http.StatusOK, recorder.Code) - assert.StringContainsT(t, recorder.Body.String(), "required-props-first=true") - }) - - t.Run("edge cases", func(t *testing.T) { - t.Run("with invalid custom template", func(t *testing.T) { - assert.Panics(t, func() { - Redoc(RedocOpts{ - Template: ` - - - spec-url='{{ .Spec - -`, - }, nil) - }) - }) - - t.Run("with custom template that fails to execute", func(t *testing.T) { - assert.Panics(t, func() { - Redoc(RedocOpts{ - Template: ` - - spec-url='{{ .Unknown }}' - -`, - }, nil) - }) - }) - }) -} diff --git a/middleware/seam.go b/middleware/seam.go new file mode 100644 index 00000000..390d3935 --- /dev/null +++ b/middleware/seam.go @@ -0,0 +1,481 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +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. +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. +func NegotiateContentType(r *http.Request, offers []string, defaultOffer string, opts ...NegotiateOption) string { + return negotiate.ContentType(r, offers, defaultOffer, opts...) +} + +// NegotiateContentEncoding returns the best offered content encoding for +// the request's Accept-Encoding header. +// +// Deprecated: moved to the [negotiate] package. Use [negotiate.ContentEncoding] instead. +func NegotiateContentEncoding(r *http.Request, offers []string) string { + return negotiate.ContentEncoding(r, offers) +} + +// 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. +func WithIgnoreParameters(ignore bool) NegotiateOption { + return negotiate.WithIgnoreParameters(ignore) +} + +/////////////////////////////////////////////////////////: +// Seam to the UI options +/////////////////////////////////////////////////////////: + +// 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. +func RapiDoc(opts RapiDocOpts, next http.Handler) http.Handler { + return docui.RapiDoc(next, opts.toFuncOptions()...) +} + +// 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. +func Redoc(opts RedocOpts, next http.Handler) http.Handler { + return docui.Redoc(next, opts.toFuncOptions()...) +} + +// 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. +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. +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 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. +// +// 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]. +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 [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 + } +} + +// UIOptions defines common options for UI serving middlewares. +// +// 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 + + // 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: use instead the function options provided by [docui]. +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 (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 + + // OAuthCallbackURL the url called after OAuth2 login + // + // NOTE: in the new [docui.SwaggerUIOptions] type, this field is named `OAuth2CallbackURL`, + // which is more appropriate. + 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 +} + +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.OAuthCallbackURL, + 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. +// +// 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: 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: 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. +// +// This may be specified as a full URL or a path. +// +// By default, this is "/swagger.json". +// +// 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. +// +// 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: 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 new file mode 100644 index 00000000..481cff76 --- /dev/null +++ b/middleware/seam_test.go @@ -0,0 +1,103 @@ +// 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 and negotiate packages. These verify that: +// +// - the type aliases still resolve so user code keeps compiling, +// - the function-value aliases still produce the documented behaviour. +// +// The exhaustive coverage lives in the destination packages themselves. + +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" +) + +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()) + }) + + 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/spec.go b/middleware/spec.go deleted file mode 100644 index 0a64a957..00000000 --- a/middleware/spec.go +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package middleware - -import ( - "net/http" - "path" -) - -const ( - contentTypeHeader = "Content-Type" - applicationJSON = "application/json" -) - -// SpecOption can be applied to the Spec serving [middleware]. -type SpecOption func(*specOptions) - -var defaultSpecOptions = specOptions{ - Path: "", - Document: "swagger.json", -} - -type specOptions struct { - Path string - Document string -} - -func specOptionsWithDefaults(opts []SpecOption) specOptions { - o := defaultSpecOptions - for _, apply := range opts { - apply(&o) - } - - return o -} - -// 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"). -func Spec(basePath string, b []byte, next http.Handler, opts ...SpecOption) http.Handler { - if basePath == "" { - basePath = "/" - } - o := specOptionsWithDefaults(opts) - pth := path.Join(basePath, o.Path, o.Document) - - 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) - - return - } - - if next != nil { - next.ServeHTTP(rw, r) - - return - } - - rw.Header().Set(contentTypeHeader, applicationJSON) - rw.WriteHeader(http.StatusNotFound) - }) -} - -// WithSpecPath sets the path to be joined to the base path of the Spec [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/middleware/swaggerui.go b/middleware/swaggerui.go deleted file mode 100644 index 14ed37ce..00000000 --- a/middleware/swaggerui.go +++ /dev/null @@ -1,178 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package middleware - -import ( - "bytes" - "fmt" - "html/template" - "net/http" - "path" -) - -// SwaggerUIOpts configures the [SwaggerUI] [middleware]. -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 - - // 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 - } -} - -func (r *SwaggerUIOpts) EnsureDefaultsOauth2() { - r.ensureDefaults() - - if r.Template == "" { - r.Template = swaggerOAuthTemplate - } -} - -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 - } - 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 { - panic(fmt.Errorf("cannot execute template: %w", err)) - } - - return serveUI(pth, assets.Bytes(), next) -} - -const ( - swaggerLatest = "https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js" - swaggerPresetLatest = "https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js" - swaggerStylesLatest = "https://unpkg.com/swagger-ui-dist/swagger-ui.css" - swaggerFavicon32Latest = "https://unpkg.com/swagger-ui-dist/favicon-32x32.png" - swaggerFavicon16Latest = "https://unpkg.com/swagger-ui-dist/favicon-16x16.png" - swaggeruiTemplate = ` - - - - - {{ .Title }} - - - - - - - - -
- - - - - - -` -) diff --git a/middleware/swaggerui_oauth2_test.go b/middleware/swaggerui_oauth2_test.go deleted file mode 100644 index 33cafd7c..00000000 --- a/middleware/swaggerui_oauth2_test.go +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package middleware - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" -) - -func TestSwaggerUIOAuth2CallbackMiddleware(t *testing.T) { - t.Run("with defaults", func(t *testing.T) { - doc := SwaggerUIOAuth2Callback(SwaggerUIOpts{}, nil) - - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs/oauth2-callback", nil) - require.NoError(t, err) - recorder := httptest.NewRecorder() - - doc.ServeHTTP(recorder, req) - require.EqualT(t, http.StatusOK, recorder.Code) - assert.EqualT(t, "text/html; charset=utf-8", recorder.Header().Get(contentTypeHeader)) - - var o SwaggerUIOpts - o.EnsureDefaultsOauth2() - htmlResponse := recorder.Body.String() - assert.StringContainsT(t, htmlResponse, fmt.Sprintf("%s", o.Title)) - assert.StringContainsT(t, htmlResponse, `oauth2.auth.schema.get("flow") === "accessCode"`) - }) - - t.Run("edge cases", func(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 }}' - -`, - }, nil) - }) - }) - }) -} diff --git a/middleware/swaggerui_test.go b/middleware/swaggerui_test.go deleted file mode 100644 index d1b9a180..00000000 --- a/middleware/swaggerui_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package middleware - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" -) - -func TestSwaggerUIMiddleware(t *testing.T) { - var o SwaggerUIOpts - o.EnsureDefaults() - swui := SwaggerUI(o, nil) - - t.Run("with defaults ", func(t *testing.T) { - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs", nil) - require.NoError(t, err) - recorder := httptest.NewRecorder() - - swui.ServeHTTP(recorder, req) - assert.EqualT(t, http.StatusOK, recorder.Code) - - assert.EqualT(t, "text/html; charset=utf-8", recorder.Header().Get(contentTypeHeader)) - assert.StringContainsT(t, recorder.Body.String(), fmt.Sprintf("%s", o.Title)) - assert.StringContainsT(t, recorder.Body.String(), fmt.Sprintf(`url: '%s',`, strings.ReplaceAll(o.SpecURL, `/`, `\/`))) - assert.StringContainsT(t, recorder.Body.String(), swaggerLatest) - assert.StringContainsT(t, recorder.Body.String(), swaggerPresetLatest) - assert.StringContainsT(t, recorder.Body.String(), swaggerStylesLatest) - assert.StringContainsT(t, recorder.Body.String(), swaggerFavicon16Latest) - assert.StringContainsT(t, recorder.Body.String(), swaggerFavicon32Latest) - }) - - t.Run("with path with a trailing / (issue #238)", func(t *testing.T) { - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs/", nil) - require.NoError(t, err) - recorder := httptest.NewRecorder() - - swui.ServeHTTP(recorder, req) - assert.EqualT(t, http.StatusOK, recorder.Code) - }) - - t.Run("should yield not found", func(t *testing.T) { - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/nowhere", nil) - require.NoError(t, err) - recorder := httptest.NewRecorder() - - swui.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) { - assert.Panics(t, func() { - SwaggerUI(SwaggerUIOpts{ - Template: ` - - spec-url='{{ .Unknown }}' - -`, - }, nil) - }) - }) - }) -} diff --git a/middleware/typeutils.go b/middleware/typeutils.go new file mode 100644 index 00000000..3f7d7976 --- /dev/null +++ b/middleware/typeutils.go @@ -0,0 +1,30 @@ +// 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. +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] +} + +// 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/middleware/ui_options.go b/middleware/ui_options.go deleted file mode 100644 index ed255426..00000000 --- a/middleware/ui_options.go +++ /dev/null @@ -1,176 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package middleware - -import ( - "bytes" - "encoding/gob" - "fmt" - "net/http" - "path" - "strings" -) - -const ( - // constants that are common to all UI-serving middlewares. - defaultDocsPath = "docs" - defaultDocsURL = "/swagger.json" - defaultDocsTitle = "API Documentation" -) - -// uiOptions defines common options for UI serving middlewares. -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 - - // Title for the documentation site, default to: API documentation - Title string - - // Template specifies a custom template to serve the UI - Template 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) - } - - err = dec.Decode(&o) - if err != nil { - panic(err) - } - - return o -} - -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) - } - - err = dec.Decode(target) - if err != nil { - panic(err) - } -} - -// UIOption can be applied to UI serving [middleware], such as Context.[APIHandler] or -// Context.[APIHandlerSwaggerUI] to alter the default behavior. -type UIOption func(*uiOptions) - -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. -// -// By default, Context [middleware] sets this value to the API base path. -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}. -func WithUIPath(pth string) UIOption { - return func(o *uiOptions) { - o.Path = pth - } -} - -// 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". -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. -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. -func WithTemplate(tpl string) UIOption { - return func(o *uiOptions) { - o.Template = tpl - } -} - -// EnsureDefaults in case some options are missing. -func (r *uiOptions) EnsureDefaults() { - if r.BasePath == "" { - r.BasePath = "/" - } - if r.Path == "" { - r.Path = defaultDocsPath - } - if r.SpecURL == "" { - r.SpecURL = defaultDocsURL - } - if r.Title == "" { - 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/middleware/ui_options_test.go deleted file mode 100644 index c0d07b56..00000000 --- a/middleware/ui_options_test.go +++ /dev/null @@ -1,108 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package middleware - -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/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/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/server-middleware/docui/options.go b/server-middleware/docui/options.go new file mode 100644 index 00000000..c9e45f77 --- /dev/null +++ b/server-middleware/docui/options.go @@ -0,0 +1,253 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package docui + +import ( + "net/http" + "net/url" + "strings" +) + +const ( + // constants that are common to all UI-serving middlewares. + defaultDocsPath = "docs" + defaultDocsURL = "/swagger.json" + defaultDocsTitle = "API Documentation" + + contentTypeHeader = "Content-Type" + applicationJSON = "application/json" +) + +// UIMiddleware is a function returning a http middleware which accepts UI [Option]. +type UIMiddleware func(...Option) func(http.Handler) http.Handler + +// 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 [WithUITemplate]. +type Option func(*options) + +// SpecOption can be applied to the [ServeSpec] middleware. +type SpecOption func(*specOptions) + +// 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. + // + // Default: https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js + SwaggerPresetURL string + + // Defines style sheet URL. + // + // Default: https://unpkg.com/swagger-ui-dist/swagger-ui.css + SwaggerStylesURL 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 +} + +func (o *SwaggerUIOptions) applySwaggerUIDefaults() { + if o.SwaggerPresetURL == "" { + o.SwaggerPresetURL = swaggerPresetLatest + } + if o.SwaggerStylesURL == "" { + o.SwaggerStylesURL = swaggerStylesLatest + } + if o.Favicon16 == "" || o.Favicon32 == "" { + o.Favicon16 = swaggerFavicon16Latest + o.Favicon32 = swaggerFavicon32Latest + } +} + +type ( + options struct { + SwaggerUIOptions + + // 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. + 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 + } + + specOptions struct { + Path string + Document string + } +) + +//////////////////////////////////////////////////////////// +// Common UI options +//////////////////////////////////////////////////////////// + +// WithUIBasePath sets the base path from where to serve the UI assets. +// +// Default: "/" +func WithUIBasePath(base string) Option { + return func(o *options) { + if !strings.HasPrefix(base, "/") { + base = "/" + base + } + o.BasePath = base + } +} + +// WithUIPath sets the path from where to serve the UI assets (i.e. /{basepath}/{path}). +// +// Default: "docs" +func WithUIPath(pth string) Option { + return func(o *options) { + o.Path = pth + } +} + +// 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. +// +// Defaults: +// +// - 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 + } +} + +// 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: https://github.com/rapi-doc/RapiDoc +// - for SwaggerUI: https://github.com/swagger-api/swagger-ui +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 + } +} + +//////////////////////////////////////////////////////////// +// Spec options +//////////////////////////////////////////////////////////// + +// WithSpecPath sets the path of the spec document. +// +// This is "/swagger.json" by default. +func WithSpecPath(pth string) SpecOption { + return func(o *specOptions) { + if pth == "" { + return + } + + o.Path = pth + } +} + +// WithSpecPathFromOptions reuses the same SpecPath as the one specified in +// a set of UI [Option] (extract the path from the URL 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 + } +} + +func optionsWithDefaults(opts []Option, prepend ...Option) options { + o := options{ + BasePath: "/", + Path: defaultDocsPath, + SpecURL: defaultDocsURL, + Title: defaultDocsTitle, + } + + prepend = append(prepend, opts...) + for _, apply := range prepend { + apply(&o) + } + + 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/rapidoc.go b/server-middleware/docui/rapidoc.go new file mode 100644 index 00000000..c050331b --- /dev/null +++ b/server-middleware/docui/rapidoc.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package docui + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "path" +) + +// UseRapiDoc creates a middleware to serve a documentation site for a swagger spec using [RapidDoc]. +// +// [RapiDoc]: https://github.com/rapi-doc/RapiDoc +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 [http.Handler] to serve a documentation site for a swagger spec using [RapidDoc]. +// +// By default, the UI is served at route "/docs" +// +// This allows for altering the spec before starting the [http] listener. +// +// [RapiDoc]: https://github.com/rapi-doc/RapiDoc +func RapiDoc(next http.Handler, opts ...Option) http.Handler { + pth, assets := rapiDocSetup(opts) + + return serveUI(pth, assets, next) +} + +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 pth, buf.Bytes() +} + +const ( + rapidocLatest = "https://unpkg.com/rapidoc/dist/rapidoc-min.js" + rapidocTemplate = ` + + + {{ .Title }} + + + + + + + +` +) diff --git a/server-middleware/docui/rapidoc_test.go b/server-middleware/docui/rapidoc_test.go new file mode 100644 index 00000000..de283bde --- /dev/null +++ b/server-middleware/docui/rapidoc_test.go @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package docui + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestRapiDocMiddleware(t *testing.T) { + t.Run("with defaults", func(t *testing.T) { + h := RapiDoc(nil) + 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.EqualT(t, "text/html; charset=utf-8", recorder.Header().Get(contentTypeHeader)) + + 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 template that fails to execute", func(t *testing.T) { + assert.Panics(t, func() { + 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 new file mode 100644 index 00000000..31054a24 --- /dev/null +++ b/server-middleware/docui/redoc.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package docui + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "path" +) + +// UseRedoc creates a middleware to serve a documentation site for a swagger spec using [Redoc]. +// +// [Redoc]: https://redocly.com/docs/redoc +func UseRedoc(opts ...Option) func(next http.Handler) http.Handler { + pth, assets := redocSetup(opts) + + return func(next http.Handler) http.Handler { + return serveUI(pth, assets, next) + } +} + +// Redoc creates a [http.Handler] to serve a documentation site for a swagger spec using [Redoc]. +// +// By default, the UI is served at route "/docs" +// +// This allows for altering the spec before starting the [http] listener. +// +// [Redoc]: https://redocly.com/docs/redoc +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(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 pth, buf.Bytes() +} + +const ( + redocLatest = "https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js" // "https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js" + redocTemplate = ` + + + {{ .Title }} + + + + + + + + + + + + + +` +) diff --git a/server-middleware/docui/redoc_test.go b/server-middleware/docui/redoc_test.go new file mode 100644 index 00000000..65ab489b --- /dev/null +++ b/server-middleware/docui/redoc_test.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package docui + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestRedocMiddleware(t *testing.T) { + t.Run("with defaults", func(t *testing.T) { + h := Redoc(nil) + 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.EqualT(t, "text/html; charset=utf-8", recorder.Header().Get(contentTypeHeader)) + + 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) { + 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() + + 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(), ` + + +` + h := Redoc(nil, WithUITemplate(tpl)) + 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(), "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 malformed template", func(t *testing.T) { + assert.Panics(t, func() { + Redoc(nil, WithUITemplate(malformedTemplate)) + }) + }) + + t.Run("with template that fails to execute", func(t *testing.T) { + assert.Panics(t, func() { + 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 new file mode 100644 index 00000000..1fb744fd --- /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 [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 { + 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/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 new file mode 100644 index 00000000..59780199 --- /dev/null +++ b/server-middleware/docui/spec.go @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package docui + +import ( + "net/http" + "path" +) + +// 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) + + return func(next http.Handler) http.Handler { + return handleSpec(o.Path, spec, next) + } +} + +// 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. +// +// 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) + + 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(spec) + + return + } + + if next != nil { + next.ServeHTTP(rw, r) + + return + } + + rw.Header().Set(contentTypeHeader, applicationJSON) + rw.WriteHeader(http.StatusNotFound) + }) +} diff --git a/middleware/spec_test.go b/server-middleware/docui/spec_test.go similarity index 66% rename from middleware/spec_test.go rename to server-middleware/docui/spec_test.go index 953dbe21..08b693a6 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,30 @@ 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) - - t.Run("Spec handler", func(t *testing.T) { - handler := Spec("", ctx.spec.Raw(), nil) + 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 +42,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,16 +54,14 @@ func TestServeSpecMiddleware(t *testing.T) { }) }) - t.Run("Spec handler with options", func(t *testing.T) { - handler := Spec("/swagger", ctx.spec.Raw(), nil, - WithSpecPath("spec"), - WithSpecDocument("myapi-swagger.json"), + t.Run("ServeSpec handler with options", func(t *testing.T) { + handler := ServeSpec(testSpec, nil, + WithSpecPath("/swagger/spec/myapi-swagger.json"), ) 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 +71,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/server-middleware/docui/swaggerui.go b/server-middleware/docui/swaggerui.go new file mode 100644 index 00000000..db0aa05e --- /dev/null +++ b/server-middleware/docui/swaggerui.go @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package docui + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "path" +) + +// UseSwaggerUI creates a middleware to serve a documentation site for a swagger spec using [SwaggerUI]. +// +// [SwaggerUI]: https://swagger.io/tools/swagger-ui +func UseSwaggerUI(opts ...Option) func(next http.Handler) http.Handler { + pth, assets := swaggeruiSetup(opts) + + return func(next http.Handler) http.Handler { + return serveUI(pth, assets, next) + } +} + +// SwaggerUI creates a [http.Handler] to serve a documentation site for a swagger spec using [SwaggerUI]. +// +// By default, the UI is served at route "/docs" +// +// This allows for altering the spec before starting the [http] listener. +// +// [SwaggerUI]: https://swagger.io/tools/swagger-ui +func SwaggerUI(next http.Handler, opts ...Option) http.Handler { + pth, assets := swaggeruiSetup(opts) + + return serveUI(pth, assets, next) +} + +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") + } + + 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 pth, buf.Bytes() +} + +const ( + swaggerLatest = "https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js" + swaggerPresetLatest = "https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js" + swaggerStylesLatest = "https://unpkg.com/swagger-ui-dist/swagger-ui.css" + swaggerFavicon32Latest = "https://unpkg.com/swagger-ui-dist/favicon-32x32.png" + swaggerFavicon16Latest = "https://unpkg.com/swagger-ui-dist/favicon-16x16.png" + swaggeruiTemplate = ` + + + + + {{ .Title }} + + {{- if .SwaggerStylesURL }} + + {{- end }} + {{- if .Favicon32 }} + + {{- end }} + {{- if .Favicon16 }} + + {{- end }} + + + + +
+ + + {{- if .SwaggerPresetURL }} + + {{- end }} + + + +` +) diff --git a/middleware/swaggerui_oauth2.go b/server-middleware/docui/swaggerui_oauth2.go similarity index 70% rename from middleware/swaggerui_oauth2.go rename to server-middleware/docui/swaggerui_oauth2.go index 879bdbaa..a38e408f 100644 --- a/middleware/swaggerui_oauth2.go +++ b/server-middleware/docui/swaggerui_oauth2.go @@ -1,30 +1,56 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -package middleware +package docui import ( "bytes" "fmt" "net/http" + "path" "text/template" ) -func SwaggerUIOAuth2Callback(opts SwaggerUIOpts, next http.Handler) http.Handler { - opts.EnsureDefaultsOauth2() +// UseSwaggerUIOAuth2Callback creates a middleware that serves a callback URL to complete +// a OAuth2 token handshake. +func UseSwaggerUIOAuth2Callback(opts ...Option) func(next http.Handler) http.Handler { + pth, assets := swaggeruiOAuth2Setup(opts) - pth := opts.OAuthCallbackURL - tmpl := template.Must(template.New("swaggeroauth").Parse(opts.Template)) - assets := bytes.NewBuffer(nil) - if err := tmpl.Execute(assets, opts); err != nil { + return func(next http.Handler) http.Handler { + return serveUI(pth, assets, next) + } +} + +// SwaggerUIOAuth2Callback creates a [http.Handler] that serves a callback URL to complete +// a OAuth2 token handshake. +func SwaggerUIOAuth2Callback(next http.Handler, opts ...Option) http.Handler { + pth, assets := swaggeruiOAuth2Setup(opts) + + return serveUI(pth, assets, next) +} + +func swaggeruiOAuth2Setup(opts []Option) (pth string, assets []byte) { + o := optionsWithDefaults(opts, + // defaults for SwaggerUI OAuth2 callback endpoint + WithUITemplate(swaggerOAuth2Template), + WithUIAssetsURL(swaggerLatest), + ) + o.applySwaggerUIDefaults() + if o.OAuth2CallbackURL == "" { + o.OAuth2CallbackURL = path.Join(o.BasePath, o.Path, "oauth2-callback") + } + + pth = o.OAuth2CallbackURL + tmpl := template.Must(template.New("swaggeroauth2").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 ( - swaggerOAuthTemplate = ` +const swaggerOAuth2Template = ` @@ -105,4 +131,3 @@ const ( ` -) diff --git a/server-middleware/docui/swaggerui_oauth2_test.go b/server-middleware/docui/swaggerui_oauth2_test.go new file mode 100644 index 00000000..299f40c6 --- /dev/null +++ b/server-middleware/docui/swaggerui_oauth2_test.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package docui + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestSwaggerUIOAuth2CallbackMiddleware(t *testing.T) { + t.Run("with defaults", func(t *testing.T) { + h := SwaggerUIOAuth2Callback(nil) + + // Default callback URL is ///oauth2-callback, + // i.e. /docs/oauth2-callback. + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs/oauth2-callback", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + require.EqualT(t, http.StatusOK, recorder.Code) + assert.EqualT(t, "text/html; charset=utf-8", recorder.Header().Get(contentTypeHeader)) + + body := recorder.Body.String() + assert.StringContainsT(t, body, fmt.Sprintf("%s", defaultDocsTitle)) + // Marker from the swagger-ui-dist OAuth2 popup callback script. + assert.StringContainsT(t, body, `oauth2.auth.schema.get("flow") === "accessCode"`) + }) + + t.Run("with explicit OAuth2CallbackURL", func(t *testing.T) { + h := SwaggerUIOAuth2Callback(nil, WithSwaggerUIOptions(SwaggerUIOptions{ + OAuth2CallbackURL: "/custom/callback", + })) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/custom/callback", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusOK, recorder.Code) + }) + + t.Run("with alternate base path and path", func(t *testing.T) { + h := SwaggerUIOAuth2Callback(nil, + WithUIBasePath("/api"), + WithUIPath("ui"), + ) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/api/ui/oauth2-callback", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusOK, recorder.Code) + }) + + t.Run("returns 404 when no next handler", func(t *testing.T) { + h := SwaggerUIOAuth2Callback(nil) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/nowhere", nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + + h.ServeHTTP(recorder, req) + assert.EqualT(t, http.StatusNotFound, recorder.Code) + }) + + 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 := SwaggerUIOAuth2Callback(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("edge cases", func(t *testing.T) { + t.Run("with template that fails to execute", func(t *testing.T) { + assert.Panics(t, func() { + SwaggerUIOAuth2Callback(nil, WithUITemplate(badTemplate)) + }) + }) + }) +} + +func TestUseSwaggerUIOAuth2Callback(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 := UseSwaggerUIOAuth2Callback()(next) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/docs/oauth2-callback", 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/swaggerui_test.go b/server-middleware/docui/swaggerui_test.go new file mode 100644 index 00000000..68e90326 --- /dev/null +++ b/server-middleware/docui/swaggerui_test.go @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package docui + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestSwaggerUIMiddleware(t *testing.T) { + t.Run("with defaults", func(t *testing.T) { + h := SwaggerUI(nil) + 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.EqualT(t, "text/html; charset=utf-8", recorder.Header().Get(contentTypeHeader)) + + body := recorder.Body.String() + assert.StringContainsT(t, body, fmt.Sprintf("%s", defaultDocsTitle)) + // html/template JS-escapes '/' as '\/' inside