aboutsummaryrefslogtreecommitdiff
path: root/pathrouter/matcher.go
blob: f0f98ec376a0330a8fd3a61e26463307878eb7a8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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
}