aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-06-27 22:34:36 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-06-27 22:34:36 +0200
commit8e9889d3e5b2874854832d1d882d124132546f49 (patch)
tree80ab6bf53dac3090630ad58d7ebfbbf2c97019ca
parent8804729561d8e5edd7179f494d89c620459d319f (diff)
downloadgo-gg-8e9889d3e5b2874854832d1d882d124132546f49.tar.gz
add package pathrouter
-rw-r--r--.golangci.yaml1
-rw-r--r--CHANGELOG.md3
-rw-r--r--README.md1
-rw-r--r--REUSE.toml3
-rw-r--r--benchmark/go.mod8
-rw-r--r--benchmark/go.sum4
-rw-r--r--benchmark/pathrouter_test.go117
-rw-r--r--go.work6
-rw-r--r--pathrouter/catchallvariable.go60
-rw-r--r--pathrouter/choice.go61
-rw-r--r--pathrouter/element.go34
-rw-r--r--pathrouter/handler.go99
-rw-r--r--pathrouter/here.go53
-rw-r--r--pathrouter/matcher.go148
-rw-r--r--pathrouter/matcher_test.go33
-rw-r--r--pathrouter/pathrouter_test.go188
-rw-r--r--pathrouter/utils.go22
-rw-r--r--pathrouter/variable.go31
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)
diff --git a/README.md b/README.md
index e840bb5..ee306fa 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/REUSE.toml b/REUSE.toml
index 16fee29..0f8b58a 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -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,
+ }))),
+ )),
+ )),
+ )
+}
diff --git a/go.work b/go.work
new file mode 100644
index 0000000..c163e32
--- /dev/null
+++ b/go.work
@@ -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
+ },
+ }
+}