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 matcherpull/7828/head^2
parent
f450068460
commit
13a4c3f43c
|
|
@ -0,0 +1,76 @@
|
|||
:80 {
|
||||
@books url_pattern /books/:id
|
||||
respond @books "book {http.url_pattern.pathname.id}"
|
||||
|
||||
@scoped url_pattern /api/:version/* {
|
||||
base_url https://example.com
|
||||
ignore_case
|
||||
}
|
||||
respond @scoped "scoped"
|
||||
|
||||
@absolute url_pattern https://example.com/users/:name
|
||||
respond @absolute "user {http.url_pattern.pathname.name}"
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":80"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"url_pattern": {
|
||||
"pattern": "/books/:id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"body": "book {http.url_pattern.pathname.id}",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"url_pattern": {
|
||||
"pattern": "/api/:version/*",
|
||||
"base_url": "https://example.com",
|
||||
"ignore_case": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"body": "scoped",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"url_pattern": {
|
||||
"pattern": "https://example.com/users/:name"
|
||||
}
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"body": "user {http.url_pattern.pathname.name}",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
go.mod
5
go.mod
|
|
@ -12,6 +12,7 @@ require (
|
|||
github.com/caddyserver/certmagic v0.25.3
|
||||
github.com/caddyserver/zerossl v0.1.5
|
||||
github.com/cloudflare/circl v1.6.3
|
||||
github.com/dunglas/go-urlpattern v0.0.0-20260421141449-cbab7cf1e16d
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/cel-go v0.28.1
|
||||
|
|
@ -58,6 +59,7 @@ require (
|
|||
dario.cat/mergo v1.0.2 // indirect
|
||||
filippo.io/bigmod v0.1.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/ccoveille/go-safecast/v2 v2.0.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
|
||||
|
|
@ -76,6 +78,7 @@ require (
|
|||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nlnwa/whatwg-url v0.6.2 // indirect
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
|
|
@ -107,7 +110,7 @@ require (
|
|||
go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
google.golang.org/api v0.277.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect
|
||||
|
|
|
|||
17
go.sum
17
go.sum
|
|
@ -85,6 +85,9 @@ github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
|||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/caddyserver/certmagic v0.25.3 h1:mGf5ba8F7xA4c5jfDZZbK2buY1VEkbnwpMDixaju94A=
|
||||
github.com/caddyserver/certmagic v0.25.3/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA=
|
||||
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
|
|||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dunglas/go-urlpattern v0.0.0-20260421141449-cbab7cf1e16d h1:Mw8vvAx2b5bjIrQIJaVTO25vJY5DMBUO/hJGNLIl+6g=
|
||||
github.com/dunglas/go-urlpattern v0.0.0-20260421141449-cbab7cf1e16d/go.mod h1:9qyjDljBPOWyWCGz7vo3Ek7cdnoG/DVk0Ucle7gWVS8=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
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
|
|||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
|
||||
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
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
|
|||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 h1:FmKxj9ocLKn45jiR2jQMwCVhDvaK7fKQFzfuT9GvyK8=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
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=
|
|||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
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=
|
|||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
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=
|
|||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
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=
|
|||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
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=
|
|||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
|
|
|
|||
|
|
@ -263,6 +263,38 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
|
|||
urlTarget: "https://example.com/foo/bar",
|
||||
wantResult: false,
|
||||
},
|
||||
{
|
||||
name: "url_pattern matches named group (MatchURLPattern)",
|
||||
expression: &MatchExpression{
|
||||
Expr: `url_pattern('/books/:id')`,
|
||||
},
|
||||
urlTarget: "https://example.com/books/123",
|
||||
wantResult: true,
|
||||
},
|
||||
{
|
||||
name: "url_pattern does not match (MatchURLPattern)",
|
||||
expression: &MatchExpression{
|
||||
Expr: `url_pattern('/books/:id')`,
|
||||
},
|
||||
urlTarget: "https://example.com/movies/123",
|
||||
wantResult: false,
|
||||
},
|
||||
{
|
||||
name: "url_pattern with base_url matches host (MatchURLPattern)",
|
||||
expression: &MatchExpression{
|
||||
Expr: `url_pattern('/foo', 'https://example.com')`,
|
||||
},
|
||||
urlTarget: "https://example.com/foo",
|
||||
wantResult: true,
|
||||
},
|
||||
{
|
||||
name: "url_pattern with base_url rejects other host (MatchURLPattern)",
|
||||
expression: &MatchExpression{
|
||||
Expr: `url_pattern('/foo', 'https://example.com')`,
|
||||
},
|
||||
urlTarget: "https://other.com/foo",
|
||||
wantResult: false,
|
||||
},
|
||||
{
|
||||
name: "protocol matches (MatchProtocol)",
|
||||
expression: &MatchExpression{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
package caddyhttp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/dunglas/go-urlpattern"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
)
|
||||
|
||||
// urlPatternPlaceholderPrefix namespaces placeholders for captured groups.
|
||||
const urlPatternPlaceholderPrefix = "http.url_pattern"
|
||||
|
||||
func init() {
|
||||
caddy.RegisterModule(&MatchURLPattern{})
|
||||
}
|
||||
|
||||
// MatchURLPattern matches requests against a [URLPattern], giving named
|
||||
// groups, wildcards and regexp components beyond what the simpler path
|
||||
// matcher offers.
|
||||
//
|
||||
// [URLPattern]: https://urlpattern.spec.whatwg.org/
|
||||
type MatchURLPattern struct {
|
||||
// Pattern is the URLPattern to match against. A relative pattern (e.g.
|
||||
// "/books/:id") matches the request path on any host; an absolute pattern
|
||||
// (e.g. "https://example.com/books/:id") also constrains scheme and host.
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
|
||||
// BaseURL resolves a relative Pattern against a fixed origin, scoping the
|
||||
// match to that scheme and host. Leave empty to match any origin.
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
|
||||
// IgnoreCase matches the pattern case-insensitively.
|
||||
IgnoreCase bool `json:"ignore_case,omitempty"`
|
||||
|
||||
compiledPattern *urlpattern.URLPattern
|
||||
}
|
||||
|
||||
// CaddyModule returns the Caddy module information.
|
||||
func (*MatchURLPattern) CaddyModule() caddy.ModuleInfo {
|
||||
return caddy.ModuleInfo{
|
||||
ID: "http.matchers.url_pattern",
|
||||
New: func() caddy.Module { return new(MatchURLPattern) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision compiles the URL pattern.
|
||||
func (m *MatchURLPattern) Provision(_ caddy.Context) error {
|
||||
input := m.Pattern
|
||||
|
||||
// A relative pattern with no base matches any origin: prefix wildcard
|
||||
// protocol, host and port so only the path, search and hash are
|
||||
// constrained. The port wildcard is needed because a request Host may
|
||||
// carry an explicit port. Host-scoped matching stays opt-in via an
|
||||
// absolute pattern or base_url.
|
||||
if m.BaseURL == "" && !strings.Contains(input, "://") {
|
||||
input = "*://*:*" + input
|
||||
}
|
||||
|
||||
p, err := urlpattern.New(input, m.BaseURL, &urlpattern.Options{IgnoreCase: m.IgnoreCase})
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to parse URL pattern: %w", err)
|
||||
}
|
||||
|
||||
m.compiledPattern = p
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match returns true if the request matches the URL pattern.
|
||||
func (m *MatchURLPattern) Match(r *http.Request) bool {
|
||||
ok, _ := m.MatchWithError(r)
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// MatchWithError returns true if the request matches the URL pattern. The
|
||||
// request's origin (scheme://host) is the base against which the path is
|
||||
// resolved, so an absolute pattern or base_url can match on scheme and host.
|
||||
//
|
||||
// On a match, captured groups are exposed as placeholders scoped by URL
|
||||
// component, mirroring the URLPattern result object: a named group :id in the
|
||||
// pathname becomes {http.url_pattern.pathname.id}, a group q in the query
|
||||
// becomes {http.url_pattern.search.q}, and so on.
|
||||
func (m *MatchURLPattern) MatchWithError(r *http.Request) (bool, error) {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
result := m.compiledPattern.Exec(r.URL.RequestURI(), scheme+"://"+r.Host)
|
||||
if result == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if repl, ok := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer); ok {
|
||||
setURLPatternGroups(repl, "protocol", result.Protocol)
|
||||
setURLPatternGroups(repl, "username", result.Username)
|
||||
setURLPatternGroups(repl, "password", result.Password)
|
||||
setURLPatternGroups(repl, "hostname", result.Hostname)
|
||||
setURLPatternGroups(repl, "port", result.Port)
|
||||
setURLPatternGroups(repl, "pathname", result.Pathname)
|
||||
setURLPatternGroups(repl, "search", result.Search)
|
||||
setURLPatternGroups(repl, "hash", result.Hash)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// setURLPatternGroups publishes a component's captured groups as
|
||||
// {http.url_pattern.<component>.<group>} placeholders.
|
||||
func setURLPatternGroups(repl *caddy.Replacer, component string, c urlpattern.URLPatternComponentResult) {
|
||||
for name, value := range c.Groups {
|
||||
repl.Set(urlPatternPlaceholderPrefix+"."+component+"."+name, value)
|
||||
}
|
||||
}
|
||||
|
||||
// CELLibrary produces options that expose this matcher for use in CEL
|
||||
// expression matchers.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// expression url_pattern('/books/:id')
|
||||
// expression url_pattern('/books/:id', 'https://example.com')
|
||||
func (MatchURLPattern) CELLibrary(ctx caddy.Context) (cel.Library, error) {
|
||||
pattern, err := CELMatcherImpl(
|
||||
"url_pattern",
|
||||
"url_pattern_request_string",
|
||||
[]*cel.Type{cel.StringType},
|
||||
func(data ref.Val) (RequestMatcherWithError, error) {
|
||||
pattern, ok := data.Value().(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("url_pattern expects a string argument")
|
||||
}
|
||||
|
||||
matcher := MatchURLPattern{Pattern: pattern}
|
||||
err := matcher.Provision(ctx)
|
||||
|
||||
return &matcher, err
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
patternWithBase, err := CELMatcherImpl(
|
||||
"url_pattern",
|
||||
"url_pattern_request_string_string",
|
||||
[]*cel.Type{cel.StringType, cel.StringType},
|
||||
func(data ref.Val) (RequestMatcherWithError, error) {
|
||||
params, err := data.ConvertToNative(stringSliceType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
strParams := params.([]string)
|
||||
matcher := MatchURLPattern{Pattern: strParams[0], BaseURL: strParams[1]}
|
||||
err = matcher.Provision(ctx)
|
||||
return &matcher, err
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
envOpts := append(pattern.CompileOptions(), patternWithBase.CompileOptions()...)
|
||||
prgOpts := append(pattern.ProgramOptions(), patternWithBase.ProgramOptions()...)
|
||||
return NewMatcherCELLibrary(envOpts, prgOpts), nil
|
||||
}
|
||||
|
||||
// UnmarshalCaddyfile implements caddyfile.Unmarshaler. Syntax:
|
||||
//
|
||||
// url_pattern <pattern> {
|
||||
// base_url <url>
|
||||
// ignore_case
|
||||
// }
|
||||
func (m *MatchURLPattern) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
||||
for d.Next() {
|
||||
if !d.Args(&m.Pattern) {
|
||||
return d.Err("expected exactly one URL pattern")
|
||||
}
|
||||
|
||||
for d.NextBlock(0) {
|
||||
switch d.Val() {
|
||||
case "base_url":
|
||||
if !d.Args(&m.BaseURL) {
|
||||
return d.ArgErr()
|
||||
}
|
||||
case "ignore_case":
|
||||
if d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
m.IgnoreCase = true
|
||||
default:
|
||||
return d.Errf("unrecognized url_pattern option '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Interface guards
|
||||
var (
|
||||
_ RequestMatcherWithError = (*MatchURLPattern)(nil)
|
||||
_ caddy.Provisioner = (*MatchURLPattern)(nil)
|
||||
_ caddyfile.Unmarshaler = (*MatchURLPattern)(nil)
|
||||
_ CELLibraryProducer = (*MatchURLPattern)(nil)
|
||||
)
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
package caddyhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
)
|
||||
|
||||
func TestURLPatternMatcher(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
match MatchURLPattern
|
||||
host string
|
||||
tls bool
|
||||
input string
|
||||
expect bool
|
||||
provisionErr bool
|
||||
}{
|
||||
{
|
||||
name: "literal path matches",
|
||||
match: MatchURLPattern{Pattern: "/foo"},
|
||||
host: "example.com",
|
||||
input: "/foo",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "literal path mismatch",
|
||||
match: MatchURLPattern{Pattern: "/foo"},
|
||||
host: "example.com",
|
||||
input: "/bar",
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "named group matches",
|
||||
match: MatchURLPattern{Pattern: "/books/:id"},
|
||||
host: "example.com",
|
||||
input: "/books/123",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "named group requires segment",
|
||||
match: MatchURLPattern{Pattern: "/books/:id"},
|
||||
host: "example.com",
|
||||
input: "/books",
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard spans segments",
|
||||
match: MatchURLPattern{Pattern: "/files/*"},
|
||||
host: "example.com",
|
||||
input: "/files/a/b/c",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "absolute pattern matches host and scheme",
|
||||
match: MatchURLPattern{Pattern: "https://example.com/foo"},
|
||||
host: "example.com",
|
||||
tls: true,
|
||||
input: "/foo",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "absolute pattern rejects scheme mismatch",
|
||||
match: MatchURLPattern{Pattern: "https://example.com/foo"},
|
||||
host: "example.com",
|
||||
input: "/foo",
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "absolute pattern rejects host mismatch",
|
||||
match: MatchURLPattern{Pattern: "https://example.com/foo"},
|
||||
host: "other.com",
|
||||
tls: true,
|
||||
input: "/foo",
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "ignore_case matches mixed case",
|
||||
match: MatchURLPattern{Pattern: "/foo", IgnoreCase: true},
|
||||
host: "example.com",
|
||||
input: "/FOO",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "case sensitive by default",
|
||||
match: MatchURLPattern{Pattern: "/foo"},
|
||||
host: "example.com",
|
||||
input: "/FOO",
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "base_url scopes to host",
|
||||
match: MatchURLPattern{Pattern: "/search", BaseURL: "https://example.com"},
|
||||
host: "example.com",
|
||||
tls: true,
|
||||
input: "/search?q=caddy",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "base_url rejects other host",
|
||||
match: MatchURLPattern{Pattern: "/search", BaseURL: "https://example.com"},
|
||||
host: "other.com",
|
||||
tls: true,
|
||||
input: "/search",
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "invalid pattern fails provisioning",
|
||||
match: MatchURLPattern{Pattern: "https://[invalid"},
|
||||
provisionErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.match.Provision(caddy.Context{})
|
||||
if tc.provisionErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := url.ParseRequestURI(tc.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := &http.Request{URL: u, Host: tc.host}
|
||||
if tc.tls {
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
}
|
||||
|
||||
actual, err := tc.match.MatchWithError(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expect, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLPatternMatcherUnmarshalCaddyfile(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
input string
|
||||
expect MatchURLPattern
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "pattern only",
|
||||
input: `url_pattern /books/:id`,
|
||||
expect: MatchURLPattern{Pattern: "/books/:id"},
|
||||
},
|
||||
{
|
||||
name: "base_url and ignore_case",
|
||||
input: `url_pattern /search {
|
||||
base_url https://example.com
|
||||
ignore_case
|
||||
}`,
|
||||
expect: MatchURLPattern{Pattern: "/search", BaseURL: "https://example.com", IgnoreCase: true},
|
||||
},
|
||||
{
|
||||
name: "missing pattern",
|
||||
input: `url_pattern`,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "unknown option",
|
||||
input: `url_pattern /foo {
|
||||
nope
|
||||
}`,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "base_url without value",
|
||||
input: `url_pattern /foo {
|
||||
base_url
|
||||
}`,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "ignore_case with stray arg",
|
||||
input: `url_pattern /foo {
|
||||
ignore_case yes
|
||||
}`,
|
||||
expectErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var m MatchURLPattern
|
||||
err := m.UnmarshalCaddyfile(caddyfile.NewTestDispenser(tc.input))
|
||||
if tc.expectErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expect, m)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestURLPatternMatcherGroups checks that captured groups are exposed as
|
||||
// component-scoped placeholders, mirroring the URLPattern result object.
|
||||
func TestURLPatternMatcherGroups(t *testing.T) {
|
||||
m := MatchURLPattern{Pattern: "/books/:id/chapters/:chapter"}
|
||||
require.NoError(t, m.Provision(caddy.Context{}))
|
||||
|
||||
u, err := url.ParseRequestURI("/books/42/chapters/7")
|
||||
require.NoError(t, err)
|
||||
|
||||
repl := caddy.NewReplacer()
|
||||
ctx := context.WithValue(context.Background(), caddy.ReplacerCtxKey, repl)
|
||||
req := (&http.Request{URL: u, Host: "example.com"}).WithContext(ctx)
|
||||
|
||||
ok, err := m.MatchWithError(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
id, _ := repl.GetString("http.url_pattern.pathname.id")
|
||||
assert.Equal(t, "42", id)
|
||||
chapter, _ := repl.GetString("http.url_pattern.pathname.chapter")
|
||||
assert.Equal(t, "7", chapter)
|
||||
}
|
||||
|
||||
// TestURLPatternMatcherRelative checks that a relative pattern matches the
|
||||
// request path regardless of the request's host.
|
||||
func TestURLPatternMatcherRelative(t *testing.T) {
|
||||
m := MatchURLPattern{Pattern: "/books/:id"}
|
||||
require.NoError(t, m.Provision(caddy.Context{}))
|
||||
|
||||
for _, host := range []string{"example.com", "other.org", "192.0.2.1:8080"} {
|
||||
u, err := url.ParseRequestURI("/books/42")
|
||||
require.NoError(t, err)
|
||||
|
||||
ok, err := m.MatchWithError(&http.Request{URL: u, Host: host})
|
||||
require.NoError(t, err)
|
||||
assert.Truef(t, ok, "expected match on host %q", host)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue