diff options
author | Mike Crute <mike@crute.us> | 2023-01-07 20:00:42 -0800 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2023-01-07 20:23:30 -0800 |
commit | 6cb3c4271d3126697c8917bce9312603fa6607ca (patch) | |
tree | d3492fe7a59eb0ac3f7083061008ff2d60d18349 | |
parent | 85c67dd133605d2b1125a1b0e8c28f991223aa75 (diff) | |
download | golib-0.5.2.tar.bz2 golib-0.5.2.tar.xz golib-0.5.2.zip |
net/http: add accept parserv0.5.2
-rw-r--r-- | go.mod | 12 | ||||
-rw-r--r-- | go.sum | 17 | ||||
-rw-r--r-- | net/http/accept.go | 214 | ||||
-rw-r--r-- | net/http/accept_test.go | 147 |
4 files changed, 388 insertions, 2 deletions
@@ -2,6 +2,14 @@ module code.crute.us/mcrute/golib | |||
2 | 2 | ||
3 | go 1.18 | 3 | go 1.18 |
4 | 4 | ||
5 | require golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 | 5 | require ( |
6 | github.com/stretchr/testify v1.8.1 | ||
7 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 | ||
8 | ) | ||
6 | 9 | ||
7 | require golang.org/x/text v0.3.3 // indirect | 10 | require ( |
11 | github.com/davecgh/go-spew v1.1.1 // indirect | ||
12 | github.com/pmezard/go-difflib v1.0.0 // indirect | ||
13 | golang.org/x/text v0.3.3 // indirect | ||
14 | gopkg.in/yaml.v3 v3.0.1 // indirect | ||
15 | ) | ||
@@ -1,3 +1,15 @@ | |||
1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
4 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||
5 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||
6 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||
7 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= | ||
8 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= | ||
9 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||
10 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= | ||
11 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= | ||
12 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= | ||
1 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= | 13 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= |
2 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= | 14 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= |
3 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | 15 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
@@ -5,3 +17,8 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn | |||
5 | golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= | 17 | golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= |
6 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | 18 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= |
7 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | 19 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
20 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | ||
21 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||
22 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||
23 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
24 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||
diff --git a/net/http/accept.go b/net/http/accept.go new file mode 100644 index 0000000..cabfc48 --- /dev/null +++ b/net/http/accept.go | |||
@@ -0,0 +1,214 @@ | |||
1 | package http | ||
2 | |||
3 | import ( | ||
4 | "fmt" | ||
5 | "mime" | ||
6 | "reflect" | ||
7 | "regexp" | ||
8 | "sort" | ||
9 | "strconv" | ||
10 | "strings" | ||
11 | ) | ||
12 | |||
13 | type MediaType struct { | ||
14 | Type string | ||
15 | Subtype string | ||
16 | Parameters map[string]string | ||
17 | Weight float64 | ||
18 | originalQ string | ||
19 | } | ||
20 | |||
21 | func ParseMediaType(v string) (*MediaType, error) { | ||
22 | mt, params, err := mime.ParseMediaType(v) | ||
23 | if err != nil { | ||
24 | return nil, err | ||
25 | } | ||
26 | |||
27 | majorMinor := strings.Split(mt, "/") | ||
28 | if len(majorMinor) != 2 { | ||
29 | return nil, fmt.Errorf("Invalid major/minor media type: %s", mt) | ||
30 | } | ||
31 | |||
32 | // No q should be weight 1.0, per spec | ||
33 | q := float64(1) | ||
34 | sq, ok := params["q"] | ||
35 | if ok { | ||
36 | delete(params, "q") | ||
37 | q, err = parseQ(sq) | ||
38 | if err != nil { | ||
39 | return nil, err | ||
40 | } | ||
41 | } | ||
42 | |||
43 | return &MediaType{ | ||
44 | Type: majorMinor[0], | ||
45 | Subtype: majorMinor[1], | ||
46 | Parameters: params, | ||
47 | Weight: q, | ||
48 | originalQ: sq, | ||
49 | }, nil | ||
50 | } | ||
51 | |||
52 | func (m MediaType) String() string { | ||
53 | b := strings.Builder{} | ||
54 | b.WriteString(m.Type + "/" + m.Subtype) | ||
55 | |||
56 | params := []string{} | ||
57 | for k, v := range m.Parameters { | ||
58 | params = append(params, fmt.Sprintf("%s=%s", k, v)) | ||
59 | } | ||
60 | |||
61 | // Keep them in the same order so they're comparable | ||
62 | sort.Strings(params) | ||
63 | |||
64 | // Q should always be last per RFC9110 | ||
65 | if m.originalQ != "" { | ||
66 | params = append(params, fmt.Sprintf("q=%s", m.originalQ)) | ||
67 | } | ||
68 | |||
69 | if len(params) > 0 { | ||
70 | b.WriteString(";") | ||
71 | b.WriteString(strings.Join(params, ";")) | ||
72 | } | ||
73 | |||
74 | return b.String() | ||
75 | } | ||
76 | |||
77 | func (m MediaType) Specificity() int { | ||
78 | s := 0 | ||
79 | |||
80 | if m.Type != "*" { | ||
81 | s += 1 | ||
82 | } | ||
83 | if m.Subtype != "*" { | ||
84 | s += 1 | ||
85 | } | ||
86 | if m.Parameters != nil { | ||
87 | s += len(m.Parameters) | ||
88 | } | ||
89 | |||
90 | return s | ||
91 | } | ||
92 | |||
93 | func (m MediaType) Satisfies(v MediaType) bool { | ||
94 | if m.Equal(v) { | ||
95 | return true | ||
96 | } | ||
97 | |||
98 | if m.Type != v.Type && m.Type != "*" && v.Type != "*" { | ||
99 | return false | ||
100 | } | ||
101 | |||
102 | if m.Subtype != v.Subtype && m.Subtype != "*" && v.Subtype != "*" { | ||
103 | return false | ||
104 | } | ||
105 | |||
106 | return reflect.DeepEqual(m.Parameters, v.Parameters) | ||
107 | } | ||
108 | |||
109 | func (m MediaType) Equal(v MediaType) bool { | ||
110 | return m.Type == v.Type && | ||
111 | m.Subtype == v.Subtype && | ||
112 | reflect.DeepEqual(m.Parameters, v.Parameters) | ||
113 | } | ||
114 | |||
115 | type AcceptableTypes []*MediaType | ||
116 | |||
117 | func (a AcceptableTypes) Sorted() AcceptableTypes { sort.Stable(sort.Reverse(a)); return a } | ||
118 | func (a AcceptableTypes) Len() int { return len(a) } | ||
119 | func (a AcceptableTypes) Less(i, j int) bool { return a[i].Weight < a[j].Weight } | ||
120 | func (a AcceptableTypes) Swap(i, j int) { a[i], a[j] = a[j], a[i] } | ||
121 | |||
122 | // FindMatch returns the MediaType in the set that has the best match | ||
123 | // for the passed media types as well as the media type that determined | ||
124 | // the match. According to the rules of RFC7231, the most specific match | ||
125 | // wins in order of weight. This function assumes that both values are | ||
126 | // sorted. | ||
127 | func (a AcceptableTypes) FindMatch(values AcceptableTypes) (match *MediaType, matcher *MediaType) { | ||
128 | if len(a) == 0 || len(values) == 0 { | ||
129 | return nil, nil | ||
130 | } | ||
131 | |||
132 | candidates := AcceptableTypes{} | ||
133 | |||
134 | // Return the highest precedence match for the type. If there is no | ||
135 | // exact match then the most specific match with the highest precedence | ||
136 | // should win. | ||
137 | for _, matcher = range values { | ||
138 | for _, match = range a { | ||
139 | if match.Equal(*matcher) { | ||
140 | return match, matcher | ||
141 | } | ||
142 | |||
143 | if match.Satisfies(*matcher) { | ||
144 | candidates = append(candidates, match) | ||
145 | } | ||
146 | } | ||
147 | |||
148 | if len(candidates) != 0 { | ||
149 | break | ||
150 | } | ||
151 | } | ||
152 | |||
153 | if len(candidates) == 0 { | ||
154 | return nil, nil | ||
155 | } | ||
156 | |||
157 | // Sort ascending by specificity | ||
158 | sort.SliceStable(candidates, func(i, j int) bool { | ||
159 | return candidates[i].Specificity() < candidates[j].Specificity() | ||
160 | }) | ||
161 | |||
162 | return candidates[len(candidates)-1], matcher | ||
163 | } | ||
164 | |||
165 | // ParseAccept parses a set of Accept headers and scores them per the | ||
166 | // rules in RFC7231, returning a slice of headers in descending order of | ||
167 | // priority. If the Accept header occurs multiple times in the request | ||
168 | // the result of all headers will be combined and scored together as if | ||
169 | // they were all in one header line. | ||
170 | // | ||
171 | // See: https://tools.ietf.org/html/rfc7231#section-5.3.2 | ||
172 | func ParseAccept(values []string) (AcceptableTypes, error) { | ||
173 | all := AcceptableTypes{} | ||
174 | |||
175 | for _, l := range values { | ||
176 | t, err := parseAcceptLine(l) | ||
177 | if err != nil { | ||
178 | return nil, err | ||
179 | } | ||
180 | all = append(all, t...) | ||
181 | } | ||
182 | |||
183 | return all.Sorted(), nil | ||
184 | } | ||
185 | |||
186 | func parseAcceptLine(l string) (AcceptableTypes, error) { | ||
187 | out := AcceptableTypes{} | ||
188 | |||
189 | for _, t := range strings.Split(l, ",") { | ||
190 | mt, err := ParseMediaType(t) | ||
191 | if err != nil { | ||
192 | return nil, err | ||
193 | } | ||
194 | out = append(out, mt) | ||
195 | } | ||
196 | |||
197 | return out, nil | ||
198 | } | ||
199 | |||
200 | // https://tools.ietf.org/html/rfc7231#section-5.3.1 | ||
201 | var validateQ = regexp.MustCompile(`(0\.[0-9]{1,3}|1\.0{1,3})$`) | ||
202 | |||
203 | func parseQ(v string) (float64, error) { | ||
204 | if !validateQ.Match([]byte(v)) { | ||
205 | return 0.0, fmt.Errorf("Invalid format for Q") | ||
206 | } | ||
207 | |||
208 | f, err := strconv.ParseFloat(v, 64) | ||
209 | if err != nil { | ||
210 | return 0.0, err | ||
211 | } | ||
212 | |||
213 | return f, nil | ||
214 | } | ||
diff --git a/net/http/accept_test.go b/net/http/accept_test.go new file mode 100644 index 0000000..4e209c2 --- /dev/null +++ b/net/http/accept_test.go | |||
@@ -0,0 +1,147 @@ | |||
1 | package http | ||
2 | |||
3 | import ( | ||
4 | "testing" | ||
5 | |||
6 | "github.com/stretchr/testify/assert" | ||
7 | ) | ||
8 | |||
9 | var rfcTestLine = "text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5" | ||
10 | |||
11 | func TestParseAccept(t *testing.T) { | ||
12 | values, err := ParseAccept([]string{ | ||
13 | "text/*;q=0.3, text/html;q=0.7", | ||
14 | "text/html;level=1, text/html;level=2;q=0.4", | ||
15 | "*/*;q=0.5", | ||
16 | }) | ||
17 | |||
18 | assert.NoError(t, err) | ||
19 | assert.Len(t, values, 5) | ||
20 | |||
21 | expected := []MediaType{ | ||
22 | MediaType{Type: "text", Subtype: "html", Weight: 1, Parameters: map[string]string{"level": "1"}}, | ||
23 | MediaType{Type: "text", Subtype: "html", Weight: 0.7, Parameters: map[string]string{}, originalQ: "0.7"}, | ||
24 | MediaType{Type: "*", Subtype: "*", Weight: 0.5, Parameters: map[string]string{}, originalQ: "0.5"}, | ||
25 | MediaType{Type: "text", Subtype: "html", Weight: 0.4, Parameters: map[string]string{"level": "2"}, originalQ: "0.4"}, | ||
26 | MediaType{Type: "text", Subtype: "*", Weight: 0.3, Parameters: map[string]string{}, originalQ: "0.3"}, | ||
27 | } | ||
28 | |||
29 | for i, e := range values { | ||
30 | assert.Equal(t, *e, expected[i]) | ||
31 | } | ||
32 | } | ||
33 | |||
34 | func TestParseQ(t *testing.T) { | ||
35 | var v float64 | ||
36 | var err error | ||
37 | |||
38 | _, err = parseQ("1.12") | ||
39 | assert.ErrorContains(t, err, "Invalid format for Q") | ||
40 | |||
41 | _, err = parseQ("0.1234") | ||
42 | assert.ErrorContains(t, err, "Invalid format for Q") | ||
43 | |||
44 | v, err = parseQ("1.0") | ||
45 | assert.NoError(t, err) | ||
46 | assert.Equal(t, 1.0, v) | ||
47 | |||
48 | v, err = parseQ("0.003") | ||
49 | assert.NoError(t, err) | ||
50 | assert.Equal(t, 0.003, v) | ||
51 | } | ||
52 | |||
53 | func TestParseMediaType(t *testing.T) { | ||
54 | mt, err := ParseMediaType("text/plain;foo=bar;q=0.3") | ||
55 | assert.NoError(t, err) | ||
56 | assert.Equal(t, "text", mt.Type) | ||
57 | assert.Equal(t, "plain", mt.Subtype) | ||
58 | assert.Equal(t, map[string]string{"foo": "bar"}, mt.Parameters) | ||
59 | assert.Equal(t, 0.3, mt.Weight) | ||
60 | |||
61 | mt, err = ParseMediaType("text/plain") | ||
62 | assert.NoError(t, err) | ||
63 | assert.Equal(t, 1.0, mt.Weight) | ||
64 | |||
65 | mt, err = ParseMediaType("foo") | ||
66 | assert.ErrorContains(t, err, "Invalid major/minor") | ||
67 | |||
68 | mt, err = ParseMediaType("foo/bar;q=11") | ||
69 | assert.ErrorContains(t, err, "Invalid format for Q") | ||
70 | } | ||
71 | |||
72 | func TestMediaTypeString(t *testing.T) { | ||
73 | mt, err := ParseMediaType("text/plain;foo=bar;biz=baz;q=0.3") | ||
74 | assert.NoError(t, err) | ||
75 | assert.Equal(t, "text/plain;biz=baz;foo=bar;q=0.3", mt.String()) | ||
76 | |||
77 | // Default q of 1 should not leak to String() | ||
78 | mt, err = ParseMediaType("text/plain;foo=bar;biz=baz") | ||
79 | assert.NoError(t, err) | ||
80 | assert.Equal(t, "text/plain;biz=baz;foo=bar", mt.String()) | ||
81 | } | ||
82 | |||
83 | func TestMediaTypeSpecificity(t *testing.T) { | ||
84 | mt, err := ParseMediaType("text/plain;foo=bar;biz=baz") | ||
85 | assert.NoError(t, err) | ||
86 | assert.Equal(t, 4, mt.Specificity()) | ||
87 | |||
88 | mt, err = ParseMediaType("text/*;foo=bar;biz=baz") | ||
89 | assert.NoError(t, err) | ||
90 | assert.Equal(t, 3, mt.Specificity()) | ||
91 | |||
92 | mt, err = ParseMediaType("text/*") | ||
93 | assert.NoError(t, err) | ||
94 | assert.Equal(t, 1, mt.Specificity()) | ||
95 | |||
96 | mt, err = ParseMediaType("*/*") | ||
97 | assert.NoError(t, err) | ||
98 | assert.Equal(t, 0, mt.Specificity()) | ||
99 | } | ||
100 | |||
101 | func TestParseAcceptLine(t *testing.T) { | ||
102 | types, err := parseAcceptLine(rfcTestLine) | ||
103 | |||
104 | assert.NoError(t, err) | ||
105 | assert.Len(t, types, 5) | ||
106 | |||
107 | for i, e := range []MediaType{ | ||
108 | MediaType{Type: "text", Subtype: "*", Weight: 0.3, Parameters: map[string]string{}, originalQ: "0.3"}, | ||
109 | MediaType{Type: "text", Subtype: "html", Weight: 0.7, Parameters: map[string]string{}, originalQ: "0.7"}, | ||
110 | MediaType{Type: "text", Subtype: "html", Weight: 1, Parameters: map[string]string{"level": "1"}}, | ||
111 | MediaType{Type: "text", Subtype: "html", Weight: 0.4, Parameters: map[string]string{"level": "2"}, originalQ: "0.4"}, | ||
112 | MediaType{Type: "*", Subtype: "*", Weight: 0.5, Parameters: map[string]string{}, originalQ: "0.5"}, | ||
113 | } { | ||
114 | assert.Equal(t, e, *types[i]) | ||
115 | } | ||
116 | } | ||
117 | |||
118 | func TestFindMatch(t *testing.T) { | ||
119 | ours, err := ParseAccept([]string{ | ||
120 | "text/html;level=1, text/html, text/plain, image/jpeg", | ||
121 | "text/html;level=2, text/html;level=3", | ||
122 | }) | ||
123 | assert.NoError(t, err) | ||
124 | |||
125 | theirs, err := ParseAccept([]string{rfcTestLine}) | ||
126 | assert.NoError(t, err) | ||
127 | |||
128 | match, matcher := ours.FindMatch(theirs) | ||
129 | assert.Equal(t, "text/html;level=1", match.String()) | ||
130 | assert.Equal(t, "text/html;level=1", matcher.String()) | ||
131 | |||
132 | match, matcher = ours.FindMatch(theirs[1:]) | ||
133 | assert.Equal(t, "text/html", match.String()) | ||
134 | assert.Equal(t, "text/html;q=0.7", matcher.String()) | ||
135 | |||
136 | match, matcher = ours.FindMatch(theirs[2:]) | ||
137 | assert.Equal(t, "image/jpeg", match.String()) | ||
138 | assert.Equal(t, "*/*;q=0.5", matcher.String()) | ||
139 | |||
140 | match, matcher = ours.FindMatch(theirs[3:]) | ||
141 | assert.Equal(t, "text/html;level=2", match.String()) | ||
142 | assert.Equal(t, "text/html;level=2;q=0.4", matcher.String()) | ||
143 | |||
144 | match, matcher = ours.FindMatch(theirs[4:]) | ||
145 | assert.Equal(t, "text/plain", match.String()) | ||
146 | assert.Equal(t, "text/*;q=0.3", matcher.String()) | ||
147 | } | ||