aboutsummaryrefslogtreecommitdiff
path: root/pathrouter
diff options
context:
space:
mode:
Diffstat (limited to 'pathrouter')
-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
10 files changed, 729 insertions, 0 deletions
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
+ },
+ }
+}