Skip to content

Commit

Permalink
feat: support serving under a path prefix (#120)
Browse files Browse the repository at this point in the history
  • Loading branch information
waschik authored Dec 12, 2023
1 parent b63a792 commit c86dfa0
Show file tree
Hide file tree
Showing 13 changed files with 668 additions and 487 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
*.prof
dist/*
coverage.txt
/cmd/go-httpbin/go-httpbin
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ variables (or a combination of the two):
| `-max-body-size` | `MAX_BODY_SIZE` | Maximum size of request or response, in bytes | 1048576 |
| `-max-duration` | `MAX_DURATION` | Maximum duration a response may take | 10s |
| `-port` | `PORT` | Port to listen on | 8080 |
| `-prefix` | `PREFIX` | Prefix of path to listen on (must start with slash and does not end with slash) | |
| `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false |
| `-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-*"` | - |

Expand Down
18 changes: 18 additions & 0 deletions httpbin/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ func mainImpl(args []string, getEnv func(string) string, getHostname func() (str
httpbin.WithObserver(httpbin.StdLogObserver(logger)),
httpbin.WithExcludeHeaders(cfg.ExcludeHeaders),
}
if cfg.Prefix != "" {
opts = append(opts, httpbin.WithPrefix(cfg.Prefix))
}
if cfg.RealHostname != "" {
opts = append(opts, httpbin.WithHostname(cfg.RealHostname))
}
Expand Down Expand Up @@ -106,6 +109,7 @@ type config struct {
ListenPort int
MaxBodySize int64
MaxDuration time.Duration
Prefix string
RealHostname string
TLSCertFile string
TLSKeyFile string
Expand Down Expand Up @@ -142,6 +146,7 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
fs.IntVar(&cfg.ListenPort, "port", defaultListenPort, "Port to listen on")
fs.StringVar(&cfg.rawAllowedRedirectDomains, "allowed-redirect-domains", "", "Comma-separated list of domains the /redirect-to endpoint will allow")
fs.StringVar(&cfg.ListenHost, "host", defaultListenHost, "Host to listen on")
fs.StringVar(&cfg.Prefix, "prefix", "", "Path prefix (empty or start with slash and does not end with slash)")
fs.StringVar(&cfg.TLSCertFile, "https-cert-file", "", "HTTPS Server certificate file")
fs.StringVar(&cfg.TLSKeyFile, "https-key-file", "", "HTTPS Server private key file")
fs.StringVar(&cfg.ExcludeHeaders, "exclude-headers", "", "Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard matching.")
Expand Down Expand Up @@ -194,6 +199,19 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
if cfg.ListenHost == defaultListenHost && getEnv("HOST") != "" {
cfg.ListenHost = getEnv("HOST")
}
if cfg.Prefix == "" {
if prefix := getEnv("PREFIX"); prefix != "" {
cfg.Prefix = prefix
}
}
if cfg.Prefix != "" {
if !strings.HasPrefix(cfg.Prefix, "/") {
return nil, configErr("Prefix %#v must start with a slash", cfg.Prefix)
}
if strings.HasSuffix(cfg.Prefix, "/") {
return nil, configErr("Prefix %#v must not end with a slash", cfg.Prefix)
}
}
if cfg.ExcludeHeaders == "" && getEnv("EXCLUDE_HEADERS") != "" {
cfg.ExcludeHeaders = getEnv("EXCLUDE_HEADERS")
}
Expand Down
36 changes: 36 additions & 0 deletions httpbin/cmd/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import (
)

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

// prefix
"invalid -prefix (does not start with slash)": {
args: []string{"-prefix", "invalidprefix1"},
wantErr: errors.New("Prefix \"invalidprefix1\" must start with a slash"),
},
"invalid -prefix (ends with with slash)": {
args: []string{"-prefix", "/invalidprefix2/"},
wantErr: errors.New("Prefix \"/invalidprefix2/\" must not end with a slash"),
},
"ok -prefix takes precedence over env": {
args: []string{"-prefix", "/prefix1"},
env: map[string]string{"PREFIX": "/prefix2"},
wantCfg: &config{
ListenHost: defaultListenHost,
ListenPort: defaultListenPort,
Prefix: "/prefix1",
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
},
},
"ok PREFIX": {
env: map[string]string{"PREFIX": "/prefix2"},
wantCfg: &config{
ListenHost: defaultListenHost,
ListenPort: defaultListenPort,
Prefix: "/prefix2",
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
},
},

// https cert file
"https cert and key must both be provided, cert only": {
args: []string{"-https-cert-file", "/tmp/test.crt"},
Expand Down
80 changes: 43 additions & 37 deletions httpbin/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ func (h *HTTPBin) Index(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' camo.githubusercontent.com")
writeHTML(w, mustStaticAsset("index.html"), http.StatusOK)
writeHTML(w, h.indexHTML, http.StatusOK)
}

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

// UTF8 renders an HTML encoding stress test
Expand Down Expand Up @@ -161,13 +161,13 @@ type statusCase struct {
body []byte
}

var (
statusRedirectHeaders = &statusCase{
func createSpecialCases(prefix string) map[int]*statusCase {
statusRedirectHeaders := &statusCase{
headers: map[string]string{
"Location": "/redirect/1",
"Location": prefix + "/redirect/1",
},
}
statusNotAcceptableBody = []byte(`{
statusNotAcceptableBody := []byte(`{
"message": "Client did not request a supported media type",
"accept": [
"image/webp",
Expand All @@ -178,31 +178,31 @@ var (
]
}
`)
statusHTTP300body = []byte(`<!doctype html>
statusHTTP300body := []byte(fmt.Sprintf(`<!doctype html>
<head>
<title>Multiple Choices</title>
</head>
<body>
<ul>
<li><a href="/image/jpeg">/image/jpeg</a></li>
<li><a href="/image/png">/image/png</a></li>
<li><a href="/image/svg">/image/svg</a></li>
<li><a href="%[1]s/image/jpeg">/image/jpeg</a></li>
<li><a href="%[1]s/image/png">/image/png</a></li>
<li><a href="%[1]s/image/svg">/image/svg</a></li>
</body>
</html>`)
</html>`, prefix))

statusHTTP308Body = []byte(`<!doctype html>
statusHTTP308Body := []byte(fmt.Sprintf(`<!doctype html>
<head>
<title>Permanent Redirect</title>
</head>
<body>Permanently redirected to <a href="/image/jpeg">/image/jpeg</a>
<body>Permanently redirected to <a href="%[1]s/image/jpeg">%[1]s/image/jpeg</a>
</body>
</html>`)
</html>`, prefix))

statusSpecialCases = map[int]*statusCase{
return map[int]*statusCase{
300: {
body: statusHTTP300body,
headers: map[string]string{
"Location": "/image/jpeg",
"Location": prefix + "/image/jpeg",
},
},
301: statusRedirectHeaders,
Expand All @@ -213,7 +213,7 @@ var (
308: {
body: statusHTTP308Body,
headers: map[string]string{
"Location": "/image/jpeg",
"Location": prefix + "/image/jpeg",
},
},
401: {
Expand Down Expand Up @@ -245,7 +245,7 @@ var (
},
},
}
)
}

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

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

func redirectLocation(r *http.Request, relative bool, n int) string {
func (h *HTTPBin) redirectLocation(r *http.Request, relative bool, n int) string {
var location string
var path string

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

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

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

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

// RelativeRedirect responds with an HTTP 302 redirect a given number of times
func (h *HTTPBin) RelativeRedirect(w http.ResponseWriter, r *http.Request) {
doRedirect(w, r, true)
h.handleRedirect(w, r, true)
}

// AbsoluteRedirect responds with an HTTP 302 redirect a given number of times
func (h *HTTPBin) AbsoluteRedirect(w http.ResponseWriter, r *http.Request) {
doRedirect(w, r, false)
h.handleRedirect(w, r, false)
}

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

w.Header().Set("Location", u.String())
w.WriteHeader(statusCode)
h.doRedirect(w, u.String(), statusCode)
}

// Cookies responds with the cookies in the incoming request
Expand All @@ -447,8 +445,7 @@ func (h *HTTPBin) SetCookies(w http.ResponseWriter, r *http.Request) {
HttpOnly: true,
})
}
w.Header().Set("Location", "/cookies")
w.WriteHeader(http.StatusFound)
h.doRedirect(w, "/cookies", http.StatusFound)
}

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

// BasicAuth requires basic authentication
Expand Down Expand Up @@ -916,18 +912,17 @@ func (h *HTTPBin) Links(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid offset: %w", err))
return
}
doLinksPage(w, r, n, offset)
h.doLinksPage(w, r, n, offset)
return
}

// Otherwise, redirect from /links/<n> to /links/<n>/0
r.URL.Path = r.URL.Path + "/0"
w.Header().Set("Location", r.URL.String())
w.WriteHeader(http.StatusFound)
h.doRedirect(w, r.URL.String(), http.StatusFound)
}

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

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

// doRedirect set redirect header
func (h *HTTPBin) doRedirect(w http.ResponseWriter, path string, code int) {
var sb strings.Builder
if strings.HasPrefix(path, "/") {
sb.WriteString(h.prefix)
}
sb.WriteString(path)
w.Header().Set("Location", sb.String())
w.WriteHeader(code)
}

// ImageAccept responds with an appropriate image based on the Accept header
func (h *HTTPBin) ImageAccept(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("Accept")
Expand Down
Loading

0 comments on commit c86dfa0

Please sign in to comment.