📖 3 min read (~ 500 words).

Content negotiation

server-middleware/negotiate sits on top of mediatype and exposes two single-purpose helpers — one for Accept, one for Accept-Encoding.

ContentType — pick a response media type

func ContentType(
    r *http.Request,
    offers []string,
    defaultOffer string,
    opts ...Option,
) string

Returns the offer most acceptable to the request’s Accept header. If two offers match with equal weight, the more specific offer wins (text/* trumps */*; type/subtype trumps type/*); after that the earlier entry in offers wins. If no offer is acceptable, defaultOffer is returned.

// Pet is the demo resource served by the negotiation handler.
type Pet struct {
	XMLName xml.Name `json:"-"    xml:"pet"`
	Name    string   `json:"name" xml:"name"`
}

func pickContentType() {
	pet := Pet{Name: "Lassie"}
	offers := []string{mediaTypeJSON, mediaTypeXML}

	http.HandleFunc("/pet", func(w http.ResponseWriter, r *http.Request) {
		chosen := negotiate.ContentType(r, offers, mediaTypeJSON)
		w.Header().Set("Content-Type", chosen)

		switch chosen {
		case mediaTypeXML:
			_ = xml.NewEncoder(w).Encode(pet)
		default:
			_ = json.NewEncoder(w).Encode(pet)
		}
	})

	srv := &http.Server{
		Addr:              ":8080",
		ReadHeaderTimeout: readHeaderTimeout,
	}
	log.Fatal(srv.ListenAndServe())
}

Full source: docs/examples/standalone/contentnegotiation/main.go

When Accept is absent entirely, the first offer is returned unchanged.

Behaviour change in v0.30 — MIME parameters honoured

Pre-v0.30 the negotiator stripped MIME-type parameters before matching: an Accept of text/plain;charset=utf-8 matched an offer of text/plain;charset=ascii (the charset was thrown away). That was expedient but wrong; v0.30 honours parameters by default:

  • Accept: text/plain;charset=utf-8 matches an offer of bare text/plain (offer carries no constraint — receiver-side params, asymmetric rule).
  • Accept: text/plain;charset=utf-8 does not match an offer of text/plain;charset=ascii (charset values disagree).

If your producers and Accept clients use mismatched charset or version params that you treat as informational, opt out per call —

chosen := negotiate.ContentType(r, offers, "",
	negotiate.WithIgnoreParameters(true),
)

Full source: docs/examples/standalone/contentnegotiation/main.go

— or server-wide via the runtime’s middleware.Context:

ctx := middleware.NewContext(spec, api, nil).SetIgnoreParameters(true)

Full source: docs/examples/standalone/contentnegotiation/main.go

Accept-Encoding — not handled here

negotiate.ContentEncoding is deprecated. The runtime does not ship response compression, and surfacing a half-feature negotiator without a matching encoder leads to subtle correctness traps (no Vary, no Content-Length rewrite, no minimum-size guard). Use a real compression middleware at the http.Handler level — see the compression recipe for a worked example using CAFxX/httpcompression.

Direct header parsing

If you only need raw header parsing without the typed MediaType layer (for example when implementing a different selection rule), drop down to negotiate/header:

specs := header.ParseAccept(r.Header, "Accept")
for _, s := range specs {
	// s.Value, s.Q, s.Params
	use(s)
}

Full source: docs/examples/standalone/contentnegotiation/main.go

Where it sits in the runtime pipeline

The full server pipeline calls ContentType (and the matching Content-Type validation through mediatype.MatchFirst) inside Context.BindValidRequest; see core / interfaces. The standalone module exposes the same primitives so you can drive negotiation from any net/http handler, with or without an OpenAPI spec in the picture.