diff options
author | Mike Crute <mike@crute.us> | 2023-10-08 12:35:38 -0700 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2023-10-08 12:35:38 -0700 |
commit | d51849bf18a32de34c35d75c118caef5294223e8 (patch) | |
tree | 5dc9eb1437eb7ffd0c635947cca275032fe390ea | |
parent | 2d903b840e18144f29a4f4a9a0305f8ba16f19a3 (diff) | |
download | golib-d51849bf18a32de34c35d75c118caef5294223e8.tar.bz2 golib-d51849bf18a32de34c35d75c118caef5294223e8.tar.xz golib-d51849bf18a32de34c35d75c118caef5294223e8.zip |
httputil: add header parsing from godoc
-rw-r--r-- | httputil/buster.go | 95 | ||||
-rw-r--r-- | httputil/buster_test.go | 29 | ||||
-rw-r--r-- | httputil/header/header.go | 298 | ||||
-rw-r--r-- | httputil/header/header_test.go | 139 | ||||
-rw-r--r-- | httputil/httputil.go | 25 | ||||
-rw-r--r-- | httputil/negotiate.go | 79 | ||||
-rw-r--r-- | httputil/negotiate_test.go | 71 | ||||
-rw-r--r-- | httputil/respbuf.go | 58 | ||||
-rw-r--r-- | httputil/static.go | 265 | ||||
-rw-r--r-- | httputil/static_test.go | 175 | ||||
-rw-r--r-- | httputil/transport.go | 87 | ||||
-rw-r--r-- | httputil/transport_test.go | 126 |
12 files changed, 1447 insertions, 0 deletions
diff --git a/httputil/buster.go b/httputil/buster.go new file mode 100644 index 0000000..beab151 --- /dev/null +++ b/httputil/buster.go | |||
@@ -0,0 +1,95 @@ | |||
1 | // Copyright 2013 The Go Authors. All rights reserved. | ||
2 | // | ||
3 | // Use of this source code is governed by a BSD-style | ||
4 | // license that can be found in the LICENSE file or at | ||
5 | // https://developers.google.com/open-source/licenses/bsd. | ||
6 | |||
7 | package httputil | ||
8 | |||
9 | import ( | ||
10 | "io" | ||
11 | "io/ioutil" | ||
12 | "net/http" | ||
13 | "net/url" | ||
14 | "strings" | ||
15 | "sync" | ||
16 | ) | ||
17 | |||
18 | type busterWriter struct { | ||
19 | headerMap http.Header | ||
20 | status int | ||
21 | io.Writer | ||
22 | } | ||
23 | |||
24 | func (bw *busterWriter) Header() http.Header { | ||
25 | return bw.headerMap | ||
26 | } | ||
27 | |||
28 | func (bw *busterWriter) WriteHeader(status int) { | ||
29 | bw.status = status | ||
30 | } | ||
31 | |||
32 | // CacheBusters maintains a cache of cache busting tokens for static resources served by Handler. | ||
33 | type CacheBusters struct { | ||
34 | Handler http.Handler | ||
35 | |||
36 | mu sync.Mutex | ||
37 | tokens map[string]string | ||
38 | } | ||
39 | |||
40 | func sanitizeTokenRune(r rune) rune { | ||
41 | if r <= ' ' || r >= 127 { | ||
42 | return -1 | ||
43 | } | ||
44 | // Convert percent encoding reserved characters to '-'. | ||
45 | if strings.ContainsRune("!#$&'()*+,/:;=?@[]", r) { | ||
46 | return '-' | ||
47 | } | ||
48 | return r | ||
49 | } | ||
50 | |||
51 | // Get returns the cache busting token for path. If the token is not already | ||
52 | // cached, Get issues a HEAD request on handler and uses the response ETag and | ||
53 | // Last-Modified headers to compute a token. | ||
54 | func (cb *CacheBusters) Get(path string) string { | ||
55 | cb.mu.Lock() | ||
56 | if cb.tokens == nil { | ||
57 | cb.tokens = make(map[string]string) | ||
58 | } | ||
59 | token, ok := cb.tokens[path] | ||
60 | cb.mu.Unlock() | ||
61 | if ok { | ||
62 | return token | ||
63 | } | ||
64 | |||
65 | w := busterWriter{ | ||
66 | Writer: ioutil.Discard, | ||
67 | headerMap: make(http.Header), | ||
68 | } | ||
69 | r := &http.Request{URL: &url.URL{Path: path}, Method: "HEAD"} | ||
70 | cb.Handler.ServeHTTP(&w, r) | ||
71 | |||
72 | if w.status == 200 { | ||
73 | token = w.headerMap.Get("Etag") | ||
74 | if token == "" { | ||
75 | token = w.headerMap.Get("Last-Modified") | ||
76 | } | ||
77 | token = strings.Trim(token, `" `) | ||
78 | token = strings.Map(sanitizeTokenRune, token) | ||
79 | } | ||
80 | |||
81 | cb.mu.Lock() | ||
82 | cb.tokens[path] = token | ||
83 | cb.mu.Unlock() | ||
84 | |||
85 | return token | ||
86 | } | ||
87 | |||
88 | // AppendQueryParam appends the token as a query parameter to path. | ||
89 | func (cb *CacheBusters) AppendQueryParam(path string, name string) string { | ||
90 | token := cb.Get(path) | ||
91 | if token == "" { | ||
92 | return path | ||
93 | } | ||
94 | return path + "?" + name + "=" + token | ||
95 | } | ||
diff --git a/httputil/buster_test.go b/httputil/buster_test.go new file mode 100644 index 0000000..eb44cf4 --- /dev/null +++ b/httputil/buster_test.go | |||
@@ -0,0 +1,29 @@ | |||
1 | // Copyright 2013 The Go Authors. All rights reserved. | ||
2 | // | ||
3 | // Use of this source code is governed by a BSD-style | ||
4 | // license that can be found in the LICENSE file or at | ||
5 | // https://developers.google.com/open-source/licenses/bsd. | ||
6 | |||
7 | package httputil | ||
8 | |||
9 | import ( | ||
10 | "net/http" | ||
11 | "testing" | ||
12 | ) | ||
13 | |||
14 | func TestCacheBusters(t *testing.T) { | ||
15 | cbs := &CacheBusters{Handler: http.FileServer(http.Dir("."))} | ||
16 | |||
17 | token := cbs.Get("/buster_test.go") | ||
18 | if token == "" { | ||
19 | t.Errorf("could not extract token from http.FileServer") | ||
20 | } | ||
21 | |||
22 | var ss StaticServer | ||
23 | cbs = &CacheBusters{Handler: ss.FileHandler("buster_test.go")} | ||
24 | |||
25 | token = cbs.Get("/xxx") | ||
26 | if token == "" { | ||
27 | t.Errorf("could not extract token from StaticServer FileHandler") | ||
28 | } | ||
29 | } | ||
diff --git a/httputil/header/header.go b/httputil/header/header.go new file mode 100644 index 0000000..0f1572e --- /dev/null +++ b/httputil/header/header.go | |||
@@ -0,0 +1,298 @@ | |||
1 | // Copyright 2013 The Go Authors. All rights reserved. | ||
2 | // | ||
3 | // Use of this source code is governed by a BSD-style | ||
4 | // license that can be found in the LICENSE file or at | ||
5 | // https://developers.google.com/open-source/licenses/bsd. | ||
6 | |||
7 | // Package header provides functions for parsing HTTP headers. | ||
8 | package header | ||
9 | |||
10 | import ( | ||
11 | "net/http" | ||
12 | "strings" | ||
13 | "time" | ||
14 | ) | ||
15 | |||
16 | // Octet types from RFC 2616. | ||
17 | var octetTypes [256]octetType | ||
18 | |||
19 | type octetType byte | ||
20 | |||
21 | const ( | ||
22 | isToken octetType = 1 << iota | ||
23 | isSpace | ||
24 | ) | ||
25 | |||
26 | func init() { | ||
27 | // OCTET = <any 8-bit sequence of data> | ||
28 | // CHAR = <any US-ASCII character (octets 0 - 127)> | ||
29 | // CTL = <any US-ASCII control character (octets 0 - 31) and DEL (127)> | ||
30 | // CR = <US-ASCII CR, carriage return (13)> | ||
31 | // LF = <US-ASCII LF, linefeed (10)> | ||
32 | // SP = <US-ASCII SP, space (32)> | ||
33 | // HT = <US-ASCII HT, horizontal-tab (9)> | ||
34 | // <"> = <US-ASCII double-quote mark (34)> | ||
35 | // CRLF = CR LF | ||
36 | // LWS = [CRLF] 1*( SP | HT ) | ||
37 | // TEXT = <any OCTET except CTLs, but including LWS> | ||
38 | // separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <"> | ||
39 | // | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT | ||
40 | // token = 1*<any CHAR except CTLs or separators> | ||
41 | // qdtext = <any TEXT except <">> | ||
42 | |||
43 | for c := 0; c < 256; c++ { | ||
44 | var t octetType | ||
45 | isCtl := c <= 31 || c == 127 | ||
46 | isChar := 0 <= c && c <= 127 | ||
47 | isSeparator := strings.IndexRune(" \t\"(),/:;<=>?@[]\\{}", rune(c)) >= 0 | ||
48 | if strings.IndexRune(" \t\r\n", rune(c)) >= 0 { | ||
49 | t |= isSpace | ||
50 | } | ||
51 | if isChar && !isCtl && !isSeparator { | ||
52 | t |= isToken | ||
53 | } | ||
54 | octetTypes[c] = t | ||
55 | } | ||
56 | } | ||
57 | |||
58 | // Copy returns a shallow copy of the header. | ||
59 | func Copy(header http.Header) http.Header { | ||
60 | h := make(http.Header) | ||
61 | for k, vs := range header { | ||
62 | h[k] = vs | ||
63 | } | ||
64 | return h | ||
65 | } | ||
66 | |||
67 | var timeLayouts = []string{"Mon, 02 Jan 2006 15:04:05 GMT", time.RFC850, time.ANSIC} | ||
68 | |||
69 | // ParseTime parses the header as time. The zero value is returned if the | ||
70 | // header is not present or there is an error parsing the | ||
71 | // header. | ||
72 | func ParseTime(header http.Header, key string) time.Time { | ||
73 | if s := header.Get(key); s != "" { | ||
74 | for _, layout := range timeLayouts { | ||
75 | if t, err := time.Parse(layout, s); err == nil { | ||
76 | return t.UTC() | ||
77 | } | ||
78 | } | ||
79 | } | ||
80 | return time.Time{} | ||
81 | } | ||
82 | |||
83 | // ParseList parses a comma separated list of values. Commas are ignored in | ||
84 | // quoted strings. Quoted values are not unescaped or unquoted. Whitespace is | ||
85 | // trimmed. | ||
86 | func ParseList(header http.Header, key string) []string { | ||
87 | var result []string | ||
88 | for _, s := range header[http.CanonicalHeaderKey(key)] { | ||
89 | begin := 0 | ||
90 | end := 0 | ||
91 | escape := false | ||
92 | quote := false | ||
93 | for i := 0; i < len(s); i++ { | ||
94 | b := s[i] | ||
95 | switch { | ||
96 | case escape: | ||
97 | escape = false | ||
98 | end = i + 1 | ||
99 | case quote: | ||
100 | switch b { | ||
101 | case '\\': | ||
102 | escape = true | ||
103 | case '"': | ||
104 | quote = false | ||
105 | } | ||
106 | end = i + 1 | ||
107 | case b == '"': | ||
108 | quote = true | ||
109 | end = i + 1 | ||
110 | case octetTypes[b]&isSpace != 0: | ||
111 | if begin == end { | ||
112 | begin = i + 1 | ||
113 | end = begin | ||
114 | } | ||
115 | case b == ',': | ||
116 | if begin < end { | ||
117 | result = append(result, s[begin:end]) | ||
118 | } | ||
119 | begin = i + 1 | ||
120 | end = begin | ||
121 | default: | ||
122 | end = i + 1 | ||
123 | } | ||
124 | } | ||
125 | if begin < end { | ||
126 | result = append(result, s[begin:end]) | ||
127 | } | ||
128 | } | ||
129 | return result | ||
130 | } | ||
131 | |||
132 | // ParseValueAndParams parses a comma separated list of values with optional | ||
133 | // semicolon separated name-value pairs. Content-Type and Content-Disposition | ||
134 | // headers are in this format. | ||
135 | func ParseValueAndParams(header http.Header, key string) (value string, params map[string]string) { | ||
136 | params = make(map[string]string) | ||
137 | s := header.Get(key) | ||
138 | value, s = expectTokenSlash(s) | ||
139 | if value == "" { | ||
140 | return | ||
141 | } | ||
142 | value = strings.ToLower(value) | ||
143 | s = skipSpace(s) | ||
144 | for strings.HasPrefix(s, ";") { | ||
145 | var pkey string | ||
146 | pkey, s = expectToken(skipSpace(s[1:])) | ||
147 | if pkey == "" { | ||
148 | return | ||
149 | } | ||
150 | if !strings.HasPrefix(s, "=") { | ||
151 | return | ||
152 | } | ||
153 | var pvalue string | ||
154 | pvalue, s = expectTokenOrQuoted(s[1:]) | ||
155 | if pvalue == "" { | ||
156 | return | ||
157 | } | ||
158 | pkey = strings.ToLower(pkey) | ||
159 | params[pkey] = pvalue | ||
160 | s = skipSpace(s) | ||
161 | } | ||
162 | return | ||
163 | } | ||
164 | |||
165 | // AcceptSpec describes an Accept* header. | ||
166 | type AcceptSpec struct { | ||
167 | Value string | ||
168 | Q float64 | ||
169 | } | ||
170 | |||
171 | // ParseAccept parses Accept* headers. | ||
172 | func ParseAccept(header http.Header, key string) (specs []AcceptSpec) { | ||
173 | loop: | ||
174 | for _, s := range header[key] { | ||
175 | for { | ||
176 | var spec AcceptSpec | ||
177 | spec.Value, s = expectTokenSlash(s) | ||
178 | if spec.Value == "" { | ||
179 | continue loop | ||
180 | } | ||
181 | spec.Q = 1.0 | ||
182 | s = skipSpace(s) | ||
183 | if strings.HasPrefix(s, ";") { | ||
184 | s = skipSpace(s[1:]) | ||
185 | if !strings.HasPrefix(s, "q=") { | ||
186 | continue loop | ||
187 | } | ||
188 | spec.Q, s = expectQuality(s[2:]) | ||
189 | if spec.Q < 0.0 { | ||
190 | continue loop | ||
191 | } | ||
192 | } | ||
193 | specs = append(specs, spec) | ||
194 | s = skipSpace(s) | ||
195 | if !strings.HasPrefix(s, ",") { | ||
196 | continue loop | ||
197 | } | ||
198 | s = skipSpace(s[1:]) | ||
199 | } | ||
200 | } | ||
201 | return | ||
202 | } | ||
203 | |||
204 | func skipSpace(s string) (rest string) { | ||
205 | i := 0 | ||
206 | for ; i < len(s); i++ { | ||
207 | if octetTypes[s[i]]&isSpace == 0 { | ||
208 | break | ||
209 | } | ||
210 | } | ||
211 | return s[i:] | ||
212 | } | ||
213 | |||
214 | func expectToken(s string) (token, rest string) { | ||
215 | i := 0 | ||
216 | for ; i < len(s); i++ { | ||
217 | if octetTypes[s[i]]&isToken == 0 { | ||
218 | break | ||
219 | } | ||
220 | } | ||
221 | return s[:i], s[i:] | ||
222 | } | ||
223 | |||
224 | func expectTokenSlash(s string) (token, rest string) { | ||
225 | i := 0 | ||
226 | for ; i < len(s); i++ { | ||
227 | b := s[i] | ||
228 | if (octetTypes[b]&isToken == 0) && b != '/' { | ||
229 | break | ||
230 | } | ||
231 | } | ||
232 | return s[:i], s[i:] | ||
233 | } | ||
234 | |||
235 | func expectQuality(s string) (q float64, rest string) { | ||
236 | switch { | ||
237 | case len(s) == 0: | ||
238 | return -1, "" | ||
239 | case s[0] == '0': | ||
240 | q = 0 | ||
241 | case s[0] == '1': | ||
242 | q = 1 | ||
243 | default: | ||
244 | return -1, "" | ||
245 | } | ||
246 | s = s[1:] | ||
247 | if !strings.HasPrefix(s, ".") { | ||
248 | return q, s | ||
249 | } | ||
250 | s = s[1:] | ||
251 | i := 0 | ||
252 | n := 0 | ||
253 | d := 1 | ||
254 | for ; i < len(s); i++ { | ||
255 | b := s[i] | ||
256 | if b < '0' || b > '9' { | ||
257 | break | ||
258 | } | ||
259 | n = n*10 + int(b) - '0' | ||
260 | d *= 10 | ||
261 | } | ||
262 | return q + float64(n)/float64(d), s[i:] | ||
263 | } | ||
264 | |||
265 | func expectTokenOrQuoted(s string) (value string, rest string) { | ||
266 | if !strings.HasPrefix(s, "\"") { | ||
267 | return expectToken(s) | ||
268 | } | ||
269 | s = s[1:] | ||
270 | for i := 0; i < len(s); i++ { | ||
271 | switch s[i] { | ||
272 | case '"': | ||
273 | return s[:i], s[i+1:] | ||
274 | case '\\': | ||
275 | p := make([]byte, len(s)-1) | ||
276 | j := copy(p, s[:i]) | ||
277 | escape := true | ||
278 | for i = i + 1; i < len(s); i++ { | ||
279 | b := s[i] | ||
280 | switch { | ||
281 | case escape: | ||
282 | escape = false | ||
283 | p[j] = b | ||
284 | j++ | ||
285 | case b == '\\': | ||
286 | escape = true | ||
287 | case b == '"': | ||
288 | return string(p[:j]), s[i+1:] | ||
289 | default: | ||
290 | p[j] = b | ||
291 | j++ | ||
292 | } | ||
293 | } | ||
294 | return "", "" | ||
295 | } | ||
296 | } | ||
297 | return "", "" | ||
298 | } | ||
diff --git a/httputil/header/header_test.go b/httputil/header/header_test.go new file mode 100644 index 0000000..97c8685 --- /dev/null +++ b/httputil/header/header_test.go | |||
@@ -0,0 +1,139 @@ | |||
1 | // Copyright 2013 The Go Authors. All rights reserved. | ||
2 | // | ||
3 | // Use of this source code is governed by a BSD-style | ||
4 | // license that can be found in the LICENSE file or at | ||
5 | // https://developers.google.com/open-source/licenses/bsd. | ||
6 | |||
7 | package header | ||
8 | |||
9 | import ( | ||
10 | "net/http" | ||
11 | "testing" | ||
12 | "time" | ||
13 | |||
14 | "github.com/google/go-cmp/cmp" | ||
15 | ) | ||
16 | |||
17 | var getHeaderListTests = []struct { | ||
18 | s string | ||
19 | l []string | ||
20 | }{ | ||
21 | {s: `a`, l: []string{`a`}}, | ||
22 | {s: `a, b , c `, l: []string{`a`, `b`, `c`}}, | ||
23 | {s: `a,, b , , c `, l: []string{`a`, `b`, `c`}}, | ||
24 | {s: `a,b,c`, l: []string{`a`, `b`, `c`}}, | ||
25 | {s: ` a b, c d `, l: []string{`a b`, `c d`}}, | ||
26 | {s: `"a, b, c", d `, l: []string{`"a, b, c"`, "d"}}, | ||
27 | {s: `","`, l: []string{`","`}}, | ||
28 | {s: `"\""`, l: []string{`"\""`}}, | ||
29 | {s: `" "`, l: []string{`" "`}}, | ||
30 | } | ||
31 | |||
32 | func TestGetHeaderList(t *testing.T) { | ||
33 | for _, tt := range getHeaderListTests { | ||
34 | header := http.Header{"Foo": {tt.s}} | ||
35 | if l := ParseList(header, "foo"); !cmp.Equal(tt.l, l) { | ||
36 | t.Errorf("ParseList for %q = %q, want %q", tt.s, l, tt.l) | ||
37 | } | ||
38 | } | ||
39 | } | ||
40 | |||
41 | var parseValueAndParamsTests = []struct { | ||
42 | s string | ||
43 | value string | ||
44 | params map[string]string | ||
45 | }{ | ||
46 | {`text/html`, "text/html", map[string]string{}}, | ||
47 | {`text/html `, "text/html", map[string]string{}}, | ||
48 | {`text/html ; `, "text/html", map[string]string{}}, | ||
49 | {`tExt/htMl`, "text/html", map[string]string{}}, | ||
50 | {`tExt/htMl; fOO=";"; hellO=world`, "text/html", map[string]string{ | ||
51 | "hello": "world", | ||
52 | "foo": `;`, | ||
53 | }}, | ||
54 | {`text/html; foo=bar, hello=world`, "text/html", map[string]string{"foo": "bar"}}, | ||
55 | {`text/html ; foo=bar `, "text/html", map[string]string{"foo": "bar"}}, | ||
56 | {`text/html ;foo=bar `, "text/html", map[string]string{"foo": "bar"}}, | ||
57 | {`text/html; foo="b\ar"`, "text/html", map[string]string{"foo": "bar"}}, | ||
58 | {`text/html; foo="bar\"baz\"qux"`, "text/html", map[string]string{"foo": `bar"baz"qux`}}, | ||
59 | {`text/html; foo="b,ar"`, "text/html", map[string]string{"foo": "b,ar"}}, | ||
60 | {`text/html; foo="b;ar"`, "text/html", map[string]string{"foo": "b;ar"}}, | ||
61 | {`text/html; FOO="bar"`, "text/html", map[string]string{"foo": "bar"}}, | ||
62 | {`form-data; filename="file.txt"; name=file`, "form-data", map[string]string{"filename": "file.txt", "name": "file"}}, | ||
63 | } | ||
64 | |||
65 | func TestParseValueAndParams(t *testing.T) { | ||
66 | for _, tt := range parseValueAndParamsTests { | ||
67 | header := http.Header{"Content-Type": {tt.s}} | ||
68 | value, params := ParseValueAndParams(header, "Content-Type") | ||
69 | if value != tt.value { | ||
70 | t.Errorf("%q, value=%q, want %q", tt.s, value, tt.value) | ||
71 | } | ||
72 | if !cmp.Equal(params, tt.params) { | ||
73 | t.Errorf("%q, param=%#v, want %#v", tt.s, params, tt.params) | ||
74 | } | ||
75 | } | ||
76 | } | ||
77 | |||
78 | var parseTimeValidTests = []string{ | ||
79 | "Sun, 06 Nov 1994 08:49:37 GMT", | ||
80 | "Sunday, 06-Nov-94 08:49:37 GMT", | ||
81 | "Sun Nov 6 08:49:37 1994", | ||
82 | } | ||
83 | |||
84 | var parseTimeInvalidTests = []string{ | ||
85 | "junk", | ||
86 | } | ||
87 | |||
88 | func TestParseTime(t *testing.T) { | ||
89 | expected := time.Date(1994, 11, 6, 8, 49, 37, 0, time.UTC) | ||
90 | for _, s := range parseTimeValidTests { | ||
91 | header := http.Header{"Date": {s}} | ||
92 | actual := ParseTime(header, "Date") | ||
93 | if actual != expected { | ||
94 | t.Errorf("GetTime(%q)=%v, want %v", s, actual, expected) | ||
95 | } | ||
96 | } | ||
97 | for _, s := range parseTimeInvalidTests { | ||
98 | header := http.Header{"Date": {s}} | ||
99 | actual := ParseTime(header, "Date") | ||
100 | if !actual.IsZero() { | ||
101 | t.Errorf("GetTime(%q) did not return zero", s) | ||
102 | } | ||
103 | } | ||
104 | } | ||
105 | |||
106 | var parseAcceptTests = []struct { | ||
107 | s string | ||
108 | expected []AcceptSpec | ||
109 | }{ | ||
110 | {"text/html", []AcceptSpec{{"text/html", 1}}}, | ||
111 | {"text/html; q=0", []AcceptSpec{{"text/html", 0}}}, | ||
112 | {"text/html; q=0.0", []AcceptSpec{{"text/html", 0}}}, | ||
113 | {"text/html; q=1", []AcceptSpec{{"text/html", 1}}}, | ||
114 | {"text/html; q=1.0", []AcceptSpec{{"text/html", 1}}}, | ||
115 | {"text/html; q=0.1", []AcceptSpec{{"text/html", 0.1}}}, | ||
116 | {"text/html;q=0.1", []AcceptSpec{{"text/html", 0.1}}}, | ||
117 | {"text/html, text/plain", []AcceptSpec{{"text/html", 1}, {"text/plain", 1}}}, | ||
118 | {"text/html; q=0.1, text/plain", []AcceptSpec{{"text/html", 0.1}, {"text/plain", 1}}}, | ||
119 | {"iso-8859-5, unicode-1-1;q=0.8,iso-8859-1", []AcceptSpec{{"iso-8859-5", 1}, {"unicode-1-1", 0.8}, {"iso-8859-1", 1}}}, | ||
120 | {"iso-8859-1", []AcceptSpec{{"iso-8859-1", 1}}}, | ||
121 | {"*", []AcceptSpec{{"*", 1}}}, | ||
122 | {"da, en-gb;q=0.8, en;q=0.7", []AcceptSpec{{"da", 1}, {"en-gb", 0.8}, {"en", 0.7}}}, | ||
123 | {"da, q, en-gb;q=0.8", []AcceptSpec{{"da", 1}, {"q", 1}, {"en-gb", 0.8}}}, | ||
124 | {"image/png, image/*;q=0.5", []AcceptSpec{{"image/png", 1}, {"image/*", 0.5}}}, | ||
125 | |||
126 | // bad cases | ||
127 | {"value1; q=0.1.2", []AcceptSpec{{"value1", 0.1}}}, | ||
128 | {"da, en-gb;q=foo", []AcceptSpec{{"da", 1}}}, | ||
129 | } | ||
130 | |||
131 | func TestParseAccept(t *testing.T) { | ||
132 | for _, tt := range parseAcceptTests { | ||
133 | header := http.Header{"Accept": {tt.s}} | ||
134 | actual := ParseAccept(header, "Accept") | ||
135 | if !cmp.Equal(actual, tt.expected) { | ||
136 | t.Errorf("ParseAccept(h, %q)=%v, want %v", tt.s, actual, tt.expected) | ||
137 | } | ||
138 | } | ||
139 | } | ||
diff --git a/httputil/httputil.go b/httputil/httputil.go new file mode 100644 index 0000000..a03717c --- /dev/null +++ b/httputil/httputil.go | |||
@@ -0,0 +1,25 @@ | |||
1 | // Copyright 2013 The Go Authors. All rights reserved. | ||
2 | // | ||
3 | // Use of this source code is governed by a BSD-style | ||
4 | // license that can be found in the LICENSE file or at | ||
5 | // https://developers.google.com/open-source/licenses/bsd. | ||
6 | |||
7 | // Package httputil is a toolkit for the Go net/http package. | ||
8 | package httputil | ||
9 | |||
10 | import ( | ||
11 | "net" | ||
12 | "net/http" | ||
13 | ) | ||
14 | |||
15 | // StripPort removes the port specification from an address. | ||
16 | func StripPort(s string) string { | ||
17 | if h, _, err := net.SplitHostPort(s); err == nil { | ||
18 | s = h | ||
19 | } | ||
20 | return s | ||
21 | } | ||
22 | |||
23 | // Error defines a type for a function that accepts a ResponseWriter for | ||
24 | // a Request with the HTTP status code and error. | ||
25 | type Error func(w http.ResponseWriter, r *http.Request, status int, err error) | ||
diff --git a/httputil/negotiate.go b/httputil/negotiate.go new file mode 100644 index 0000000..6af3e4c --- /dev/null +++ b/httputil/negotiate.go | |||
@@ -0,0 +1,79 @@ | |||
1 | // Copyright 2013 The Go Authors. All rights reserved. | ||
2 | // | ||
3 | // Use of this source code is governed by a BSD-style | ||
4 | // license that can be found in the LICENSE file or at | ||
5 | // https://developers.google.com/open-source/licenses/bsd. | ||
6 | |||
7 | package httputil | ||
8 | |||
9 | import ( | ||
10 | "github.com/golang/gddo/httputil/header" | ||
11 | "net/http" | ||
12 | "strings" | ||
13 | ) | ||
14 | |||
15 | // NegotiateContentEncoding returns the best offered content encoding for the | ||
16 | // request's Accept-Encoding header. If two offers match with equal weight and | ||
17 | // then the offer earlier in the list is preferred. If no offers are | ||
18 | // acceptable, then "" is returned. | ||
19 | func NegotiateContentEncoding(r *http.Request, offers []string) string { | ||
20 | bestOffer := "identity" | ||
21 | bestQ := -1.0 | ||
22 | specs := header.ParseAccept(r.Header, "Accept-Encoding") | ||
23 | for _, offer := range offers { | ||
24 | for _, spec := range specs { | ||
25 | if spec.Q > bestQ && | ||
26 | (spec.Value == "*" || spec.Value == offer) { | ||
27 | bestQ = spec.Q | ||
28 | bestOffer = offer | ||
29 | } | ||
30 | } | ||
31 | } | ||
32 | if bestQ == 0 { | ||
33 | bestOffer = "" | ||
34 | } | ||
35 | return bestOffer | ||
36 | } | ||
37 | |||
38 | // NegotiateContentType returns the best offered content type for the request's | ||
39 | // Accept header. If two offers match with equal weight, then the more specific | ||
40 | // offer is preferred. For example, text/* trumps */*. If two offers match | ||
41 | // with equal weight and specificity, then the offer earlier in the list is | ||
42 | // preferred. If no offers match, then defaultOffer is returned. | ||
43 | func NegotiateContentType(r *http.Request, offers []string, defaultOffer string) string { | ||
44 | bestOffer := defaultOffer | ||
45 | bestQ := -1.0 | ||
46 | bestWild := 3 | ||
47 | specs := header.ParseAccept(r.Header, "Accept") | ||
48 | for _, offer := range offers { | ||
49 | for _, spec := range specs { | ||
50 | switch { | ||
51 | case spec.Q == 0.0: | ||
52 | // ignore | ||
53 | case spec.Q < bestQ: | ||
54 | // better match found | ||
55 | case spec.Value == "*/*": | ||
56 | if spec.Q > bestQ || bestWild > 2 { | ||
57 | bestQ = spec.Q | ||
58 | bestWild = 2 | ||
59 | bestOffer = offer | ||
60 | } | ||
61 | case strings.HasSuffix(spec.Value, "/*"): | ||
62 | if strings.HasPrefix(offer, spec.Value[:len(spec.Value)-1]) && | ||
63 | (spec.Q > bestQ || bestWild > 1) { | ||
64 | bestQ = spec.Q | ||
65 | bestWild = 1 | ||
66 | bestOffer = offer | ||
67 | } | ||
68 | default: | ||
69 | if spec.Value == offer && | ||
70 | (spec.Q > bestQ || bestWild > 0) { | ||
71 | bestQ = spec.Q | ||
72 | bestWild = 0 | ||
73 | bestOffer = offer | ||
74 | } | ||
75 | } | ||
76 | } | ||
77 | } | ||
78 | return bestOffer | ||
79 | } | ||
diff --git a/httputil/negotiate_test.go b/httputil/negotiate_test.go new file mode 100644 index 0000000..24bf4be --- /dev/null +++ b/httputil/negotiate_test.go | |||
@@ -0,0 +1,71 @@ | |||
1 | // Copyright 2013 The Go Authors. All rights reserved. | ||
2 | // | ||
3 | // Use of this source code is governed by a BSD-style | ||
4 | // license that can be found in the LICENSE file or at | ||
5 | // https://developers.google.com/open-source/licenses/bsd. | ||
6 | |||
7 | package httputil_test | ||
8 | |||
9 | import ( | ||
10 | "github.com/golang/gddo/httputil" | ||
11 | "net/http" | ||
12 | "testing" | ||
13 | ) | ||
14 | |||
15 | var negotiateContentEncodingTests = []struct { | ||
16 | s string | ||
17 | offers []string | ||
18 | expect string | ||
19 | }{ | ||
20 | {"", []string{"identity", "gzip"}, "identity"}, | ||
21 | {"*;q=0", []string{"identity", "gzip"}, ""}, | ||
22 | {"gzip", []string{"identity", "gzip"}, "gzip"}, | ||
23 | } | ||
24 | |||
25 | func TestNegotiateContentEnoding(t *testing.T) { | ||
26 | for _, tt := range negotiateContentEncodingTests { | ||
27 | r := &http.Request{Header: http.Header{"Accept-Encoding": {tt.s}}} | ||
28 | actual := httputil.NegotiateContentEncoding(r, tt.offers) | ||
29 | if actual != tt.expect { | ||
30 | t.Errorf("NegotiateContentEncoding(%q, %#v)=%q, want %q", tt.s, tt.offers, actual, tt.expect) | ||
31 | } | ||
32 | } | ||
33 | } | ||
34 | |||
35 | var negotiateContentTypeTests = []struct { | ||
36 | s string | ||
37 | offers []string | ||
38 | defaultOffer string | ||
39 | expect string | ||
40 | }{ | ||
41 | {"text/html, */*;q=0", []string{"x/y"}, "", ""}, | ||
42 | {"text/html, */*", []string{"x/y"}, "", "x/y"}, | ||
43 | {"text/html, image/png", []string{"text/html", "image/png"}, "", "text/html"}, | ||
44 | {"text/html, image/png", []string{"image/png", "text/html"}, "", "image/png"}, | ||
45 | {"text/html, image/png; q=0.5", []string{"image/png"}, "", "image/png"}, | ||
46 | {"text/html, image/png; q=0.5", []string{"text/html"}, "", "text/html"}, | ||
47 | {"text/html, image/png; q=0.5", []string{"foo/bar"}, "", ""}, | ||
48 | {"text/html, image/png; q=0.5", []string{"image/png", "text/html"}, "", "text/html"}, | ||
49 | {"text/html, image/png; q=0.5", []string{"text/html", "image/png"}, "", "text/html"}, | ||
50 | {"text/html;q=0.5, image/png", []string{"image/png"}, "", "image/png"}, | ||
51 | {"text/html;q=0.5, image/png", []string{"text/html"}, "", "text/html"}, | ||
52 | {"text/html;q=0.5, image/png", []string{"image/png", "text/html"}, "", "image/png"}, | ||
53 | {"text/html;q=0.5, image/png", []string{"text/html", "image/png"}, "", "image/png"}, | ||
54 | {"image/png, image/*;q=0.5", []string{"image/jpg", "image/png"}, "", "image/png"}, | ||
55 | {"image/png, image/*;q=0.5", []string{"image/jpg"}, "", "image/jpg"}, | ||
56 | {"image/png, image/*;q=0.5", []string{"image/jpg", "image/gif"}, "", "image/jpg"}, | ||
57 | {"image/png, image/*", []string{"image/jpg", "image/gif"}, "", "image/jpg"}, | ||
58 | {"image/png, image/*", []string{"image/gif", "image/jpg"}, "", "image/gif"}, | ||
59 | {"image/png, image/*", []string{"image/gif", "image/png"}, "", "image/png"}, | ||
60 | {"image/png, image/*", []string{"image/png", "image/gif"}, "", "image/png"}, | ||
61 | } | ||
62 | |||
63 | func TestNegotiateContentType(t *testing.T) { | ||
64 | for _, tt := range negotiateContentTypeTests { | ||
65 | r := &http.Request{Header: http.Header{"Accept": {tt.s}}} | ||
66 | actual := httputil.NegotiateContentType(r, tt.offers, tt.defaultOffer) | ||
67 | if actual != tt.expect { | ||
68 | t.Errorf("NegotiateContentType(%q, %#v, %q)=%q, want %q", tt.s, tt.offers, tt.defaultOffer, actual, tt.expect) | ||
69 | } | ||
70 | } | ||
71 | } | ||
diff --git a/httputil/respbuf.go b/httputil/respbuf.go new file mode 100644 index 0000000..051af21 --- /dev/null +++ b/httputil/respbuf.go | |||
@@ -0,0 +1,58 @@ | |||
1 | // Copyright 2013 The Go Authors. All rights reserved. | ||
2 | // | ||
3 | // Use of this source code is governed by a BSD-style | ||
4 | // license that can be found in the LICENSE file or at | ||
5 | // https://developers.google.com/open-source/licenses/bsd. | ||
6 | |||
7 | package httputil | ||
8 | |||
9 | import ( | ||
10 | "bytes" | ||
11 | "net/http" | ||
12 | "strconv" | ||
13 | ) | ||
14 | |||
15 | // ResponseBuffer is the current response being composed by its owner. | ||
16 | // It implements http.ResponseWriter and io.WriterTo. | ||
17 | type ResponseBuffer struct { | ||
18 | buf bytes.Buffer | ||
19 | status int | ||
20 | header http.Header | ||
21 | } | ||
22 | |||
23 | // Write implements the http.ResponseWriter interface. | ||
24 | func (rb *ResponseBuffer) Write(p []byte) (int, error) { | ||
25 | return rb.buf.Write(p) | ||
26 | } | ||
27 | |||
28 | // WriteHeader implements the http.ResponseWriter interface. | ||
29 | func (rb *ResponseBuffer) WriteHeader(status int) { | ||
30 | rb.status = status | ||
31 | } | ||
32 | |||
33 | // Header implements the http.ResponseWriter interface. | ||
34 | func (rb *ResponseBuffer) Header() http.Header { | ||
35 | if rb.header == nil { | ||
36 | rb.header = make(http.Header) | ||
37 | } | ||
38 | return rb.header | ||
39 | } | ||
40 | |||
41 | // WriteTo implements the io.WriterTo interface. | ||
42 | func (rb *ResponseBuffer) WriteTo(w http.ResponseWriter) error { | ||
43 | for k, v := range rb.header { | ||
44 | w.Header()[k] = v | ||
45 | } | ||
46 | if rb.buf.Len() > 0 { | ||
47 | w.Header().Set("Content-Length", strconv.Itoa(rb.buf.Len())) | ||
48 | } | ||
49 | if rb.status != 0 { | ||
50 | w.WriteHeader(rb.status) | ||
51 | } | ||
52 | if rb.buf.Len() > 0 { | ||
53 | if _, err := w.Write(rb.buf.Bytes()); err != nil { | ||
54 | return err | ||
55 | } | ||
56 | } | ||
57 | return nil | ||
58 | } | ||
diff --git a/httputil/static.go b/httputil/static.go new file mode 100644 index 0000000..6610dde --- /dev/null +++ b/httputil/static.go | |||
@@ -0,0 +1,265 @@ | |||
1 | // Copyright 2013 The Go Authors. All rights reserved. | ||
2 | // | ||
3 | // Use of this source code is governed by a BSD-style | ||
4 | // license that can be found in the LICENSE file or at | ||
5 | // https://developers.google.com/open-source/licenses/bsd. | ||
6 | |||
7 | package httputil | ||
8 | |||
9 | import ( | ||
10 | "bytes" | ||
11 | "crypto/sha1" | ||
12 | "errors" | ||
13 | "fmt" | ||
14 | "github.com/golang/gddo/httputil/header" | ||
15 | "io" | ||
16 | "io/ioutil" | ||
17 | "mime" | ||
18 | "net/http" | ||
19 | "os" | ||
20 | "path" | ||
21 | "path/filepath" | ||
22 | "strconv" | ||
23 | "strings" | ||
24 | "sync" | ||
25 | "time" | ||
26 | ) | ||
27 | |||
28 | // StaticServer serves static files. | ||
29 | type StaticServer struct { | ||
30 | // Dir specifies the location of the directory containing the files to serve. | ||
31 | Dir string | ||
32 | |||
33 | // MaxAge specifies the maximum age for the cache control and expiration | ||
34 | // headers. | ||
35 | MaxAge time.Duration | ||
36 | |||
37 | // Error specifies the function used to generate error responses. If Error | ||
38 | // is nil, then http.Error is used to generate error responses. | ||
39 | Error Error | ||
40 | |||
41 | // MIMETypes is a map from file extensions to MIME types. | ||
42 | MIMETypes map[string]string | ||
43 | |||
44 | mu sync.Mutex | ||
45 | etags map[string]string | ||
46 | } | ||
47 | |||
48 | func (ss *StaticServer) resolve(fname string) string { | ||
49 | if path.IsAbs(fname) { | ||
50 | panic("Absolute path not allowed when creating a StaticServer handler") | ||
51 | } | ||
52 | dir := ss.Dir | ||
53 | if dir == "" { | ||
54 | dir = "." | ||
55 | } | ||
56 | fname = filepath.FromSlash(fname) | ||
57 | return filepath.Join(dir, fname) | ||
58 | } | ||
59 | |||
60 | func (ss *StaticServer) mimeType(fname string) string { | ||
61 | ext := path.Ext(fname) | ||
62 | var mimeType string | ||
63 | if ss.MIMETypes != nil { | ||
64 | mimeType = ss.MIMETypes[ext] | ||
65 | } | ||
66 | if mimeType == "" { | ||
67 | mimeType = mime.TypeByExtension(ext) | ||
68 | } | ||
69 | if mimeType == "" { | ||
70 | mimeType = "application/octet-stream" | ||
71 | } | ||
72 | return mimeType | ||
73 | } | ||
74 | |||
75 | func (ss *StaticServer) openFile(fname string) (io.ReadCloser, int64, string, error) { | ||
76 | f, err := os.Open(fname) | ||
77 | if err != nil { | ||
78 | return nil, 0, "", err | ||
79 | } | ||
80 | fi, err := f.Stat() | ||
81 | if err != nil { | ||
82 | f.Close() | ||
83 | return nil, 0, "", err | ||
84 | } | ||
85 | const modeType = os.ModeDir | os.ModeSymlink | os.ModeNamedPipe | os.ModeSocket | os.ModeDevice | ||
86 | if fi.Mode()&modeType != 0 { | ||
87 | f.Close() | ||
88 | return nil, 0, "", errors.New("not a regular file") | ||
89 | } | ||
90 | return f, fi.Size(), ss.mimeType(fname), nil | ||
91 | } | ||
92 | |||
93 | // FileHandler returns a handler that serves a single file. The file is | ||
94 | // specified by a slash separated path relative to the static server's Dir | ||
95 | // field. | ||
96 | func (ss *StaticServer) FileHandler(fileName string) http.Handler { | ||
97 | id := fileName | ||
98 | fileName = ss.resolve(fileName) | ||
99 | return &staticHandler{ | ||
100 | ss: ss, | ||
101 | id: func(_ string) string { return id }, | ||
102 | open: func(_ string) (io.ReadCloser, int64, string, error) { return ss.openFile(fileName) }, | ||
103 | } | ||
104 | } | ||
105 | |||
106 | // DirectoryHandler returns a handler that serves files from a directory tree. | ||
107 | // The directory is specified by a slash separated path relative to the static | ||
108 | // server's Dir field. | ||
109 | func (ss *StaticServer) DirectoryHandler(prefix, dirName string) http.Handler { | ||
110 | if !strings.HasSuffix(prefix, "/") { | ||
111 | prefix += "/" | ||
112 | } | ||
113 | idBase := dirName | ||
114 | dirName = ss.resolve(dirName) | ||
115 | return &staticHandler{ | ||
116 | ss: ss, | ||
117 | id: func(p string) string { | ||
118 | if !strings.HasPrefix(p, prefix) { | ||
119 | return "." | ||
120 | } | ||
121 | return path.Join(idBase, p[len(prefix):]) | ||
122 | }, | ||
123 | open: func(p string) (io.ReadCloser, int64, string, error) { | ||
124 | if !strings.HasPrefix(p, prefix) { | ||
125 | return nil, 0, "", errors.New("request url does not match directory prefix") | ||
126 | } | ||
127 | p = p[len(prefix):] | ||
128 | return ss.openFile(filepath.Join(dirName, filepath.FromSlash(p))) | ||
129 | }, | ||
130 | } | ||
131 | } | ||
132 | |||
133 | // FilesHandler returns a handler that serves the concatentation of the | ||
134 | // specified files. The files are specified by slash separated paths relative | ||
135 | // to the static server's Dir field. | ||
136 | func (ss *StaticServer) FilesHandler(fileNames ...string) http.Handler { | ||
137 | |||
138 | // todo: cache concatenated files on disk and serve from there. | ||
139 | |||
140 | mimeType := ss.mimeType(fileNames[0]) | ||
141 | var buf []byte | ||
142 | var openErr error | ||
143 | |||
144 | for _, fileName := range fileNames { | ||
145 | p, err := ioutil.ReadFile(ss.resolve(fileName)) | ||
146 | if err != nil { | ||
147 | openErr = err | ||
148 | buf = nil | ||
149 | break | ||
150 | } | ||
151 | buf = append(buf, p...) | ||
152 | } | ||
153 | |||
154 | id := strings.Join(fileNames, " ") | ||
155 | |||
156 | return &staticHandler{ | ||
157 | ss: ss, | ||
158 | id: func(_ string) string { return id }, | ||
159 | open: func(p string) (io.ReadCloser, int64, string, error) { | ||
160 | return ioutil.NopCloser(bytes.NewReader(buf)), int64(len(buf)), mimeType, openErr | ||
161 | }, | ||
162 | } | ||
163 | } | ||
164 | |||
165 | type staticHandler struct { | ||
166 | id func(fname string) string | ||
167 | open func(p string) (io.ReadCloser, int64, string, error) | ||
168 | ss *StaticServer | ||
169 | } | ||
170 | |||
171 | func (h *staticHandler) error(w http.ResponseWriter, r *http.Request, status int, err error) { | ||
172 | http.Error(w, http.StatusText(status), status) | ||
173 | } | ||
174 | |||
175 | func (h *staticHandler) etag(p string) (string, error) { | ||
176 | id := h.id(p) | ||
177 | |||
178 | h.ss.mu.Lock() | ||
179 | if h.ss.etags == nil { | ||
180 | h.ss.etags = make(map[string]string) | ||
181 | } | ||
182 | etag := h.ss.etags[id] | ||
183 | h.ss.mu.Unlock() | ||
184 | |||
185 | if etag != "" { | ||
186 | return etag, nil | ||
187 | } | ||
188 | |||
189 | // todo: if a concurrent goroutine is calculating the hash, then wait for | ||
190 | // it instead of computing it again here. | ||
191 | |||
192 | rc, _, _, err := h.open(p) | ||
193 | if err != nil { | ||
194 | return "", err | ||
195 | } | ||
196 | |||
197 | defer rc.Close() | ||
198 | |||
199 | w := sha1.New() | ||
200 | _, err = io.Copy(w, rc) | ||
201 | if err != nil { | ||
202 | return "", err | ||
203 | } | ||
204 | |||
205 | etag = fmt.Sprintf(`"%x"`, w.Sum(nil)) | ||
206 | |||
207 | h.ss.mu.Lock() | ||
208 | h.ss.etags[id] = etag | ||
209 | h.ss.mu.Unlock() | ||
210 | |||
211 | return etag, nil | ||
212 | } | ||
213 | |||
214 | func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
215 | p := path.Clean(r.URL.Path) | ||
216 | if p != r.URL.Path { | ||
217 | http.Redirect(w, r, p, 301) | ||
218 | return | ||
219 | } | ||
220 | |||
221 | etag, err := h.etag(p) | ||
222 | if err != nil { | ||
223 | h.error(w, r, http.StatusNotFound, err) | ||
224 | return | ||
225 | } | ||
226 | |||
227 | maxAge := h.ss.MaxAge | ||
228 | if maxAge == 0 { | ||
229 | maxAge = 24 * time.Hour | ||
230 | } | ||
231 | if r.FormValue("v") != "" { | ||
232 | maxAge = 365 * 24 * time.Hour | ||
233 | } | ||
234 | |||
235 | cacheControl := fmt.Sprintf("public, max-age=%d", maxAge/time.Second) | ||
236 | |||
237 | for _, e := range header.ParseList(r.Header, "If-None-Match") { | ||
238 | if e == etag { | ||
239 | w.Header().Set("Cache-Control", cacheControl) | ||
240 | w.Header().Set("Etag", etag) | ||
241 | w.WriteHeader(http.StatusNotModified) | ||
242 | return | ||
243 | } | ||
244 | } | ||
245 | |||
246 | rc, cl, ct, err := h.open(p) | ||
247 | if err != nil { | ||
248 | h.error(w, r, http.StatusNotFound, err) | ||
249 | return | ||
250 | } | ||
251 | defer rc.Close() | ||
252 | |||
253 | w.Header().Set("Cache-Control", cacheControl) | ||
254 | w.Header().Set("Etag", etag) | ||
255 | if ct != "" { | ||
256 | w.Header().Set("Content-Type", ct) | ||
257 | } | ||
258 | if cl != 0 { | ||
259 | w.Header().Set("Content-Length", strconv.FormatInt(cl, 10)) | ||
260 | } | ||
261 | w.WriteHeader(http.StatusOK) | ||
262 | if r.Method != "HEAD" { | ||
263 | io.Copy(w, rc) | ||
264 | } | ||
265 | } | ||
diff --git a/httputil/static_test.go b/httputil/static_test.go new file mode 100644 index 0000000..a258d52 --- /dev/null +++ b/httputil/static_test.go | |||
@@ -0,0 +1,175 @@ | |||
1 | // Copyright 2013 The Go Authors. All rights reserved. | ||
2 | // | ||
3 | // Use of this source code is governed by a BSD-style | ||
4 | // license that can be found in the LICENSE file or at | ||
5 | // https://developers.google.com/open-source/licenses/bsd. | ||
6 | |||
7 | package httputil_test | ||
8 | |||
9 | import ( | ||
10 | "crypto/sha1" | ||
11 | "encoding/hex" | ||
12 | "io/ioutil" | ||
13 | "net/http" | ||
14 | "net/http/httptest" | ||
15 | "net/url" | ||
16 | "os" | ||
17 | "strconv" | ||
18 | "testing" | ||
19 | "time" | ||
20 | |||
21 | "github.com/golang/gddo/httputil" | ||
22 | "github.com/google/go-cmp/cmp" | ||
23 | ) | ||
24 | |||
25 | var ( | ||
26 | testHash = computeTestHash() | ||
27 | testEtag = `"` + testHash + `"` | ||
28 | testContentLength = computeTestContentLength() | ||
29 | ) | ||
30 | |||
31 | func mustParseURL(urlStr string) *url.URL { | ||
32 | u, err := url.Parse(urlStr) | ||
33 | if err != nil { | ||
34 | panic(err) | ||
35 | } | ||
36 | return u | ||
37 | } | ||
38 | |||
39 | func computeTestHash() string { | ||
40 | p, err := ioutil.ReadFile("static_test.go") | ||
41 | if err != nil { | ||
42 | panic(err) | ||
43 | } | ||
44 | w := sha1.New() | ||
45 | w.Write(p) | ||
46 | return hex.EncodeToString(w.Sum(nil)) | ||
47 | } | ||
48 | |||
49 | func computeTestContentLength() string { | ||
50 | info, err := os.Stat("static_test.go") | ||
51 | if err != nil { | ||
52 | panic(err) | ||
53 | } | ||
54 | return strconv.FormatInt(info.Size(), 10) | ||
55 | } | ||
56 | |||
57 | var fileServerTests = []*struct { | ||
58 | name string // test name for log | ||
59 | ss *httputil.StaticServer | ||
60 | r *http.Request | ||
61 | header http.Header // expected response headers | ||
62 | status int // expected response status | ||
63 | empty bool // true if response body not expected. | ||
64 | }{ | ||
65 | { | ||
66 | name: "get", | ||
67 | ss: &httputil.StaticServer{MaxAge: 3 * time.Second}, | ||
68 | r: &http.Request{ | ||
69 | URL: mustParseURL("/dir/static_test.go"), | ||
70 | Method: "GET", | ||
71 | }, | ||
72 | status: http.StatusOK, | ||
73 | header: http.Header{ | ||
74 | "Etag": {testEtag}, | ||
75 | "Cache-Control": {"public, max-age=3"}, | ||
76 | "Content-Length": {testContentLength}, | ||
77 | "Content-Type": {"application/octet-stream"}, | ||
78 | }, | ||
79 | }, | ||
80 | { | ||
81 | name: "get .", | ||
82 | ss: &httputil.StaticServer{Dir: ".", MaxAge: 3 * time.Second}, | ||
83 | r: &http.Request{ | ||
84 | URL: mustParseURL("/dir/static_test.go"), | ||
85 | Method: "GET", | ||
86 | }, | ||
87 | status: http.StatusOK, | ||
88 | header: http.Header{ | ||
89 | "Etag": {testEtag}, | ||
90 | "Cache-Control": {"public, max-age=3"}, | ||
91 | "Content-Length": {testContentLength}, | ||
92 | "Content-Type": {"application/octet-stream"}, | ||
93 | }, | ||
94 | }, | ||
95 | { | ||
96 | name: "get with ?v=", | ||
97 | ss: &httputil.StaticServer{MaxAge: 3 * time.Second}, | ||
98 | r: &http.Request{ | ||
99 | URL: mustParseURL("/dir/static_test.go?v=xxxxx"), | ||
100 | Method: "GET", | ||
101 | }, | ||
102 | status: http.StatusOK, | ||
103 | header: http.Header{ | ||
104 | "Etag": {testEtag}, | ||
105 | "Cache-Control": {"public, max-age=31536000"}, | ||
106 | "Content-Length": {testContentLength}, | ||
107 | "Content-Type": {"application/octet-stream"}, | ||
108 | }, | ||
109 | }, | ||
110 | { | ||
111 | name: "head", | ||
112 | ss: &httputil.StaticServer{MaxAge: 3 * time.Second}, | ||
113 | r: &http.Request{ | ||
114 | URL: mustParseURL("/dir/static_test.go"), | ||
115 | Method: "HEAD", | ||
116 | }, | ||
117 | status: http.StatusOK, | ||
118 | header: http.Header{ | ||
119 | "Etag": {testEtag}, | ||
120 | "Cache-Control": {"public, max-age=3"}, | ||
121 | "Content-Length": {testContentLength}, | ||
122 | "Content-Type": {"application/octet-stream"}, | ||
123 | }, | ||
124 | empty: true, | ||
125 | }, | ||
126 | { | ||
127 | name: "if-none-match", | ||
128 | ss: &httputil.StaticServer{MaxAge: 3 * time.Second}, | ||
129 | r: &http.Request{ | ||
130 | URL: mustParseURL("/dir/static_test.go"), | ||
131 | Method: "GET", | ||
132 | Header: http.Header{"If-None-Match": {testEtag}}, | ||
133 | }, | ||
134 | status: http.StatusNotModified, | ||
135 | header: http.Header{ | ||
136 | "Cache-Control": {"public, max-age=3"}, | ||
137 | "Etag": {testEtag}, | ||
138 | }, | ||
139 | empty: true, | ||
140 | }, | ||
141 | } | ||
142 | |||
143 | func testStaticServer(t *testing.T, f func(*httputil.StaticServer) http.Handler) { | ||
144 | for _, tt := range fileServerTests { | ||
145 | w := httptest.NewRecorder() | ||
146 | |||
147 | h := f(tt.ss) | ||
148 | h.ServeHTTP(w, tt.r) | ||
149 | |||
150 | if w.Code != tt.status { | ||
151 | t.Errorf("%s, status=%d, want %d", tt.name, w.Code, tt.status) | ||
152 | } | ||
153 | |||
154 | if !cmp.Equal(w.HeaderMap, tt.header) { | ||
155 | t.Errorf("%s\n\theader=%v,\n\twant %v", tt.name, w.HeaderMap, tt.header) | ||
156 | } | ||
157 | |||
158 | empty := w.Body.Len() == 0 | ||
159 | if empty != tt.empty { | ||
160 | t.Errorf("%s empty=%v, want %v", tt.name, empty, tt.empty) | ||
161 | } | ||
162 | } | ||
163 | } | ||
164 | |||
165 | func TestFileHandler(t *testing.T) { | ||
166 | testStaticServer(t, func(ss *httputil.StaticServer) http.Handler { return ss.FileHandler("static_test.go") }) | ||
167 | } | ||
168 | |||
169 | func TestDirectoryHandler(t *testing.T) { | ||
170 | testStaticServer(t, func(ss *httputil.StaticServer) http.Handler { return ss.DirectoryHandler("/dir", ".") }) | ||
171 | } | ||
172 | |||
173 | func TestFilesHandler(t *testing.T) { | ||
174 | testStaticServer(t, func(ss *httputil.StaticServer) http.Handler { return ss.FilesHandler("static_test.go") }) | ||
175 | } | ||
diff --git a/httputil/transport.go b/httputil/transport.go new file mode 100644 index 0000000..fdad3b4 --- /dev/null +++ b/httputil/transport.go | |||
@@ -0,0 +1,87 @@ | |||
1 | // Copyright 2015 The Go Authors. All rights reserved. | ||
2 | // | ||
3 | // Use of this source code is governed by a BSD-style | ||
4 | // license that can be found in the LICENSE file or at | ||
5 | // https://developers.google.com/open-source/licenses/bsd. | ||
6 | |||
7 | // This file implements a http.RoundTripper that authenticates | ||
8 | // requests issued against api.github.com endpoint. | ||
9 | |||
10 | package httputil | ||
11 | |||
12 | import ( | ||
13 | "net/http" | ||
14 | "net/url" | ||
15 | ) | ||
16 | |||
17 | // AuthTransport is an implementation of http.RoundTripper that authenticates | ||
18 | // with the GitHub API. | ||
19 | // | ||
20 | // When both a token and client credentials are set, the latter is preferred. | ||
21 | type AuthTransport struct { | ||
22 | UserAgent string | ||
23 | GithubToken string | ||
24 | GithubClientID string | ||
25 | GithubClientSecret string | ||
26 | Base http.RoundTripper | ||
27 | } | ||
28 | |||
29 | // RoundTrip implements the http.RoundTripper interface. | ||
30 | func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { | ||
31 | var reqCopy *http.Request | ||
32 | if t.UserAgent != "" { | ||
33 | reqCopy = copyRequest(req) | ||
34 | reqCopy.Header.Set("User-Agent", t.UserAgent) | ||
35 | } | ||
36 | if req.URL.Host == "api.github.com" && req.URL.Scheme == "https" { | ||
37 | switch { | ||
38 | case t.GithubClientID != "" && t.GithubClientSecret != "": | ||
39 | if reqCopy == nil { | ||
40 | reqCopy = copyRequest(req) | ||
41 | } | ||
42 | if reqCopy.URL.RawQuery == "" { | ||
43 | reqCopy.URL.RawQuery = "client_id=" + t.GithubClientID + "&client_secret=" + t.GithubClientSecret | ||
44 | } else { | ||
45 | reqCopy.URL.RawQuery += "&client_id=" + t.GithubClientID + "&client_secret=" + t.GithubClientSecret | ||
46 | } | ||
47 | case t.GithubToken != "": | ||
48 | if reqCopy == nil { | ||
49 | reqCopy = copyRequest(req) | ||
50 | } | ||
51 | reqCopy.Header.Set("Authorization", "token "+t.GithubToken) | ||
52 | } | ||
53 | } | ||
54 | if reqCopy != nil { | ||
55 | return t.base().RoundTrip(reqCopy) | ||
56 | } | ||
57 | return t.base().RoundTrip(req) | ||
58 | } | ||
59 | |||
60 | // CancelRequest cancels an in-flight request by closing its connection. | ||
61 | func (t *AuthTransport) CancelRequest(req *http.Request) { | ||
62 | type canceler interface { | ||
63 | CancelRequest(req *http.Request) | ||
64 | } | ||
65 | if cr, ok := t.base().(canceler); ok { | ||
66 | cr.CancelRequest(req) | ||
67 | } | ||
68 | } | ||
69 | |||
70 | func (t *AuthTransport) base() http.RoundTripper { | ||
71 | if t.Base != nil { | ||
72 | return t.Base | ||
73 | } | ||
74 | return http.DefaultTransport | ||
75 | } | ||
76 | |||
77 | func copyRequest(req *http.Request) *http.Request { | ||
78 | req2 := new(http.Request) | ||
79 | *req2 = *req | ||
80 | req2.URL = new(url.URL) | ||
81 | *req2.URL = *req.URL | ||
82 | req2.Header = make(http.Header, len(req.Header)) | ||
83 | for k, s := range req.Header { | ||
84 | req2.Header[k] = append([]string(nil), s...) | ||
85 | } | ||
86 | return req2 | ||
87 | } | ||
diff --git a/httputil/transport_test.go b/httputil/transport_test.go new file mode 100644 index 0000000..4b36118 --- /dev/null +++ b/httputil/transport_test.go | |||
@@ -0,0 +1,126 @@ | |||
1 | // Copyright 2017 The Go Authors. All rights reserved. | ||
2 | // | ||
3 | // Use of this source code is governed by a BSD-style | ||
4 | // license that can be found in the LICENSE file or at | ||
5 | // https://developers.google.com/open-source/licenses/bsd. | ||
6 | |||
7 | package httputil | ||
8 | |||
9 | import ( | ||
10 | "bytes" | ||
11 | "io/ioutil" | ||
12 | "net/http" | ||
13 | "net/url" | ||
14 | "testing" | ||
15 | ) | ||
16 | |||
17 | func TestTransportGithubAuth(t *testing.T) { | ||
18 | tests := []struct { | ||
19 | name string | ||
20 | |||
21 | url string | ||
22 | token string | ||
23 | clientID string | ||
24 | clientSecret string | ||
25 | |||
26 | queryClientID string | ||
27 | queryClientSecret string | ||
28 | authorization string | ||
29 | }{ | ||
30 | { | ||
31 | name: "Github token", | ||
32 | url: "https://api.github.com/", | ||
33 | token: "xyzzy", | ||
34 | authorization: "token xyzzy", | ||
35 | }, | ||
36 | { | ||
37 | name: "Github client ID/secret", | ||
38 | url: "https://api.github.com/", | ||
39 | clientID: "12345", | ||
40 | clientSecret: "xyzzy", | ||
41 | queryClientID: "12345", | ||
42 | queryClientSecret: "xyzzy", | ||
43 | }, | ||
44 | { | ||
45 | name: "non-Github site does not have token headers", | ||
46 | url: "http://www.example.com/", | ||
47 | token: "xyzzy", | ||
48 | }, | ||
49 | { | ||
50 | name: "non-Github site does not have client ID/secret headers", | ||
51 | url: "http://www.example.com/", | ||
52 | clientID: "12345", | ||
53 | clientSecret: "xyzzy", | ||
54 | }, | ||
55 | { | ||
56 | name: "Github token not sent over HTTP", | ||
57 | url: "http://api.github.com/", | ||
58 | token: "xyzzy", | ||
59 | }, | ||
60 | { | ||
61 | name: "Github client ID/secret not sent over HTTP", | ||
62 | url: "http://api.github.com/", | ||
63 | clientID: "12345", | ||
64 | clientSecret: "xyzzy", | ||
65 | }, | ||
66 | { | ||
67 | name: "Github token not sent over schemeless", | ||
68 | url: "//api.github.com/", | ||
69 | token: "xyzzy", | ||
70 | }, | ||
71 | { | ||
72 | name: "Github client ID/secret not sent over schemeless", | ||
73 | url: "//api.github.com/", | ||
74 | clientID: "12345", | ||
75 | clientSecret: "xyzzy", | ||
76 | }, | ||
77 | } | ||
78 | for _, test := range tests { | ||
79 | t.Run(test.name, func(t *testing.T) { | ||
80 | var ( | ||
81 | query url.Values | ||
82 | authHeader string | ||
83 | ) | ||
84 | client := &http.Client{ | ||
85 | Transport: &AuthTransport{ | ||
86 | Base: roundTripFunc(func(r *http.Request) { | ||
87 | query = r.URL.Query() | ||
88 | authHeader = r.Header.Get("Authorization") | ||
89 | }), | ||
90 | GithubToken: test.token, | ||
91 | GithubClientID: test.clientID, | ||
92 | GithubClientSecret: test.clientSecret, | ||
93 | }, | ||
94 | } | ||
95 | _, err := client.Get(test.url) | ||
96 | if err != nil { | ||
97 | t.Fatal(err) | ||
98 | } | ||
99 | if got := query.Get("client_id"); got != test.queryClientID { | ||
100 | t.Errorf("url query client_id = %q; want %q", got, test.queryClientID) | ||
101 | } | ||
102 | if got := query.Get("client_secret"); got != test.queryClientSecret { | ||
103 | t.Errorf("url query client_secret = %q; want %q", got, test.queryClientSecret) | ||
104 | } | ||
105 | if authHeader != test.authorization { | ||
106 | t.Errorf("header Authorization = %q; want %q", authHeader, test.authorization) | ||
107 | } | ||
108 | }) | ||
109 | } | ||
110 | } | ||
111 | |||
112 | type roundTripFunc func(r *http.Request) | ||
113 | |||
114 | func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { | ||
115 | f(r) | ||
116 | return &http.Response{ | ||
117 | Status: "200 OK", | ||
118 | StatusCode: http.StatusOK, | ||
119 | Proto: "HTTP/1.1", | ||
120 | ProtoMajor: 1, | ||
121 | ProtoMinor: 1, | ||
122 | Body: ioutil.NopCloser(bytes.NewReader(nil)), | ||
123 | ContentLength: 0, | ||
124 | Request: r, | ||
125 | }, nil | ||
126 | } | ||