Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 08:30 UTC
caddyhttp: add URL pattern request matcher (#7787)
* caddyhttp: add url_pattern request matcher Match requests against a URLPattern (https://urlpattern.spec.whatwg.org/), supporting named groups, wildcards and regexp components beyond the path matcher. Relative patterns match any origin; absolute patterns or base_url scope to scheme and host. Exposes a url_pattern CEL function and publishes captured groups as {http.url_pattern.<component>.<group>} placeholders. * caddyhttp: add Caddyfile adapt test for url_pattern matcher
Kévin Dunglas committed
Jun 22, 2026 at 18:25 UTC
13a4c3f43c79ca04064457ab9cf95b376c294141
6 files changed
+581
-3
caddytest/integration/caddyfile_adapt/matcher_url_pattern.caddyfiletest
new
+76
@@ -0,0 +1,76 @@
1
+:80 {
2
+ @books url_pattern /books/:id
3
+ respond @books "book {http.url_pattern.pathname.id}"
4
+
5
+ @scoped url_pattern /api/:version/* {
6
+ base_url https://example.com
7
+ ignore_case
8
+ }
9
+ respond @scoped "scoped"
10
+
11
+ @absolute url_pattern https://example.com/users/:name
12
+ respond @absolute "user {http.url_pattern.pathname.name}"
13
+}
14
+----------
15
+{
16
+ "apps": {
17
+ "http": {
18
+ "servers": {
19
+ "srv0": {
20
+ "listen": [
21
+ ":80"
22
+ ],
23
+ "routes": [
24
+ {
25
+ "match": [
26
+ {
27
+ "url_pattern": {
28
+ "pattern": "/books/:id"
29
+ }
30
+ }
31
+ ],
32
+ "handle": [
33
+ {
34
+ "body": "book {http.url_pattern.pathname.id}",
35
+ "handler": "static_response"
36
+ }
37
+ ]
38
+ },
39
+ {
40
+ "match": [
41
+ {
42
+ "url_pattern": {
43
+ "pattern": "/api/:version/*",
44
+ "base_url": "https://example.com",
45
+ "ignore_case": true
46
+ }
47
+ }
48
+ ],
49
+ "handle": [
50
+ {
51
+ "body": "scoped",
52
+ "handler": "static_response"
53
+ }
54
+ ]
55
+ },
56
+ {
57
+ "match": [
58
+ {
59
+ "url_pattern": {
60
+ "pattern": "https://example.com/users/:name"
61
+ }
62
+ }
63
+ ],
64
+ "handle": [
65
+ {
66
+ "body": "user {http.url_pattern.pathname.name}",
67
+ "handler": "static_response"
68
+ }
69
+ ]
70
+ }
71
+ ]
72
+ }
73
+ }
74
+ }
75
+ }
76
+}
go.mod
+4
-1
@@ -12,6 +12,7 @@ require (
12
github.com/caddyserver/certmagic v0.25.3
13
github.com/caddyserver/zerossl v0.1.5
14
github.com/cloudflare/circl v1.6.3
15
+ github.com/dunglas/go-urlpattern v0.0.0-20260421141449-cbab7cf1e16d
16
github.com/dustin/go-humanize v1.0.1
17
github.com/go-chi/chi/v5 v5.2.5
18
github.com/google/cel-go v0.28.1
@@ -58,6 +59,7 @@ require (
59
dario.cat/mergo v1.0.2 // indirect
60
filippo.io/bigmod v0.1.0 // indirect
61
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
62
+ github.com/bits-and-blooms/bitset v1.24.4 // indirect
63
github.com/ccoveille/go-safecast/v2 v2.0.0 // indirect
64
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
65
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
@@ -76,6 +78,7 @@ require (
78
github.com/jackc/puddle/v2 v2.2.2 // indirect
79
github.com/kylelemons/godebug v1.1.0 // indirect
80
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
81
+ github.com/nlnwa/whatwg-url v0.6.2 // indirect
82
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
83
github.com/pmezard/go-difflib v1.0.0 // indirect
84
github.com/prometheus/otlptranslator v1.0.0 // indirect
@@ -107,7 +110,7 @@ require (
110
go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect
111
go.yaml.in/yaml/v2 v2.4.4 // indirect
112
go.yaml.in/yaml/v3 v3.0.4 // indirect
110
- golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
113
+ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
114
golang.org/x/oauth2 v0.36.0 // indirect
115
google.golang.org/api v0.277.0 // indirect
116
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect
go.sum
+15
-2
@@ -85,6 +85,9 @@ github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
85
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
86
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
87
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
88
+github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
89
+github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
90
+github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
91
github.com/caddyserver/certmagic v0.25.3 h1:mGf5ba8F7xA4c5jfDZZbK2buY1VEkbnwpMDixaju94A=
92
github.com/caddyserver/certmagic v0.25.3/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA=
93
github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE=
@@ -135,6 +138,8 @@ github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k
138
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
139
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
140
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
141
+github.com/dunglas/go-urlpattern v0.0.0-20260421141449-cbab7cf1e16d h1:Mw8vvAx2b5bjIrQIJaVTO25vJY5DMBUO/hJGNLIl+6g=
142
+github.com/dunglas/go-urlpattern v0.0.0-20260421141449-cbab7cf1e16d/go.mod h1:9qyjDljBPOWyWCGz7vo3Ek7cdnoG/DVk0Ucle7gWVS8=
143
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
144
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
145
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
@@ -254,6 +259,8 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
259
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
260
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
261
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
262
+github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
263
+github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
264
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
265
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
266
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
@@ -455,13 +462,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
462
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
463
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
464
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
465
+golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
466
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
467
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
468
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
469
golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 h1:FmKxj9ocLKn45jiR2jQMwCVhDvaK7fKQFzfuT9GvyK8=
470
golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
463
-golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
464
-golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
471
+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
472
+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
473
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
474
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
475
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -477,6 +485,7 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
485
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
486
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
487
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
488
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
489
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
490
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
491
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -487,6 +496,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
496
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
497
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
498
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
499
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
500
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
501
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
502
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
@@ -505,6 +515,7 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
515
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
516
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
517
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
518
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
519
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
520
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
521
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -516,6 +527,7 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
527
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
528
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
529
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
530
+golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
531
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
532
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
533
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
@@ -527,6 +539,7 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
539
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
540
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
541
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
542
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
543
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
544
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
545
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
modules/caddyhttp/celmatcher_test.go
+32
@@ -263,6 +263,38 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
263
urlTarget: "https://example.com/foo/bar",
264
wantResult: false,
265
},
266
+ {
267
+ name: "url_pattern matches named group (MatchURLPattern)",
268
+ expression: &MatchExpression{
269
+ Expr: `url_pattern('/books/:id')`,
270
+ },
271
+ urlTarget: "https://example.com/books/123",
272
+ wantResult: true,
273
+ },
274
+ {
275
+ name: "url_pattern does not match (MatchURLPattern)",
276
+ expression: &MatchExpression{
277
+ Expr: `url_pattern('/books/:id')`,
278
+ },
279
+ urlTarget: "https://example.com/movies/123",
280
+ wantResult: false,
281
+ },
282
+ {
283
+ name: "url_pattern with base_url matches host (MatchURLPattern)",
284
+ expression: &MatchExpression{
285
+ Expr: `url_pattern('/foo', 'https://example.com')`,
286
+ },
287
+ urlTarget: "https://example.com/foo",
288
+ wantResult: true,
289
+ },
290
+ {
291
+ name: "url_pattern with base_url rejects other host (MatchURLPattern)",
292
+ expression: &MatchExpression{
293
+ Expr: `url_pattern('/foo', 'https://example.com')`,
294
+ },
295
+ urlTarget: "https://other.com/foo",
296
+ wantResult: false,
297
+ },
298
{
299
name: "protocol matches (MatchProtocol)",
300
expression: &MatchExpression{
modules/caddyhttp/urlpatternmatcher.go
new
+213
@@ -0,0 +1,213 @@
1
+package caddyhttp
2
+
3
+import (
4
+ "fmt"
5
+ "net/http"
6
+ "strings"
7
+
8
+ "github.com/dunglas/go-urlpattern"
9
+ "github.com/google/cel-go/cel"
10
+ "github.com/google/cel-go/common/types/ref"
11
+
12
+ "github.com/caddyserver/caddy/v2"
13
+ "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
14
+)
15
+
16
+// urlPatternPlaceholderPrefix namespaces placeholders for captured groups.
17
+const urlPatternPlaceholderPrefix = "http.url_pattern"
18
+
19
+func init() {
20
+ caddy.RegisterModule(&MatchURLPattern{})
21
+}
22
+
23
+// MatchURLPattern matches requests against a [URLPattern], giving named
24
+// groups, wildcards and regexp components beyond what the simpler path
25
+// matcher offers.
26
+//
27
+// [URLPattern]: https://urlpattern.spec.whatwg.org/
28
+type MatchURLPattern struct {
29
+ // Pattern is the URLPattern to match against. A relative pattern (e.g.
30
+ // "/books/:id") matches the request path on any host; an absolute pattern
31
+ // (e.g. "https://example.com/books/:id") also constrains scheme and host.
32
+ Pattern string `json:"pattern,omitempty"`
33
+
34
+ // BaseURL resolves a relative Pattern against a fixed origin, scoping the
35
+ // match to that scheme and host. Leave empty to match any origin.
36
+ BaseURL string `json:"base_url,omitempty"`
37
+
38
+ // IgnoreCase matches the pattern case-insensitively.
39
+ IgnoreCase bool `json:"ignore_case,omitempty"`
40
+
41
+ compiledPattern *urlpattern.URLPattern
42
+}
43
+
44
+// CaddyModule returns the Caddy module information.
45
+func (*MatchURLPattern) CaddyModule() caddy.ModuleInfo {
46
+ return caddy.ModuleInfo{
47
+ ID: "http.matchers.url_pattern",
48
+ New: func() caddy.Module { return new(MatchURLPattern) },
49
+ }
50
+}
51
+
52
+// Provision compiles the URL pattern.
53
+func (m *MatchURLPattern) Provision(_ caddy.Context) error {
54
+ input := m.Pattern
55
+
56
+ // A relative pattern with no base matches any origin: prefix wildcard
57
+ // protocol, host and port so only the path, search and hash are
58
+ // constrained. The port wildcard is needed because a request Host may
59
+ // carry an explicit port. Host-scoped matching stays opt-in via an
60
+ // absolute pattern or base_url.
61
+ if m.BaseURL == "" && !strings.Contains(input, "://") {
62
+ input = "*://*:*" + input
63
+ }
64
+
65
+ p, err := urlpattern.New(input, m.BaseURL, &urlpattern.Options{IgnoreCase: m.IgnoreCase})
66
+ if err != nil {
67
+ return fmt.Errorf("unable to parse URL pattern: %w", err)
68
+ }
69
+
70
+ m.compiledPattern = p
71
+
72
+ return nil
73
+}
74
+
75
+// Match returns true if the request matches the URL pattern.
76
+func (m *MatchURLPattern) Match(r *http.Request) bool {
77
+ ok, _ := m.MatchWithError(r)
78
+
79
+ return ok
80
+}
81
+
82
+// MatchWithError returns true if the request matches the URL pattern. The
83
+// request's origin (scheme://host) is the base against which the path is
84
+// resolved, so an absolute pattern or base_url can match on scheme and host.
85
+//
86
+// On a match, captured groups are exposed as placeholders scoped by URL
87
+// component, mirroring the URLPattern result object: a named group :id in the
88
+// pathname becomes {http.url_pattern.pathname.id}, a group q in the query
89
+// becomes {http.url_pattern.search.q}, and so on.
90
+func (m *MatchURLPattern) MatchWithError(r *http.Request) (bool, error) {
91
+ scheme := "http"
92
+ if r.TLS != nil {
93
+ scheme = "https"
94
+ }
95
+
96
+ result := m.compiledPattern.Exec(r.URL.RequestURI(), scheme+"://"+r.Host)
97
+ if result == nil {
98
+ return false, nil
99
+ }
100
+
101
+ if repl, ok := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer); ok {
102
+ setURLPatternGroups(repl, "protocol", result.Protocol)
103
+ setURLPatternGroups(repl, "username", result.Username)
104
+ setURLPatternGroups(repl, "password", result.Password)
105
+ setURLPatternGroups(repl, "hostname", result.Hostname)
106
+ setURLPatternGroups(repl, "port", result.Port)
107
+ setURLPatternGroups(repl, "pathname", result.Pathname)
108
+ setURLPatternGroups(repl, "search", result.Search)
109
+ setURLPatternGroups(repl, "hash", result.Hash)
110
+ }
111
+
112
+ return true, nil
113
+}
114
+
115
+// setURLPatternGroups publishes a component's captured groups as
116
+// {http.url_pattern.<component>.<group>} placeholders.
117
+func setURLPatternGroups(repl *caddy.Replacer, component string, c urlpattern.URLPatternComponentResult) {
118
+ for name, value := range c.Groups {
119
+ repl.Set(urlPatternPlaceholderPrefix+"."+component+"."+name, value)
120
+ }
121
+}
122
+
123
+// CELLibrary produces options that expose this matcher for use in CEL
124
+// expression matchers.
125
+//
126
+// Example:
127
+//
128
+// expression url_pattern('/books/:id')
129
+// expression url_pattern('/books/:id', 'https://example.com')
130
+func (MatchURLPattern) CELLibrary(ctx caddy.Context) (cel.Library, error) {
131
+ pattern, err := CELMatcherImpl(
132
+ "url_pattern",
133
+ "url_pattern_request_string",
134
+ []*cel.Type{cel.StringType},
135
+ func(data ref.Val) (RequestMatcherWithError, error) {
136
+ pattern, ok := data.Value().(string)
137
+ if !ok {
138
+ return nil, fmt.Errorf("url_pattern expects a string argument")
139
+ }
140
+
141
+ matcher := MatchURLPattern{Pattern: pattern}
142
+ err := matcher.Provision(ctx)
143
+
144
+ return &matcher, err
145
+ },
146
+ )
147
+ if err != nil {
148
+ return nil, err
149
+ }
150
+
151
+ patternWithBase, err := CELMatcherImpl(
152
+ "url_pattern",
153
+ "url_pattern_request_string_string",
154
+ []*cel.Type{cel.StringType, cel.StringType},
155
+ func(data ref.Val) (RequestMatcherWithError, error) {
156
+ params, err := data.ConvertToNative(stringSliceType)
157
+ if err != nil {
158
+ return nil, err
159
+ }
160
+ strParams := params.([]string)
161
+ matcher := MatchURLPattern{Pattern: strParams[0], BaseURL: strParams[1]}
162
+ err = matcher.Provision(ctx)
163
+ return &matcher, err
164
+ },
165
+ )
166
+ if err != nil {
167
+ return nil, err
168
+ }
169
+
170
+ envOpts := append(pattern.CompileOptions(), patternWithBase.CompileOptions()...)
171
+ prgOpts := append(pattern.ProgramOptions(), patternWithBase.ProgramOptions()...)
172
+ return NewMatcherCELLibrary(envOpts, prgOpts), nil
173
+}
174
+
175
+// UnmarshalCaddyfile implements caddyfile.Unmarshaler. Syntax:
176
+//
177
+// url_pattern <pattern> {
178
+// base_url <url>
179
+// ignore_case
180
+// }
181
+func (m *MatchURLPattern) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
182
+ for d.Next() {
183
+ if !d.Args(&m.Pattern) {
184
+ return d.Err("expected exactly one URL pattern")
185
+ }
186
+
187
+ for d.NextBlock(0) {
188
+ switch d.Val() {
189
+ case "base_url":
190
+ if !d.Args(&m.BaseURL) {
191
+ return d.ArgErr()
192
+ }
193
+ case "ignore_case":
194
+ if d.NextArg() {
195
+ return d.ArgErr()
196
+ }
197
+ m.IgnoreCase = true
198
+ default:
199
+ return d.Errf("unrecognized url_pattern option '%s'", d.Val())
200
+ }
201
+ }
202
+ }
203
+
204
+ return nil
205
+}
206
+
207
+// Interface guards
208
+var (
209
+ _ RequestMatcherWithError = (*MatchURLPattern)(nil)
210
+ _ caddy.Provisioner = (*MatchURLPattern)(nil)
211
+ _ caddyfile.Unmarshaler = (*MatchURLPattern)(nil)
212
+ _ CELLibraryProducer = (*MatchURLPattern)(nil)
213
+)
modules/caddyhttp/urlpatternmatcher_test.go
new
+241
@@ -0,0 +1,241 @@
1
+package caddyhttp
2
+
3
+import (
4
+ "context"
5
+ "crypto/tls"
6
+ "net/http"
7
+ "net/url"
8
+ "testing"
9
+
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+
13
+ "github.com/caddyserver/caddy/v2"
14
+ "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
15
+)
16
+
17
+func TestURLPatternMatcher(t *testing.T) {
18
+ for _, tc := range []struct {
19
+ name string
20
+ match MatchURLPattern
21
+ host string
22
+ tls bool
23
+ input string
24
+ expect bool
25
+ provisionErr bool
26
+ }{
27
+ {
28
+ name: "literal path matches",
29
+ match: MatchURLPattern{Pattern: "/foo"},
30
+ host: "example.com",
31
+ input: "/foo",
32
+ expect: true,
33
+ },
34
+ {
35
+ name: "literal path mismatch",
36
+ match: MatchURLPattern{Pattern: "/foo"},
37
+ host: "example.com",
38
+ input: "/bar",
39
+ expect: false,
40
+ },
41
+ {
42
+ name: "named group matches",
43
+ match: MatchURLPattern{Pattern: "/books/:id"},
44
+ host: "example.com",
45
+ input: "/books/123",
46
+ expect: true,
47
+ },
48
+ {
49
+ name: "named group requires segment",
50
+ match: MatchURLPattern{Pattern: "/books/:id"},
51
+ host: "example.com",
52
+ input: "/books",
53
+ expect: false,
54
+ },
55
+ {
56
+ name: "wildcard spans segments",
57
+ match: MatchURLPattern{Pattern: "/files/*"},
58
+ host: "example.com",
59
+ input: "/files/a/b/c",
60
+ expect: true,
61
+ },
62
+ {
63
+ name: "absolute pattern matches host and scheme",
64
+ match: MatchURLPattern{Pattern: "https://example.com/foo"},
65
+ host: "example.com",
66
+ tls: true,
67
+ input: "/foo",
68
+ expect: true,
69
+ },
70
+ {
71
+ name: "absolute pattern rejects scheme mismatch",
72
+ match: MatchURLPattern{Pattern: "https://example.com/foo"},
73
+ host: "example.com",
74
+ input: "/foo",
75
+ expect: false,
76
+ },
77
+ {
78
+ name: "absolute pattern rejects host mismatch",
79
+ match: MatchURLPattern{Pattern: "https://example.com/foo"},
80
+ host: "other.com",
81
+ tls: true,
82
+ input: "/foo",
83
+ expect: false,
84
+ },
85
+ {
86
+ name: "ignore_case matches mixed case",
87
+ match: MatchURLPattern{Pattern: "/foo", IgnoreCase: true},
88
+ host: "example.com",
89
+ input: "/FOO",
90
+ expect: true,
91
+ },
92
+ {
93
+ name: "case sensitive by default",
94
+ match: MatchURLPattern{Pattern: "/foo"},
95
+ host: "example.com",
96
+ input: "/FOO",
97
+ expect: false,
98
+ },
99
+ {
100
+ name: "base_url scopes to host",
101
+ match: MatchURLPattern{Pattern: "/search", BaseURL: "https://example.com"},
102
+ host: "example.com",
103
+ tls: true,
104
+ input: "/search?q=caddy",
105
+ expect: true,
106
+ },
107
+ {
108
+ name: "base_url rejects other host",
109
+ match: MatchURLPattern{Pattern: "/search", BaseURL: "https://example.com"},
110
+ host: "other.com",
111
+ tls: true,
112
+ input: "/search",
113
+ expect: false,
114
+ },
115
+ {
116
+ name: "invalid pattern fails provisioning",
117
+ match: MatchURLPattern{Pattern: "https://[invalid"},
118
+ provisionErr: true,
119
+ },
120
+ } {
121
+ t.Run(tc.name, func(t *testing.T) {
122
+ err := tc.match.Provision(caddy.Context{})
123
+ if tc.provisionErr {
124
+ require.Error(t, err)
125
+ return
126
+ }
127
+ require.NoError(t, err)
128
+
129
+ u, err := url.ParseRequestURI(tc.input)
130
+ require.NoError(t, err)
131
+
132
+ req := &http.Request{URL: u, Host: tc.host}
133
+ if tc.tls {
134
+ req.TLS = &tls.ConnectionState{}
135
+ }
136
+
137
+ actual, err := tc.match.MatchWithError(req)
138
+ require.NoError(t, err)
139
+ assert.Equal(t, tc.expect, actual)
140
+ })
141
+ }
142
+}
143
+
144
+func TestURLPatternMatcherUnmarshalCaddyfile(t *testing.T) {
145
+ for _, tc := range []struct {
146
+ name string
147
+ input string
148
+ expect MatchURLPattern
149
+ expectErr bool
150
+ }{
151
+ {
152
+ name: "pattern only",
153
+ input: `url_pattern /books/:id`,
154
+ expect: MatchURLPattern{Pattern: "/books/:id"},
155
+ },
156
+ {
157
+ name: "base_url and ignore_case",
158
+ input: `url_pattern /search {
159
+ base_url https://example.com
160
+ ignore_case
161
+ }`,
162
+ expect: MatchURLPattern{Pattern: "/search", BaseURL: "https://example.com", IgnoreCase: true},
163
+ },
164
+ {
165
+ name: "missing pattern",
166
+ input: `url_pattern`,
167
+ expectErr: true,
168
+ },
169
+ {
170
+ name: "unknown option",
171
+ input: `url_pattern /foo {
172
+ nope
173
+ }`,
174
+ expectErr: true,
175
+ },
176
+ {
177
+ name: "base_url without value",
178
+ input: `url_pattern /foo {
179
+ base_url
180
+ }`,
181
+ expectErr: true,
182
+ },
183
+ {
184
+ name: "ignore_case with stray arg",
185
+ input: `url_pattern /foo {
186
+ ignore_case yes
187
+ }`,
188
+ expectErr: true,
189
+ },
190
+ } {
191
+ t.Run(tc.name, func(t *testing.T) {
192
+ var m MatchURLPattern
193
+ err := m.UnmarshalCaddyfile(caddyfile.NewTestDispenser(tc.input))
194
+ if tc.expectErr {
195
+ require.Error(t, err)
196
+ return
197
+ }
198
+ require.NoError(t, err)
199
+ assert.Equal(t, tc.expect, m)
200
+ })
201
+ }
202
+}
203
+
204
+// TestURLPatternMatcherGroups checks that captured groups are exposed as
205
+// component-scoped placeholders, mirroring the URLPattern result object.
206
+func TestURLPatternMatcherGroups(t *testing.T) {
207
+ m := MatchURLPattern{Pattern: "/books/:id/chapters/:chapter"}
208
+ require.NoError(t, m.Provision(caddy.Context{}))
209
+
210
+ u, err := url.ParseRequestURI("/books/42/chapters/7")
211
+ require.NoError(t, err)
212
+
213
+ repl := caddy.NewReplacer()
214
+ ctx := context.WithValue(context.Background(), caddy.ReplacerCtxKey, repl)
215
+ req := (&http.Request{URL: u, Host: "example.com"}).WithContext(ctx)
216
+
217
+ ok, err := m.MatchWithError(req)
218
+ require.NoError(t, err)
219
+ require.True(t, ok)
220
+
221
+ id, _ := repl.GetString("http.url_pattern.pathname.id")
222
+ assert.Equal(t, "42", id)
223
+ chapter, _ := repl.GetString("http.url_pattern.pathname.chapter")
224
+ assert.Equal(t, "7", chapter)
225
+}
226
+
227
+// TestURLPatternMatcherRelative checks that a relative pattern matches the
228
+// request path regardless of the request's host.
229
+func TestURLPatternMatcherRelative(t *testing.T) {
230
+ m := MatchURLPattern{Pattern: "/books/:id"}
231
+ require.NoError(t, m.Provision(caddy.Context{}))
232
+
233
+ for _, host := range []string{"example.com", "other.org", "192.0.2.1:8080"} {
234
+ u, err := url.ParseRequestURI("/books/42")
235
+ require.NoError(t, err)
236
+
237
+ ok, err := m.MatchWithError(&http.Request{URL: u, Host: host})
238
+ require.NoError(t, err)
239
+ assert.Truef(t, ok, "expected match on host %q", host)
240
+ }
241
+}