Skip to content

Commit 9226c22

Browse files
ldanilekConvex, Inc.
authored and
Convex, Inc.
committed
route to the most specific http endpoint (#27567)
the httpRouter currently has somewhat odd behavior, because we allow paths to overlap but only if you define them in a certain order. new behavior: http route paths can always overlap, unless they are exactly the same. when picking one to execute, we choose an exact path if one matches, and otherwise we pick the longest prefix path that matches. added test cases, and also ran similar tests manually GitOrigin-RevId: 372c4bdaf384e91dfa8d4c6cf77e6d75ec1708e2
1 parent a92fc71 commit 9226c22

File tree

2 files changed

+50
-37
lines changed

2 files changed

+50
-37
lines changed

npm-packages/convex/src/server/router.test.ts

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -93,29 +93,45 @@ test("HttpRouter pathPrefix", () => {
9393
http.route({ pathPrefix: "/path1/", method: "GET", handler: action1 });
9494
}).toThrow();
9595

96-
// prefix is shadowed by prefix
97-
expect(() => {
98-
http.route({
99-
pathPrefix: "/path1/foo/",
100-
method: "GET",
101-
handler: action1,
102-
});
103-
}).toThrow();
96+
// more specific pathPrefix
97+
http.route({
98+
pathPrefix: "/path1/foo/",
99+
method: "GET",
100+
handler: action1,
101+
});
104102

105-
// path is shadowed by prefix
106-
expect(() => {
107-
http.route({ path: "/path1/foo", method: "GET", handler: action1 });
108-
}).toThrow();
103+
// less specific pathPrefix
104+
http.route({
105+
pathPrefix: "/",
106+
method: "GET",
107+
handler: action1,
108+
});
109+
110+
// Longest matching prefix is used.
111+
expect(http.lookup("/path1/foo/bar", "GET")).toEqual([
112+
action1,
113+
"GET",
114+
"/path1/foo/*",
115+
]);
116+
expect(http.lookup("/path1/foo", "GET")).toEqual([
117+
action1,
118+
"GET",
119+
"/path1/*",
120+
]);
121+
expect(http.lookup("/path1", "GET")).toEqual([action1, "GET", "/*"]);
122+
123+
// Exact path is more specific than prefix
124+
http.route({ path: "/path1/foo", method: "GET", handler: action1 });
125+
expect(http.lookup("/path1/foo", "GET")).toEqual([
126+
action1,
127+
"GET",
128+
"/path1/foo",
129+
]);
130+
// Duplicate exact match
131+
expect(() =>
132+
http.route({ path: "/path1/foo", method: "GET", handler: action1 }),
133+
).toThrow();
109134

110135
// Not shadowed: last path segment is different
111136
http.route({ pathPrefix: "/path11/", method: "GET", handler: action1 });
112-
113-
// path is shadowed by prefix
114-
expect(() => {
115-
http.route({
116-
pathPrefix: "/path1/foo/",
117-
method: "GET",
118-
handler: action1,
119-
});
120-
}).toThrow();
121137
});

npm-packages/convex/src/server/router.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -169,18 +169,14 @@ export class HttpRouter {
169169
}
170170

171171
if ("path" in spec) {
172+
if ("pathPrefix" in spec) {
173+
throw new Error(
174+
`Invalid httpRouter route: cannot contain both 'path' and 'pathPrefix'`,
175+
);
176+
}
172177
if (!spec.path.startsWith("/")) {
173178
throw new Error(`path '${spec.path}' does not start with a /`);
174179
}
175-
const prefixes =
176-
this.prefixRoutes.get(method) || new Map<string, PublicHttpAction>();
177-
for (const [prefix, _] of prefixes.entries()) {
178-
if (spec.path.startsWith(prefix)) {
179-
throw new Error(
180-
`${spec.method} path ${spec.path} is shadowed by pathPrefix ${prefix}`,
181-
);
182-
}
183-
}
184180
const methods: Map<RoutableMethod, PublicHttpAction> =
185181
this.exactRoutes.has(spec.path)
186182
? this.exactRoutes.get(spec.path)!
@@ -203,12 +199,10 @@ export class HttpRouter {
203199
}
204200
const prefixes =
205201
this.prefixRoutes.get(method) || new Map<string, PublicHttpAction>();
206-
for (const [prefix, _] of prefixes.entries()) {
207-
if (spec.pathPrefix.startsWith(prefix)) {
208-
throw new Error(
209-
`${spec.method} pathPrefix ${spec.pathPrefix} is shadowed by pathPrefix ${prefix}`,
210-
);
211-
}
202+
if (prefixes.has(spec.pathPrefix)) {
203+
throw new Error(
204+
`${spec.method} pathPrefix ${spec.pathPrefix} is already defined`,
205+
);
212206
}
213207
prefixes.set(spec.pathPrefix, handler);
214208
this.prefixRoutes.set(method, prefixes);
@@ -281,7 +275,10 @@ export class HttpRouter {
281275
if (exactMatch) return [exactMatch, method, path];
282276

283277
const prefixes = this.prefixRoutes.get(method) || new Map();
284-
for (const [pathPrefix, endpoint] of prefixes.entries()) {
278+
const prefixesSorted = [...prefixes.entries()].sort(
279+
([prefixA, _a], [prefixB, _b]) => prefixB.length - prefixA.length,
280+
);
281+
for (const [pathPrefix, endpoint] of prefixesSorted) {
285282
if (path.startsWith(pathPrefix)) {
286283
return [endpoint, method, `${pathPrefix}*`];
287284
}

0 commit comments

Comments
 (0)