Skip to content

Commit c86dfa0

Browse files
authored
feat: support serving under a path prefix (#120)
1 parent b63a792 commit c86dfa0

File tree

13 files changed

+668
-487
lines changed

13 files changed

+668
-487
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
*.prof
55
dist/*
66
coverage.txt
7+
/cmd/go-httpbin/go-httpbin

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ variables (or a combination of the two):
9090
| `-max-body-size` | `MAX_BODY_SIZE` | Maximum size of request or response, in bytes | 1048576 |
9191
| `-max-duration` | `MAX_DURATION` | Maximum duration a response may take | 10s |
9292
| `-port` | `PORT` | Port to listen on | 8080 |
93+
| `-prefix` | `PREFIX` | Prefix of path to listen on (must start with slash and does not end with slash) | |
9394
| `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false |
9495
| `-exclude-headers` | `EXCLUDE_HEADERS` | Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard suffix matching. For example: `"foo,bar,x-fc-*"` | - |
9596

httpbin/cmd/cmd.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ func mainImpl(args []string, getEnv func(string) string, getHostname func() (str
7373
httpbin.WithObserver(httpbin.StdLogObserver(logger)),
7474
httpbin.WithExcludeHeaders(cfg.ExcludeHeaders),
7575
}
76+
if cfg.Prefix != "" {
77+
opts = append(opts, httpbin.WithPrefix(cfg.Prefix))
78+
}
7679
if cfg.RealHostname != "" {
7780
opts = append(opts, httpbin.WithHostname(cfg.RealHostname))
7881
}
@@ -106,6 +109,7 @@ type config struct {
106109
ListenPort int
107110
MaxBodySize int64
108111
MaxDuration time.Duration
112+
Prefix string
109113
RealHostname string
110114
TLSCertFile string
111115
TLSKeyFile string
@@ -142,6 +146,7 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
142146
fs.IntVar(&cfg.ListenPort, "port", defaultListenPort, "Port to listen on")
143147
fs.StringVar(&cfg.rawAllowedRedirectDomains, "allowed-redirect-domains", "", "Comma-separated list of domains the /redirect-to endpoint will allow")
144148
fs.StringVar(&cfg.ListenHost, "host", defaultListenHost, "Host to listen on")
149+
fs.StringVar(&cfg.Prefix, "prefix", "", "Path prefix (empty or start with slash and does not end with slash)")
145150
fs.StringVar(&cfg.TLSCertFile, "https-cert-file", "", "HTTPS Server certificate file")
146151
fs.StringVar(&cfg.TLSKeyFile, "https-key-file", "", "HTTPS Server private key file")
147152
fs.StringVar(&cfg.ExcludeHeaders, "exclude-headers", "", "Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard matching.")
@@ -194,6 +199,19 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
194199
if cfg.ListenHost == defaultListenHost && getEnv("HOST") != "" {
195200
cfg.ListenHost = getEnv("HOST")
196201
}
202+
if cfg.Prefix == "" {
203+
if prefix := getEnv("PREFIX"); prefix != "" {
204+
cfg.Prefix = prefix
205+
}
206+
}
207+
if cfg.Prefix != "" {
208+
if !strings.HasPrefix(cfg.Prefix, "/") {
209+
return nil, configErr("Prefix %#v must start with a slash", cfg.Prefix)
210+
}
211+
if strings.HasSuffix(cfg.Prefix, "/") {
212+
return nil, configErr("Prefix %#v must not end with a slash", cfg.Prefix)
213+
}
214+
}
197215
if cfg.ExcludeHeaders == "" && getEnv("EXCLUDE_HEADERS") != "" {
198216
cfg.ExcludeHeaders = getEnv("EXCLUDE_HEADERS")
199217
}

httpbin/cmd/cmd_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import (
1313
)
1414

1515
// To update, run:
16+
// OSX:
1617
// make && ./dist/go-httpbin -h 2>&1 | pbcopy
18+
// Linux (paste with middle mouse):
19+
// make && ./dist/go-httpbin -h 2>&1 | xclip
1720
const usage = `Usage of go-httpbin:
1821
-allowed-redirect-domains string
1922
Comma-separated list of domains the /redirect-to endpoint will allow
@@ -31,6 +34,8 @@ const usage = `Usage of go-httpbin:
3134
Maximum duration a response may take (default 10s)
3235
-port int
3336
Port to listen on (default 8080)
37+
-prefix string
38+
Path prefix (empty or start with slash and does not end with slash)
3439
-use-real-hostname
3540
Expose value of os.Hostname() in the /hostname endpoint instead of dummy value
3641
`
@@ -212,6 +217,37 @@ func TestLoadConfig(t *testing.T) {
212217
},
213218
},
214219

220+
// prefix
221+
"invalid -prefix (does not start with slash)": {
222+
args: []string{"-prefix", "invalidprefix1"},
223+
wantErr: errors.New("Prefix \"invalidprefix1\" must start with a slash"),
224+
},
225+
"invalid -prefix (ends with with slash)": {
226+
args: []string{"-prefix", "/invalidprefix2/"},
227+
wantErr: errors.New("Prefix \"/invalidprefix2/\" must not end with a slash"),
228+
},
229+
"ok -prefix takes precedence over env": {
230+
args: []string{"-prefix", "/prefix1"},
231+
env: map[string]string{"PREFIX": "/prefix2"},
232+
wantCfg: &config{
233+
ListenHost: defaultListenHost,
234+
ListenPort: defaultListenPort,
235+
Prefix: "/prefix1",
236+
MaxBodySize: httpbin.DefaultMaxBodySize,
237+
MaxDuration: httpbin.DefaultMaxDuration,
238+
},
239+
},
240+
"ok PREFIX": {
241+
env: map[string]string{"PREFIX": "/prefix2"},
242+
wantCfg: &config{
243+
ListenHost: defaultListenHost,
244+
ListenPort: defaultListenPort,
245+
Prefix: "/prefix2",
246+
MaxBodySize: httpbin.DefaultMaxBodySize,
247+
MaxDuration: httpbin.DefaultMaxDuration,
248+
},
249+
},
250+
215251
// https cert file
216252
"https cert and key must both be provided, cert only": {
217253
args: []string{"-https-cert-file", "/tmp/test.crt"},

httpbin/handlers.go

Lines changed: 43 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ func (h *HTTPBin) Index(w http.ResponseWriter, r *http.Request) {
3131
return
3232
}
3333
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' camo.githubusercontent.com")
34-
writeHTML(w, mustStaticAsset("index.html"), http.StatusOK)
34+
writeHTML(w, h.indexHTML, http.StatusOK)
3535
}
3636

3737
// FormsPost renders an HTML form that submits a request to the /post endpoint
3838
func (h *HTTPBin) FormsPost(w http.ResponseWriter, _ *http.Request) {
39-
writeHTML(w, mustStaticAsset("forms-post.html"), http.StatusOK)
39+
writeHTML(w, h.formsPostHTML, http.StatusOK)
4040
}
4141

4242
// UTF8 renders an HTML encoding stress test
@@ -161,13 +161,13 @@ type statusCase struct {
161161
body []byte
162162
}
163163

164-
var (
165-
statusRedirectHeaders = &statusCase{
164+
func createSpecialCases(prefix string) map[int]*statusCase {
165+
statusRedirectHeaders := &statusCase{
166166
headers: map[string]string{
167-
"Location": "/redirect/1",
167+
"Location": prefix + "/redirect/1",
168168
},
169169
}
170-
statusNotAcceptableBody = []byte(`{
170+
statusNotAcceptableBody := []byte(`{
171171
"message": "Client did not request a supported media type",
172172
"accept": [
173173
"image/webp",
@@ -178,31 +178,31 @@ var (
178178
]
179179
}
180180
`)
181-
statusHTTP300body = []byte(`<!doctype html>
181+
statusHTTP300body := []byte(fmt.Sprintf(`<!doctype html>
182182
<head>
183183
<title>Multiple Choices</title>
184184
</head>
185185
<body>
186186
<ul>
187-
<li><a href="/image/jpeg">/image/jpeg</a></li>
188-
<li><a href="/image/png">/image/png</a></li>
189-
<li><a href="/image/svg">/image/svg</a></li>
187+
<li><a href="%[1]s/image/jpeg">/image/jpeg</a></li>
188+
<li><a href="%[1]s/image/png">/image/png</a></li>
189+
<li><a href="%[1]s/image/svg">/image/svg</a></li>
190190
</body>
191-
</html>`)
191+
</html>`, prefix))
192192

193-
statusHTTP308Body = []byte(`<!doctype html>
193+
statusHTTP308Body := []byte(fmt.Sprintf(`<!doctype html>
194194
<head>
195195
<title>Permanent Redirect</title>
196196
</head>
197-
<body>Permanently redirected to <a href="/image/jpeg">/image/jpeg</a>
197+
<body>Permanently redirected to <a href="%[1]s/image/jpeg">%[1]s/image/jpeg</a>
198198
</body>
199-
</html>`)
199+
</html>`, prefix))
200200

201-
statusSpecialCases = map[int]*statusCase{
201+
return map[int]*statusCase{
202202
300: {
203203
body: statusHTTP300body,
204204
headers: map[string]string{
205-
"Location": "/image/jpeg",
205+
"Location": prefix + "/image/jpeg",
206206
},
207207
},
208208
301: statusRedirectHeaders,
@@ -213,7 +213,7 @@ var (
213213
308: {
214214
body: statusHTTP308Body,
215215
headers: map[string]string{
216-
"Location": "/image/jpeg",
216+
"Location": prefix + "/image/jpeg",
217217
},
218218
},
219219
401: {
@@ -245,7 +245,7 @@ var (
245245
},
246246
},
247247
}
248-
)
248+
}
249249

250250
// Status responds with the specified status code. TODO: support random choice
251251
// from multiple, optionally weighted status codes.
@@ -265,7 +265,7 @@ func (h *HTTPBin) Status(w http.ResponseWriter, r *http.Request) {
265265
// for special cases
266266
w.Header().Set("Content-Type", textContentType)
267267

268-
if specialCase, ok := statusSpecialCases[code]; ok {
268+
if specialCase, ok := h.statusSpecialCases[code]; ok {
269269
for key, val := range specialCase.headers {
270270
w.Header().Set(key, val)
271271
}
@@ -326,7 +326,7 @@ func (h *HTTPBin) ResponseHeaders(w http.ResponseWriter, r *http.Request) {
326326
mustMarshalJSON(w, args)
327327
}
328328

329-
func redirectLocation(r *http.Request, relative bool, n int) string {
329+
func (h *HTTPBin) redirectLocation(r *http.Request, relative bool, n int) string {
330330
var location string
331331
var path string
332332

@@ -350,7 +350,7 @@ func redirectLocation(r *http.Request, relative bool, n int) string {
350350
return location
351351
}
352352

353-
func doRedirect(w http.ResponseWriter, r *http.Request, relative bool) {
353+
func (h *HTTPBin) handleRedirect(w http.ResponseWriter, r *http.Request, relative bool) {
354354
parts := strings.Split(r.URL.Path, "/")
355355
if len(parts) != 3 {
356356
writeError(w, http.StatusNotFound, nil)
@@ -365,8 +365,7 @@ func doRedirect(w http.ResponseWriter, r *http.Request, relative bool) {
365365
return
366366
}
367367

368-
w.Header().Set("Location", redirectLocation(r, relative, n-1))
369-
w.WriteHeader(http.StatusFound)
368+
h.doRedirect(w, h.redirectLocation(r, relative, n-1), http.StatusFound)
370369
}
371370

372371
// Redirect responds with 302 redirect a given number of times. Defaults to a
@@ -375,17 +374,17 @@ func doRedirect(w http.ResponseWriter, r *http.Request, relative bool) {
375374
func (h *HTTPBin) Redirect(w http.ResponseWriter, r *http.Request) {
376375
params := r.URL.Query()
377376
relative := strings.ToLower(params.Get("absolute")) != "true"
378-
doRedirect(w, r, relative)
377+
h.handleRedirect(w, r, relative)
379378
}
380379

381380
// RelativeRedirect responds with an HTTP 302 redirect a given number of times
382381
func (h *HTTPBin) RelativeRedirect(w http.ResponseWriter, r *http.Request) {
383-
doRedirect(w, r, true)
382+
h.handleRedirect(w, r, true)
384383
}
385384

386385
// AbsoluteRedirect responds with an HTTP 302 redirect a given number of times
387386
func (h *HTTPBin) AbsoluteRedirect(w http.ResponseWriter, r *http.Request) {
388-
doRedirect(w, r, false)
387+
h.handleRedirect(w, r, false)
389388
}
390389

391390
// RedirectTo responds with a redirect to a specific URL with an optional
@@ -423,8 +422,7 @@ func (h *HTTPBin) RedirectTo(w http.ResponseWriter, r *http.Request) {
423422
}
424423
}
425424

426-
w.Header().Set("Location", u.String())
427-
w.WriteHeader(statusCode)
425+
h.doRedirect(w, u.String(), statusCode)
428426
}
429427

430428
// Cookies responds with the cookies in the incoming request
@@ -447,8 +445,7 @@ func (h *HTTPBin) SetCookies(w http.ResponseWriter, r *http.Request) {
447445
HttpOnly: true,
448446
})
449447
}
450-
w.Header().Set("Location", "/cookies")
451-
w.WriteHeader(http.StatusFound)
448+
h.doRedirect(w, "/cookies", http.StatusFound)
452449
}
453450

454451
// DeleteCookies deletes cookies specified in query params and redirects to
@@ -464,8 +461,7 @@ func (h *HTTPBin) DeleteCookies(w http.ResponseWriter, r *http.Request) {
464461
Expires: time.Now().Add(-1 * 24 * 365 * time.Hour),
465462
})
466463
}
467-
w.Header().Set("Location", "/cookies")
468-
w.WriteHeader(http.StatusFound)
464+
h.doRedirect(w, "/cookies", http.StatusFound)
469465
}
470466

471467
// BasicAuth requires basic authentication
@@ -916,18 +912,17 @@ func (h *HTTPBin) Links(w http.ResponseWriter, r *http.Request) {
916912
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid offset: %w", err))
917913
return
918914
}
919-
doLinksPage(w, r, n, offset)
915+
h.doLinksPage(w, r, n, offset)
920916
return
921917
}
922918

923919
// Otherwise, redirect from /links/<n> to /links/<n>/0
924920
r.URL.Path = r.URL.Path + "/0"
925-
w.Header().Set("Location", r.URL.String())
926-
w.WriteHeader(http.StatusFound)
921+
h.doRedirect(w, r.URL.String(), http.StatusFound)
927922
}
928923

929924
// doLinksPage renders a page with a series of N links
930-
func doLinksPage(w http.ResponseWriter, _ *http.Request, n int, offset int) {
925+
func (h *HTTPBin) doLinksPage(w http.ResponseWriter, _ *http.Request, n int, offset int) {
931926
w.Header().Add("Content-Type", htmlContentType)
932927
w.WriteHeader(http.StatusOK)
933928

@@ -936,12 +931,23 @@ func doLinksPage(w http.ResponseWriter, _ *http.Request, n int, offset int) {
936931
if i == offset {
937932
fmt.Fprintf(w, "%d ", i)
938933
} else {
939-
fmt.Fprintf(w, `<a href="/links/%d/%d">%d</a> `, n, i, i)
934+
fmt.Fprintf(w, `<a href="%s/links/%d/%d">%d</a> `, h.prefix, n, i, i)
940935
}
941936
}
942937
w.Write([]byte("</body></html>"))
943938
}
944939

940+
// doRedirect set redirect header
941+
func (h *HTTPBin) doRedirect(w http.ResponseWriter, path string, code int) {
942+
var sb strings.Builder
943+
if strings.HasPrefix(path, "/") {
944+
sb.WriteString(h.prefix)
945+
}
946+
sb.WriteString(path)
947+
w.Header().Set("Location", sb.String())
948+
w.WriteHeader(code)
949+
}
950+
945951
// ImageAccept responds with an appropriate image based on the Accept header
946952
func (h *HTTPBin) ImageAccept(w http.ResponseWriter, r *http.Request) {
947953
accept := r.Header.Get("Accept")

0 commit comments

Comments
 (0)