feat(http): add route(path) proxy for sub-path scoped registration
RouteProxy<T> wraps a Router and prepends a fixed prefix to every path-taking method (get, post, put, patch, delete, options, use, layer, route). Routes and middleware registered through the proxy land directly in the underlying router's layer list, so the original server's handler picks them up without any extra wiring. Nesting is supported: route().route() compounds the prefix, and the context-extension API (use with next(extra)) works identically on proxies. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
Router,
|
||||
RouteProxy,
|
||||
Server,
|
||||
ServerConnection,
|
||||
type Body,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { ServerConnection, type ServerConnectionOptions } from "./connection.js"
|
||||
export {
|
||||
Router,
|
||||
RouteProxy,
|
||||
type Match,
|
||||
type PartialMatch,
|
||||
type HandlerMatch,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test, { suite } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { Router } from "./router.js"
|
||||
import { Router, RouteProxy } from "./router.js"
|
||||
import type { Body } from "../common/types.js"
|
||||
import type { Context } from "./types.js"
|
||||
|
||||
@@ -437,3 +437,168 @@ suite("Router", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
suite("RouteProxy", () => {
|
||||
test("route() returns a RouteProxy", () => {
|
||||
const r = new Router()
|
||||
assert.ok(r.route("/api") instanceof RouteProxy)
|
||||
})
|
||||
|
||||
test("get() registers route with prefix prepended", async () => {
|
||||
const r = new Router()
|
||||
r.route("/api").get("/users", async ({ res }) => {
|
||||
res.body = "users"
|
||||
})
|
||||
const ctx = makeCtx("GET", "/api/users")
|
||||
await r.handler(ctx)
|
||||
assert.strictEqual(ctx.res.body, "users")
|
||||
})
|
||||
|
||||
test("route('/') prefix acts as root", async () => {
|
||||
const r = new Router()
|
||||
r.route("/").get("/users", async ({ res }) => {
|
||||
res.body = "ok"
|
||||
})
|
||||
const ctx = makeCtx("GET", "/users")
|
||||
await r.handler(ctx)
|
||||
assert.strictEqual(ctx.res.body, "ok")
|
||||
})
|
||||
|
||||
test("get('/') on proxy registers at exactly the prefix", async () => {
|
||||
const r = new Router()
|
||||
r.route("/api").get("/", async ({ res }) => {
|
||||
res.body = "api-root"
|
||||
})
|
||||
const ctx = makeCtx("GET", "/api")
|
||||
await r.handler(ctx)
|
||||
assert.strictEqual(ctx.res.body, "api-root")
|
||||
})
|
||||
|
||||
test("all HTTP method shorthands work on proxy", async () => {
|
||||
const r = new Router()
|
||||
const api = r.route("/api")
|
||||
for (const [method, fn] of [
|
||||
["GET", api.get.bind(api)],
|
||||
["POST", api.post.bind(api)],
|
||||
["PUT", api.put.bind(api)],
|
||||
["PATCH", api.patch.bind(api)],
|
||||
["DELETE", api.delete.bind(api)],
|
||||
["OPTIONS", api.options.bind(api)],
|
||||
] as const) {
|
||||
fn(`/${method.toLowerCase()}`, async () => {})
|
||||
assert.ok(
|
||||
"handler" in r.match(makeReq(method, `/api/${method.toLowerCase()}`)),
|
||||
`${method} route should match`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("proxy methods return this for chaining", async () => {
|
||||
const r = new Router()
|
||||
const api = r.route("/api")
|
||||
const result = api
|
||||
.get("/a", async () => {})
|
||||
.post("/b", async () => {})
|
||||
assert.strictEqual(result, api)
|
||||
})
|
||||
|
||||
test("route().route() nests prefixes", async () => {
|
||||
const r = new Router()
|
||||
r.route("/api").route("/v1").get("/users", async ({ res }) => {
|
||||
res.body = "nested"
|
||||
})
|
||||
const ctx = makeCtx("GET", "/api/v1/users")
|
||||
await r.handler(ctx)
|
||||
assert.strictEqual(ctx.res.body, "nested")
|
||||
})
|
||||
|
||||
test("route() returns RouteProxy of RouteProxy", () => {
|
||||
const r = new Router()
|
||||
const api = r.route("/api")
|
||||
const v1 = api.route("/v1")
|
||||
assert.ok(v1 instanceof RouteProxy)
|
||||
})
|
||||
|
||||
test("use() on proxy registers middleware scoped to prefix", async () => {
|
||||
const r = new Router()
|
||||
const order: string[] = []
|
||||
r.route("/api").use(async (_, next) => {
|
||||
order.push("api-mw")
|
||||
await next()
|
||||
})
|
||||
r.get("/api/users", async () => { order.push("handler") })
|
||||
r.get("/other", async () => { order.push("other-handler") })
|
||||
|
||||
await r.handler(makeCtx("GET", "/api/users"))
|
||||
assert.deepStrictEqual(order, ["api-mw", "handler"])
|
||||
|
||||
order.length = 0
|
||||
await r.handler(makeCtx("GET", "/other"))
|
||||
assert.deepStrictEqual(order, ["other-handler"])
|
||||
})
|
||||
|
||||
test("use(subpath) on proxy scopes middleware to prefix + subpath", async () => {
|
||||
const r = new Router()
|
||||
let mwRan = false
|
||||
r.route("/api").use("/admin", async (_, next) => {
|
||||
mwRan = true
|
||||
await next()
|
||||
})
|
||||
r.get("/api/admin/users", async () => {})
|
||||
r.get("/api/users", async () => {})
|
||||
|
||||
await r.handler(makeCtx("GET", "/api/admin/users"))
|
||||
assert.ok(mwRan, "middleware should run for /api/admin paths")
|
||||
|
||||
mwRan = false
|
||||
await r.handler(makeCtx("GET", "/api/users"))
|
||||
assert.ok(!mwRan, "middleware should not run for /api/users")
|
||||
})
|
||||
|
||||
test("proxy use() with extending next(extra) propagates to handler", async () => {
|
||||
const r = new Router()
|
||||
let captured: unknown = null
|
||||
r.route("/api").use(async (ctx, next) => {
|
||||
await next({ role: "admin" })
|
||||
})
|
||||
r.get("/api/data", async (ctx) => {
|
||||
captured = (ctx as typeof ctx & { role: string }).role
|
||||
})
|
||||
const ctx = makeCtx("GET", "/api/data")
|
||||
await r.handler(ctx)
|
||||
assert.strictEqual(captured, "admin")
|
||||
})
|
||||
|
||||
test("layer() on proxy prepends prefix", async () => {
|
||||
const r = new Router()
|
||||
r.route("/api").layer({
|
||||
path: "/items",
|
||||
method: "GET",
|
||||
handler: async (ctx) => {
|
||||
;(ctx as { res: { body: unknown } }).res.body = "items"
|
||||
},
|
||||
})
|
||||
const ctx = makeCtx("GET", "/api/items")
|
||||
await r.handler(ctx)
|
||||
assert.strictEqual(ctx.res.body, "items")
|
||||
})
|
||||
|
||||
test("404 when request does not match prefix", async () => {
|
||||
const r = new Router()
|
||||
r.route("/api").get("/users", async () => {})
|
||||
const ctx = makeCtx("GET", "/other/users")
|
||||
await r.handler(ctx)
|
||||
assert.strictEqual(ctx.res.status, 404)
|
||||
})
|
||||
|
||||
test("path params work through proxy", async () => {
|
||||
const r = new Router()
|
||||
let capturedId = ""
|
||||
r.route("/api").get("/users/:id", async ({ keys }) => {
|
||||
capturedId = (keys as { id: string }).id
|
||||
})
|
||||
const ctx = makeCtx("GET", "/api/users/42")
|
||||
await r.handler(ctx)
|
||||
assert.strictEqual(capturedId, "42")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -196,6 +196,10 @@ export class Router<T extends object = {}> {
|
||||
}
|
||||
}
|
||||
|
||||
route(path: string): RouteProxy<T> {
|
||||
return new RouteProxy(this, path)
|
||||
}
|
||||
|
||||
#handler?: Handler
|
||||
get handler(): Handler {
|
||||
if (this.#handler) return this.#handler
|
||||
@@ -245,3 +249,102 @@ export class Router<T extends object = {}> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type LayerOptions = {
|
||||
path?: string
|
||||
method?: string | string[] | ReadonlySet<string> | null
|
||||
middlewares?: AnyFn[]
|
||||
handler?: AnyFn | null
|
||||
loose?: boolean
|
||||
global?: boolean
|
||||
}
|
||||
|
||||
export class RouteProxy<T extends object = {}> {
|
||||
readonly #router: Router<T>
|
||||
readonly #prefix: string
|
||||
|
||||
constructor(router: Router<T>, prefix: string) {
|
||||
this.#router = router
|
||||
this.#prefix = prefix
|
||||
}
|
||||
|
||||
#join(path: string): string {
|
||||
if (this.#prefix === "/") return path.startsWith("/") ? path : "/" + path
|
||||
const base = this.#prefix.endsWith("/") ? this.#prefix.slice(0, -1) : this.#prefix
|
||||
if (path === "/" || path === "") return base
|
||||
return base + (path.startsWith("/") ? path : "/" + path)
|
||||
}
|
||||
|
||||
#method(method: string, path: string, middlewaresAndHandler: unknown[]): void {
|
||||
const handler = middlewaresAndHandler.at(-1) as AnyFn
|
||||
const middlewares = middlewaresAndHandler.slice(0, -1) as AnyFn[]
|
||||
this.#router.layer({ path: this.#join(path), method, middlewares, handler })
|
||||
}
|
||||
|
||||
get(path: string, ...rest: [...Middleware<T & Match>[], Handler<T & Match>]): this {
|
||||
this.#method("GET", path, rest)
|
||||
return this
|
||||
}
|
||||
post(path: string, ...rest: [...Middleware<T & Match>[], Handler<T & Match>]): this {
|
||||
this.#method("POST", path, rest)
|
||||
return this
|
||||
}
|
||||
put(path: string, ...rest: [...Middleware<T & Match>[], Handler<T & Match>]): this {
|
||||
this.#method("PUT", path, rest)
|
||||
return this
|
||||
}
|
||||
patch(path: string, ...rest: [...Middleware<T & Match>[], Handler<T & Match>]): this {
|
||||
this.#method("PATCH", path, rest)
|
||||
return this
|
||||
}
|
||||
delete(path: string, ...rest: [...Middleware<T & Match>[], Handler<T & Match>]): this {
|
||||
this.#method("DELETE", path, rest)
|
||||
return this
|
||||
}
|
||||
options(path: string, ...rest: [...Middleware<T & Match>[], Handler<T & Match>]): this {
|
||||
this.#method("OPTIONS", path, rest)
|
||||
return this
|
||||
}
|
||||
|
||||
// Context-extending overloads — same ordering convention as Router.use()
|
||||
use<TAdd extends object>(
|
||||
middleware: (ctx: Context<T & Match>, next: (extra: TAdd) => Promise<void>) => Promise<void>,
|
||||
): RouteProxy<T & TAdd>
|
||||
use<TAdd extends object>(
|
||||
path: string,
|
||||
middleware: (ctx: Context<T & Match>, next: (extra: TAdd) => Promise<void>) => Promise<void>,
|
||||
): RouteProxy<T & TAdd>
|
||||
|
||||
// Non-extending overloads
|
||||
use(...middlewares: Middleware<T & Match>[]): this
|
||||
use(path: string, ...middlewares: Middleware<T & Match>[]): this
|
||||
use(options: { path?: string; global?: false }, ...middlewares: Middleware<T & Match>[]): this
|
||||
use(options: { path?: string; global: true }, ...middlewares: Middleware<PartialMatch & T>[]): this
|
||||
use(path: string, options: { global?: false }, ...middlewares: Middleware<T & Match>[]): this
|
||||
use(path: string, options: { global: true }, ...middlewares: Middleware<PartialMatch & T>[]): this
|
||||
|
||||
use(...pathAndmiddlewares: unknown[]): unknown {
|
||||
if (!pathAndmiddlewares.length) return this
|
||||
let path: string = "/"
|
||||
let global = false
|
||||
if (typeof pathAndmiddlewares[0] === "string") {
|
||||
path = pathAndmiddlewares.shift() as string
|
||||
}
|
||||
if (typeof pathAndmiddlewares[0] === "object" && pathAndmiddlewares[0] !== null) {
|
||||
const options = pathAndmiddlewares.shift() as { path?: string; global?: boolean }
|
||||
if (options.global) global = true
|
||||
if (options.path) path = options.path
|
||||
}
|
||||
const middlewares = pathAndmiddlewares as AnyFn[]
|
||||
this.#router.layer({ path: this.#join(path), middlewares, loose: true, global })
|
||||
return this
|
||||
}
|
||||
|
||||
layer({ path = "/", ...rest }: LayerOptions): void {
|
||||
this.#router.layer({ path: this.#join(path), ...rest })
|
||||
}
|
||||
|
||||
route(subpath: string): RouteProxy<T> {
|
||||
return new RouteProxy(this.#router, this.#join(subpath))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user