diff --git a/e2e/openapi.yaml b/e2e/openapi.yaml index c541c3970..e1cb6b89c 100644 --- a/e2e/openapi.yaml +++ b/e2e/openapi.yaml @@ -7,6 +7,7 @@ tags: - name: validation - name: escape hatches - name: media types + - name: route matching servers: - url: '{protocol}://{host}:{port}' variables: @@ -196,6 +197,7 @@ paths: type: array items: type: string + /validation/numbers/random-number: get: tags: @@ -355,6 +357,36 @@ paths: schema: type: string + /route-matching/fixed-field: + get: + operationId: routeMatchingGetByFixedField + tags: + - route matching + responses: + 200: + description: 'ok' + content: + application/json: + schema: {} + + /route-matching/{id}: + get: + operationId: routeMatchingGetById + tags: + - route matching + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + 200: + description: 'ok' + content: + application/json: + schema: {} + components: responses: GetHeaders: diff --git a/e2e/src/express.entrypoint.ts b/e2e/src/express.entrypoint.ts index dfe3adcc7..1bdec67d2 100644 --- a/e2e/src/express.entrypoint.ts +++ b/e2e/src/express.entrypoint.ts @@ -4,6 +4,7 @@ import {createEscapeHatchesRouter} from "./routes/express/escape-hatches.ts" import {createMediaTypesRouter} from "./routes/express/media-types.ts" import {createQueryParametersRouter} from "./routes/express/query-parameters.ts" import {createRequestHeadersRouter} from "./routes/express/request-headers.ts" +import {createRouteMatchingRouter} from "./routes/express/route-matching.ts" import {createValidationRouter} from "./routes/express/validation.ts" import {createErrorResponse} from "./shared.ts" @@ -15,12 +16,14 @@ function createRouter() { const escapeHatchesRouter = createEscapeHatchesRouter() const mediaTypesRouter = createMediaTypesRouter() const queryParametersRouter = createQueryParametersRouter() + const routeMatchingRouter = createRouteMatchingRouter() router.use(requestHeadersRouter) router.use(validationRouter) router.use(escapeHatchesRouter) router.use(mediaTypesRouter) router.use(queryParametersRouter) + router.use(routeMatchingRouter) return router } diff --git a/e2e/src/generated/client/axios/client.ts b/e2e/src/generated/client/axios/client.ts index 777cb22fc..9f743abda 100644 --- a/e2e/src/generated/client/axios/client.ts +++ b/e2e/src/generated/client/axios/client.ts @@ -488,6 +488,45 @@ export class E2ETestClient extends AbstractAxiosClient { return {...res, data: z.string().parse(res.data)} } + + async routeMatchingGetByFixedField( + timeout?: number, + opts: AxiosRequestConfig = {}, + ): Promise> { + const url = `/route-matching/fixed-field` + const headers = this._headers({Accept: "application/json"}, opts.headers) + + const res = await this._request({ + url: url, + method: "GET", + ...(timeout ? {timeout} : {}), + ...opts, + headers, + }) + + return {...res, data: z.unknown().parse(res.data)} + } + + async routeMatchingGetById( + p: { + id: string + }, + timeout?: number, + opts: AxiosRequestConfig = {}, + ): Promise> { + const url = `/route-matching/${p["id"]}` + const headers = this._headers({Accept: "application/json"}, opts.headers) + + const res = await this._request({ + url: url, + method: "GET", + ...(timeout ? {timeout} : {}), + ...opts, + headers, + }) + + return {...res, data: z.unknown().parse(res.data)} + } } export {E2ETestClient as ApiClient} diff --git a/e2e/src/generated/client/fetch/client.ts b/e2e/src/generated/client/fetch/client.ts index 62fcae4e8..faae388fd 100644 --- a/e2e/src/generated/client/fetch/client.ts +++ b/e2e/src/generated/client/fetch/client.ts @@ -450,6 +450,33 @@ export class E2ETestClient extends AbstractFetchClient { return responseValidationFactory([["200", z.string()]], undefined)(res) } + + async routeMatchingGetByFixedField( + timeout?: number, + opts: RequestInit = {}, + ): Promise> { + const url = this.basePath + `/route-matching/fixed-field` + const headers = this._headers({Accept: "application/json"}, opts.headers) + + const res = this._fetch(url, {method: "GET", ...opts, headers}, timeout) + + return responseValidationFactory([["200", z.unknown()]], undefined)(res) + } + + async routeMatchingGetById( + p: { + id: string + }, + timeout?: number, + opts: RequestInit = {}, + ): Promise> { + const url = this.basePath + `/route-matching/${p["id"]}` + const headers = this._headers({Accept: "application/json"}, opts.headers) + + const res = this._fetch(url, {method: "GET", ...opts, headers}, timeout) + + return responseValidationFactory([["200", z.unknown()]], undefined)(res) + } } export {E2ETestClient as ApiClient} diff --git a/e2e/src/generated/server/express/models.ts b/e2e/src/generated/server/express/models.ts index d68fc9c4d..368e20d78 100644 --- a/e2e/src/generated/server/express/models.ts +++ b/e2e/src/generated/server/express/models.ts @@ -108,3 +108,7 @@ export type t_PostValidationOptionalBody200Response = { export type t_PostValidationOptionalBodyRequestBody = { id?: string | undefined } + +export type t_RouteMatchingGetByIdParamSchema = { + id: string +} diff --git a/e2e/src/generated/server/express/routes/route-matching.ts b/e2e/src/generated/server/express/routes/route-matching.ts new file mode 100644 index 000000000..d06c5171e --- /dev/null +++ b/e2e/src/generated/server/express/routes/route-matching.ts @@ -0,0 +1,142 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import {RequestInputType} from "@nahkies/typescript-express-runtime/errors" +import { + type ExpressRuntimeResponder, + ExpressRuntimeResponse, + handleImplementationError, + handleResponse, + type Params, + type SkipResponse, + type StatusCode, +} from "@nahkies/typescript-express-runtime/server" +import { + parseRequestInput, + responseValidationFactory, +} from "@nahkies/typescript-express-runtime/zod-v4" +import {type NextFunction, type Request, type Response, Router} from "express" +import {z} from "zod/v4" +import type {t_RouteMatchingGetByIdParamSchema} from "../models.ts" + +export type RouteMatchingGetByFixedFieldResponder = { + with200(): ExpressRuntimeResponse +} & ExpressRuntimeResponder + +export type RouteMatchingGetByFixedField = ( + params: Params, + respond: RouteMatchingGetByFixedFieldResponder, + req: Request, + res: Response, + next: NextFunction, +) => Promise | typeof SkipResponse> + +export type RouteMatchingGetByIdResponder = { + with200(): ExpressRuntimeResponse +} & ExpressRuntimeResponder + +export type RouteMatchingGetById = ( + params: Params, + respond: RouteMatchingGetByIdResponder, + req: Request, + res: Response, + next: NextFunction, +) => Promise | typeof SkipResponse> + +export type RouteMatchingImplementation = { + routeMatchingGetByFixedField: RouteMatchingGetByFixedField + routeMatchingGetById: RouteMatchingGetById +} + +export function createRouteMatchingRouter( + implementation: RouteMatchingImplementation, +): Router { + const router = Router() + + const routeMatchingGetByFixedFieldResponseBodyValidator = + responseValidationFactory([["200", z.unknown()]], undefined) + + // routeMatchingGetByFixedField + router.get( + `/route-matching/fixed-field`, + async (req: Request, res: Response, next: NextFunction) => { + try { + const input = { + params: undefined, + query: undefined, + body: undefined, + headers: undefined, + } + + const responder = { + with200() { + return new ExpressRuntimeResponse(200) + }, + withStatus(status: StatusCode) { + return new ExpressRuntimeResponse(status) + }, + } + + await implementation + .routeMatchingGetByFixedField(input, responder, req, res, next) + .catch(handleImplementationError) + .then( + handleResponse( + res, + routeMatchingGetByFixedFieldResponseBodyValidator, + ), + ) + } catch (error) { + next(error) + } + }, + ) + + const routeMatchingGetByIdParamSchema = z.object({id: z.string()}) + + const routeMatchingGetByIdResponseBodyValidator = responseValidationFactory( + [["200", z.unknown()]], + undefined, + ) + + // routeMatchingGetById + router.get( + `/route-matching/:id`, + async (req: Request, res: Response, next: NextFunction) => { + try { + const input = { + params: parseRequestInput( + routeMatchingGetByIdParamSchema, + req.params, + RequestInputType.RouteParam, + ), + query: undefined, + body: undefined, + headers: undefined, + } + + const responder = { + with200() { + return new ExpressRuntimeResponse(200) + }, + withStatus(status: StatusCode) { + return new ExpressRuntimeResponse(status) + }, + } + + await implementation + .routeMatchingGetById(input, responder, req, res, next) + .catch(handleImplementationError) + .then(handleResponse(res, routeMatchingGetByIdResponseBodyValidator)) + } catch (error) { + next(error) + } + }, + ) + + return router +} + +export {createRouteMatchingRouter as createRouter} +export type {RouteMatchingImplementation as Implementation} diff --git a/e2e/src/generated/server/koa/models.ts b/e2e/src/generated/server/koa/models.ts index d68fc9c4d..368e20d78 100644 --- a/e2e/src/generated/server/koa/models.ts +++ b/e2e/src/generated/server/koa/models.ts @@ -108,3 +108,7 @@ export type t_PostValidationOptionalBody200Response = { export type t_PostValidationOptionalBodyRequestBody = { id?: string | undefined } + +export type t_RouteMatchingGetByIdParamSchema = { + id: string +} diff --git a/e2e/src/generated/server/koa/routes/route-matching.ts b/e2e/src/generated/server/koa/routes/route-matching.ts new file mode 100644 index 000000000..b69450f74 --- /dev/null +++ b/e2e/src/generated/server/koa/routes/route-matching.ts @@ -0,0 +1,139 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import KoaRouter, {type RouterContext} from "@koa/router" +import {RequestInputType} from "@nahkies/typescript-koa-runtime/errors" +import { + handleImplementationError, + handleResponse, + type KoaRuntimeResponder, + KoaRuntimeResponse, + type Params, + type Res, + type SkipResponse, + type StatusCode, +} from "@nahkies/typescript-koa-runtime/server" +import { + parseRequestInput, + responseValidationFactory, +} from "@nahkies/typescript-koa-runtime/zod-v4" +import type {Next} from "koa" +import {z} from "zod/v4" +import type {t_RouteMatchingGetByIdParamSchema} from "../models.ts" + +export type RouteMatchingGetByFixedFieldResponder = { + with200(): KoaRuntimeResponse +} & KoaRuntimeResponder + +export type RouteMatchingGetByFixedField = ( + params: Params, + respond: RouteMatchingGetByFixedFieldResponder, + ctx: RouterContext, + next: Next, +) => Promise< + KoaRuntimeResponse | Res<200, unknown> | typeof SkipResponse +> + +export type RouteMatchingGetByIdResponder = { + with200(): KoaRuntimeResponse +} & KoaRuntimeResponder + +export type RouteMatchingGetById = ( + params: Params, + respond: RouteMatchingGetByIdResponder, + ctx: RouterContext, + next: Next, +) => Promise< + KoaRuntimeResponse | Res<200, unknown> | typeof SkipResponse +> + +export type RouteMatchingImplementation = { + routeMatchingGetByFixedField: RouteMatchingGetByFixedField + routeMatchingGetById: RouteMatchingGetById +} + +export function createRouteMatchingRouter( + implementation: RouteMatchingImplementation, +): KoaRouter { + const router = new KoaRouter({exclusive: true}) + + const routeMatchingGetByFixedFieldResponseValidator = + responseValidationFactory([["200", z.unknown()]], undefined) + + router.get( + "routeMatchingGetByFixedField", + "/route-matching/fixed-field", + async (ctx, next) => { + const input = { + params: undefined, + query: undefined, + body: undefined, + headers: undefined, + } + + const responder = { + with200() { + return new KoaRuntimeResponse(200) + }, + withStatus(status: StatusCode) { + return new KoaRuntimeResponse(status) + }, + } + + await implementation + .routeMatchingGetByFixedField(input, responder, ctx, next) + .catch(handleImplementationError) + .then( + handleResponse( + ctx, + next, + routeMatchingGetByFixedFieldResponseValidator, + ), + ) + }, + ) + + const routeMatchingGetByIdParamSchema = z.object({id: z.string()}) + + const routeMatchingGetByIdResponseValidator = responseValidationFactory( + [["200", z.unknown()]], + undefined, + ) + + router.get( + "routeMatchingGetById", + "/route-matching/:id", + async (ctx, next) => { + const input = { + params: parseRequestInput( + routeMatchingGetByIdParamSchema, + ctx.params, + RequestInputType.RouteParam, + ), + query: undefined, + body: undefined, + headers: undefined, + } + + const responder = { + with200() { + return new KoaRuntimeResponse(200) + }, + withStatus(status: StatusCode) { + return new KoaRuntimeResponse(status) + }, + } + + await implementation + .routeMatchingGetById(input, responder, ctx, next) + .catch(handleImplementationError) + .then(handleResponse(ctx, next, routeMatchingGetByIdResponseValidator)) + }, + ) + + return router +} + +export {createRouteMatchingRouter as createRouter} +export type {RouteMatchingImplementation as Implementation} diff --git a/e2e/src/index.axios.spec.ts b/e2e/src/index.axios.spec.ts index 3e84e0bab..6034a05cb 100644 --- a/e2e/src/index.axios.spec.ts +++ b/e2e/src/index.axios.spec.ts @@ -493,4 +493,20 @@ describe.each( }) }) }) + + describe("route matching", () => { + it("should match fixed field route over parameterized route", async () => { + const {status, data} = await client.routeMatchingGetByFixedField() + + expect(status).toBe(200) + expect(data).toEqual({matched: "fixed-field"}) + }) + + it("should match parameterized route", async () => { + const {status, data} = await client.routeMatchingGetById({id: "123"}) + + expect(status).toBe(200) + expect(data).toEqual({matched: "id", id: "123"}) + }) + }) }) diff --git a/e2e/src/index.fetch.spec.ts b/e2e/src/index.fetch.spec.ts index ed52b44c1..ed271ee7c 100644 --- a/e2e/src/index.fetch.spec.ts +++ b/e2e/src/index.fetch.spec.ts @@ -494,4 +494,20 @@ describe.each( }) }) }) + + describe("route matching", () => { + it("should match fixed field route over parameterized route", async () => { + const res = await client.routeMatchingGetByFixedField() + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({matched: "fixed-field"}) + }) + + it("should match parameterized route", async () => { + const res = await client.routeMatchingGetById({id: "123"}) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({matched: "id", id: "123"}) + }) + }) }) diff --git a/e2e/src/koa.entrypoint.ts b/e2e/src/koa.entrypoint.ts index 01ba886a8..fe7146963 100644 --- a/e2e/src/koa.entrypoint.ts +++ b/e2e/src/koa.entrypoint.ts @@ -4,6 +4,7 @@ import {createEscapeHatchesRouter} from "./routes/koa/escape-hatches.ts" import {createMediaTypesRouter} from "./routes/koa/media-types.ts" import {createQueryParametersRouter} from "./routes/koa/query-parameters.ts" import {createRequestHeadersRouter} from "./routes/koa/request-headers.ts" +import {createRouteMatchingRouter} from "./routes/koa/route-matching.ts" import {createValidationRouter} from "./routes/koa/validation.ts" import {createErrorResponse} from "./shared.ts" @@ -15,6 +16,7 @@ function createRouter() { const escapeHatchesRouter = createEscapeHatchesRouter() const mediaTypesRouter = createMediaTypesRouter() const queryParametersRouter = createQueryParametersRouter() + const routeMatchingRouter = createRouteMatchingRouter() router.use( requestHeadersRouter.allowedMethods(), @@ -27,6 +29,7 @@ function createRouter() { queryParametersRouter.allowedMethods(), queryParametersRouter.routes(), ) + router.use(routeMatchingRouter.allowedMethods(), routeMatchingRouter.routes()) return router } diff --git a/e2e/src/routes/express/route-matching.ts b/e2e/src/routes/express/route-matching.ts new file mode 100644 index 000000000..0a0c7c4a7 --- /dev/null +++ b/e2e/src/routes/express/route-matching.ts @@ -0,0 +1,26 @@ +import { + createRouter, + type RouteMatchingGetByFixedField, + type RouteMatchingGetById, +} from "../../generated/server/express/routes/route-matching.ts" + +const routeMatchingGetByFixedField: RouteMatchingGetByFixedField = async ( + _params, + respond, +) => { + return respond.with200().body({matched: "fixed-field"}) +} + +const routeMatchingGetById: RouteMatchingGetById = async ( + {params}, + respond, +) => { + return respond.with200().body({matched: "id", id: params.id}) +} + +export function createRouteMatchingRouter() { + return createRouter({ + routeMatchingGetByFixedField, + routeMatchingGetById, + }) +} diff --git a/e2e/src/routes/koa/route-matching.ts b/e2e/src/routes/koa/route-matching.ts new file mode 100644 index 000000000..c681bc8b6 --- /dev/null +++ b/e2e/src/routes/koa/route-matching.ts @@ -0,0 +1,26 @@ +import { + createRouter, + type RouteMatchingGetByFixedField, + type RouteMatchingGetById, +} from "../../generated/server/koa/routes/route-matching.ts" + +const routeMatchingGetByFixedField: RouteMatchingGetByFixedField = async ( + _params, + respond, +) => { + return respond.with200().body({matched: "fixed-field"}) +} + +const routeMatchingGetById: RouteMatchingGetById = async ( + {params}, + respond, +) => { + return respond.with200().body({matched: "id", id: params.id}) +} + +export function createRouteMatchingRouter() { + return createRouter({ + routeMatchingGetByFixedField, + routeMatchingGetById, + }) +}