mirror of
https://github.com/livekit/livekit.git
synced 2026-09-15 21:46:03 +00:00
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:
co-authored by
Claude Opus 5
parent
0da02c4717
commit
e53cec5a88
+71
-40
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user