agent endpoints: route manifests through the path router

ParseManifest builds a router.Router rather than a slice of compiled templates,
Manifest aliases it, and Route keeps the template and the public flag the front
reads. Declared methods become a mask at build time, so a method no route could
declare masks to 0 and matches nothing, as an undeclared one already did.

Three behavior changes ride along.

Templates anchor as `\n?$`. A manifest declaring private /files/{p:path} before
public /files/{p} routed GET /files/x%0A to the public route here and to the
private one on the worker, because the path convertor refuses a newline where
str accepts it. The access gate reads the public flag of the route the front
picked, so it opened on a route starlette would not have selected.

slashAlternatePaths derives the escaped form from the transform it applied
rather than from the result's suffix. A request path of // trimmed to /, the
caller saw a trailing slash on that result, took the append branch, and handed
the worker a request line of /// against a decoded path of /.

The decoded path is capped at MaxPathLength, answering 414 above it. Matching
runs before anything sizes the request head, so net/http's own limit was the
only bound on what the matcher was handed.

A table too ambiguous to decide now dispatches rather than 404s: the worker's
own router still knows, and the preamble carries no route for it to be told
about. Nothing here knows whether that route is public, so the request needs a
grant. Dispatch keys on the match set, since a candidate can now join it
without resolving a route.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Wells
2026-09-15 10:22:04 -07:00
co-authored by Claude Opus 5
parent 0da02c4717
commit e53cec5a88
6 changed files with 286 additions and 342 deletions
+71 -40
View File
@@ -29,6 +29,7 @@ import (
"sync/atomic"
"time"
"github.com/livekit/livekit-server/pkg/agent/endpoint/router"
"github.com/livekit/livekit-server/pkg/agent/endpoint/wire"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
@@ -38,6 +39,10 @@ const (
// PathPrefix is the public route namespace: /agents/{agent_name}/{deployment}/{path...}
PathPrefix = "/agents/"
// MaxPathLength caps the escaped route path. Matching runs before the
// request head is sized, so this is the only bound on it.
MaxPathLength = 8 << 10
// responseHeadTimeout bounds the wait for the worker's response head. Bodies
// (SSE, long streams) are unbounded; the head never legitimately takes this
// long.
@@ -61,6 +66,7 @@ const (
var (
errNotEndpointPath = errors.New("endpoint: not an agent endpoint path")
errMalformedPath = errors.New("endpoint: malformed agent endpoint path")
errPathTooLong = errors.New("endpoint: agent endpoint path too long")
errRequestHeadTooLarge = errors.New("endpoint: request head too large")
errProtocolSwitch = errors.New("endpoint: worker switched protocols on an HTTP exchange")
errTooManyInformational = errors.New("endpoint: too many informational responses")
@@ -169,6 +175,10 @@ func (f *Front) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request path", http.StatusBadRequest)
return
}
if errors.Is(err, errPathTooLong) {
http.Error(w, "uri too long", http.StatusRequestURITooLong)
return
}
http.NotFound(w, r)
return
}
@@ -204,42 +214,22 @@ func (f *Front) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// manifest match across the deployment's workers: FULL wins; PARTIAL only
// yields 405 when nothing matches fully.
matchAll := func(p string) (matched []*Registration, route *Route, partial, denied bool) {
for _, reg := range candidates {
rt, res := reg.Manifest.Match(p, r.Method)
switch res {
case MatchFull:
if access.Level < AccessGranted && !rt.Public {
denied = true
continue
}
if route == nil {
route = rt
}
matched = append(matched, reg)
case MatchPartial:
partial = true
}
}
return
}
matched, route, partial, denied := matchAll(path)
mask := methodMask(r.Method)
granted := access.Level >= AccessGranted
matched, route, partial, denied := matchDeployment(candidates, path, mask, granted)
// no exact match: if only the trailing-slash alternate matches a registered
// route, normalize the path to that form and serve it directly (no client
// redirect). The exact form is tried first, so a route registered with a
// trailing slash is served as-is; this only rewrites a slash mismatch toward
// the registered form. When the request must be relayed, the serving node
// runs this same normalization, so no redirect is ever emitted.
if route == nil && !partial && !denied {
if alt, altEsc, ok := slashAlternatePaths(candidates, path, escPath, r.Method); ok {
if len(matched) == 0 && !partial && !denied {
if alt, altEsc, ok := slashAlternatePaths(candidates, path, escPath, mask); ok {
path, escPath = alt, altEsc
matched, route, partial, denied = matchAll(path)
matched, route, partial, denied = matchDeployment(candidates, path, mask, granted)
}
}
if route == nil && f.params.Fallback != nil {
if len(matched) == 0 && f.params.Fallback != nil {
// nothing local matched: hand off to the multi-node fallback (relay to a
// node holding the deployment) before the local status mapping. The
// serving node's relay listener installs no fallback of its own, so a
@@ -255,7 +245,7 @@ func (f *Front) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
}
if route == nil {
if len(matched) == 0 {
switch {
case denied:
// access does not vary across candidates, so one verdict covers them all
@@ -280,7 +270,7 @@ func (f *Front) ServeHTTP(w http.ResponseWriter, r *http.Request) {
target: requestTarget(escPath, r.URL.RawQuery),
route: route,
requestID: reqID,
granted: access.Level >= AccessGranted,
granted: granted,
pools: f.pools,
}
a.body = &countingReader{r: r.Body, n: &bodyConsumed}
@@ -334,21 +324,58 @@ func (f *Front) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.writeUnavailable(w, "no worker could serve the request")
}
// slashAlternatePaths looks for a candidate that serves the trailing-slash
// alternate of path, returning the decoded and escaped forms to retry.
func slashAlternatePaths(candidates []*Registration, path, escPath, method string) (string, string, bool) {
// matchDeployment resolves a path against every candidate worker's manifest.
// A candidate joins matched when it can serve it; route stays nil when a table
// was too ambiguous to decide, so matched is the dispatch test.
func matchDeployment(candidates []*Registration, path string, mask router.Mask, granted bool) (matched []*Registration, route *Route, partial, denied bool) {
for _, reg := range candidates {
alt, ok := reg.Manifest.slashAlternate(path, method)
if !ok {
continue
rt, res := reg.Manifest.Match(path, mask)
switch res {
case router.ResultFull:
if !granted && !rt.Public {
denied = true
continue
}
if route == nil {
route = rt
}
matched = append(matched, reg)
case router.ResultPartial:
partial = true
case router.ResultOverBudget:
// no route was decided, so its Public flag is unknown and only a grant
// can clear the request
if !granted {
denied = true
continue
}
matched = append(matched, reg)
}
}
return
}
// slashAlternatePaths looks for a candidate that serves the trailing-slash
// alternate of path, returning the decoded and escaped forms to retry. Both
// forms come out of the same transform.
func slashAlternatePaths(candidates []*Registration, path, escPath string, mask router.Mask) (string, string, bool) {
if path == "/" {
return path, escPath, false
}
var alt, altEsc string
if strings.HasSuffix(path, "/") {
// %2F decodes to a slash without being a separator, so the alternate
// applies only while the slash is literal in escPath too
switch {
case strings.HasSuffix(alt, "/"):
return alt, escPath + "/", true
case strings.HasSuffix(escPath, "/"):
return alt, strings.TrimSuffix(escPath, "/"), true
if !strings.HasSuffix(escPath, "/") {
return path, escPath, false
}
alt, altEsc = strings.TrimSuffix(path, "/"), strings.TrimSuffix(escPath, "/")
} else {
alt, altEsc = path+"/", escPath+"/"
}
for _, reg := range candidates {
if _, res := reg.Manifest.Match(alt, mask); res == router.ResultFull {
return alt, altEsc, true
}
}
return path, escPath, false
@@ -386,6 +413,10 @@ func splitEndpointPath(u *url.URL) (endpointPath, error) {
}
escPath = "/" + escPath
if len(escPath) > MaxPathLength {
return endpointPath{}, errPathTooLong
}
agentName, err1 := url.PathUnescape(rawAgentName)
deployment, err2 := url.PathUnescape(rawDeployment)
path, err3 := url.PathUnescape(escPath)
+55
View File
@@ -316,3 +316,58 @@ func TestIsReservedAgentName(t *testing.T) {
require.False(t, IsReservedAgentName(n), n)
}
}
// matching runs before anything sizes the request head, so the split is where
// an over-long path is refused.
func TestSplitEndpointPathLength(t *testing.T) {
long := PathPrefix + "a/d/" + strings.Repeat("x", MaxPathLength)
_, err := splitEndpointPath(&url.URL{Path: long, RawPath: long})
require.ErrorIs(t, err, errPathTooLong)
f := fallbackFront(t, nil, true)
require.Equal(t, http.StatusRequestURITooLong, serveFront(f, "/"+strings.Repeat("x", MaxPathLength)).Code)
ok := PathPrefix + "a/d/" + strings.Repeat("x", MaxPathLength-4)
_, err = splitEndpointPath(&url.URL{Path: ok, RawPath: ok})
require.NoError(t, err)
}
// budgetFront registers a worker whose routes are ambiguous enough that the
// matcher gives up deciding.
func budgetFront(t *testing.T, a Access) *Front {
reg := NewRegistry()
m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
{Path: "/{a}{b}{c}{d}x", Methods: []string{"GET"}, Public: true},
})
require.NoError(t, err)
require.Len(t, m.Ambiguous(), 1)
reg.Register(NewRegistration(RegistrationParams{
WorkerID: "w1", APIKey: "proj", AgentName: "a", Deployment: "d",
Manifest: m, Session: &fakeSession{},
}))
return NewFront(FrontParams{
Registry: reg,
ResolveAccess: func(*http.Request, string, string) Access { return a },
Logger: logger.GetLogger(),
})
}
// A table too ambiguous to decide forwards to the worker. No route is decided,
// so its Public flag is unknown and only a grant clears the request.
func TestFrontOverBudgetForwardsWithAGrant(t *testing.T) {
long := "/" + strings.Repeat("a", 512)
f := budgetFront(t, Access{APIKey: "proj", Level: AccessGranted})
// 503 is dispatch reached: the fake session opens no stream
require.Equal(t, http.StatusServiceUnavailable, serveFront(f, long).Code)
f = budgetFront(t, Access{APIKey: "proj", Level: AccessCredentialed})
require.Equal(t, http.StatusForbidden, serveFront(f, long).Code)
f = budgetFront(t, Access{APIKey: "proj", Level: AccessNone})
require.Equal(t, http.StatusUnauthorized, serveFront(f, long).Code)
// a path the same table decides normally is unaffected
f = budgetFront(t, Access{APIKey: "proj", Level: AccessNone})
require.Equal(t, http.StatusNotFound, serveFront(f, "/ab").Code)
}
+37 -85
View File
@@ -18,55 +18,49 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"strings"
"github.com/livekit/protocol/livekit"
"github.com/livekit/livekit-server/pkg/agent/endpoint/router"
"github.com/livekit/livekit-server/pkg/agent/endpoint/wire"
)
const MaxManifestRoutes = 256
// allowedMethods is the set of HTTP methods a route may declare: exactly the
// verbs FastAPI (starlette) can route. Registration rejects anything else, so a
// typo ("GTE") or a garbage token can't sit in a manifest silently never
// matching. Widen this only if the worker side ever routes beyond FastAPI.
var allowedMethods = map[string]struct{}{
http.MethodGet: {},
http.MethodHead: {},
http.MethodPost: {},
http.MethodPut: {},
http.MethodPatch: {},
http.MethodDelete: {},
http.MethodOptions: {},
http.MethodTrace: {},
}
// Manifest is a worker's route table, in declaration order.
type Manifest = router.Router[*Route]
// Route is one validated manifest entry.
type Route struct {
Template *Template
Methods []string // uppercase
Template *router.Template
Public bool
}
// Manifest is a worker's ordered route table.
type Manifest struct {
routes []Route
// methodMask tags a route with the methods it serves. The set is exactly the
// verbs FastAPI can route; anything else masks to 0 and matches nothing.
func methodMask(method string) router.Mask {
switch method {
case http.MethodGet:
return 1 << 0
case http.MethodHead:
return 1 << 1
case http.MethodPost:
return 1 << 2
case http.MethodPut:
return 1 << 3
case http.MethodPatch:
return 1 << 4
case http.MethodDelete:
return 1 << 5
case http.MethodOptions:
return 1 << 6
case http.MethodTrace:
return 1 << 7
}
return 0
}
// MatchResult mirrors starlette's Match enum: a FULL match selects the route, a
// PARTIAL match (path matched, method didn't) yields 405 only after the whole
// table has been scanned, so a later route with the right method still wins.
type MatchResult int
const (
MatchNone MatchResult = iota
MatchPartial
MatchFull
)
// ParseManifest validates a registration's endpoint list.
// NegotiateSettings validates a registration's endpoint manifest and negotiates
// the data-plane protocol version. The data plane is a WebTransport session (no
// attach token, no fixed connection pool), so the version is all there is to
@@ -91,13 +85,15 @@ func NegotiateSettings(req *livekit.RegisterWorkerRequest) (*livekit.AgentHttp_A
return &livekit.AgentHttp_AgentEndpointSettings{Protocol: negotiated}, nil
}
// ParseManifest validates a registration's endpoint list and compiles it into
// a router.
func ParseManifest(endpoints []*livekit.AgentHttp_AgentEndpoint) (*Manifest, error) {
if len(endpoints) > MaxManifestRoutes {
return nil, fmt.Errorf("manifest exceeds %d routes", MaxManifestRoutes)
}
m := &Manifest{routes: make([]Route, 0, len(endpoints))}
b := router.NewBuilder[*Route]()
for _, ep := range endpoints {
tpl, err := ParseTemplate(ep.GetPath())
tpl, err := router.ParseTemplate(ep.GetPath())
if err != nil {
return nil, err
}
@@ -107,65 +103,21 @@ func ParseManifest(endpoints []*livekit.AgentHttp_AgentEndpoint) (*Manifest, err
if len(ep.GetMethods()) == 0 {
return nil, fmt.Errorf("endpoint %q declares no methods", ep.GetPath())
}
var methods []string
var mask router.Mask
for _, method := range ep.GetMethods() {
u := strings.ToUpper(method)
if u != method {
return nil, fmt.Errorf("endpoint %q method %q must be uppercase", ep.GetPath(), method)
}
if _, ok := allowedMethods[u]; !ok {
m := methodMask(u)
if m == 0 {
return nil, fmt.Errorf("endpoint %q declares unsupported method %q", ep.GetPath(), method)
}
methods = append(methods, u)
mask |= m
}
m.routes = append(m.routes, Route{
Template: tpl,
Methods: methods,
Public: ep.GetPublic(),
})
}
return m, nil
}
// Match resolves a request path+method against the table: a FULL match on both,
// else PARTIAL if the path matched but no route had the method (405).
func (m *Manifest) Match(path, method string) (*Route, MatchResult) {
partial := false
for i := range m.routes {
r := &m.routes[i]
if !r.Template.Match(path) {
continue
if err := b.Add(tpl, mask, &Route{Template: tpl, Public: ep.GetPublic()}); err != nil {
return nil, err
}
if slices.Contains(r.Methods, method) {
return r, MatchFull
}
partial = true
}
if partial {
return nil, MatchPartial
}
return nil, MatchNone
}
// slashAlternate returns the trailing-slash-normalized form of path when only
// that alternate form fully matches a registered route. The front tries the
// exact form first and uses this to rewrite a slash-mismatched request to the
// registered form and serve it directly, rather than redirecting the client
// (webhook clients often don't follow redirects, and a redirect from the final
// routing hop would pay the whole routing path twice). A route registered with
// a trailing slash is matched exactly and left untouched.
func (m *Manifest) slashAlternate(path, method string) (string, bool) {
if path == "/" {
return "", false
}
var alt string
if strings.HasSuffix(path, "/") {
alt = strings.TrimSuffix(path, "/")
} else {
alt = path + "/"
}
if _, res := m.Match(alt, method); res == MatchFull {
return alt, true
}
return "", false
return b.Build(), nil
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 LiveKit, Inc.
package endpoint
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/livekit/protocol/livekit"
"github.com/livekit/livekit-server/pkg/agent/endpoint/router"
)
func ep(path string, methods []string, public bool) *livekit.AgentHttp_AgentEndpoint {
return &livekit.AgentHttp_AgentEndpoint{Path: path, Methods: methods, Public: public}
}
func TestManifestFullPartialSemantics(t *testing.T) {
// POST /x registered after GET /x must still serve POSTs (starlette scans
// for a FULL match before settling for the PARTIAL 405)
m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
ep("/x", []string{"GET"}, true),
ep("/x", []string{"POST"}, true),
})
require.NoError(t, err)
r, res := m.Match("/x", methodMask(http.MethodPost))
require.Equal(t, router.ResultFull, res)
require.Equal(t, "/x", r.Template.String())
// the manifest carries the app's methods verbatim: FastAPI does not imply
// HEAD from GET, so neither does the matcher
_, res = m.Match("/x", methodMask(http.MethodHead))
require.Equal(t, router.ResultPartial, res)
// PARTIAL only when no route serves the method
_, res = m.Match("/x", methodMask(http.MethodDelete))
require.Equal(t, router.ResultPartial, res)
// an unroutable method masks to 0
_, res = m.Match("/x", methodMask("BREW"))
require.Equal(t, router.ResultPartial, res)
_, res = m.Match("/nope", methodMask(http.MethodGet))
require.Equal(t, router.ResultNone, res)
}
func TestSlashAlternatePaths(t *testing.T) {
m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
ep("/hook", []string{"POST"}, true),
ep("/", []string{"POST"}, true),
})
require.NoError(t, err)
candidates := []*Registration{{Manifest: m}}
cases := []struct {
path, escPath string
alt, altEsc string
ok bool
}{
// a slash-mismatched request normalizes to the registered form
{"/hook/", "/hook/", "/hook", "/hook", true},
// no route matches either slash form
{"/other/", "/other/", "/other/", "/other/", false},
// %2F decodes to a slash without being a separator
{"/hook/", "/hook%2F", "/hook/", "/hook%2F", false},
// "//" trims to "/" in both forms
{"//", "//", "/", "/", true},
// "/" has no alternate to try
{"/", "/", "/", "/", false},
}
for _, c := range cases {
alt, altEsc, ok := slashAlternatePaths(candidates, c.path, c.escPath, methodMask(http.MethodPost))
require.Equal(t, c.ok, ok, "%q %q", c.path, c.escPath)
require.Equal(t, c.alt, alt, "%q %q", c.path, c.escPath)
require.Equal(t, c.altEsc, altEsc, "%q %q", c.path, c.escPath)
}
}
// Templates anchor as `\n?$`: path refuses '\n' where str accepts it, so a
// decoded %0A would otherwise change which route wins.
func TestManifestTrailingNewlineRouteIdentity(t *testing.T) {
m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
ep("/files/{p:path}", []string{"GET"}, false),
ep("/files/{p}", []string{"GET"}, true),
})
require.NoError(t, err)
r, res := m.Match("/files/x\n", methodMask(http.MethodGet))
require.Equal(t, router.ResultFull, res)
require.Equal(t, "/files/{p:path}", r.Template.String())
require.False(t, r.Public, "the private route must win, as it does on the worker")
}
func TestManifestValidation(t *testing.T) {
_, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", nil, false)})
require.Error(t, err, "http endpoint without methods")
_, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", []string{"get"}, false)})
require.Error(t, err, "lowercase method")
_, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", []string{"GTE"}, false)})
require.Error(t, err, "unsupported method rejected")
_, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("no-slash", []string{"GET"}, false)})
require.Error(t, err, "template without a leading slash")
_, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x/{a:slug}", []string{"GET"}, false)})
require.Error(t, err, "custom convertor")
// the full FastAPI/starlette verb set is accepted
_, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
ep("/x", []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"}, false),
})
require.NoError(t, err, "all FastAPI verbs accepted")
_, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
{Path: "/ws", Kind: livekit.AgentHttp_AEK_TEXT, Methods: []string{"GET"}},
})
require.Error(t, err, "unsupported endpoint kind")
}
-98
View File
@@ -1,98 +0,0 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package endpoint
import (
"fmt"
"regexp"
"strings"
)
// Template is a compiled starlette-style path template. The compilation mirrors
// starlette's compile_path exactly: {name} or {name:convertor} segments become the
// convertor's pattern, everything else is matched literally, anchored on both ends.
// Workers declare templates through FastAPI routes, so any divergence from
// starlette's semantics would make the server route requests the worker-side
// router then refuses.
type Template struct {
re *regexp.Regexp
// the declared template, retained so a matched route can be reported
raw string
}
// String returns the template as the worker declared it.
func (t *Template) String() string { return t.raw }
// convertor patterns copied verbatim from starlette's convertors.py. The uuid
// pattern deliberately makes every hyphen optional, so 32 bare hex characters
// match too.
var convertorPatterns = map[string]string{
"str": `[^/]+`,
"path": `.*`,
"int": `[0-9]+`,
"float": `[0-9]+(?:\.[0-9]+)?`,
"uuid": `[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}`,
}
// starlette's PARAM_REGEX
var paramRegex = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?\}`)
// ParseTemplate compiles a starlette path template. Custom convertors are
// rejected: only the five built-ins may travel over the wire.
func ParseTemplate(path string) (*Template, error) {
if !strings.HasPrefix(path, "/") {
return nil, fmt.Errorf("path template must start with '/': %q", path)
}
var pattern strings.Builder
pattern.WriteString("^")
idx := 0
seen := map[string]bool{}
for _, m := range paramRegex.FindAllStringSubmatchIndex(path, -1) {
start, end := m[0], m[1]
name := path[m[2]:m[3]]
convertor := "str"
if m[4] != -1 {
convertor = path[m[4]+1 : m[5]] // skip the ':'
}
convPattern, ok := convertorPatterns[convertor]
if !ok {
return nil, fmt.Errorf("unknown path convertor %q in template %q", convertor, path)
}
if seen[name] {
return nil, fmt.Errorf("duplicated param name %q in template %q", name, path)
}
seen[name] = true
pattern.WriteString(regexp.QuoteMeta(path[idx:start]))
pattern.WriteString("(?:")
pattern.WriteString(convPattern)
pattern.WriteString(")")
idx = end
}
pattern.WriteString(regexp.QuoteMeta(path[idx:]))
pattern.WriteString("$")
re, err := regexp.Compile(pattern.String())
if err != nil {
return nil, fmt.Errorf("invalid path template %q: %w", path, err)
}
return &Template{re: re, raw: path}, nil
}
func (t *Template) Match(path string) bool {
return t.re.MatchString(path)
}
-119
View File
@@ -1,119 +0,0 @@
package endpoint
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/livekit/protocol/livekit"
)
func TestTemplateStarletteSemantics(t *testing.T) {
cases := []struct {
template string
path string
match bool
}{
{"/token", "/token", true},
{"/token", "/token/", false},
{"/token", "/Token", false},
{"/users/{id}", "/users/42", true},
{"/users/{id}", "/users/42/posts", false},
{"/users/{id}", "/users/", false},
{"/users/{id:int}", "/users/42", true},
{"/users/{id:int}", "/users/4x2", false},
{"/files/{p:path}", "/files/a/b/c.txt", true},
{"/files/{p:path}", "/files/", true},
{"/price/{v:float}", "/price/1.25", true},
{"/price/{v:float}", "/price/1.", false},
{"/obj/{u:uuid}", "/obj/123e4567-e89b-12d3-a456-426614174000", true},
// starlette's uuid convertor makes every hyphen optional
{"/obj/{u:uuid}", "/obj/123e4567e89b12d3a456426614174000", true},
{"/obj/{u:uuid}", "/obj/123e4567", false},
{"/a/{x}/b/{y}", "/a/1/b/2", true},
{"/a/{x}/b/{y}", "/a/1/c/2", false},
}
for _, c := range cases {
tpl, err := ParseTemplate(c.template)
require.NoError(t, err, c.template)
require.Equal(t, c.match, tpl.Match(c.path), "%s vs %s", c.template, c.path)
}
}
func TestTemplateRejectsCustomConvertors(t *testing.T) {
_, err := ParseTemplate("/x/{id:slug}")
require.Error(t, err)
_, err = ParseTemplate("/x/{a}/{a}")
require.Error(t, err)
_, err = ParseTemplate("no-slash")
require.Error(t, err)
}
func ep(path string, methods []string, public bool) *livekit.AgentHttp_AgentEndpoint {
return &livekit.AgentHttp_AgentEndpoint{Path: path, Methods: methods, Public: public}
}
func TestManifestFullPartialSemantics(t *testing.T) {
// POST /x registered after GET /x must still serve POSTs (starlette scans
// for a FULL match before settling for the PARTIAL 405)
m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
ep("/x", []string{"GET"}, true),
ep("/x", []string{"POST"}, true),
})
require.NoError(t, err)
r, res := m.Match("/x", http.MethodPost)
require.Equal(t, MatchFull, res)
require.Contains(t, r.Methods, "POST")
// the manifest carries the app's methods verbatim: FastAPI does not imply
// HEAD from GET, so neither does the matcher
_, res = m.Match("/x", http.MethodHead)
require.Equal(t, MatchPartial, res)
// PARTIAL only when no route serves the method
_, res = m.Match("/x", http.MethodDelete)
require.Equal(t, MatchPartial, res)
_, res = m.Match("/nope", http.MethodGet)
require.Equal(t, MatchNone, res)
}
func TestManifestSlashAlternate(t *testing.T) {
m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
ep("/hook", []string{"POST"}, true),
})
require.NoError(t, err)
// a slash-mismatched request normalizes to the registered form
alt, ok := m.slashAlternate("/hook/", http.MethodPost)
require.True(t, ok)
require.Equal(t, "/hook", alt)
// no route matches either slash form
_, ok = m.slashAlternate("/other/", http.MethodPost)
require.False(t, ok)
}
func TestManifestValidation(t *testing.T) {
_, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", nil, false)})
require.Error(t, err, "http endpoint without methods")
_, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", []string{"get"}, false)})
require.Error(t, err, "lowercase method")
_, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", []string{"GTE"}, false)})
require.Error(t, err, "unsupported method rejected")
// the full FastAPI/starlette verb set is accepted
_, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
ep("/x", []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"}, false),
})
require.NoError(t, err, "all FastAPI verbs accepted")
_, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{
{Path: "/ws", Kind: livekit.AgentHttp_AEK_TEXT, Methods: []string{"GET"}},
})
require.Error(t, err, "unsupported endpoint kind")
}