diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2026-06-27 22:34:36 +0200 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2026-06-27 22:34:36 +0200 |
| commit | 8e9889d3e5b2874854832d1d882d124132546f49 (patch) | |
| tree | 80ab6bf53dac3090630ad58d7ebfbbf2c97019ca | |
| parent | 8804729561d8e5edd7179f494d89c620459d319f (diff) | |
| download | go-gg-8e9889d3e5b2874854832d1d882d124132546f49.tar.gz | |
add package pathrouter
| -rw-r--r-- | .golangci.yaml | 1 | ||||
| -rw-r--r-- | CHANGELOG.md | 3 | ||||
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | REUSE.toml | 3 | ||||
| -rw-r--r-- | benchmark/go.mod | 8 | ||||
| -rw-r--r-- | benchmark/go.sum | 4 | ||||
| -rw-r--r-- | benchmark/pathrouter_test.go | 117 | ||||
| -rw-r--r-- | go.work | 6 | ||||
| -rw-r--r-- | pathrouter/catchallvariable.go | 60 | ||||
| -rw-r--r-- | pathrouter/choice.go | 61 | ||||
| -rw-r--r-- | pathrouter/element.go | 34 | ||||
| -rw-r--r-- | pathrouter/handler.go | 99 | ||||
| -rw-r--r-- | pathrouter/here.go | 53 | ||||
| -rw-r--r-- | pathrouter/matcher.go | 148 | ||||
| -rw-r--r-- | pathrouter/matcher_test.go | 33 | ||||
| -rw-r--r-- | pathrouter/pathrouter_test.go | 188 | ||||
| -rw-r--r-- | pathrouter/utils.go | 22 | ||||
| -rw-r--r-- | pathrouter/variable.go | 31 |
18 files changed, 870 insertions, 2 deletions
diff --git a/.golangci.yaml b/.golangci.yaml index 12e9cb8..6e6ffc1 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -3,7 +3,6 @@ version: "2" run: - modules-download-mode: vendor timeout: 3m0s # none by default in v2 formatters: diff --git a/CHANGELOG.md b/CHANGELOG.md index 391cadb..989e607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,11 @@ SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> SPDX-License-Identifier: Apache-2.0 --> -# v1.10.2 (TBD) +# v1.11.0 (TBD) Changes: +- Add package pathrouter. - In assert.Equal output, present string literals with backticks instead of quotes when it makes the output more readable. # v1.10.1 (2026-06-20) @@ -19,6 +19,7 @@ My personal extension of the standard library. ### Addons for net/http - [assetembed](./assetembed/): HTTP handler for efficiently serving embedded assets using the cache-busting pattern +- [pathrouter](./pathrouter/): HTTP router that differentiates endpoints based on paths without any regex matching ### Addons for testing @@ -9,6 +9,9 @@ path = [ ".gitignore", "description", "go.mod", + "go.work", + "benchmark/go.mod", + "benchmark/go.sum", ] SPDX-FileCopyrightText = "Stefan Majewsky <majewsky@gmx.net>" SPDX-License-Identifier = "Apache-2.0" diff --git a/benchmark/go.mod b/benchmark/go.mod new file mode 100644 index 0000000..a8125f8 --- /dev/null +++ b/benchmark/go.mod @@ -0,0 +1,8 @@ +module go.xyrillian.de/gg/benchmark + +go 1.26 + +require ( + github.com/gorilla/mux v1.8.1 + go.xyrillian.de/gg v1.10.1 +) diff --git a/benchmark/go.sum b/benchmark/go.sum new file mode 100644 index 0000000..e5138fd --- /dev/null +++ b/benchmark/go.sum @@ -0,0 +1,4 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +go.xyrillian.de/gg v1.10.1 h1:V6oSU+tl25vaRQaMy6Y3jl/0kNoY/a25x4WIk5zQFAw= +go.xyrillian.de/gg v1.10.1/go.mod h1:DoO4fQSWIrBRlNlCjVyrYM0kAEBt/Jg2GkMH+cGRZ0k= diff --git a/benchmark/pathrouter_test.go b/benchmark/pathrouter_test.go new file mode 100644 index 0000000..361cc2f --- /dev/null +++ b/benchmark/pathrouter_test.go @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package benchmark_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "go.xyrillian.de/gg/assert" + pr "go.xyrillian.de/gg/pathrouter" +) + +func BenchmarkRouterWithDistributionAPI(b *testing.B) { + // This benchmark uses the OCI Distribution API as a moderately complex + // real-world example of an HTTP API with parametrized endpoint paths. + + testWith := func(h http.Handler) func(b *testing.B) { + return func(b *testing.B) { + for b.Loop() { + req := httptest.NewRequest(http.MethodGet, "/v2/foo/bar/manifests/latest", http.NoBody) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + assert.Equal(b, rec.Code, http.StatusForbidden) + assert.Equal(b, rec.Body.String(), "GetOrHeadManifest is forbidden\n") + if b.Failed() { + b.FailNow() + } + } + } + } + + b.Run("platform=gorilla-mux", testWith(buildMuxRouterForDistributionAPI())) + b.Run("platform=gg-pathrouter", testWith(buildPathrouterForDistributionAPI())) +} + +func buildMuxRouterForDistributionAPI() http.Handler { + handle := func(endpointName string) http.HandlerFunc { + msg := endpointName + " is forbidden" + return func(w http.ResponseWriter, r *http.Request) { + http.Error(w, msg, http.StatusForbidden) + } + } + r := mux.NewRouter() + r.Methods("GET").Path("/v2/").HandlerFunc(handle("GetToplevel")) + r.Methods("GET").Path("/v2/_catalog").HandlerFunc(handle("GetCatalog")) + r.Methods("DELETE").Path("/v2/{repository:.+}/blobs/{digest}").HandlerFunc(handle("DeleteBlob")) + r.Methods("GET", "HEAD").Path("/v2/{repository:.+}/blobs/{digest}").HandlerFunc(handle("GetOrHeadBlob")) + r.Methods("POST").Path("/v2/{repository:.+}/blobs/uploads/").HandlerFunc(handle("StartBlobUpload")) + r.Methods("DELETE").Path("/v2/{repository:.+}/blobs/uploads/{uuid}").HandlerFunc(handle("DeleteBlobUpload")) + r.Methods("GET").Path("/v2/{repository:.+}/blobs/uploads/{uuid}").HandlerFunc(handle("GetBlobUpload")) + r.Methods("PATCH").Path("/v2/{repository:.+}/blobs/uploads/{uuid}").HandlerFunc(handle("ContinueBlobUpload")) + r.Methods("PUT").Path("/v2/{repository:.+}/blobs/uploads/{uuid}").HandlerFunc(handle("FinishBlobUpload")) + r.Methods("DELETE").Path("/v2/{repository:.+}/manifests/{reference}").HandlerFunc(handle("DeleteManifest")) + r.Methods("GET", "HEAD").Path("/v2/{repository:.+}/manifests/{reference}").HandlerFunc(handle("GetOrHeadManifest")) + r.Methods("PUT").Path("/v2/{repository:.+}/manifests/{reference}").HandlerFunc(handle("PutManifest")) + r.Methods("GET").Path("/v2/{repository:.+}/referrers/{reference}").HandlerFunc(handle("GetReferrers")) + r.Methods("GET").Path("/v2/{repository:.+}/tags/list").HandlerFunc(handle("ListTags")) + return r +} + +func buildPathrouterForDistributionAPI() http.Handler { + handle := func(endpointName string) pr.HandlerFunc { + msg := endpointName + " is forbidden" + return func(w http.ResponseWriter, r *http.Request, vars map[string]string) { + http.Error(w, msg, http.StatusForbidden) + } + } + + return pr.Choice( + pr.Element("v2", pr.Choice( + pr.Element("/", pr.Handlers(pr.ByMethod{ + http.MethodGet: handle("GetToplevel"), + http.MethodHead: nil, + })), + pr.Element("_catalog", pr.Handlers(pr.ByMethod{ + http.MethodGet: handle("GetCatalog"), + http.MethodHead: nil, + })), + pr.CatchAllVariable("repository", pr.Choice( + pr.Element("blobs", pr.Choice( + pr.Variable("digest", pr.Handlers(pr.ByMethod{ + http.MethodDelete: handle("DeleteBlob"), + http.MethodGet: handle("GetOrHeadBlob"), + })), + pr.Element("uploads", pr.Choice( + pr.Element("/", pr.Handlers(pr.ByMethod{ + http.MethodPost: handle("StartBlobUpload"), + })), + pr.Variable("uuid", pr.Handlers(pr.ByMethod{ + http.MethodDelete: handle("DeleteBlobUpload"), + http.MethodGet: handle("GetBlobUpload"), + http.MethodHead: nil, + http.MethodPatch: handle("ContinueBlobUpload"), + http.MethodPut: handle("FinishBlobUpload"), + })), + )), + )), + pr.Element("manifests", pr.Variable("reference", pr.Handlers(pr.ByMethod{ + http.MethodDelete: handle("DeleteManifest"), + http.MethodGet: handle("GetOrHeadManifest"), + http.MethodPut: handle("PutManifest"), + }))), + pr.Element("referrers", pr.Variable("reference", pr.Handlers(pr.ByMethod{ + http.MethodGet: handle("GetReferrers"), + http.MethodHead: nil, + }))), + pr.Element("tags", pr.Element("list", pr.Handlers(pr.ByMethod{ + http.MethodGet: handle("ListTags"), + http.MethodHead: nil, + }))), + )), + )), + ) +} @@ -0,0 +1,6 @@ +go 1.26 + +use ( + . + ./benchmark +) diff --git a/pathrouter/catchallvariable.go b/pathrouter/catchallvariable.go new file mode 100644 index 0000000..1b98672 --- /dev/null +++ b/pathrouter/catchallvariable.go @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package pathrouter + +import ( + "strings" + + . "go.xyrillian.de/gg/option" +) + +// CatchAllVariable is a [Matcher] that accepts subpaths with an arbitrary number of path elements. +// The value of any amount of leading path elements will be collected into vars[name], +// such that the remainder is accepted by the next matcher. +// At least one element must be collected into vars[name]. +// +// CatchAllVariable() may appear at any point within the routing tree, +// but it may not contain another CatchAllVariable() anywhere within it. +func CatchAllVariable(name string, matcher Matcher) Matcher { + return catchAllVariable(name, matcher.downcast()) +} + +func catchAllVariable(name string, matcher realMatcher) Matcher { + // NOTE: The specific behavior of CatchAllVariable() is why this package exists in the first place. + // I wanted to replace gorilla/mux with something more performance in Keppel, + // but all the fast routers do not accept catch-all variables in the middle of a path + // like the OCI Distribution API requires (e.g. "/v2/*repo/manifests/:reference" with "repo" being a full path). + + innerMinLength := matcher.minLength + innerMaxLength, ok := matcher.maxLength.Unpack() + if !ok { + panic("matcher within CatchAllVariable() may not accept unlimited path lengths") + } + + accept := func(path []string, vars map[string]string) HandlerFunc { + for length := innerMinLength; length <= innerMaxLength; length++ { + if length > len(path) { + break + } + caughtPath, subpath := path[0:len(path)-length], path[len(path)-length:] + if len(caughtPath) == 0 { + continue + } + + handlerFunc := matcher.accept(subpath, vars) + if handlerFunc == nil { + continue + } + vars[name] = pathUnescape(strings.Join(caughtPath, "/")) + return handlerFunc + } + return nil + } + + return realMatcher{ + minLength: innerMinLength + 1, + maxLength: None[int](), + accept: accept, + } +} diff --git a/pathrouter/choice.go b/pathrouter/choice.go new file mode 100644 index 0000000..09026b8 --- /dev/null +++ b/pathrouter/choice.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package pathrouter + +import ( + "slices" + + . "go.xyrillian.de/gg/option" + "go.xyrillian.de/gg/options" +) + +// Choice is a [Matcher] that accepts all subpaths that are accepted by any of its contained matchers. +// If multiple matchers accept the requested subpath, the first match wins. +// +// Choice is used to specify different matchers for different subpaths, +// as illustrated in the example in the package docstring. +func Choice(matchers ...Matcher) Matcher { + downcasted := make([]realMatcher, len(matchers)) + for idx, matcher := range matchers { + downcasted[idx] = matcher.downcast() + } + return choice(downcasted) +} + +func choice(matchers []realMatcher) Matcher { + if len(matchers) == 0 { + panic("Choice() called without any matchers") + } + + var ( + minLengths = make([]int, len(matchers)) + maxLengths = make([]Option[int], len(matchers)) + maxLengthIsInf = false + ) + for idx, m := range matchers { + minLengths[idx] = m.minLength + maxLengths[idx] = m.maxLength + if m.maxLength.IsNone() { + maxLengthIsInf = true + } + } + maxLength := None[int]() + if !maxLengthIsInf { + maxLength = options.Max(maxLengths...) + } + + return realMatcher{ + minLength: slices.Min(minLengths), + maxLength: maxLength, + accept: func(path []string, vars map[string]string) HandlerFunc { + for _, m := range matchers { + hf := m.accept(path, vars) + if hf != nil { + return hf + } + } + return nil + }, + } +} diff --git a/pathrouter/element.go b/pathrouter/element.go new file mode 100644 index 0000000..9512924 --- /dev/null +++ b/pathrouter/element.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package pathrouter + +import "go.xyrillian.de/gg/options" + +// Element is a [Matcher] that accepts subpaths with at least one path element. +// The first path element must be equal to the given value, +// and the remaining subpath will have to be accepted by the next matcher. +func Element(value string, matcher Matcher) Matcher { + return element(value, matcher.downcast()) +} + +func element(value string, matcher realMatcher) Matcher { + switch value { + case "": + panic(`Element() called with value = ""`) + case "/": + value = "" // trailing slash will lead to an empty element in `path`, e.g. strings.Split("foo/bar/") == []string{"foo","bar",""} + default: + } + + return realMatcher{ + minLength: matcher.minLength + 1, + maxLength: options.Map(matcher.maxLength, increment), + accept: func(path []string, vars map[string]string) HandlerFunc { + if len(path) == 0 || path[0] != value { + return nil + } + return matcher.accept(path[1:], vars) + }, + } +} diff --git a/pathrouter/handler.go b/pathrouter/handler.go new file mode 100644 index 0000000..ca493be --- /dev/null +++ b/pathrouter/handler.go @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package pathrouter + +import ( + "fmt" + "net/http" + "slices" + "strings" + + . "go.xyrillian.de/gg/option" +) + +// HandlerFunc is like [http.HandlerFunc], but also receives variable values that were extracted from the URL path. +// +// Package pathrouter passes variables explicitly like this, instead of via [context.Context.WithValue], +// because doing so imposes a performance penalty for every usage of the respective context. +type HandlerFunc = func(w http.ResponseWriter, r *http.Request, vars map[string]string) + +// ByMethod is a set of request handlers matching the same request path, keyed on request method. +// It is commonly constructed as a literal using the respective constants from the net/http package, such as [http.MethodGet]. +// This type appears in the signature of func [Handlers], see documentation over there for details. +type ByMethod map[string]HandlerFunc + +// Handlers is a [Matcher] that accepts empty subpaths only. +// It appears at the leaf nodes of a [Matcher] tree, which correspond to specific endpoint paths, +// and contains the actual request handlers for one endpoint path: +// +// import pr "go.xyrillian.de/gg/pathrouter" +// handler := pr.Element("v1", pr.Choice( +// pr.Element("teams", pr.Variable("id", pr.Handlers(pr.ByMethod{ +// http.MethodGet: handleGetTeam, // GET /v1/teams/:id +// http.MethodPut: handlePutTeam, // PUT /v1/teams/:id +// http.MethodDelete: handleDeleteTeam, // DELETE /v1/teams/:id +// }))), +// pr.Element("employees", pr.Variable("id", pr.Handlers(pr.ByMethod{ +// http.MethodGet: handleGetEmployee, // GET /v1/employees/:id +// }))), +// )) +// +// If there is a handler for [http.MethodGet], but none for [http.MethodHead], the GET handler will be called for HEAD as well. +// To have a GET handler, but no HEAD handler, set the handler for [http.MethodHead] to nil. +// Any other use of a nil [HandlerFunc] is invalid and will cause a panic. +func Handlers(m ByMethod) Matcher { + // reuse GET handler for HEAD if not overridden + if handler, exists := m[http.MethodGet]; exists { + if _, exists := m[http.MethodHead]; !exists { + m[http.MethodHead] = handler + } + if m[http.MethodHead] == nil { + delete(m, http.MethodHead) + } + } + + // check that all handlers are valid + for method, handler := range m { + if handler == nil { + panic(fmt.Sprintf("handler is nil for method %q", method)) + } + } + + // precomputations for accept() + allowHeader := m.buildAllowHeader() + serve := func(w http.ResponseWriter, r *http.Request, vars map[string]string) { + handler, ok := m[r.Method] + if ok { + handler(w, r, vars) + return + } + + w.Header().Set("Allow", allowHeader) + if r.Method == http.MethodOptions { + http.Error(w, "", http.StatusOK) + } else { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } + } + + return realMatcher{ + minLength: 0, + maxLength: Some(0), + accept: func(path []string, vars map[string]string) HandlerFunc { + if len(path) != 0 { + return nil + } + return serve + }, + } +} + +func (m ByMethod) buildAllowHeader() string { + allowedMethods := make([]string, 0, len(m)) + for method := range m { + allowedMethods = append(allowedMethods, method) + } + slices.Sort(allowedMethods) + return strings.Join(allowedMethods, ", ") +} diff --git a/pathrouter/here.go b/pathrouter/here.go new file mode 100644 index 0000000..a59cdf0 --- /dev/null +++ b/pathrouter/here.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package pathrouter + +import ( + . "go.xyrillian.de/gg/option" +) + +// Here is a [Matcher] that matches effectively empty subpaths, that is: +// subpaths that are either empty or only contain a heretofore unmatched trailing slash. +// It can be used to opt-in to allowing trailing slashes at the ends of URL paths: +// +// // only matches "/foo/bar" +// pr.Element("foo", pr.Element("bar", pr.Handlers(...)) +// +// // also matches "/foo/bar/" +// pr.Element("foo", pr.Element("bar", pr.Here(pr.Handlers(...))) +// +// The next matcher must match only the empty path, so currently only [Handlers] is allowed. +// +// Here is provided as a useful shorthand, but could be implemented using the other matchers like so: +// +// // this... +// m := pr.Here(pr.Handlers(byMethod)) +// +// // ...is equivalent to this +// h := pr.Handlers(byMethod) +// m := pr.Choice(h, pr.Element("/", h)) +// +// The name "Here" makes sense when Here() appears as one of several options within Choice(), +// where the other options would match subpaths, while Here() matches, well, "here". +// The example in the package docstring illustrates this. +func Here(matcher Matcher) Matcher { + return here(matcher.downcast()) +} + +func here(matcher realMatcher) Matcher { + if matcher.minLength != 0 || matcher.maxLength != Some(0) { + panic("matcher within Here() must be Handlers()") + } + + return realMatcher{ + minLength: 0, + maxLength: Some(1), + accept: func(path []string, vars map[string]string) HandlerFunc { + if len(path) == 0 || (len(path) == 1 && path[0] == "") { + return matcher.accept(nil, vars) + } + return nil + }, + } +} diff --git a/pathrouter/matcher.go b/pathrouter/matcher.go new file mode 100644 index 0000000..f0f98ec --- /dev/null +++ b/pathrouter/matcher.go @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +// Package pathrouter contains an HTTP router that differentiates endpoints based on paths without any regex matching. +// +// # Comparison to other HTTP router libraries +// +// In contrast to most other router implementations, pathrouter does not take its routes as a list of declarations. +// Instead, the routing table is declared as a nested structure describing the decision tree that the router follows: +// +// // not like this (this is sometimes called "Sinatra style" after a popular framework using this approach) +// r := otherlibrary.NewRouter() +// r.Handle(http.MethodGet, "/v1/objects", api.ListObjects) +// r.Handle(http.MethodPost, "/v1/objects/new", api.CreateObject) +// r.Handle(http.MethodDelete, "/v1/objects/:id", api.DeleteObject) +// r.Handle(http.MethodGet, "/v1/objects/:id", api.GetObject) +// r.Handle(http.MethodPatch, "/v1/objects/:id", api.PatchObject) +// +// // but like this +// import pr "go.xyrillian.de/gg/pathrouter" +// r := pr.Element("v1", +// pr.Element("objects", +// pr.Choice( +// pr.Here(pr.Handlers(pr.ByMethod{ +// http.MethodGet: api.ListObjects, +// })), +// pr.Element("new", pr.Here(pr.Handlers(pr.ByMethod{ +// http.MethodPost: api.CreateObject, +// }))), +// pr.Variable("id", pr.Here(pr.Handlers(pr.ByMethod{ +// http.MethodDelete: api.DeleteObject, +// http.MethodGet: api.GetObject, +// http.MethodPatch: api.PatchObject, +// }))), +// ), +// ), +// ) +// +// Compared to other common router libraries like [gorilla/mux], +// pathrouter does not use regular expressions in its implementation, +// thus making it extremely fast at the expense of reducing flexibility in what can be matched. +// For instance, in the example above, requests for /v1/objects/:id will accept any non-empty string for the "id" variable. +// Package pathrouter expects that request handlers will perform additional format checks on extracted path variables as required. +// +// Unlike other fast HTTP router libraries such as [httprouter] or [httptreemux], pathrouter can match a catch-all path (i.e. a variable extending over multiple path elements) anywhere in the path, not just at the end. +// The only limitation with catch-all paths is that only one catch-all path may be matched per route, e.g. "/v1/objects/*path/relations" can be matched, but "v1/objects/*path/compare/*otherpath" cannot. +// +// # Handling of escape sequences in paths +// +// Path matching is performed on the escaped form of the URL path, as returned by [url.URL.EscapedPath], +// so any slashes that were encoded as %2F in the URL path will be considered part of a path element instead of a boundary. +// +// For instance, using the example above, the URL path "/v1/objects/42/23" would not match and generate a 404 response, +// but the URL path "/v1/objects/42%2F23" would match and invoke (e.g. with method GET) the GetObject handler with vars["id"] = "42/23". +// Like in this example, [HandlerFunc] will receive unescaped values in its vars argument. +// +// # Implicit normalizations +// +// If the request path contains consecutive runs of unescaped slashes, they will be normalized to behave like a single slash. +// For example, "http://localhost//foo%2F//bar//" is identical to "http://localhost/foo%2F/bar/". +// +// [gorilla/mux]: https://github.com/gorilla/mux +// [httprouter]: https://github.com/julienschmidt/httprouter +// [httptreemux]: https://github.com/dimfeld/httptreemux +package pathrouter + +import ( + "context" + "net/http" + "net/url" + "strings" + + . "go.xyrillian.de/gg/option" +) + +var ( + // force imports that are necessary to make docstring links work + _ context.Context = nil + _ url.URL +) + +// Matcher is the common type for any actor that can inspect the path of a request URL (or a suffix of it) +// and either accept or decline to handle the request. +// +// Matcher implements the ServeHTTP method of [http.Handler] and can thus be used with any net/http facility like [http.ListenAndServe]. +// +// Alternatively, the TryServeHTTP method behaves like ServeHTTP, but will not render a "404 Not Found" response +// when the matcher is not capable of handling requests with the given path, instead only returning false without touching the ResponseWriter. +// This method may be useful when composing e.g. multiple [Matcher] instances that each implement a different API with different endpoints. +type Matcher interface { + http.Handler + TryServeHTTP(w http.ResponseWriter, r *http.Request) bool + + // Casts a Matcher into type realMatcher, which is the only type that actually implements this interface. + // This allows eliminating fat pointers in the matcher tree. + downcast() realMatcher +} + +type realMatcher struct { + accept func(path []string, vars map[string]string) HandlerFunc + + // The smallest len(path) that accept() can accept. + minLength int + // The largest len(path) that accept() can accept, or None if accept() can handle arbitrarily long paths. + maxLength Option[int] +} + +// ServeHTTP implements the [Matcher] interface. +func (m realMatcher) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !m.TryServeHTTP(w, r) { + http.NotFound(w, r) + } +} + +// TryServeHTTP implements the [Matcher] interface. +func (m realMatcher) TryServeHTTP(w http.ResponseWriter, r *http.Request) bool { + path := extractPath(r.URL) + vars := make(map[string]string) + handlerFunc := m.accept(path, vars) + if handlerFunc == nil { + return false + } else { + handlerFunc(w, r, vars) + return true + } +} + +// realMatcher implements the [Matcher] interface. +func (m realMatcher) downcast() realMatcher { + return m +} + +func extractPath(u *url.URL) []string { + // e.g. u.Path = "//foo//bar//" becomes ["", "", "foo", "", "", "bar", "", ""] + path := strings.Split(u.EscapedPath(), "/") + + // sequences of slashes are supposed to behave like single slashes + // e.g. u.Path = "//foo//bar//" becomes ["foo", "bar", ""] here + for idx := 0; idx < len(path)-1; idx++ { + if path[idx] == "" { + copy(path[idx:], path[idx+1:]) + path = path[0 : len(path)-1] + idx-- + } + } + + return path +} diff --git a/pathrouter/matcher_test.go b/pathrouter/matcher_test.go new file mode 100644 index 0000000..4b1382c --- /dev/null +++ b/pathrouter/matcher_test.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package pathrouter // This file contains tests that access unexported helper functions. + +import ( + "net/url" + "testing" + + "go.xyrillian.de/gg/assert" +) + +func TestExtractPath(t *testing.T) { + extract := func(rawURL string) []string { + t.Helper() + parsedURL, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + return extractPath(parsedURL) + } + + assert.Equal(t, extract("http://localhost/"), []string{""}) + assert.Equal(t, extract("http://localhost///"), []string{""}) + assert.Equal(t, extract("http://localhost/foo"), []string{"foo"}) + assert.Equal(t, extract("http://localhost///foo"), []string{"foo"}) + assert.Equal(t, extract("http://localhost/foo/"), []string{"foo", ""}) + assert.Equal(t, extract("http://localhost///foo///"), []string{"foo", ""}) + assert.Equal(t, extract("http://localhost/foo/%2Fbar%2F"), []string{"foo", "%2Fbar%2F"}) + assert.Equal(t, extract("http://localhost///foo///%2Fbar%2F"), []string{"foo", "%2Fbar%2F"}) + assert.Equal(t, extract("http://localhost/foo/%2Fbar%2F/"), []string{"foo", "%2Fbar%2F", ""}) + assert.Equal(t, extract("http://localhost///foo///%2Fbar%2F///"), []string{"foo", "%2Fbar%2F", ""}) +} diff --git a/pathrouter/pathrouter_test.go b/pathrouter/pathrouter_test.go new file mode 100644 index 0000000..3e91ce9 --- /dev/null +++ b/pathrouter/pathrouter_test.go @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package pathrouter_test + +import ( + "cmp" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "go.xyrillian.de/gg/assert" + pr "go.xyrillian.de/gg/pathrouter" + "go.xyrillian.de/gg/testcapture" +) + +func TestRouting(t *testing.T) { + // helper methods + varRx := regexp.MustCompile(`\$\w+`) + h := func(status int, msgBase string) func(w http.ResponseWriter, r *http.Request, vars map[string]string) { + return func(w http.ResponseWriter, r *http.Request, vars map[string]string) { + msg := varRx.ReplaceAllStringFunc(msgBase, func(match string) string { + return vars[strings.TrimPrefix(match, "$")] + }) + if r.Method == http.MethodHead { + w.WriteHeader(status) + } else { + http.Error(w, msg, status) + } + } + } + var m pr.Matcher + check := func(method, path, expected string, expectedHeaders map[string]string) { + t.Helper() + + req := httptest.NewRequest(method, "http://localhost"+path, http.NoBody) + rec := httptest.NewRecorder() + m.ServeHTTP(rec, req) + + body := strings.TrimSuffix(rec.Body.String(), "\n") + actual := fmt.Sprintf("%d: %s", rec.Code, cmp.Or(body, "<none>")) + assert.Equal(t, actual, expected) + + actualHeaders := make(map[string]string) + for key, value := range rec.Header() { + actualHeaders[key] = value[0] + } + if actualHeaders["Content-Type"] == "text/plain; charset=utf-8" { + delete(actualHeaders, "Content-Type") + } + if actualHeaders["X-Content-Type-Options"] == "nosniff" { + delete(actualHeaders, "X-Content-Type-Options") + } + if len(actualHeaders) == 0 { + actualHeaders = nil + } + assert.Equal(t, actualHeaders, expectedHeaders) + } + + // test router matching only "/" path + m = pr.Element("/", pr.Handlers(pr.ByMethod{ + http.MethodGet: h(http.StatusOK, "root"), + })) + check("GET", "/", "200: root", nil) + check("PUT", "/", "405: Method Not Allowed", map[string]string{"Allow": "GET, HEAD"}) + check("OPTIONS", "/", "200: <none>", map[string]string{"Allow": "GET, HEAD"}) + check("GET", "///", "200: root", nil) + check("PUT", "///", "405: Method Not Allowed", map[string]string{"Allow": "GET, HEAD"}) + check("OPTIONS", "///", "200: <none>", map[string]string{"Allow": "GET, HEAD"}) + check("GET", "/foo", "404: 404 page not found", nil) + check("PUT", "/foo", "404: 404 page not found", nil) + check("OPTIONS", "/foo", "404: 404 page not found", nil) + + // test alternative representation for router matching only "/" path + m = pr.Here(pr.Handlers(pr.ByMethod{ + http.MethodGet: h(http.StatusOK, "hello"), + })) + check("GET", "/", "200: hello", nil) + check("GET", "/foo", "404: 404 page not found", nil) + + // check Element() + m = pr.Element("foo", pr.Element("bar", pr.Handlers(pr.ByMethod{ + http.MethodGet: h(http.StatusOK, "that's me"), + http.MethodPut: h(http.StatusCreated, "here I am"), + http.MethodDelete: h(http.StatusAccepted, "goodbye"), + }))) + check("GET", "/", "404: 404 page not found", nil) + check("GET", "/foo/", "404: 404 page not found", nil) + check("GET", "/foo", "404: 404 page not found", nil) + check("GET", "/bar/", "404: 404 page not found", nil) + check("GET", "/bar", "404: 404 page not found", nil) + check("GET", "/foo/bar/", "404: 404 page not found", nil) + check("GET", "/foo/bar", "200: that's me", nil) + check("PUT", "/foo/bar", "201: here I am", nil) + check("DELETE", "/foo/bar", "202: goodbye", nil) + + // check Choice(), different behavior for MethodHead in Handlers() + m = pr.Choice( + pr.Element("implied", pr.Handlers(pr.ByMethod{ + http.MethodGet: h(http.StatusOK, "implied"), + })), + pr.Element("separate", pr.Handlers(pr.ByMethod{ + http.MethodGet: h(http.StatusOK, "separate"), + http.MethodHead: h(http.StatusNoContent, ""), + })), + pr.Element("deleted", pr.Handlers(pr.ByMethod{ + http.MethodGet: h(http.StatusOK, "deleted"), + http.MethodHead: nil, + })), + ) + check("GET", "/implied", "200: implied", nil) + check("HEAD", "/implied", "200: <none>", nil) + check("GET", "/separate", "200: separate", nil) + check("HEAD", "/separate", "204: <none>", nil) + check("GET", "/deleted", "200: deleted", nil) + check("HEAD", "/deleted", "405: Method Not Allowed", map[string]string{"Allow": "GET"}) + + // check CatchAllVariable(), esp. with a variable number of acceptable path length in the inner matcher + m = pr.CatchAllVariable("path", pr.Choice( + pr.Element("one", pr.Handlers(pr.ByMethod{ + http.MethodGet: h(http.StatusOK, "one $path"), + })), + pr.Element("two", pr.Element("two", pr.Handlers(pr.ByMethod{ + http.MethodGet: h(http.StatusOK, "two $path"), + }))), + pr.Element("three", pr.Element("three", pr.Element("three", pr.Handlers(pr.ByMethod{ + http.MethodGet: h(http.StatusOK, "three $path"), + })))), + )) + + check("GET", "/foo/bar/one", "200: one foo/bar", nil) + check("GET", "/foo/bar/two/two", "200: two foo/bar", nil) + check("GET", "/foo/bar/three/three/three", "200: three foo/bar", nil) + + check("GET", "/foo%2Fbar/one", "200: one foo/bar", nil) + check("GET", "/foo%2Fbar/two%2Ftwo", "404: 404 page not found", nil) + + check("GET", "/long/path/but/no/match", "404: 404 page not found", nil) + check("GET", "/shortpathbutnomatch", "404: 404 page not found", nil) + + // check Variable() + m = pr.Element("nice", pr.Element("objects", pr.Variable("id", pr.Here(pr.Handlers(pr.ByMethod{ + http.MethodPut: h(http.StatusCreated, "created object $id"), + }))))) + + check("PUT", "/nice/objects", "404: 404 page not found", nil) + check("PUT", "/nice/objects/", "404: 404 page not found", nil) + check("PUT", "/nice/objects/42", "201: created object 42", nil) + check("PUT", "/nice/objects/4/2", "404: 404 page not found", nil) // variable can onlt catch one element + check("PUT", "/nice/objects/4%2F2", "201: created object 4/2", nil) + check("PUT", "/nice/objects/4%2F2/", "201: created object 4/2", nil) +} + +func TestPanics(t *testing.T) { + check := func(expected string, action func()) { + t.Helper() + result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) { action() }) + assert.Equal(t, result, testcapture.Result{ + Outcome: testcapture.OutcomePanicked, + Panic: expected, + }) + } + + check(`matcher within CatchAllVariable() may not accept unlimited path lengths`, func() { + pr.CatchAllVariable("first", pr.Choice(pr.CatchAllVariable("second", pr.Handlers(nil)))) + }) + + check(`Choice() called without any matchers`, func() { + pr.Element("", pr.Choice()) + }) + + check(`Element() called with value = ""`, func() { + pr.Element("", pr.Handlers(nil)) + }) + + check(`matcher within Here() must be Handlers()`, func() { + pr.Here(pr.Element("foo", pr.Handlers(nil))) + }) + + check(`handler is nil for method "POST"`, func() { + pr.Handlers(pr.ByMethod{ + http.MethodPost: nil, + }) + }) +} diff --git a/pathrouter/utils.go b/pathrouter/utils.go new file mode 100644 index 0000000..e8704a7 --- /dev/null +++ b/pathrouter/utils.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package pathrouter + +import ( + "fmt" + "net/url" +) + +func pathUnescape(in string) string { + out, err := url.PathUnescape(in) + if err != nil { + // defense in depth: should not fail because `in` was indirectly obtained from r.URL.EscapedPath() + panic(fmt.Sprintf("PathUnescape failed on %q: %s", in, err.Error())) + } + return out +} + +func increment(x int) int { + return x + 1 +} diff --git a/pathrouter/variable.go b/pathrouter/variable.go new file mode 100644 index 0000000..e712f46 --- /dev/null +++ b/pathrouter/variable.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package pathrouter + +import "go.xyrillian.de/gg/options" + +// Variable is a [Matcher] that accepts subpaths with at least one path element. +// The value of the first path element will be collected into vars[name], +// and the remaining subpath will have to be accepted by the next matcher. +func Variable(name string, matcher Matcher) Matcher { + return variable(name, matcher.downcast()) +} + +func variable(name string, matcher realMatcher) Matcher { + return realMatcher{ + minLength: matcher.minLength + 1, + maxLength: options.Map(matcher.maxLength, increment), + accept: func(path []string, vars map[string]string) HandlerFunc { + if len(path) == 0 || path[0] == "" { + return nil + } + handlerFunc := matcher.accept(path[1:], vars) + if handlerFunc == nil { + return nil + } + vars[name] = pathUnescape(path[0]) + return handlerFunc + }, + } +} |
