Skip to content

Commit

Permalink
feat: use enhanced stdlib HTTP router (#181)
Browse files Browse the repository at this point in the history
Use the enhanced support for defining HTTP routes including methods and
path parameters introduced in Go 1.22 to clean up and simplify fiddly
handler code for manually extracting path params from URLs.

See [this post][1] for more info on the routing updates.

[1]: https://go.dev/blog/routing-enhancements
  • Loading branch information
mccutchen authored Sep 13, 2024
1 parent f4d9684 commit dff73e2
Show file tree
Hide file tree
Showing 10 changed files with 105 additions and 217 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ run: build
.PHONY: run

watch:
$(REFLEX) -s -r '\.(go|html)$$' make run
$(REFLEX) -s -r '\.(go|html|tmpl)$$' make run
.PHONY: watch


Expand Down
10 changes: 4 additions & 6 deletions examples/custom-instrumentation/go.mod
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
module httpbin-instrumentation

go 1.18
go 1.22

require (
github.com/DataDog/datadog-go v4.8.3+incompatible
github.com/mccutchen/go-httpbin/v2 v2.5.1
github.com/mccutchen/go-httpbin/v2 v2.14.1
)

require (
github.com/Microsoft/go-winio v0.6.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/stretchr/objx v0.3.0 // indirect
github.com/stretchr/testify v1.3.0 // indirect
golang.org/x/mod v0.7.0 // indirect
golang.org/x/sys v0.2.0 // indirect
golang.org/x/tools v0.3.0 // indirect
golang.org/x/sys v0.25.0 // indirect
)

// Always build against the local version, to make it easier to update examples
Expand Down
13 changes: 4 additions & 9 deletions examples/custom-instrumentation/go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
github.com/DataDog/datadog-go v4.8.3+incompatible h1:fNGaYSuObuQb5nzeTQqowRAd9bpDIRRV4/gUtIBjh8Q=
github.com/DataDog/datadog-go v4.8.3+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg=
github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand All @@ -12,10 +12,5 @@ github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module github.com/mccutchen/go-httpbin/v2

go 1.18
go 1.22
128 changes: 26 additions & 102 deletions httpbin/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,16 +251,11 @@ func createSpecialCases(prefix string) map[int]*statusCase {
// Status responds with the specified status code. TODO: support random choice
// from multiple, optionally weighted status codes.
func (h *HTTPBin) Status(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 3 {
writeError(w, http.StatusNotFound, nil)
return
}
rawStatus := parts[2]
rawStatus := r.PathValue("code")

// simple case, specific status code is requested
if !strings.Contains(rawStatus, ",") {
code, err := parseStatusCode(parts[2])
code, err := parseStatusCode(rawStatus)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
Expand Down Expand Up @@ -368,20 +363,14 @@ func (h *HTTPBin) redirectLocation(r *http.Request, relative bool, n int) string
}

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)
return
}
n, err := strconv.Atoi(parts[2])
n, err := strconv.Atoi(r.PathValue("numRedirects"))
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid redirect count: %w", err))
return
} else if n < 1 {
writeError(w, http.StatusBadRequest, errors.New("redirect count must be > 0"))
return
}

h.doRedirect(w, h.redirectLocation(r, relative, n-1), http.StatusFound)
}

Expand Down Expand Up @@ -490,13 +479,8 @@ func (h *HTTPBin) DeleteCookies(w http.ResponseWriter, r *http.Request) {

// BasicAuth requires basic authentication
func (h *HTTPBin) BasicAuth(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 4 {
writeError(w, http.StatusNotFound, nil)
return
}
expectedUser := parts[2]
expectedPass := parts[3]
expectedUser := r.PathValue("user")
expectedPass := r.PathValue("password")

givenUser, givenPass, _ := r.BasicAuth()

Expand All @@ -516,13 +500,8 @@ func (h *HTTPBin) BasicAuth(w http.ResponseWriter, r *http.Request) {
// HiddenBasicAuth requires HTTP Basic authentication but returns a status of
// 404 if the request is unauthorized
func (h *HTTPBin) HiddenBasicAuth(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 4 {
writeError(w, http.StatusNotFound, nil)
return
}
expectedUser := parts[2]
expectedPass := parts[3]
expectedUser := r.PathValue("user")
expectedPass := r.PathValue("password")

givenUser, givenPass, _ := r.BasicAuth()

Expand All @@ -540,12 +519,7 @@ func (h *HTTPBin) HiddenBasicAuth(w http.ResponseWriter, r *http.Request) {

// Stream responds with max(n, 100) lines of JSON-encoded request data.
func (h *HTTPBin) Stream(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 3 {
writeError(w, http.StatusNotFound, nil)
return
}
n, err := strconv.Atoi(parts[2])
n, err := strconv.Atoi(r.PathValue("numLines"))
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid count: %w", err))
return
Expand Down Expand Up @@ -577,13 +551,7 @@ func (h *HTTPBin) Stream(w http.ResponseWriter, r *http.Request) {
// Delay waits for a given amount of time before responding, where the time may
// be specified as a golang-style duration or seconds in floating point.
func (h *HTTPBin) Delay(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 3 {
writeError(w, http.StatusNotFound, nil)
return
}

delay, err := parseBoundedDuration(parts[2], 0, h.MaxDuration)
delay, err := parseBoundedDuration(r.PathValue("duration"), 0, h.MaxDuration)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid duration: %w", err))
return
Expand Down Expand Up @@ -711,13 +679,7 @@ func (h *HTTPBin) Drip(w http.ResponseWriter, r *http.Request) {
// This departs from httpbin by not supporting the chunk_size or duration
// parameters.
func (h *HTTPBin) Range(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 3 {
writeError(w, http.StatusNotFound, nil)
return
}

numBytes, err := strconv.ParseInt(parts[2], 10, 64)
numBytes, err := strconv.ParseInt(r.PathValue("numBytes"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid count: %w", err))
return
Expand Down Expand Up @@ -763,7 +725,6 @@ func (h *HTTPBin) Cache(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotModified)
return
}

lastModified := time.Now().Format(time.RFC1123)
w.Header().Add("Last-Modified", lastModified)
w.Header().Add("ETag", sha1hash(lastModified))
Expand All @@ -772,32 +733,19 @@ func (h *HTTPBin) Cache(w http.ResponseWriter, r *http.Request) {

// CacheControl sets a Cache-Control header for N seconds for /cache/N requests
func (h *HTTPBin) CacheControl(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 3 {
writeError(w, http.StatusNotFound, nil)
return
}

seconds, err := strconv.ParseInt(parts[2], 10, 64)
seconds, err := strconv.ParseInt(r.PathValue("numSeconds"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid seconds: %w", err))
return
}

w.Header().Add("Cache-Control", fmt.Sprintf("public, max-age=%d", seconds))
h.Get(w, r)
}

// ETag assumes the resource has the given etag and responds to If-None-Match
// and If-Match headers appropriately.
func (h *HTTPBin) ETag(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 3 {
writeError(w, http.StatusNotFound, nil)
return
}

etag := parts[2]
etag := r.PathValue("etag")
w.Header().Set("ETag", fmt.Sprintf(`"%s"`, etag))
w.Header().Set("Content-Type", textContentType)

Expand Down Expand Up @@ -830,13 +778,7 @@ func (h *HTTPBin) StreamBytes(w http.ResponseWriter, r *http.Request) {
// and StreamBytes endpoints and knows how to write the response in chunks if
// streaming is true.
func handleBytes(w http.ResponseWriter, r *http.Request, streaming bool) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 3 {
writeError(w, http.StatusNotFound, nil)
return
}

numBytes, err := strconv.Atoi(parts[2])
numBytes, err := strconv.Atoi(r.PathValue("numBytes"))
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid byte count: %w", err))
return
Expand Down Expand Up @@ -914,13 +856,7 @@ func handleBytes(w http.ResponseWriter, r *http.Request, streaming bool) {

// Links redirects to the first page in a series of N links
func (h *HTTPBin) Links(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 3 && len(parts) != 4 {
writeError(w, http.StatusNotFound, nil)
return
}

n, err := strconv.Atoi(parts[2])
n, err := strconv.Atoi(r.PathValue("numLinks"))
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid link count: %w", err))
return
Expand All @@ -930,8 +866,8 @@ func (h *HTTPBin) Links(w http.ResponseWriter, r *http.Request) {
}

// Are we handling /links/<n>/<offset>? If so, render an HTML page
if len(parts) == 4 {
offset, err := strconv.Atoi(parts[3])
if rawOffset := r.PathValue("offset"); rawOffset != "" {
offset, err := strconv.Atoi(rawOffset)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid offset: %w", err))
return
Expand Down Expand Up @@ -995,12 +931,7 @@ func (h *HTTPBin) ImageAccept(w http.ResponseWriter, r *http.Request) {

// Image responds with an image of a specific kind, from /image/<kind>
func (h *HTTPBin) Image(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 3 {
writeError(w, http.StatusNotFound, nil)
return
}
doImage(w, parts[2])
doImage(w, r.PathValue("kind"))
}

// doImage responds with a specific kind of image, if there is an image asset
Expand Down Expand Up @@ -1029,21 +960,14 @@ func (h *HTTPBin) XML(w http.ResponseWriter, _ *http.Request) {
// /digest-auth/<qop>/<user>/<passwd>
// /digest-auth/<qop>/<user>/<passwd>/<algorithm>
func (h *HTTPBin) DigestAuth(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
count := len(parts)

if count != 5 && count != 6 {
writeError(w, http.StatusNotFound, nil)
return
}

qop := strings.ToLower(parts[2])
user := parts[3]
password := parts[4]

algoName := "MD5"
if count == 6 {
algoName = strings.ToUpper(parts[5])
var (
qop = strings.ToLower(r.PathValue("qop"))
user = r.PathValue("user")
password = r.PathValue("password")
algoName = strings.ToUpper(r.PathValue("algorithm"))
)
if algoName == "" {
algoName = "MD5"
}

if qop != "auth" {
Expand Down Expand Up @@ -1081,7 +1005,7 @@ func (h *HTTPBin) UUID(w http.ResponseWriter, _ *http.Request) {

// Base64 - encodes/decodes input data
func (h *HTTPBin) Base64(w http.ResponseWriter, r *http.Request) {
result, err := newBase64Helper(r.URL.Path, h.MaxBodySize).transform()
result, err := newBase64Helper(r, h.MaxBodySize).transform()
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
Expand Down
Loading

0 comments on commit dff73e2

Please sign in to comment.