From 8e9889d3e5b2874854832d1d882d124132546f49 Mon Sep 17 00:00:00 2001 From: Stefan Majewsky Date: Sat, 27 Jun 2026 22:34:36 +0200 Subject: add package pathrouter --- pathrouter/catchallvariable.go | 60 +++++++++++++ pathrouter/choice.go | 61 +++++++++++++ pathrouter/element.go | 34 ++++++++ pathrouter/handler.go | 99 ++++++++++++++++++++++ pathrouter/here.go | 53 ++++++++++++ pathrouter/matcher.go | 148 ++++++++++++++++++++++++++++++++ pathrouter/matcher_test.go | 33 ++++++++ pathrouter/pathrouter_test.go | 188 +++++++++++++++++++++++++++++++++++++++++ pathrouter/utils.go | 22 +++++ pathrouter/variable.go | 31 +++++++ 10 files changed, 729 insertions(+) create mode 100644 pathrouter/catchallvariable.go create mode 100644 pathrouter/choice.go create mode 100644 pathrouter/element.go create mode 100644 pathrouter/handler.go create mode 100644 pathrouter/here.go create mode 100644 pathrouter/matcher.go create mode 100644 pathrouter/matcher_test.go create mode 100644 pathrouter/pathrouter_test.go create mode 100644 pathrouter/utils.go create mode 100644 pathrouter/variable.go (limited to 'pathrouter') 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 +// 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 +// 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 +// 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 +// 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 +// 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 +// 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 +// 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 +// 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, "")) + 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: ", 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: ", 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: ", nil) + check("GET", "/separate", "200: separate", nil) + check("HEAD", "/separate", "204: ", 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 +// 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 +// 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 + }, + } +} -- cgit v1.3.1