📖 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

ContentEncoding — pick a response encoding

func ContentEncoding(r *http.Request, offers []string) string

Returns the best-matching offered encoding for the request’s Accept-Encoding header. Two offers tied on q go to the earlier one; no acceptable offer returns "" (so the caller can choose to send no encoding rather than substituting identity).

Encoding tokens have no parameters, so this function is unaffected by the v0.30 parameter-honouring change.

chosen := negotiate.ContentEncoding(r, []string{"gzip", "deflate"})
if chosen != "" {
	w.Header().Set("Content-Encoding", chosen)
}

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

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.