Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support serving under a path prefix #120

Merged
merged 13 commits into from
Dec 12, 2023
Merged
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)")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every config option may be set by CLI arguments and by environment variables. I think we need to add support for setting the prefix via a PREFIX env var.

We also need to update this section of the README to note the new option:
https://github.com/mccutchen/go-httpbin/blob/main/README.md#configuration

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I implemented this. Now also with check for slash at beginning and end. But will be checked only on command line as the httpbin itself does not config checking.

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>
mccutchen marked this conversation as resolved.
Show resolved Hide resolved
</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
Loading