From 978440b4a55855d82d66b9d5ab7433ea2cf2e9f7 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sun, 25 Feb 2024 12:33:13 +0100 Subject: [PATCH] feat: handler resolver (#669) --- src/app.ts | 47 +++++++++++++++++-- src/event/utils.ts | 18 +++++--- src/router.ts | 79 ++++++++++++++++++++++--------- src/types.ts | 7 +++ test/resolve.test.ts | 108 +++++++++++++++++++++++++++++++++++++++++++ test/status.test.ts | 2 +- 6 files changed, 226 insertions(+), 35 deletions(-) create mode 100644 test/resolve.test.ts diff --git a/src/app.ts b/src/app.ts index d88170ca..a8392789 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,4 @@ -import { withoutTrailingSlash } from "ufo"; +import { joinURL, withoutTrailingSlash } from "ufo"; import { lazyEventHandler, toEventHandler, @@ -16,7 +16,11 @@ import { isWebResponse, sendNoContent, } from "./utils"; -import type { EventHandler, LazyEventHandler } from "./types"; +import type { + EventHandler, + EventHandlerResolver, + LazyEventHandler, +} from "./types"; export interface Layer { route: string; @@ -66,6 +70,7 @@ export interface App { handler: EventHandler; options: AppOptions; use: AppUse; + resolve: EventHandlerResolver; } /** @@ -73,10 +78,13 @@ export interface App { */ export function createApp(options: AppOptions = {}): App { const stack: Stack = []; + const resolve = createResolver(stack); const handler = createAppEventHandler(stack, options); + handler.__resolve__ = resolve; const app: App = { // @ts-ignore use: (arg1, arg2, arg3) => use(app as App, arg1, arg2, arg3), + resolve, handler, stack, options, @@ -103,9 +111,7 @@ export function use( normalizeLayer({ ...arg3, route: arg1, handler: arg2 as EventHandler }), ); } else if (typeof arg1 === "function") { - app.stack.push( - normalizeLayer({ ...arg2, route: "/", handler: arg1 as EventHandler }), - ); + app.stack.push(normalizeLayer({ ...arg2, handler: arg1 as EventHandler })); } else { app.stack.push(normalizeLayer({ ...arg1 })); } @@ -190,6 +196,37 @@ export function createAppEventHandler(stack: Stack, options: AppOptions) { }); } +function createResolver(stack: Stack): EventHandlerResolver { + return async (path: string) => { + let _layerPath: string; + for (const layer of stack) { + if (layer.route === "/" && !layer.handler.__resolve__) { + continue; + } + if (!path.startsWith(layer.route)) { + continue; + } + _layerPath = path.slice(layer.route.length) || "/"; + if (layer.match && !layer.match(_layerPath, undefined)) { + continue; + } + let res = { route: layer.route, handler: layer.handler }; + if (res.handler.__resolve__) { + const _res = await res.handler.__resolve__(_layerPath); + if (!_res) { + continue; + } + res = { + ...res, + ..._res, + route: joinURL(res.route || "/", _res.route || "/"), + }; + } + return res; + } + }; +} + function normalizeLayer(input: InputLayer) { let handler = input.handler; // @ts-ignore diff --git a/src/event/utils.ts b/src/event/utils.ts index 24ee2b1e..7c11d9a0 100644 --- a/src/event/utils.ts +++ b/src/event/utils.ts @@ -148,8 +148,9 @@ export function dynamicEventHandler( export function defineLazyEventHandler( factory: T, ): Awaited> { - let _promise: Promise; - let _resolved: EventHandler; + let _promise: Promise; + let _resolved: { handler: EventHandler }; + const resolveHandler = () => { if (_resolved) { return Promise.resolve(_resolved); @@ -163,17 +164,22 @@ export function defineLazyEventHandler( handler, ); } - _resolved = toEventHandler(r.default || r); + _resolved = { handler: toEventHandler(r.default || r) }; return _resolved; }); } return _promise; }; - return eventHandler((event) => { + + const handler = eventHandler((event) => { if (_resolved) { - return _resolved(event); + return _resolved.handler(event); } - return resolveHandler().then((handler) => handler(event)); + return resolveHandler().then((r) => r.handler(event)); }) as Awaited>; + + handler.__resolve__ = resolveHandler; + + return handler; } export const lazyEventHandler = defineLazyEventHandler; diff --git a/src/router.ts b/src/router.ts index 262def9e..8a691b8e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -3,6 +3,7 @@ import { toRouteMatcher, RouteMatcher, } from "radix3"; +import { withLeadingSlash } from "ufo"; import type { HTTPMethod, EventHandler } from "./types"; import { createError } from "./error"; import { eventHandler, toEventHandler } from "./event"; @@ -82,10 +83,9 @@ export function createRouter(opts: CreateRouterOptions = {}): Router { router[method] = (path, handle) => router.add(path, handle, method); } - // Main handle - router.handler = eventHandler((event) => { + // Handler matcher + const matchHandler = (path = "/", method: RouterMethod = "get") => { // Remove query parameters for matching - let path = event.path || "/"; const qIndex = path.indexOf("?"); if (qIndex !== -1) { path = path.slice(0, Math.max(0, qIndex)); @@ -94,26 +94,20 @@ export function createRouter(opts: CreateRouterOptions = {}): Router { // Match route const matched = _router.lookup(path); if (!matched || !matched.handlers) { - if (opts.preemptive || opts.preemtive) { - throw createError({ + return { + error: createError({ statusCode: 404, name: "Not Found", - statusMessage: `Cannot find any route matching ${event.path || "/"}.`, - }); - } else { - return; // Let app match other handlers - } + statusMessage: `Cannot find any route matching ${path || "/"}.`, + }), + }; } // Match method - const method = ( - event.node.req.method || "get" - ).toLowerCase() as RouterMethod; - let handler: EventHandler | undefined = matched.handlers[method] || matched.handlers.all; - // Fallback to search for shadowed routes + // Fallback to search for (method) shadowed routes if (!handler) { if (!_matcher) { _matcher = toRouteMatcher(_router); @@ -134,32 +128,71 @@ export function createRouter(opts: CreateRouterOptions = {}): Router { } } - // Method not matched if (!handler) { - if (opts.preemptive || opts.preemtive) { - throw createError({ + return { + error: createError({ statusCode: 405, name: "Method Not Allowed", statusMessage: `Method ${method} is not allowed on this route.`, - }); + }), + }; + } + + return { matched, handler }; + }; + + // Main handle + const isPreemptive = opts.preemptive || opts.preemtive; + router.handler = eventHandler((event) => { + // Match handler + const match = matchHandler( + event.path, + event.method.toLowerCase() as RouterMethod, + ); + + // No match (method or route) + if ("error" in match) { + if (isPreemptive) { + throw match.error; } else { return; // Let app match other handlers } } // Add matched route and params to the context - event.context.matchedRoute = matched; - const params = matched.params || {}; + event.context.matchedRoute = match.matched; + const params = match.matched.params || {}; event.context.params = params; // Call handler - return Promise.resolve(handler(event)).then((res) => { - if (res === undefined && (opts.preemptive || opts.preemtive)) { + return Promise.resolve(match.handler(event)).then((res) => { + if (res === undefined && isPreemptive) { return null; // Send empty content } return res; }); }); + // Resolver + router.handler.__resolve__ = async (path) => { + path = withLeadingSlash(path); + const match = matchHandler(path); + if ("error" in match) { + return; + } + let res = { + route: match.matched.path, + handler: match.handler, + }; + if (match.handler.__resolve__) { + const _res = await match.handler.__resolve__(path); + if (!_res) { + return; + } + res = { ...res, ..._res }; + } + return res; + }; + return router; } diff --git a/src/types.ts b/src/types.ts index 1c664df3..977c3f92 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,11 +62,18 @@ export type InferEventInput< T, > = void extends T ? (Event extends H3Event ? E[Key] : never) : T; +type MaybePromise = T | Promise; + +export type EventHandlerResolver = ( + path: string, +) => MaybePromise; + export interface EventHandler< Request extends EventHandlerRequest = EventHandlerRequest, Response extends EventHandlerResponse = EventHandlerResponse, > { __is_handler__?: true; + __resolve__?: EventHandlerResolver; (event: H3Event): Response; } diff --git a/test/resolve.test.ts b/test/resolve.test.ts new file mode 100644 index 00000000..50e4a4d8 --- /dev/null +++ b/test/resolve.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { + createApp, + createRouter, + eventHandler, + lazyEventHandler, +} from "../src"; + +describe("Event handler resolver", () => { + const testHandlers = Array.from({ length: 10 }).map((_, i) => + eventHandler(() => i), + ); + + const app = createApp(); + + const router = createRouter(); + app.use(router); + + // Middlware + app.use(testHandlers[0]); + app.use("/", testHandlers[1]); + + // Path prefix + app.use("/test", testHandlers[2]); + app.use("/lazy", () => testHandlers[3], { lazy: true }); + + // Sub app + const app2 = createApp(); + app.use("/nested", app2 as any); + app2.use("/path", testHandlers[4]); + // app2.use("/lazy", () => testHandlers[5], { lazy: true }); + + // Router + router.get("/router", testHandlers[6]); + router.get("/router/:id", testHandlers[7]); + router.get( + "/router/lazy", + lazyEventHandler(() => testHandlers[8]), + ); + + describe("middleware", () => { + it("does not resolves /", async () => { + expect(await app.resolve("/")).toBeUndefined(); + }); + }); + + describe("path prefix", () => { + it("resolves /test", async () => { + expect(await app.resolve("/test")).toMatchObject({ + route: "/test", + handler: testHandlers[2], + }); + }); + + it("resolves /test/foo", async () => { + expect((await app.resolve("/test/foo"))?.route).toEqual("/test"); + }); + }); + + it("resolves /lazy", async () => { + expect(await app.resolve("/lazy")).toMatchObject({ + route: "/lazy", + handler: testHandlers[3], + }); + }); + + describe("nested app", () => { + it("resolves /nested/path/foo", async () => { + expect(await app.resolve("/nested/path/foo")).toMatchObject({ + route: "/nested/path", + handler: testHandlers[4], + }); + }); + + it.skip("resolves /nested/lazy", async () => { + expect(await app.resolve("/nested/lazy")).toMatchObject({ + route: "/nested/lazy", + handler: testHandlers[5], + }); + }); + }); + + describe("router", () => { + it("resolves /router", async () => { + expect(await app.resolve("/router")).toMatchObject({ + route: "/router", + handler: testHandlers[6], + }); + expect(await app.resolve("/router/")).toMatchObject( + (await app.resolve("/router")) as any, + ); + }); + + it("resolves /router/:id", async () => { + expect(await app.resolve("/router/foo")).toMatchObject({ + route: "/router/:id", + handler: testHandlers[7], + }); + }); + + it("resolves /router/lazy", async () => { + expect(await app.resolve("/router/lazy")).toMatchObject({ + route: "/router/lazy", + handler: testHandlers[8], + }); + }); + }); +}); diff --git a/test/status.test.ts b/test/status.test.ts index 9304df60..ca29feba 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -126,7 +126,7 @@ describe("setResponseStatus", () => { body: "", }); - console.log(res.headers); + // console.log(res.headers); expect(res).toMatchObject({ status: 304,