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
pull/7828/head^2
Kévin Dunglas 2026-06-22 18:25:43 +02:00 committed by GitHub
parent f450068460
commit 13a4c3f43c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 581 additions and 3 deletions

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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{

View File

@ -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)
)

View File

@ -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)
}
}