Repository: goadesign/goa
Pull request #3346 (2023-09-09 18:55:06)

Switch to chi as default Goa mux
chi mux offers a few benefits as compared to `httpmux` including a more active community and the ability to retrieve the pattern that was used to match a route. Various alternatives were considered including Gorilla mux, https://github.com/julienschmidt/httprouter and https://bunrouter.uptrace.dev/guide/golang-router.html.


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index af3cf6d0..e484ffe9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,10 +7,10 @@ jobs:
     runs-on: ubuntu-latest
     steps:
 
-    - name: Set up Go 1.20
+    - name: Set up Go 1.21
       uses: actions/setup-go@v4.1.0
       with:
-        go-version: '1.20'
+        go-version: '1.21'
       id: go
 
     - name: Check out code into the Go module directory
@@ -41,10 +41,10 @@ jobs:
     runs-on: windows-latest
     steps:
 
-    - name: Set up Go 1.20
+    - name: Set up Go 1.21
       uses: actions/setup-go@v4.1.0
       with:
-        go-version: '1.20'
+        go-version: '1.21'
       id: go
 
     - name: Check out code into the Go module directory
diff --git a/go.mod b/go.mod
index e2e12810..4282dd7f 100644
--- a/go.mod
+++ b/go.mod
@@ -5,13 +5,14 @@ go 1.20
 require (
 	github.com/AnatolyRugalev/goregen v0.1.0
 	github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598
-	github.com/dimfeld/httptreemux/v5 v5.5.0
 	github.com/getkin/kin-openapi v0.119.0
+	github.com/go-chi/chi/v5 v5.0.10
 	github.com/google/uuid v1.3.1
 	github.com/gorilla/websocket v1.5.0
 	github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d
 	github.com/pkg/errors v0.9.1
 	github.com/sergi/go-diff v1.3.1
+	github.com/stretchr/testify v1.8.1
 	golang.org/x/text v0.13.0
 	golang.org/x/tools v0.12.0
 	google.golang.org/grpc v1.57.0
@@ -20,6 +21,7 @@ require (
 )
 
 require (
+	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/go-openapi/jsonpointer v0.19.6 // indirect
 	github.com/go-openapi/swag v0.22.4 // indirect
 	github.com/golang/protobuf v1.5.3 // indirect
@@ -29,6 +31,7 @@ require (
 	github.com/manveru/gobdd v0.0.0-20131210092515-f1a17fdd710b // indirect
 	github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
 	github.com/perimeterx/marshmallow v1.1.4 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	golang.org/x/mod v0.12.0 // indirect
 	golang.org/x/net v0.14.0 // indirect
 	golang.org/x/sys v0.11.0 // indirect
diff --git a/go.sum b/go.sum
index 3998c355..b67274c7 100644
--- a/go.sum
+++ b/go.sum
@@ -6,10 +6,10 @@ 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=
 github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598 h1:MGKhKyiYrvMDZsmLR/+RGffQSXwEkXgfLSA08qDn9AI=
 github.com/dimfeld/httppath v0.0.0-20170720192232-ee938bf73598/go.mod h1:0FpDmbrt36utu8jEmeU05dPC9AB5tsLYVVi+ZHfyuwI=
-github.com/dimfeld/httptreemux/v5 v5.5.0 h1:p8jkiMrCuZ0CmhwYLcbNbl7DDo21fozhKHQ2PccwOFQ=
-github.com/dimfeld/httptreemux/v5 v5.5.0/go.mod h1:QeEylH57C0v3VO0tkKraVz9oD3Uu93CKPnTLbsidvSw=
 github.com/getkin/kin-openapi v0.119.0 h1:FIn6hYX3huTFV28GS1hotceX8MAsGGf/c+jFta5XRaE=
 github.com/getkin/kin-openapi v0.119.0/go.mod h1:Kc71dQTNCxy/6sBdhR5dbKoSHwzLCcgygpCzizUy4XA=
+github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
+github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
 github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
 github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
diff --git a/http/mux.go b/http/mux.go
index 6fa4c44b..abf53681 100644
--- a/http/mux.go
+++ b/http/mux.go
@@ -6,7 +6,7 @@ import (
 	"net/http"
 	"regexp"
 
-	"github.com/dimfeld/httptreemux/v5"
+	chi "github.com/go-chi/chi/v5"
 )
 
 type (
@@ -59,49 +59,59 @@ type (
 		Use(func(http.Handler) http.Handler)
 	}
 
-	// mux is the default Muxer implementation. It leverages the
-	// httptreemux router and simply substitutes the syntax used to define
-	// wildcards from ":wildcard" and "*wildcard" to "{wildcard}" and
-	// "{*wildcard}" respectively.
+	// mux is the default Muxer implementation.
 	mux struct {
-		*httptreemux.ContextMux
+		chi.Router
+		wildcard string
 	}
 )
 
-// NewMuxer returns a Muxer implementation based on the httptreemux router.
+// NewMuxer returns a Muxer implementation based on a Chi router.
 func NewMuxer() MiddlewareMuxer {
-	r := httptreemux.NewContextMux()
-	r.EscapeAddedRoutes = true
-	r.NotFoundHandler = func(w http.ResponseWriter, req *http.Request) {
+	r := chi.NewRouter()
+	r.NotFound(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 		ctx := context.WithValue(req.Context(), AcceptTypeKey, req.Header.Get("Accept"))
 		enc := ResponseEncoder(ctx, w)
 		w.WriteHeader(http.StatusNotFound)
-		enc.Encode(NewErrorResponse(ctx, fmt.Errorf("404 page not found")))
-	}
-	return &mux{r}
+		enc.Encode(NewErrorResponse(ctx, fmt.Errorf("404 page not found"))) // nolint:errcheck
+	}))
+	return &mux{Router: r}
 }
 
-// Handle maps the wildcard format used by goa to the one used by httptreemux.
+// wildPath matches a wildcard path segment.
+var wildPath = regexp.MustCompile(`/{\*([a-zA-Z0-9_]+)}`)
+
+// Handle registers the handler function for the given method and pattern.
 func (m *mux) Handle(method, pattern string, handler http.HandlerFunc) {
-	m.ContextMux.Handle(method, treemuxify(pattern), handler)
+	if wildcards := wildPath.FindStringSubmatch(pattern); len(wildcards) > 0 {
+		if len(wildcards) > 2 {
+			panic("too many wildcards")
+		}
+		m.wildcard = wildcards[1]
+		pattern = wildPath.ReplaceAllString(pattern, "/*")
+	}
+	m.Method(method, pattern, handler)
 }
 
 // Vars extracts the path variables from the request context.
-func (*mux) Vars(r *http.Request) map[string]string {
-	return httptreemux.ContextParams(r.Context())
+func (m *mux) Vars(r *http.Request) map[string]string {
+	params := chi.RouteContext(r.Context()).URLParams
+	if len(params.Keys) == 0 {
+		return nil
+	}
+	vars := make(map[string]string, len(params.Keys))
+	for i, k := range params.Keys {
+		if k == "*" {
+			vars[m.wildcard] = params.Values[i]
+			continue
+		}
+		vars[k] = params.Values[i]
+	}
+	return vars
 }
 
 // Use appends a middleware to the list of middlewares to be applied
 // downstream the Muxer.
 func (m *mux) Use(f func(http.Handler) http.Handler) {
-	m.ContextMux.UseHandler(f)
-}
-
-var wildSeg = regexp.MustCompile(`/{([a-zA-Z0-9_]+)}`)
-var wildPath = regexp.MustCompile(`/{\*([a-zA-Z0-9_]+)}`)
-
-func treemuxify(pattern string) string {
-	pattern = wildSeg.ReplaceAllString(pattern, "/:$1")
-	pattern = wildPath.ReplaceAllString(pattern, "/*$1")
-	return pattern
+	m.Router.Use(f)
 }


Test patch:
diff --git a/http/mux_test.go b/http/mux_test.go
index c4616a66..09b39c0b 100644
--- a/http/mux_test.go
+++ b/http/mux_test.go
@@ -5,6 +5,8 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"testing"
+
+	"github.com/stretchr/testify/assert"
 )
 
 func TestMuxRegexp(t *testing.T) {
@@ -13,20 +15,20 @@ func TestMuxRegexp(t *testing.T) {
 		{"no capture", "a", "a"},
 		{"no capture 2", "/a", "/a"},
 		{"no capture 3", "/a/b", "/a/b"},
-		{"no capture 4", "{a}", "{a}"},
-		{"no capture 5", "{*a}", "{*a}"},
-		{"segment", "/{a}", "/:a"},
-		{"segment 2", "/a/{b}", "/a/:b"},
-		{"segment 3", "/{a}/b", "/:a/b"},
-		{"segment 4", "/a/{b}/c", "/a/:b/c"},
-		{"path", "/{*a}", "/*a"},
-		{"path 2", "/a/{*b}", "/a/*b"},
+		{"no capture 4", ":a", ":a"},
+		{"no capture 5", ":*a", ":*a"},
+		{"segment", "/{a}", "/{a}"},
+		{"segment 2", "/a/{b}", "/a/{b}"},
+		{"segment 3", "/{a}/b", "/{a}/b"},
+		{"segment 4", "/a/{b}/c", "/a/{b}/c"},
+		{"wildcard", "/{*a}", "/{a:.*}"},
+		{"wildcard 2", "/a/{*b}", "/a/{b:.*}"},
 	}
 	for _, c := range cases {
-		actual := treemuxify(c.Pattern)
-		if actual != c.Expected {
-			t.Errorf("%s: expected %#v, got %#v", c.Name, c.Expected, actual)
-		}
+		t.Run(c.Name, func(t *testing.T) {
+			actual := wildPath.ReplaceAllString(c.Pattern, "/{$1:.*}")
+			assert.Equal(t, c.Expected, actual)
+		})
 	}
 }
 
@@ -53,18 +55,76 @@ func TestMiddlewares(t *testing.T) {
 		{"two", []func(http.Handler) http.Handler{m1, m2}, "m1m2"},
 	}
 	for _, c := range cases {
-		m := NewMuxer()
-		for _, mw := range c.Middlewares {
-			m.Use(mw)
-		}
-		m.Handle("GET", "/", func(w http.ResponseWriter, r *http.Request) {
-			w.Write([]byte("hello"))
+		t.Run(c.Name, func(t *testing.T) {
+			m := NewMuxer()
+			for _, mw := range c.Middlewares {
+				m.Use(mw)
+			}
+			m.Handle("GET", "/", func(w http.ResponseWriter, _ *http.Request) {
+				w.Write([]byte("hello"))
+			})
+			r, _ := http.NewRequest("GET", "/", nil)
+			w := httptest.NewRecorder()
+			m.ServeHTTP(w, r)
+			assert.Equal(t, fmt.Sprintf("%shello", c.BodyPrefix), w.Body.String())
+		})
+	}
+}
+
+func TestVars(t *testing.T) {
+	cases := []struct {
+		Name     string
+		Pattern  string
+		URL      string
+		Expected map[string]string
+	}{
+		{
+			Name:    "simple",
+			Pattern: "/users/{id}",
+			URL:     "/users/123",
+			Expected: map[string]string{
+				"id": "123",
+			},
+		},
+		{
+			Name:    "multiple",
+			Pattern: "/users/{id}/posts/{post_id}",
+			URL:     "/users/123/posts/456",
+			Expected: map[string]string{
+				"id":      "123",
+				"post_id": "456",
+			},
+		},
+		{
+			Name:    "wildcard",
+			Pattern: "/users/{id}/posts/{*post_id}",
+			URL:     "/users/123/posts/456/789",
+			Expected: map[string]string{
+				"id":      "123",
+				"post_id": "456/789",
+			},
+		},
+		{
+			Name:     "no var",
+			Pattern:  "/users",
+			URL:      "/users",
+			Expected: nil,
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.Name, func(t *testing.T) {
+			var called bool
+			mux := NewMuxer()
+			mux.Handle("GET", c.Pattern, func(_ http.ResponseWriter, r *http.Request) {
+				vars := mux.Vars(r)
+				assert.Equal(t, c.Expected, vars)
+				called = true
+			})
+			req, _ := http.NewRequest("GET", c.URL, nil)
+			w := httptest.NewRecorder()
+			mux.ServeHTTP(w, req)
+			assert.True(t, called)
 		})
-		r, _ := http.NewRequest("GET", "/", nil)
-		w := httptest.NewRecorder()
-		m.ServeHTTP(w, r)
-		if w.Body.String() != fmt.Sprintf("%shello", c.BodyPrefix) {
-			t.Errorf("%s: got %s, expected %s", c.Name, w.Body.String(), fmt.Sprintf("%shello", c.BodyPrefix))
-		}
 	}
 }