aboutsummaryrefslogtreecommitdiff
path: root/internal/accept
diff options
context:
space:
mode:
Diffstat (limited to 'internal/accept')
-rw-r--r--internal/accept/accept.go137
-rw-r--r--internal/accept/accept_test.go94
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"))
+ }
+}