Files
2026-06-12 13:43:37 +02:00

206 lines
6.0 KiB
Go

// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package negotiate
import (
"net/http"
"strings"
"github.com/go-openapi/runtime/server-middleware/mediatype"
"github.com/go-openapi/runtime/server-middleware/negotiate/header"
)
// Option configures [ContentType] behaviour.
type Option func(*options)
type options struct {
ignoreParameters bool
matchSuffix bool
}
func optionsWithDefaults(opts []Option) options {
var o options
for _, apply := range opts {
apply(&o)
}
return o
}
// WithIgnoreParameters returns an [Option] that strips MIME-type
// parameters from both Accept entries and offers before matching, restoring
// the behaviour the runtime had before v0.30.
//
// New code should leave parameters honoured (the default). This option
// exists for applications that depend on the looser pre-v0.30 match —
// most often because their producers and Accept clients use mismatched
// charset or version params that they treat as informational.
//
// Example — per-call opt-out:
//
// chosen := negotiate.ContentType(r, offers, "",
// negotiate.WithIgnoreParameters(true),
// )
//
// Example — server-wide opt-out (via [middleware.Context]):
//
// ctx := middleware.NewContext(spec, api, nil).SetIgnoreParameters(true)
func WithIgnoreParameters(ignore bool) Option {
return func(o *options) {
o.ignoreParameters = ignore
}
}
// WithMatchSuffix returns an [Option] that extends content
// negotiation to tolerate RFC 6839 structured-syntax suffix media
// types. When enabled, an Accept entry of "application/json"
// matches an offer of "application/vnd.api+json" and vice versa,
// for the suffixes recognised by the runtime (+json, +xml, +yaml).
//
// Default: strict (false). Use only when interoperating with
// clients or servers that do not strictly abide by the spec — for
// example, servers returning application/problem+json error
// responses against operations that only declare application/json
// in produces.
//
// Suffix matches always lose to exact and alias matches when those
// are on offer; see [mediatype.MatchKind] for the tier ordering.
//
// Example — per-call opt-in:
//
// chosen := negotiate.ContentType(r, offers, "",
// negotiate.WithMatchSuffix(true),
// )
//
// Example — server-wide opt-in (via [middleware.Context]):
//
// ctx := middleware.NewContext(spec, api, nil).SetMatchSuffix(true)
func WithMatchSuffix(enable bool) Option {
return func(o *options) {
o.matchSuffix = enable
}
}
// ContentEncoding returns the best offered content encoding for the
// request's Accept-Encoding header. If two offers match with equal
// weight then the offer earlier in the list is preferred. If no offers
// are acceptable, then "" is returned.
//
// Encoding tokens have no parameters, so this function is unaffected by
// the v0.30 parameter-honouring change to [ContentType].
func ContentEncoding(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
}
// ContentType returns the best offered content type for the request's
// Accept header. If two offers match with equal weight, then the more
// specific offer is preferred (text/* trumps */*; type/subtype trumps
// type/*). If two offers match with equal weight and specificity, then
// the offer earlier in the list is preferred. If no offers match, then
// defaultOffer is returned.
//
// As of v0.30 the matching rule honours MIME-type parameters: an Accept
// entry of "text/plain;charset=utf-8" matches an offer of bare
// "text/plain" (offer carries no constraint), but it does NOT match an
// offer of "text/plain;charset=ascii" (charset values disagree). Pass
// [WithIgnoreParameters](true) to restore the pre-v0.30 behaviour where
// parameters were stripped before matching — see [WithIgnoreParameters]
// for details and an example.
//
// When the Accept header is absent, the first offer is returned
// unchanged (param-stripping is irrelevant in that case).
func ContentType(r *http.Request, offers []string, defaultOffer string, opts ...Option) string {
if len(offers) == 0 {
return defaultOffer
}
o := optionsWithDefaults(opts)
// Per RFC 7230 §3.2.2, multiple Accept headers are equivalent to a
// single comma-joined value. Join before parsing so we don't drop
// later entries.
acceptValues := r.Header.Values("Accept")
if len(acceptValues) == 0 {
return offers[0]
}
acceptSet := mediatype.ParseAccept(strings.Join(acceptValues, ", "))
if len(acceptSet) == 0 {
return defaultOffer
}
offerSet := make(mediatype.Set, 0, len(offers))
rawByIdx := make([]string, 0, len(offers))
for _, raw := range offers {
mt, err := mediatype.Parse(raw)
if err != nil {
continue
}
offerSet = append(offerSet, mt)
rawByIdx = append(rawByIdx, raw)
}
if len(offerSet) == 0 {
return defaultOffer
}
if o.ignoreParameters {
acceptSet = stripSet(acceptSet)
offerSet = stripSet(offerSet)
}
var matchOpts []mediatype.MatchOption
if o.matchSuffix {
matchOpts = append(matchOpts, mediatype.AllowSuffix())
}
best, ok := acceptSet.BestMatch(offerSet, matchOpts...)
if !ok {
return defaultOffer
}
// Return the original raw offer string so callers receive the value
// they declared, with its parameters preserved.
for i, mt := range offerSet {
if mt.Type == best.Type && mt.Subtype == best.Subtype && sameParams(mt.Params, best.Params) {
return rawByIdx[i]
}
}
return best.String()
}
func stripSet(s mediatype.Set) mediatype.Set {
out := make(mediatype.Set, len(s))
for i, m := range s {
out[i] = m.StripParams()
}
return out
}
func sameParams(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}