diff --git a/pkg/agent/endpoint/front.go b/pkg/agent/endpoint/front.go index 088091d50..434e1e2f0 100644 --- a/pkg/agent/endpoint/front.go +++ b/pkg/agent/endpoint/front.go @@ -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) diff --git a/pkg/agent/endpoint/front_test.go b/pkg/agent/endpoint/front_test.go index 375f86feb..c072fd682 100644 --- a/pkg/agent/endpoint/front_test.go +++ b/pkg/agent/endpoint/front_test.go @@ -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) +} diff --git a/pkg/agent/endpoint/manifest.go b/pkg/agent/endpoint/manifest.go index fde4e17e4..066755d7d 100644 --- a/pkg/agent/endpoint/manifest.go +++ b/pkg/agent/endpoint/manifest.go @@ -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 } diff --git a/pkg/agent/endpoint/manifest_test.go b/pkg/agent/endpoint/manifest_test.go new file mode 100644 index 000000000..00e81b561 --- /dev/null +++ b/pkg/agent/endpoint/manifest_test.go @@ -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") +} diff --git a/pkg/agent/endpoint/template.go b/pkg/agent/endpoint/template.go deleted file mode 100644 index 63f27d8b5..000000000 --- a/pkg/agent/endpoint/template.go +++ /dev/null @@ -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) -} diff --git a/pkg/agent/endpoint/template_test.go b/pkg/agent/endpoint/template_test.go deleted file mode 100644 index 9388dbb2d..000000000 --- a/pkg/agent/endpoint/template_test.go +++ /dev/null @@ -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") -}