Skip to content

Commit 853ccde

Browse files
fredbiclaude
andauthored
Fix/140 json dialects (#442)
* feat(mediatype): expose RFC 6839 structured syntax suffix MediaType now carries a Suffix field, populated by Parse from the trailing '+'-delimited token of the subtype. A new Base() method returns the base media type implied by the suffix (+json → application/json, +xml → application/xml, +yaml → application/yaml) or the receiver unchanged for unsuffixed or unknown-suffix types. Subtype retains the full wire value, so existing callers comparing Subtype against a string see no change. StripParams carries Suffix through. No matching, negotiation, or codec-lookup behavior changes in this commit. This is the parsing primitive that later layers (alias canonicalization, opt-in suffix-aware matching, codec-lookup fallback for issue #140) build on. Refs #140 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Frederic BIDON <fredbi@yahoo.com> * feat(mediatype): alias-aware matching with RFC 9512 alias table Adds a fixed Aliases map seeded with the three YAML aliases that RFC 9512 §2.1 explicitly enumerates as "Deprecated alias names for this type" in the IANA registration template for application/yaml: application/x-yaml, text/yaml, and text/x-yaml. A new Canonical() method rewrites a MediaType through the table; a new Match() returns a graded MatchKind {None, Alias, Exact} where direct RFC 7231 agreement is MatchExact and alias-bridged agreement is MatchAlias. BestMatch and MatchFirst now consult the alias bridge as a lower-priority fallback: - BestMatch tie-break order becomes (q, specificity, MatchKind, offer index). A request for application/yaml against offers [application/x-yaml, application/yaml] picks application/yaml regardless of offer order; against [application/x-yaml] alone it picks the alias. - MatchFirst runs a two-pass scan over the allowed list: pass 1 returns the first MatchExact, pass 2 returns the first MatchAlias. Direct callers of Matches are unchanged — strict RFC 7231 semantics (no callers outside the package). feat(mediatype): alias-aware codec lookup helper Adds mediatype.Lookup[T] — a generic codec-map lookup with four fallback tiers: 1. mediaType verbatim (fast path; preserves existing behavior for callers that store and pass canonical strings). 2. Parsed canonical "type/subtype" form (strips params, lowercases). Recovers the match when mediaType carries "; charset=...". 3. Alias-canonicalized form from Aliases — query side. Catches "request asks for application/yaml, map keyed by canonical". 4. Walks the map and matches any key whose own alias-canonical form agrees with the query — catches "map keyed under one alias, query uses a different alias of the same canonical" (e.g. registered under text/yaml, queried as application/x-yaml). O(len(m)), negligible for codec maps. The seam keeps alias definitions (and any future lookup tolerances) in one place; both runtimes now route their codec-map lookups through it. Server-side response-producer lookups (context.go:559, 595, 612) are intentionally untouched: they consume the negotiation output, which is already alias-aware via the BestMatch / MatchFirst. test(mediatype): pin param-aware Match behavior under alias bridge Adds two rows to the Match table and one BestMatch scenario covering the case where param disagreement defeats subtype agreement: - alias subtype with matching params → MatchAlias - exact subtype with param mismatch → MatchNone - BestMatch picks the alias-with-matching-params offer over the exact-subtype-with-mismatched-params offer, because the latter fails the param-subset rule and isn't actually a match — not because the alias tier outranks the exact tier on specificity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Frederic BIDON <fredbi@yahoo.com> (squash) refactor(mediatype): privatize alias and suffix-base maps Renames the package-level Aliases and SuffixBase variables to their lowercase counterparts. The maps were only ever exported to make test introspection cheap; same-package tests reach them just as well at lowercase, and Canonical / Base / Match / Lookup are the intended external surface. Privatizing removes: - the mutation hazard from external code (these maps are read-only by design but Go can't enforce that on an exported var); - the implicit promise that the table shape is part of the API. Refs #140 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Frederic BIDON <fredbi@yahoo.com> fix(runtime): YAMLMime defaults to the RFC 9512 canonical name Flips the YAMLMime constant from "application/x-yaml" to "application/yaml" (the IANA-registered canonical per RFC 9512). The default client Consumers/Producers maps read YAMLMime as their key and pick up the change automatically. Backward compatibility is preserved end-to-end via the mediatype alias bridge landed earlier. - Requests / responses with Content-Type "application/x-yaml", "text/yaml", or "text/x-yaml" still resolve to the YAML codec (mediatype.Lookup tier 3-4, RFC 9512 §2.1 "Deprecated alias names"). - Accept negotiation matches the legacy forms against canonical offers and vice versa (Set.BestMatch / MatchFirst, MatchKind tier). - User code that registered codecs explicitly at "application/x-yaml" keeps working without changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Frederic BIDON <fredbi@yahoo.com> * feat(mediatype): opt-in RFC 6839 suffix tier for Match and Lookup Refs #140 Introduces a single MatchOption mechanism (the AllowSuffix() opt-in) that extends Match, BestMatch, MatchFirst, and Lookup to tolerate RFC 6839 structured-syntax suffix media types (+json, +xml, +yaml) when the caller explicitly asks for it. Default behavior is unchanged. - MatchKind gains MatchSuffix, slotted below MatchAlias. Order is now None < Suffix < Alias < Exact, encoding semantic strength. - MediaType.Match always returns the strongest tier that succeeds, including MatchSuffix when the two values agree only after folding the structured-syntax suffix (m.Base().Canonical(). Matches(other.Base().Canonical())). - Set.BestMatch, MatchFirst, and Lookup accept variadic ...MatchOption arguments. With no options they reject MatchSuffix candidates (same as today); with AllowSuffix() they count them at lower priority than alias and exact matches. - Lookup's tier 5 (opt-in) walks the map applying alias canonicalization against the suffix base — query-side suffix fold only, never a map-side suffix fold (the inverse case is not a real scenario and would surprise users). - MatchFirst becomes a 2- or 3-pass scan depending on opts: exact → alias → suffix (if AllowSuffix). Tier ordering survives. This is the core mechanism for loosening the contract negotiation contract. The user- facing knobs (negotiate.WithMatchSuffix, middleware.Context. SetMatchSuffix, client.Runtime.MatchSuffix) come in follow-up commits. feat(negotiate): WithMatchSuffix opt-in for RFC 6839 suffix tolerance negotiate.WithMatchSuffix(true) extends ContentType to tolerate structured-syntax suffix media types in Accept negotiation. When enabled, an offer of application/vnd.api+json matches an Accept entry of application/json and vice versa, for the suffixes the runtime recognises (+json, +xml, +yaml). Mirrors WithIgnoreParameters in shape: per-call Option, off by default, plumbed to mediatype.Set.BestMatch as mediatype.AllowSuffix(). feat(middleware,client): SetMatchSuffix / Runtime.MatchSuffix opt-in Wires the RFC 6839 suffix tolerance opt-in into the server and client runtimes. The mediatype tier mechanics and the negotiate. WithMatchSuffix Option landed in earlier commits on this branch (ec89b9b, 386e79e); this commit threads the flag through both runtimes so users can enable it server-wide or client-wide. Server side (middleware/context.go, middleware/validation.go): - Context grows a matchSuffix bool field. - SetMatchSuffix(bool) *Context mirrors SetIgnoreParameters in shape (fluent, server-wide). - negotiateOpts() now emits WithMatchSuffix(true) in addition to WithIgnoreParameters(true) when those flags are set. - A new matchOpts() helper emits mediatype.AllowSuffix() for the direct mediatype-level call sites. - validateContentType signature grows an opts ...mediatype.MatchOption parameter; both the validation-time call (validation.go) and the codec-lookup call sites (context.go, validation.go) pass matchOpts() through. Client side (client/runtime.go): - Runtime grows an exported MatchSuffix bool field. - resolveConsumer, the post-pickConsumesMediaType gate, and the inner producer check in pickConsumesMediaType all consult r.matchOpts() (which emits mediatype.AllowSuffix() when set). - pickConsumesMediaType's signature grows a trailing opts ...mediatype.MatchOption parameter. Default behavior unchanged — every existing test stays green. With the flag on, a spec declaring consumes/produces in the base form (e.g. application/json) end-to-end tolerates traffic that uses any recognised structured-syntax variant (+json, +xml, +yaml). Closes #140 docs(media-types): document alias bridge and WithMatchSuffix opt-in Adds a "Beyond strict matching" section between the strict Matches rule and the server inbound flow, covering: - the MatchKind tier ordering (None < Suffix < Alias < Exact) and how BestMatch / MatchFirst / Lookup rank candidates by it; - the always-on alias bridge with the three RFC 9512 §2.1 YAML aliases; - the opt-in WithMatchSuffix / SetMatchSuffix / Runtime.MatchSuffix surface, with the rationale (use it when you don't control both sides of the wire, otherwise align the spec); - tier interactions worth pinning: params still bind at every tier, exact registrations always win, map-side suffix folding is intentionally absent. Plus a new gotcha entry on the +json / problem+json 415 case and reference entries pointing at mediatype.Lookup and the three opt-in surfaces. Refs #140 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Frederic BIDON <fredbi@yahoo.com> --------- Signed-off-by: Frederic BIDON <fredbi@yahoo.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5286530 commit 853ccde

17 files changed

Lines changed: 1611 additions & 63 deletions

.golangci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ linters:
5353
- legacy
5454
- std-error-handling
5555
paths:
56+
- .worktrees
5657
- third_party$
5758
- builtin$
5859
- examples$
@@ -63,6 +64,7 @@ formatters:
6364
exclusions:
6465
generated: lax
6566
paths:
67+
- .worktrees
6668
- third_party$
6769
- builtin$
6870
- examples$

client/runtime.go

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/go-openapi/runtime/client/internal/request"
1818
"github.com/go-openapi/runtime/logger"
1919
"github.com/go-openapi/runtime/middleware"
20+
"github.com/go-openapi/runtime/server-middleware/mediatype"
2021
"github.com/go-openapi/runtime/yamlpc"
2122
"github.com/go-openapi/strfmt"
2223
)
@@ -48,6 +49,14 @@ type Runtime struct {
4849
Debug bool
4950
logger logger.Logger
5051

52+
// MatchSuffix enables RFC 6839 structured-syntax suffix tolerance
53+
// for codec lookup. When true, a response with Content-Type
54+
// "application/problem+json" finds the JSON consumer registered
55+
// under "application/json"; with the default false, the lookup
56+
// is strict and falls through to the "*/*" wildcard if present.
57+
// See [mediatype.AllowSuffix] for the semantics.
58+
MatchSuffix bool
59+
5160
clientOnce *sync.Once
5261
client *http.Client
5362
schemes []string
@@ -340,23 +349,34 @@ func (r *Runtime) dumpResponse(res *http.Response, ct string) error {
340349
}
341350

342351
// resolveConsumer parses ct and returns the registered Consumer for
343-
// that media type, falling back to the "*/*" entry if any.
352+
// that media type. Lookup is alias-aware (RFC 9512 §2.1 — yaml
353+
// aliases) and, when [Runtime.MatchSuffix] is true, also tolerates
354+
// RFC 6839 structured-syntax suffix media types (+json, +xml, +yaml).
355+
// Falls back to the "*/*" entry if no match found.
344356
func (r *Runtime) resolveConsumer(ct string) (runtime.Consumer, error) {
345-
mt, _, err := mime.ParseMediaType(ct)
346-
if err != nil {
357+
if _, _, err := mime.ParseMediaType(ct); err != nil {
347358
return nil, fmt.Errorf("parse content type: %s", err)
348359
}
349-
cons, ok := r.Consumers[mt]
350-
if ok {
360+
if cons, ok := mediatype.Lookup(r.Consumers, ct, r.matchOpts()...); ok {
351361
return cons, nil
352362
}
353-
if cons, ok = r.Consumers["*/*"]; ok {
363+
if cons, ok := r.Consumers["*/*"]; ok {
354364
return cons, nil
355365
}
356366
// scream about not knowing what to do
357367
return nil, fmt.Errorf("no consumer: %q", ct)
358368
}
359369

370+
// matchOpts builds the mediatype.MatchOption slice for codec
371+
// lookups on the Runtime, currently just the AllowSuffix opt-in.
372+
func (r *Runtime) matchOpts() []mediatype.MatchOption {
373+
if !r.MatchSuffix {
374+
return nil
375+
}
376+
377+
return []mediatype.MatchOption{mediatype.AllowSuffix()}
378+
}
379+
360380
// createHTTPRequestContext is the context-aware builder of a [http.Request].
361381
//
362382
// The returned [http.Request] carries a context derived from parentCtx that
@@ -403,8 +423,8 @@ func (r *Runtime) prepareRequest(operation *runtime.ClientOperation) (*request.R
403423
})
404424
}
405425

406-
cmt := pickConsumesMediaType(operation.ConsumesMediaTypes, r.Producers, r.DefaultMediaType)
407-
if _, ok := r.Producers[cmt]; !ok && cmt != runtime.MultipartFormMime && cmt != runtime.URLencodedFormMime {
426+
cmt := pickConsumesMediaType(operation.ConsumesMediaTypes, r.Producers, r.DefaultMediaType, r.matchOpts()...)
427+
if _, ok := mediatype.Lookup(r.Producers, cmt, r.matchOpts()...); !ok && cmt != runtime.MultipartFormMime && cmt != runtime.URLencodedFormMime {
408428
return nil, "", nil, fmt.Errorf("none of producers: %v registered. try %s", r.Producers, cmt)
409429
}
410430

@@ -440,7 +460,7 @@ func (r *Runtime) applyHostScheme(httpReq *http.Request, operation *runtime.Clie
440460
// Step 2 closes part of issues #32 and #386: an operation declaring
441461
// `consumes: [application/x-vendor, application/json]` with no vendor
442462
// producer registered now silently uses JSON instead of erroring.
443-
func pickConsumesMediaType(consumes []string, producers map[string]runtime.Producer, def string) string {
463+
func pickConsumesMediaType(consumes []string, producers map[string]runtime.Producer, def string, opts ...mediatype.MatchOption) string {
444464
for _, mt := range consumes {
445465
if strings.EqualFold(mt, runtime.MultipartFormMime) {
446466
return mt
@@ -457,7 +477,7 @@ func pickConsumesMediaType(consumes []string, producers map[string]runtime.Produ
457477
if isStructuralMime(mt) {
458478
return mt
459479
}
460-
if _, ok := producers[mt]; ok {
480+
if _, ok := mediatype.Lookup(producers, mt, opts...); ok {
461481
return mt
462482
}
463483
}

constants.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ const (
2121
DefaultMime = "application/octet-stream"
2222
// JSONMime the json mime type.
2323
JSONMime = "application/json"
24-
// YAMLMime the [yaml] mime type.
25-
YAMLMime = "application/x-yaml"
24+
// YAMLMime the [yaml] mime type. Set to the canonical RFC 9512
25+
// name (application/yaml). Legacy forms application/x-yaml,
26+
// text/yaml, and text/x-yaml — per RFC 9512 §2.1 "Deprecated
27+
// alias names for this type" — resolve to the same codec via
28+
// the mediatype alias bridge.
29+
YAMLMime = "application/yaml"
2630
// XMLMime the [xml] mime type.
2731
XMLMime = "application/xml"
2832
// TextMime the text mime type.

docs/MEDIA_TYPES.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,105 @@ The asymmetry is intrinsic to the semantics ("loose if the bound has no
8686
params, otherwise the constraint must be a subset"), not to which side is
8787
the server.
8888

89+
## Beyond strict matching — alias and suffix tolerances
90+
91+
The bare `Matches` rule above is strict RFC 7231: type, subtype, and the
92+
parameter subset. Two extensions sit on top of it, both surfaced through
93+
the graded result of `MediaType.Match`:
94+
95+
| Tier | Reached when | Example |
96+
|---|---|---|
97+
| `MatchExact` | Strict RFC 7231 match. | `application/json` vs `application/json` |
98+
| `MatchAlias` | Strict fails but both sides resolve to the same canonical form via the package-internal alias table. | `application/x-yaml` vs `application/yaml` |
99+
| `MatchSuffix` | Strict and alias both fail but both sides resolve to the same base after folding the RFC 6839 structured-syntax suffix. | `application/vnd.api+json` vs `application/json` |
100+
| `MatchNone` | None of the above. | |
101+
102+
`Set.BestMatch`, `MatchFirst`, and `mediatype.Lookup` rank candidates by
103+
this tier in addition to q-value and specificity — when two offers fit a
104+
constraint at different tiers, the stronger tier wins regardless of
105+
offer order. Exact beats alias, alias beats suffix.
106+
107+
### Alias bridge — always on
108+
109+
RFC 9512 §2.1 enumerates three deprecated alias names for the
110+
`application/yaml` registration:
111+
112+
| Alias | Canonical |
113+
|---|---|
114+
| `application/x-yaml` | `application/yaml` |
115+
| `text/yaml` | `application/yaml` |
116+
| `text/x-yaml` | `application/yaml` |
117+
118+
A request, offer, or codec registration in any of these forms matches a
119+
counterpart in any of the others. The bridge is wire-format equivalence
120+
backed by an explicit IANA registration-template field — no opt-in
121+
needed and no way to disable it.
122+
123+
### Structured-syntax suffix tolerance — opt-in
124+
125+
`+json`, `+xml`, and `+yaml` are the RFC 6839 structured-syntax suffixes
126+
the runtime recognises. Their wire format is the underlying base
127+
(`+json` is JSON), but their semantics carry application-specific
128+
structure on top (`application/problem+json` is JSON-on-the-wire with
129+
the RFC 7807 problem-details document shape). Tolerating these as
130+
equivalent to the base format is a contract loosening, so the runtime
131+
defaults to strict and surfaces the leniency through an explicit
132+
opt-in.
133+
134+
Three matching knobs at three layers:
135+
136+
```go
137+
// per-call (in negotiation only)
138+
chosen := negotiate.ContentType(r, offers, "",
139+
negotiate.WithMatchSuffix(true),
140+
)
141+
142+
// server-wide
143+
ctx := middleware.NewContext(spec, api, nil).SetMatchSuffix(true)
144+
145+
// client-wide
146+
rt := client.New(host, basePath, schemes)
147+
rt.MatchSuffix = true
148+
```
149+
150+
All three feed the same `mediatype.AllowSuffix()` option through
151+
`Set.BestMatch`, `MatchFirst`, and `mediatype.Lookup`. With the flag on,
152+
a spec declaring `consumes: [application/json]` end-to-end tolerates
153+
request bodies sent with `Content-Type: application/vnd.api+json` (and
154+
likewise for `+xml` / `+yaml`). With the flag off — the default — such
155+
a request is rejected with 415, exactly as before.
156+
157+
The opt-in is intended for situations where the user does not control
158+
both sides of the wire:
159+
160+
- a server that wants to accept `application/problem+json` errors from
161+
upstream services declared as `application/json`;
162+
- a client that needs to consume `application/problem+json` responses
163+
from servers whose spec only declares `application/json` in `produces`.
164+
165+
If both sides are under your control, **prefer to align the spec**:
166+
list `application/vnd.api+json` (or whichever variant applies)
167+
explicitly in `consumes` / `produces`. The opt-in is leeway for the
168+
common real-world mismatch, not a substitute for a faithful spec.
169+
170+
### Tier interactions worth pinning
171+
172+
- **Parameters still bind at every tier.** A constraint of
173+
`application/yaml; charset=utf-8` does not match an offer of
174+
`application/yaml; charset=ascii` even with subtypes equal — the
175+
parameter-subset rule from `Matches` applies regardless of which tier
176+
resolved the subtype. Suffix tolerance does not loosen the param
177+
rule.
178+
- **Exact registrations always win.** If `application/vnd.api+json` is
179+
explicitly in `consumes` (or registered as a producer), routing and
180+
codec lookup never fall through to the suffix tier for that mime —
181+
even with `WithMatchSuffix(true)`.
182+
- **Map-side suffix folding is intentionally absent.** A registration
183+
at `application/vnd.api+json` does *not* receive a query of
184+
`application/json` even with the opt-in. The inverse case ("only the
185+
vendor consumer is registered, plain-base query arrives") is not a
186+
scenario the runtime tries to cover.
187+
89188
## Server side — inbound `Content-Type` validation
90189

91190
Flow when a request arrives with a body:
@@ -395,6 +494,16 @@ your `produces` use mismatched charset/version params and you treat
395494
those as informational, opt out with `negotiate.WithIgnoreParameters(true)`
396495
(per call) or `Context.SetIgnoreParameters(true)` (server-wide).
397496

497+
**"My server rejects `application/vnd.api+json` (or `application/problem+json`) with 415."**
498+
The default match is strict RFC 7231 — a vendor `+json` mime is *not*
499+
a `application/json` mime. Two routes forward: (1) list the vendor
500+
mime explicitly in the operation's `consumes` and register a codec
501+
under that key (the spec-faithful path); or (2) enable
502+
`Context.SetMatchSuffix(true)` server-wide to fold `+json` / `+xml` /
503+
`+yaml` to the underlying base codec at lookup time (the leeway path,
504+
for situations where the client is not under your control). See
505+
the "Beyond strict matching" section above.
506+
398507
**"My client request returns 415 even though the API lists my type in `consumes`."**
399508
Check the wire `Content-Type` against your server's `consumes` matching
400509
rules. The client sends the picker's choice (with Stage-2 upgrades for
@@ -425,6 +534,8 @@ or the cached value at `middleware.MatchedRouteFrom(r).Consumes`.
425534

426535
- Server matching primitive: `github.com/go-openapi/runtime/server-middleware/mediatype`
427536
- Server negotiator: `github.com/go-openapi/runtime/server-middleware/negotiate`
537+
- Codec lookup helper: `mediatype.Lookup[T]` — used by both server (`middleware/context.go`, `middleware/validation.go`) and client (`client/runtime.go`)
538+
- Alias and suffix tolerances: `mediatype.Match`, `mediatype.MatchKind`, `mediatype.AllowSuffix`; opt-in surfaces `negotiate.WithMatchSuffix`, `middleware.Context.SetMatchSuffix`, `client.Runtime.MatchSuffix`
428539
- Server validation: `middleware/validation.go` (`validateContentType`)
429540
- Client Stage-1 picker: `client/runtime.go` (`pickConsumesMediaType`)
430541
- Client Stage-2 fallback: `client/request.go` (`setStreamContentType`,

middleware/context.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/go-openapi/runtime/middleware/untyped"
2323
"github.com/go-openapi/runtime/security"
2424
"github.com/go-openapi/runtime/server-middleware/docui"
25+
"github.com/go-openapi/runtime/server-middleware/mediatype"
2526
"github.com/go-openapi/runtime/server-middleware/negotiate"
2627
)
2728

@@ -80,6 +81,7 @@ type Context struct {
8081
router Router
8182
debugLogf func(string, ...any) // a logging function to debug context and all components using it
8283
ignoreParameters bool // see SetIgnoreParameters / WithIgnoreParameters
84+
matchSuffix bool // see SetMatchSuffix / WithMatchSuffix
8385
}
8486

8587
// NewRoutableContext creates a new context for a routable API.
@@ -151,6 +153,27 @@ func (c *Context) SetIgnoreParameters(ignore bool) *Context {
151153
return c
152154
}
153155

156+
// SetMatchSuffix toggles RFC 6839 structured-syntax suffix tolerance
157+
// server-wide. When enabled, both Accept negotiation and codec lookup
158+
// fall back through the suffix base for the recognised suffixes
159+
// (+json, +xml, +yaml) — so an operation declaring
160+
// consumes: [application/json] also accepts request bodies sent with
161+
// Content-Type: application/vnd.api+json (or any other +json variant).
162+
//
163+
// Default: strict (false). Use only when interoperating with clients
164+
// that do not strictly abide by the spec.
165+
//
166+
// Returns the receiver for fluent configuration:
167+
//
168+
// ctx := middleware.NewContext(spec, api, nil).SetMatchSuffix(true)
169+
//
170+
// See [negotiate.WithMatchSuffix] for the per-call form and rationale.
171+
func (c *Context) SetMatchSuffix(enable bool) *Context {
172+
c.matchSuffix = enable
173+
174+
return c
175+
}
176+
154177
type routableUntypedAPI struct {
155178
api *untyped.API
156179
hlock *sync.Mutex
@@ -348,7 +371,7 @@ func (c *Context) BindValidRequest(request *http.Request, route *MatchedRoute, b
348371
res = append(res, err)
349372
}
350373
if len(res) == 0 {
351-
cons, ok := route.Consumers[ct]
374+
cons, ok := mediatype.Lookup(route.Consumers, ct, c.matchOpts()...)
352375
if !ok {
353376
res = append(res, errors.New(http.StatusInternalServerError, "no consumer registered for %s", ct))
354377
} else {
@@ -722,11 +745,27 @@ func (c Context) uiOptionsForHandler(opts []UIOption) []docui.Option {
722745
}
723746

724747
func (c *Context) negotiateOpts() []negotiate.Option {
725-
if !c.ignoreParameters {
748+
var opts []negotiate.Option
749+
if c.ignoreParameters {
750+
opts = append(opts, negotiate.WithIgnoreParameters(true))
751+
}
752+
if c.matchSuffix {
753+
opts = append(opts, negotiate.WithMatchSuffix(true))
754+
}
755+
756+
return opts
757+
}
758+
759+
// matchOpts builds the mediatype.MatchOption slice that the
760+
// codec-lookup and Content-Type validation paths apply server-wide.
761+
// Mirrors negotiateOpts but at the mediatype level (without going
762+
// through the negotiate.Option wrapper).
763+
func (c *Context) matchOpts() []mediatype.MatchOption {
764+
if !c.matchSuffix {
726765
return nil
727766
}
728767

729-
return []negotiate.Option{negotiate.WithIgnoreParameters(true)}
768+
return []mediatype.MatchOption{mediatype.AllowSuffix()}
730769
}
731770

732771
func cantFindProducer(format string) string {

middleware/validation.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@ type validation struct {
3333
// a 400 [errors.ParseError]). This function therefore only sees the
3434
// malformed case when invoked directly by callers that have bypassed
3535
// that step.
36-
func validateContentType(allowed []string, actual string) error {
36+
func validateContentType(allowed []string, actual string, opts ...mediatype.MatchOption) error {
3737
if len(allowed) == 0 {
3838
return nil
3939
}
40-
_, ok, err := mediatype.MatchFirst(allowed, actual)
40+
_, ok, err := mediatype.MatchFirst(allowed, actual, opts...)
4141
if ok {
4242
return nil
4343
}
@@ -96,12 +96,12 @@ func (v *validation) contentType() {
9696

9797
if len(v.result) == 0 {
9898
v.debugLogf("validating content type for %q against [%s]", ct, strings.Join(v.route.Consumes, ", "))
99-
if err := validateContentType(v.route.Consumes, ct); err != nil {
99+
if err := validateContentType(v.route.Consumes, ct, v.context.matchOpts()...); err != nil {
100100
v.result = append(v.result, err)
101101
}
102102
}
103103
if ct != "" && v.route.Consumer == nil {
104-
cons, ok := v.route.Consumers[ct]
104+
cons, ok := mediatype.Lookup(v.route.Consumers, ct, v.context.matchOpts()...)
105105
if !ok {
106106
v.result = append(v.result, errors.New(http.StatusInternalServerError, "no consumer registered for %s", ct))
107107
} else {

0 commit comments

Comments
 (0)