Media-type selection
How go-openapi/runtime parses, matches, and negotiates HTTP media types,
on both the server and client sides. The reference for the rules behind a
415, a 406, or a 400 you see in production.
Scope:
Content-TypeandAcceptheaders, both inbound and outbound.Accept-Encodingis mentioned briefly. Charset, language, and version tags are treated as opaque parameters under the rules below.
At a glance — error mapping
| Outcome | HTTP | Where it’s raised |
|---|---|---|
Inbound Content-Type does not parse | 400 Bad Request | runtime.ContentType, errors.ParseError |
Inbound Content-Type is well-formed but not in the operation’s consumes | 415 Unsupported Media Type | errors.InvalidContentType |
Accept cannot be satisfied by the operation’s produces | 406 Not Acceptable | errors.InvalidResponseFormat |
No consumer registered for an otherwise-allowed Content-Type | 500 Internal Server Error | server-side configuration error |
The shared model — mediatype.MediaType
Both sides use the same parser and value type:
Casing
Type,Subtype, parameter keys → lowercased on parse.- Parameter values → preserved verbatim.
- Comparisons of parameter values are case-insensitive
(
charset=UTF-8matchescharset=utf-8, the convention for charset, version, etc.).
Wildcards
*/* and type/* are accepted on either side of a comparison.
*/subtype is invalid per RFC 7231 §5.3.2 and Parse rejects it.
Malformed input
Every Parse failure wraps the sentinel mediatype.ErrMalformed,
so callers can distinguish “client sent garbage” from “client sent
something well-formed that nothing here accepts”:
The matching rule
MediaType.Matches(other) is asymmetric. The receiver is the bound
(an allowed entry on the server side, or a candidate offer when matching
against an Accept entry); the argument is the constraint (the actual
incoming request, or the Accept entry being satisfied).
The rule:
- Bare
type/subtypemust agree (with wildcards on either side). - If the receiver carries no parameters, any constraint is accepted regardless of its parameters.
- Otherwise every
(key, value)pair on the constraint must be present on the receiver, with case-insensitive value comparison. The receiver may carry additional parameters that the constraint does not list.
q-values are not considered by Matches — they are the negotiator’s
concern, handled inside Set.BestMatch.
The same direction is used in both call sites:
| Call | Bound (receiver) | Constraint (argument) |
|---|---|---|
| Inbound validation | each entry in consumes | the request’s Content-Type |
Accept negotiation | each candidate offer | each Accept entry |
The asymmetry is intrinsic to the semantics (“loose if the bound has no params, otherwise the constraint must be a subset”), not to which side is the server.
Beyond strict matching — alias and suffix tolerances
The bare Matches rule above is strict RFC 7231: type, subtype, and the
parameter subset. Two extensions sit on top of it, both surfaced through
the graded result of MediaType.Match:
| Tier | Reached when | Example |
|---|---|---|
MatchExact | Strict RFC 7231 match. | application/json vs application/json |
MatchAlias | Strict fails but both sides resolve to the same canonical form via the package-internal alias table. | application/x-yaml vs application/yaml |
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 |
MatchNone | None of the above. |
Set.BestMatch, MatchFirst, and mediatype.Lookup rank candidates by
this tier in addition to q-value and specificity — when two offers fit a
constraint at different tiers, the stronger tier wins regardless of
offer order. Exact beats alias, alias beats suffix.
Alias bridge — always on
RFC 9512 §2.1 enumerates three deprecated alias names for the
application/yaml registration:
| Alias | Canonical |
|---|---|
application/x-yaml | application/yaml |
text/yaml | application/yaml |
text/x-yaml | application/yaml |
A request, offer, or codec registration in any of these forms matches a counterpart in any of the others. The bridge is wire-format equivalence backed by an explicit IANA registration-template field — no opt-in needed and no way to disable it.
Structured-syntax suffix tolerance — opt-in
+json, +xml, and +yaml are the RFC 6839 structured-syntax suffixes
the runtime recognises. Their wire format is the underlying base
(+json is JSON), but their semantics carry application-specific
structure on top (application/problem+json is JSON-on-the-wire with
the RFC 7807 problem-details document shape). Tolerating these as
equivalent to the base format is a contract loosening, so the runtime
defaults to strict and surfaces the leniency through an explicit
opt-in.
Three matching knobs at three layers:
All three feed the same mediatype.AllowSuffix() option through
Set.BestMatch, MatchFirst, and mediatype.Lookup. With the flag on,
a spec declaring consumes: [application/json] end-to-end tolerates
request bodies sent with Content-Type: application/vnd.api+json (and
likewise for +xml / +yaml). With the flag off — the default — such
a request is rejected with 415, exactly as before.
The opt-in is intended for situations where the user does not control both sides of the wire:
- a server that wants to accept
application/problem+jsonerrors from upstream services declared asapplication/json; - a client that needs to consume
application/problem+jsonresponses from servers whose spec only declaresapplication/jsoninproduces.
If both sides are under your control, prefer to align the spec:
list application/vnd.api+json (or whichever variant applies)
explicitly in consumes / produces. The opt-in is leeway for the
common real-world mismatch, not a substitute for a faithful spec.
Tier interactions worth pinning
- Parameters still bind at every tier. A constraint of
application/yaml; charset=utf-8does not match an offer ofapplication/yaml; charset=asciieven with subtypes equal — the parameter-subset rule fromMatchesapplies regardless of which tier resolved the subtype. Suffix tolerance does not loosen the param rule. - Exact registrations always win. If
application/vnd.api+jsonis explicitly inconsumes(or registered as a producer), routing and codec lookup never fall through to the suffix tier for that mime — even withWithMatchSuffix(true). - Map-side suffix folding is intentionally absent. A registration
at
application/vnd.api+jsondoes not receive a query ofapplication/jsoneven with the opt-in. The inverse case (“only the vendor consumer is registered, plain-base query arrives”) is not a scenario the runtime tries to cover.
Server side — inbound Content-Type validation
Flow when a request arrives with a body:
validateContentType is a thin wrapper around
mediatype.MatchFirst.
It short-circuits on the first allowed entry that accepts the actual —
not the most specific match. For ranked matching use Set.BestMatch.
What “missing Content-Type” does
When the request body is non-empty but the header is missing,
runtime.ContentType substitutes the package-level default
(runtime.DefaultMime = application/octet-stream). The validator
then matches that default against the operation’s consumes. So a
request with a body and no Content-Type typically yields 415
unless the operation lists application/octet-stream.
Parameter honouring (since v0.30)
Before v0.30, parameters were stripped on both sides before matching:
Content-Type: text/plain;charset=ascii would pass against
consumes: [text/plain;charset=utf-8]. Since v0.30 this is rejected
(charset values disagree). The fix landed with PR #426 (issue #136).
Server side — outbound Accept negotiation
negotiate.ContentType(r, offers, defaultOffer, opts...)
reads the request’s Accept header(s), parses each entry,
ranks the offers, and returns the winning offer (a string from the
offers slice). If nothing matches, defaultOffer is returned.
Ranking
Per RFC 7231 §5.3.2, in order:
- Highest q-value (
q=0excludes an offer entirely). - Highest specificity of the matched
Acceptentry (type/subtype;params>type/subtype>type/*>*/*). - Earliest position in the
offersslice.
Multiple Accept headers
Per RFC 7230 §3.2.2, multiple Accept headers are equivalent to a single
comma-joined value. The negotiator joins before parsing, so all entries
contribute to the decision regardless of how the client batched them.
Parameter honouring and the opt-out
Same v0.30 change as inbound validation. An Accept entry of
text/plain;charset=utf-8 matches an offer of bare text/plain (offer
carries no constraint), but not text/plain;charset=ascii.
To restore the looser pre-v0.30 behaviour for one operation:
…or server-wide, threaded through the middleware Context:
The opt-out exists for applications whose producers and Accept clients
use mismatched charset or version params that they treat as
informational.
Codec dispatch is keyed by bare type
The negotiator returns the verbatim offer (parameters preserved) and the
runtime sets Content-Type from it. Codec dispatch is a separate step:
the runtime looks up the producer in route.Producers, which is a
map[string]Producer keyed by the bare type/subtype (no params).
You will see calls to normalizeOffer(format) and
normalizeOffers(...) in the middleware and the router doing exactly
this stripping — they are about map lookup, not about negotiation.
The practical consequence: you cannot register two different producers
for the same bare type that differ only by parameters
(text/plain;charset=utf-8 vs text/plain;charset=ascii). They would
collide on the bare-type key. The negotiator can still choose
between two such offers (parameters are honoured during matching), but
the codec invoked is the single one registered under the bare key.
If you need parameter-specific encoding, do it inside one producer and
inspect the negotiated Content-Type from the response writer.
Client side — outbound Content-Type
Selection runs in two stages. Stage 1 picks a candidate from the
operation’s consumes list before the payload is known; Stage 2 runs
inside buildHTTP after the request writer has populated the payload,
and may upgrade Stage 1’s choice when the payload is a stream.
Stage 1 — pickConsumesMediaType
Source: client/runtime.go.
- If
multipart/form-datais one of the entries, prefer it (it streams and preserves per-fileContent-Type). Resolves issue #286. - Otherwise the first non-empty entry that is either a structural
mime (
multipart/form-data,application/x-www-form-urlencoded) or has a producer registered inRuntime.Producers. This skips spec entries the client cannot serialise — useful when the spec lists a vendor mime first and a registered alternative second. Closes part of issues #32 and #386. - If nothing in the list is registered, the first non-empty entry is
returned anyway so the gate at the call site emits its
none of producers: …diagnostic. - Falls back to
Runtime.DefaultMediaType(application/jsonby default) only when the list is empty (or all empty strings).
Stage 1 cannot see the payload — the request writer hasn’t run yet — so its choice is “best effort given only the spec and the registered producers.”
Stage 2 — setStreamContentType
Source: client/request.go. Runs inside buildHTTP after the writer
has populated r.payload. For stream payloads (io.Reader,
io.ReadCloser) only — the producer is bypassed in this branch, so
the wire header is the only place where the body’s actual MIME type
is asserted.
Three checks, in priority order:
Explicit
SetHeaderParam("Content-Type", …). The historical header escape hatch wins over every derivation. If the writer setContent-TypeduringWriteToRequest, the runtime keeps it as-is. This was not the original purpose ofSetHeaderParam, but it has become the natural way to say “send THIS exact header”, and we honour it. Caveat: the user is then responsible for matching their declared header to their actual body bytes.Payload-declared content type. If
r.payloadimplements the exportedruntime.ContentTyperinterface and returns a non-empty value, that value wins. The value declares its own nature — useful for line-delimited formats, custom MIME types, or any case where the spec offers no matching entry. The same interface is also consulted on each part of a multipart file upload.Octet-stream upgrade. When neither of the above applies, and
application/octet-streamis in the operation’sconsumeslist AND a producer is registered for it, the wire header is upgraded from the picker’s choice to octet-stream — a safer “raw bytes” claim than a structural mime like JSON.
If none of the three checks fire, the picker’s mediaType from
Stage 1 is used as the terminal fallback.
Non-stream paths are deliberately not honoured
SetHeaderParam("Content-Type", …) and runtime.ContentTyper are
honoured only for stream payloads. Non-stream paths have
structural constraints that conflict with arbitrary user-supplied
content types:
struct/[]bytepayloads — the producer is dispatched offmediaType. Honouring an arbitrary user header here would mean either swapping the producer (complex) or sending a body that doesn’t match the declared header (still a lie).- Multipart bodies — the runtime owns the
Content-Typeheader because of the boundary parameter requirement. - URL-encoded forms — the body is form-encoded; lying about the type would break parsing on the server.
Users with these payload shapes who need a custom content type
should adjust the operation’s consumes list (so the picker selects
the right entry) or register a producer under the desired MIME.
Wire Content-Type matrix
| Payload | SetHeader Content-Type | declares ContentType() | octet-stream offered + registered | Wire Content-Type |
|---|---|---|---|---|
| stream | set | — | — | the SetHeader value |
| stream | unset | yes, non-empty | — | declared value |
| stream | unset | no / empty | yes | application/octet-stream |
| stream | unset | no / empty | no | picker’s choice (best-effort; may misrepresent body) |
struct | (ignored) | — | — | picker’s choice (producer runs) |
[]byte | (ignored) | — | — | picker’s choice (producer runs; e.g. JSON producer base64-encodes) |
Declaring a stream’s MIME type
Wrap the reader in a type that satisfies runtime.ContentTyper:
The wire Content-Type will be application/x-ndjson regardless of
which entry the picker chose from the operation’s consumes.
Codec registration
The client transport ships with a fixed codec set (JSON, YAML, XML, CSV, text, HTML, byte-stream). Register additional MIME types directly:
Known gaps
- Issue #385 /
#33 — The codec
set is hardcoded; it is not derived from the spec. Apps that don’t
declare an exotic
consumes/producescarry codecs they will never use. Tracked as Track A.2 in the modularization roadmap. []bytepayloads. A[]byteflows through the picker’s chosen producer. The JSON producer base64-encodes it as a JSON string. If you want raw bytes on the wire, wrap asbytes.NewReader([]byte{…})— it then takes the stream path and the Stage-2 octet-stream upgrade applies.
What changed in v0.30 (client-side outbound)
Four behaviour deltas vs. v0.29. Three are confined to stream
payloads (io.Reader, io.ReadCloser); the fourth touches the
Stage-1 picker for any payload type.
The first three surface only when there is at least one stream payload
involved; existing client code that uses generated parameter types
with struct/[]byte payloads is unaffected by those.
| Delta | Pre-v0.30 (master) | v0.30 |
|---|---|---|
Body payload’s ContentType() | not consulted; picker’s mediaType is sent | when the payload satisfies runtime.ContentTyper, its non-empty return value becomes the wire Content-Type |
| Stage-2 octet-stream upgrade | absent; the picker’s choice is the only signal | when the payload is a stream and lacks an explicit declaration, application/octet-stream from the operation’s consumes list is used in preference to a structural mime like application/json |
SetHeaderParam("Content-Type", X) | silently overwritten by buildHTTP | honoured at top priority; the user’s explicit assertion wins |
| Stage-1 producer-capability filter | picker returns the first non-empty entry; if no producer is registered for it, the gate at the call site errors | picker skips entries with no registered producer (and no structural status) and tries the next one; only errors when nothing in consumes is registered |
Each delta is verified by a row in the behavioural harness at
client/content_negotiation_test.go.
The rows that fail when the harness runs against the v0.29 baseline
are exactly the rows that exercise these three deltas — there are no
incidental behaviour changes outside this set. The structural paths
(form, multipart, file uploads) and the multipart-vs-urlencoded
preference fix from #286 are preserved verbatim.
Migration notes
- No action needed for callers using
struct-typed parameters generated by go-swagger. The wireContent-Typeis unchanged. - Streams that need a specific MIME type can implement
runtime.ContentTyperon the payload value, or addapplication/octet-streamto the operation’sconsumes, or fall back to setting the header explicitly via the params writer. - Callers that relied on
SetHeaderParam("Content-Type", …)and found it didn’t work (it never did, on body requests) can now rely on it as a documented escape hatch for stream payloads.
Client side — inbound responses
There is no Accept negotiation step at decode time. The client sent
its Accept header on the request and is now reading whatever the
server chose to return — the response’s Content-Type header is the
single input the codec dispatcher consults.
Pipeline
The codegen-emitted operation Reader is the piece most users
never see. It’s a generated function per operation that:
- Reads the HTTP status code and selects the matching response definition from the spec.
- Calls
runtime.ContentType(response.Header)to extract the bare mime. - Invokes the runtime to resolve a consumer for that mime
(
resolveConsumer). - Decodes the body into the response definition’s Go type via
consumer.Consume(body, target).
If you are writing a custom client without codegen, you implement this function yourself.
resolveConsumer — picking a consumer
resolveConsumer(ct string) in client/runtime.go is the single
codec-lookup site on the client. It runs:
- Parse
ct(rejects malformed values with a"parse content type: …"error — surfaced as a client-side error, not as a server response). mediatype.Lookup(r.Consumers, ct, r.matchOpts()...)— runs the four always-on tiers (raw key, parsed canonical, alias query-side, alias map-side) plus the opt-in suffix tier whenRuntime.MatchSuffixis set. See “Beyond strict matching” above.- On lookup miss, fall back to
r.Consumers["*/*"]if a wildcard consumer is registered. - On full miss, return
"no consumer: %q"— the operationReaderpropagates this as the operation’s error.
Where Runtime.MatchSuffix lands
Setting rt.MatchSuffix = true flips the inbound decode path to
tolerate RFC 6839 suffix media types: a response with
Content-Type: application/problem+json finds the JSON consumer
registered at application/json, decoded into whatever Go type the
response definition declares. The wildcard "*/*" fallback runs
unchanged after the suffix tier.
Symmetric to the server-side Context.SetMatchSuffix(true) — the
opt-in is independent on each side and exists for exactly the same
reason: real servers (or real clients) that don’t strictly abide by
the spec’s produces / consumes declarations.
Alias bridge — also active here
The always-on alias bridge applies on this path too. A client that
registers the YAML consumer at the legacy application/x-yaml key
(or, for that matter, leaves the default-map flip in place at
application/yaml) handles a server response with
Content-Type: text/yaml correctly — mediatype.Lookup
canonicalizes both keys to application/yaml and finds the consumer
regardless of which form was registered.
Failure modes worth knowing
- Malformed
Content-Type(e.g. trailing garbage, unterminated quoted string) —resolveConsumerreturns an error sourced frommime.ParseMediaType, prefixed withparse content type:. The operationReadersurfaces this as the operation’s error; no decode is attempted. - No consumer, no wildcard registered —
"no consumer: %q"with the offending Content-Type. Most commonly hit when the server returns an undeclared error mime (application/problem+jsonis the canonical example) andRuntime.MatchSuffixis off and"*/*"is not registered. - Silent wildcard fallback — if
Consumers["*/*"]is registered (the default-map registersruntime.ByteStreamConsumerthere), any unrecognisedContent-Typedecodes through that consumer. For a typed response struct, this usually fails inside the consumer’s own unmarshal with a less specific error than the no-consumer case. Worth knowing if the runtime appears to “silently succeed at decoding garbage.”
Accept-Encoding
negotiate.ContentEncoding(r, offers)
implements Accept-Encoding negotiation against a list of offered
encoding tokens (gzip, deflate, …). Encoding tokens have no
parameters, so the v0.30 parameter-honouring change does not apply.
The runtime itself does not transparently encode response bodies; this helper is for handlers that want to make the choice explicitly.
Common gotchas
“My matching test broke after upgrading to v0.30.”
Likely the parameter-honouring change. If your Accept clients and
your produces use mismatched charset/version params and you treat
those as informational, opt out with negotiate.WithIgnoreParameters(true)
(per call) or Context.SetIgnoreParameters(true) (server-wide).
“My server rejects application/vnd.api+json (or application/problem+json) with 415.”
The default match is strict RFC 7231 — a vendor +json mime is not
a application/json mime. Two routes forward: (1) list the vendor
mime explicitly in the operation’s consumes and register a codec
under that key (the spec-faithful path); or (2) enable
Context.SetMatchSuffix(true) server-wide to fold +json / +xml /
+yaml to the underlying base codec at lookup time (the leeway path,
for situations where the client is not under your control). See
the “Beyond strict matching” section above.
“My client request returns 415 even though the API lists my type in consumes.”
Check the wire Content-Type against your server’s consumes matching
rules. The client sends the picker’s choice (with Stage-2 upgrades for
streams), so a stray space, missing charset, or trailing ; in the
spec entry will be sent through and rejected by a strict server. If
the payload is a stream, consider implementing ContentType() string
on it to declare the type explicitly.
“My stream payload’s wire Content-Type is wrong.”
Four cases in priority order: set the header explicitly via
SetHeaderParam("Content-Type", …) in your params writer; implement
runtime.ContentTyper (ContentType() string) on the payload to
declare an explicit type; add application/octet-stream to the
operation’s consumes list to trigger the Stage-2 upgrade; or list
the desired mime first in consumes so the picker chooses it.
“My server returns 400 for a missing Content-Type on a body request.”
It shouldn’t — missing headers fall through to application/octet-stream
via runtime.DefaultMime and that produces 415, not 400. A 400 means
the header is present and unparseable. Check for stray characters
(unmatched parens, wildcards in parameter names, etc.).
“How do I get the parsed Content-Type value in my handler?”
Use runtime.ContentType(r.Header)
or the cached value at middleware.MatchedRouteFrom(r).Consumes.
Reference
- Server matching primitive:
github.com/go-openapi/runtime/server-middleware/mediatype - Server negotiator:
github.com/go-openapi/runtime/server-middleware/negotiate - Codec lookup helper:
mediatype.Lookup[T]— used by both server (middleware/context.go,middleware/validation.go) and client (client/runtime.go) - Alias and suffix tolerances:
mediatype.Match,mediatype.MatchKind,mediatype.AllowSuffix; opt-in surfacesnegotiate.WithMatchSuffix,middleware.Context.SetMatchSuffix,client.Runtime.MatchSuffix - Server validation:
middleware/validation.go(validateContentType) - Client Stage-1 picker:
client/runtime.go(pickConsumesMediaType) - Client Stage-2 fallback:
client/request.go(setStreamContentType,streamFallbackMime,payloadContentType) - Behavioural test harness:
client/content_negotiation_test.go - RFC 7231 §3.1.1 (media type), §5.3.1 (q-values), §5.3.2 (Accept).