diff options
Diffstat (limited to 'internal/accept')
| -rw-r--r-- | internal/accept/accept.go | 137 | ||||
| -rw-r--r-- | internal/accept/accept_test.go | 94 |
2 files changed, 231 insertions, 0 deletions
diff --git a/internal/accept/accept.go b/internal/accept/accept.go new file mode 100644 index 0000000..c23905a --- /dev/null +++ b/internal/accept/accept.go @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package accept + +import ( + "cmp" + "mime" + "regexp" + "slices" + "strconv" + "strings" + + . "go.xyrillian.de/gg/option" +) + +// Header contains a parsed set of "Accept" HTTP headers [RFC 9110, 12.5.1]. +type Header struct { + options []option +} + +type option struct { + MediaType string + Params map[string]string + Weight float64 +} + +var weightRx = regexp.MustCompile(`\s*;\s*q=([01](?:\.[0-9]{0,3})?)$`) + +// ParseHeader parses a Set of "Accept" HTTP headers [RFC 9110, 12.5.1]. +// If any part of the header is malformed, an empty Header struct is returned. +func ParseHeader(headers []string) Header { + var ( + result Header + none Header // return in case of errors + ) + for _, header := range headers { + for _, section := range strings.Split(header, ",") { + section = strings.TrimSpace(section) + + // remove weight from `section` while capturing the weight number in `weightStr` + var weightStr string + section = weightRx.ReplaceAllStringFunc(section, func(match string) string { + _, weightStr, _ = strings.Cut(match, "=") + return "" + }) + + mediaType, params, err := mime.ParseMediaType(section) + if err != nil { + return none + } + if _, ok := params["q"]; ok { + // malformed q-value that was not caught by the regex + return none + } + opt := option{mediaType, params, 1.0} + if weightStr != "" { + opt.Weight, err = strconv.ParseFloat(weightStr, 64) + if err != nil { + // defense in depth: unreachable because the regex match has + // extremely constrained grammar for `weightStr` + return none + } + if opt.Weight > 1.0 { // this boundary is easier to express here than in the regex + return none + } + } + + result.options = append(result.options, opt) + } + } + + // sort options by descending weight to simplify lookups + slices.SortFunc(result.options, func(lhs, rhs option) int { + return cmp.Compare(rhs.Weight, lhs.Weight) + }) + return result +} + +// Negotiate picks from a list of supported media types according to the client's request. +// If h is empty, the first argument is returned (thus the first argument is the server's preference). +// If none of the arguments (the server's options) satisfy the client's request, +// None is returned and a 406 response shall be generated. +func (h Header) Negotiate(mediaTypes ...string) Option[string] { + // parse all `mediaTypes` once + // TODO: if we decide to turn this package into public API, change the API to allow precomputing this + type offer struct { + OriginalValue string + MediaType string + Params map[string]string + } + var offers []offer + for _, mt := range mediaTypes { + mediaType, params, err := mime.ParseMediaType(mt) + if err == nil { + offers = append(offers, offer{mt, mediaType, params}) + } + } + + // we cannot choose from an empty set of options (this can only happen if the + // caller gave us no or only malformed media types) + if len(offers) == 0 { + return None[string]() + } + + // if nothing was offered, we default to our own preferred option + if len(h.options) == 0 { + return Some(offers[0].OriginalValue) + } + + // NOTE: ParseHeader() sorts options by descending weight, so the first match wins. + for _, opt := range h.options { + MEDIATYPE: + for _, offer := range offers { + // can only consider offered media types that match on all requested parameters + for k, v1 := range opt.Params { + if v2, ok := offer.Params[k]; !ok || v1 != v2 { + continue MEDIATYPE + } + } + + // check if offered media type matches requested media type or media type pattern + if opt.MediaType == "*/*" { + return Some(offer.OriginalValue) + } + if category, ok := strings.CutSuffix(opt.MediaType, "/*"); ok { + if rest, ok := strings.CutPrefix(offer.MediaType, category); ok && strings.HasPrefix(rest, "/") { + return Some(offer.OriginalValue) + } + } else if opt.MediaType == offer.MediaType { + return Some(offer.OriginalValue) + } + } + } + + return None[string]() +} diff --git a/internal/accept/accept_test.go b/internal/accept/accept_test.go new file mode 100644 index 0000000..edfef42 --- /dev/null +++ b/internal/accept/accept_test.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package accept_test + +import ( + "testing" + + "go.xyrillian.de/gg/assert" + "go.xyrillian.de/gg/internal/accept" + . "go.xyrillian.de/gg/option" +) + +func TestAcceptWithHeader(t *testing.T) { + // asking for a wide range of formats, including wildcard matches + h := accept.ParseHeader([]string{"text/*;q=0.3, text/plain;format=flowed, text/plain;format=fixed;q=0.4, */*;q=0.5"}) + + assert.Equal(t, h.Negotiate( + "image/png", // matches with q=0.5 + "text/plain; format=fixed", // matches with q=0.4 + ), Some("image/png")) + + assert.Equal(t, h.Negotiate( + "image/png", // matches with q=0.5 + "text/plain; format=flowed", // matches with q=1.0 + ), Some("text/plain; format=flowed")) + + assert.Equal(t, h.Negotiate( + "text/plain", // matches with q=0.7 + "text/plain; format=flowed", // matches with q=1.0 + ), Some("text/plain; format=flowed")) + + assert.Equal(t, h.Negotiate( + "text/plain", // matches with q=0.7 + "text/plain; format=other", // matches with q=0.3 + ), Some("text/plain")) + + assert.Equal(t, h.Negotiate( + "text/markdown", // matches with q=0.3 + "text/plain", // matches with q=0.3 (but first wins) + ), Some("text/markdown")) + + // asking for specific formats only + h = accept.ParseHeader([]string{"image/png, image/jpeg"}) + + assert.Equal(t, h.Negotiate( + "text/plain", + "image/png", + ), Some("image/png")) + + assert.Equal(t, h.Negotiate( + "text/plain", + ), None[string]()) +} + +func TestAcceptWithoutHeader(t *testing.T) { + // Negotiate() will always pick the first option + h := accept.ParseHeader(nil) + + assert.Equal(t, h.Negotiate( + "image/png", + "image/jpeg", + ), Some("image/png")) + + assert.Equal(t, h.Negotiate(nil...), None[string]()) + + // malformed media types are ignored + assert.Equal(t, h.Negotiate( + "image/png/foo", + "image/jpeg", + ), Some("image/jpeg")) + + assert.Equal(t, h.Negotiate( + "image/png/foo", + "image/jpeg/foo", + ), None[string]()) +} + +func TestAcceptWithMalformedHeader(t *testing.T) { + for _, brokenHeader := range []string{ + "text/plain, text/markdown/foo", // malformed media type + "text/plain, image/png; q=high", // malformed q-value + "text/plain, image/jpeg; q=1.25", // q-value out of range + } { + h := accept.ParseHeader([]string{brokenHeader}) + + // broken headers are ignored completely, so the first option wins by default + assert.Equal(t, h.Negotiate( + "image/png", + "image/jpeg", + "text/plain", + ), Some("image/png")) + } +} |
