@marcel91200 / caddy Mirror · read-only / commits / 13a4c3f4
Fork 0

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 +}