diff --git a/.changeset/fifty-lemons-wonder.md b/.changeset/fifty-lemons-wonder.md new file mode 100644 index 0000000..c7ee4b6 --- /dev/null +++ b/.changeset/fifty-lemons-wonder.md @@ -0,0 +1,5 @@ +--- +"@dreamkit/app": patch +--- + +Fix path matching diff --git a/packages/app/src/RequestUrl.ts b/packages/app/src/RequestUrl.ts index 66fbc2f..3097fee 100644 --- a/packages/app/src/RequestUrl.ts +++ b/packages/app/src/RequestUrl.ts @@ -1,11 +1,13 @@ import { kindApp } from "./utils/kind.js"; +import { createRoutePathRegex, extractPathParams } from "./utils/routing.js"; export class RequestUrl extends URL { static { kindApp(this, "RequestUrl"); } is(...paths: (keyof T)[]): boolean { - // [review] - return paths.includes(this.pathname as any); + return paths.some((path) => + createRoutePathRegex(path as string).test(this.pathname), + ); } } diff --git a/packages/app/src/builders/RouteBuilder.ts b/packages/app/src/builders/RouteBuilder.ts index c4108ad..f0c109e 100644 --- a/packages/app/src/builders/RouteBuilder.ts +++ b/packages/app/src/builders/RouteBuilder.ts @@ -7,6 +7,7 @@ import { ObjectType, ObjectTypeProps, s, + type Type, } from "@dreamkit/schema"; import type { Merge } from "@dreamkit/utils/ts.js"; @@ -119,7 +120,14 @@ export class RouteBuilder { if (typeof value === "string") return this.clone({ path: value }) as this; const params: any = new Proxy( {}, - { get: (_, prop) => `:${prop as string}` }, + { + get: (_, prop: string) => { + const optional = ( + this.options.params?.props?.[prop as string] as Type + ).options.optional; + return `:${prop}${optional ? "?" : ""}`; + }, + }, ); return this.clone({ path: value(params) }); } diff --git a/packages/app/src/utils/routing.ts b/packages/app/src/utils/routing.ts index 8908621..31f696f 100644 --- a/packages/app/src/utils/routing.ts +++ b/packages/app/src/utils/routing.ts @@ -1,13 +1,38 @@ export function extractPathParams( path: string, -): { spread: boolean; name: string; key: string }[] { - return [...path.matchAll(/([:\*])(\w+)/g)].map((match) => ({ +): { spread: boolean; name: string; key: string; optional?: boolean }[] { + return [...path.matchAll(/([:\*])(\w+)(\?)?/g)].map((match) => ({ key: match[0], spread: match[1] === "*", name: match[2], + optional: match[3] === "?", })); } +export function createRoutePathRegex(path: string) { + const paramNames = extractPathParams(path); + let pattern = path.replace(/\//g, "\\/"); + const optionalPattern = "([^\\/]+)?"; + const endOptionalPattern = `\\/${optionalPattern}`; + const spreadPattern = "(.*)"; + const endOptionalSpreadPattern = `\\/${spreadPattern}`; + for (const param of paramNames) { + if (param.spread) { + pattern = pattern.replace(param.key, spreadPattern); + } else if (param.optional) { + pattern = pattern.replace(param.key, optionalPattern); + } else { + pattern = pattern.replace(param.key, "([^\\/]+)"); + } + } + if (pattern.endsWith(endOptionalSpreadPattern)) { + pattern = pattern.slice(0, -endOptionalSpreadPattern.length) + `(\\/.*)?`; + } else if (pattern.endsWith(endOptionalPattern)) { + pattern = pattern.slice(0, -endOptionalPattern.length) + `(\\/[^\\/]+)?`; + } + return new RegExp(`^${pattern}\/?$`); +} + export function createRouteUrl( path: string, params: Record = {}, @@ -15,7 +40,9 @@ export function createRouteUrl( ) { const paramNames = extractPathParams(path); for (const param of paramNames) - path = path.replaceAll(param.key, params[param.name]); + path = path.replaceAll(param.key, params[param.name] ?? ""); + + if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1); const url = new URL(path, base); diff --git a/packages/app/test/utils/routing.test.ts b/packages/app/test/utils/routing.test.ts new file mode 100644 index 0000000..f89c6ef --- /dev/null +++ b/packages/app/test/utils/routing.test.ts @@ -0,0 +1,63 @@ +import { + createRoutePathRegex, + createRouteUrl, + extractPathParams, +} from "../../src/utils/routing.js"; +import { describe, it, expect } from "vitest"; + +describe("extractPathParams", () => { + it("extract optional param", () => { + expect(extractPathParams("/users/:id?")).toMatchObject([ + { + key: ":id?", + name: "id", + optional: true, + spread: false, + }, + ]); + }); +}); + +describe("createRouteUrl", () => { + it("with optional param", () => { + expect(createRouteUrl("/users/:id?", { id: 1 }).pathname).toBe("/users/1"); + expect(createRouteUrl("/users/:id?").pathname).toBe("/users"); + }); +}); + +describe("createRoutePathRegex", () => { + it("with optional param", () => { + const regex = createRoutePathRegex("/users/:id?"); + expect(regex.test("/users")).toBe(true); + expect(regex.test("/users/")).toBe(true); + expect(regex.test("/users/1")).toBe(true); + expect(regex.test("/users/1/")).toBe(true); + expect(regex.test("/users/1/2")).toBe(false); + }); + it("with required param", () => { + const regex = createRoutePathRegex("/users/:id"); + expect(regex.test("/users")).toBe(false); + expect(regex.test("/users/")).toBe(false); + expect(regex.test("/users/1")).toBe(true); + expect(regex.test("/users/1/")).toBe(true); + expect(regex.test("/users/1/2")).toBe(false); + }); + it("with spread", () => { + const regex = createRoutePathRegex("/users/*any"); + expect(regex.test("/")).toBe(false); + expect(regex.test("/users")).toBe(true); + expect(regex.test("/users/")).toBe(true); + expect(regex.test("/users/1")).toBe(true); + expect(regex.test("/users/1/")).toBe(true); + expect(regex.test("/users/1/2")).toBe(true); + }); + it("with all", () => { + const regex = createRoutePathRegex("/*any"); + expect(regex.test("/")).toBe(true); + expect(regex.test("/users")).toBe(true); + expect(regex.test("/users/")).toBe(true); + expect(regex.test("/users/1")).toBe(true); + expect(regex.test("/users/1/")).toBe(true); + expect(regex.test("/users/1/2")).toBe(true); + }); +});