diff --git a/e2e/e2e/src/routes/express/timeout.ts b/e2e/e2e/src/routes/express/timeout.ts new file mode 100644 index 00000000..febc3666 --- /dev/null +++ b/e2e/e2e/src/routes/express/timeout.ts @@ -0,0 +1,15 @@ +import { + createRouter, + type GetTimeout, +} from "../../generated/server/express/routes/timeout.ts" + +const getTimeout: GetTimeout = async ({query}, respond) => { + await new Promise((resolve) => setTimeout(resolve, query.ms)) + return respond.with200().body({ms: query.ms}) +} + +export function createTimeoutRouter() { + return createRouter({ + getTimeout, + }) +} diff --git a/e2e/openapi.yaml b/e2e/openapi.yaml index 1d814547..9ccec545 100644 --- a/e2e/openapi.yaml +++ b/e2e/openapi.yaml @@ -416,6 +416,28 @@ paths: application/json: schema: {} + /timeout: + get: + operationId: getTimeout + tags: + - timeout + parameters: + - name: ms + in: query + required: true + schema: + type: number + responses: + 200: + description: success + content: + application/json: + schema: + type: object + properties: + ms: + type: number + components: responses: GetHeaders: diff --git a/e2e/src/express.entrypoint.ts b/e2e/src/express.entrypoint.ts index 1bdec67d..fa92239d 100644 --- a/e2e/src/express.entrypoint.ts +++ b/e2e/src/express.entrypoint.ts @@ -5,6 +5,7 @@ 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 {createTimeoutRouter} from "./routes/express/timeout.ts" import {createValidationRouter} from "./routes/express/validation.ts" import {createErrorResponse} from "./shared.ts" @@ -17,6 +18,7 @@ function createRouter() { const mediaTypesRouter = createMediaTypesRouter() const queryParametersRouter = createQueryParametersRouter() const routeMatchingRouter = createRouteMatchingRouter() + const timeoutRouter = createTimeoutRouter() router.use(requestHeadersRouter) router.use(validationRouter) @@ -24,6 +26,7 @@ function createRouter() { router.use(mediaTypesRouter) router.use(queryParametersRouter) router.use(routeMatchingRouter) + router.use(timeoutRouter) return router } diff --git a/e2e/src/generated/client/axios/client.ts b/e2e/src/generated/client/axios/client.ts index 35ac9222..fa9fbdcf 100644 --- a/e2e/src/generated/client/axios/client.ts +++ b/e2e/src/generated/client/axios/client.ts @@ -18,6 +18,7 @@ import type { t_GetParamsSimpleQuery200Response, t_GetParamsUnexplodedObjectQuery200Response, t_GetResponsesDefault200Response, + t_GetTimeout200Response, t_PostValidationOptionalBody200Response, t_PostValidationOptionalBodyRequestBody, t_ProductOrder, @@ -33,6 +34,7 @@ import { s_GetParamsSimpleQuery200Response, s_GetParamsUnexplodedObjectQuery200Response, s_GetResponsesDefault200Response, + s_GetTimeout200Response, s_PostValidationOptionalBody200Response, s_ProductOrder, s_RandomNumber, @@ -551,6 +553,28 @@ export class E2ETestClient extends AbstractAxiosClient { return {...res, data: z.unknown().parse(res.data)} } + + async getTimeout( + p: { + ms: number + }, + timeout?: number, + opts: AxiosRequestConfig = {}, + ): Promise> { + const url = `/timeout` + const headers = this._headers({Accept: "application/json"}, opts.headers) + const query = this._query({ms: p["ms"]}) + + const res = await this._request({ + url: url + query, + method: "GET", + ...(timeout ? {timeout} : {}), + ...opts, + headers, + }) + + return {...res, data: s_GetTimeout200Response.parse(res.data)} + } } export {E2ETestClient as ApiClient} diff --git a/e2e/src/generated/client/axios/models.ts b/e2e/src/generated/client/axios/models.ts index d442bc8f..dede0e61 100644 --- a/e2e/src/generated/client/axios/models.ts +++ b/e2e/src/generated/client/axios/models.ts @@ -79,6 +79,10 @@ export type t_GetResponsesDefaultdefaultResponse = { error?: string | undefined } +export type t_GetTimeout200Response = { + ms?: number | undefined +} + export type t_PostValidationOptionalBody200Response = { id?: string | undefined } diff --git a/e2e/src/generated/client/axios/schemas.ts b/e2e/src/generated/client/axios/schemas.ts index 71f5b31a..c4bc864e 100644 --- a/e2e/src/generated/client/axios/schemas.ts +++ b/e2e/src/generated/client/axios/schemas.ts @@ -76,3 +76,7 @@ export const s_GetResponsesDefault200Response = z.object({ export const s_GetResponsesDefaultdefaultResponse = z.object({ error: z.string().optional(), }) + +export const s_GetTimeout200Response = z.object({ + ms: z.coerce.number().optional(), +}) diff --git a/e2e/src/generated/client/fetch/client.ts b/e2e/src/generated/client/fetch/client.ts index 8b98191f..763a57a2 100644 --- a/e2e/src/generated/client/fetch/client.ts +++ b/e2e/src/generated/client/fetch/client.ts @@ -21,6 +21,7 @@ import type { t_GetParamsUnexplodedObjectQuery200Response, t_GetResponsesDefault200Response, t_GetResponsesDefaultdefaultResponse, + t_GetTimeout200Response, t_PostValidationOptionalBody200Response, t_PostValidationOptionalBodyRequestBody, t_ProductOrder, @@ -37,6 +38,7 @@ import { s_GetParamsUnexplodedObjectQuery200Response, s_GetResponsesDefault200Response, s_GetResponsesDefaultdefaultResponse, + s_GetTimeout200Response, s_PostValidationOptionalBody200Response, s_ProductOrder, s_RandomNumber, @@ -508,6 +510,29 @@ export class E2ETestClient extends AbstractFetchClient { return responseValidationFactory([["200", z.unknown()]], undefined)(res) } + + async getTimeout( + p: { + ms: number + }, + timeout?: number, + opts: RequestInit = {}, + ): Promise> { + const url = this.basePath + `/timeout` + const headers = this._headers({Accept: "application/json"}, opts.headers) + const query = this._query({ms: p["ms"]}) + + const res = this._fetch( + url + query, + {method: "GET", ...opts, headers}, + timeout, + ) + + return responseValidationFactory( + [["200", s_GetTimeout200Response]], + undefined, + )(res) + } } export {E2ETestClient as ApiClient} diff --git a/e2e/src/generated/client/fetch/models.ts b/e2e/src/generated/client/fetch/models.ts index d442bc8f..dede0e61 100644 --- a/e2e/src/generated/client/fetch/models.ts +++ b/e2e/src/generated/client/fetch/models.ts @@ -79,6 +79,10 @@ export type t_GetResponsesDefaultdefaultResponse = { error?: string | undefined } +export type t_GetTimeout200Response = { + ms?: number | undefined +} + export type t_PostValidationOptionalBody200Response = { id?: string | undefined } diff --git a/e2e/src/generated/client/fetch/schemas.ts b/e2e/src/generated/client/fetch/schemas.ts index 71f5b31a..c4bc864e 100644 --- a/e2e/src/generated/client/fetch/schemas.ts +++ b/e2e/src/generated/client/fetch/schemas.ts @@ -76,3 +76,7 @@ export const s_GetResponsesDefault200Response = z.object({ export const s_GetResponsesDefaultdefaultResponse = z.object({ error: z.string().optional(), }) + +export const s_GetTimeout200Response = z.object({ + ms: z.coerce.number().optional(), +}) diff --git a/e2e/src/generated/server/express/models.ts b/e2e/src/generated/server/express/models.ts index 306f5af7..95fa917c 100644 --- a/e2e/src/generated/server/express/models.ts +++ b/e2e/src/generated/server/express/models.ts @@ -107,6 +107,14 @@ export type t_GetResponsesDefaultdefaultResponse = { error?: string | undefined } +export type t_GetTimeout200Response = { + ms?: number | undefined +} + +export type t_GetTimeoutQuerySchema = { + ms: number +} + export type t_GetValidationNumbersRandomNumberQuerySchema = { forbidden?: number[] | undefined max?: number | undefined diff --git a/e2e/src/generated/server/express/routes/timeout.ts b/e2e/src/generated/server/express/routes/timeout.ts new file mode 100644 index 00000000..ad9e7d76 --- /dev/null +++ b/e2e/src/generated/server/express/routes/timeout.ts @@ -0,0 +1,105 @@ +/** 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 RequestHandler, + type Response, + Router, +} from "express" +import {z} from "zod/v4" +import type { + t_GetTimeout200Response, + t_GetTimeoutQuerySchema, +} from "../models.ts" +import {s_GetTimeout200Response} from "../schemas.ts" + +export type GetTimeoutResponder = { + with200(): ExpressRuntimeResponse +} & ExpressRuntimeResponder + +export type GetTimeout = ( + params: Params, + respond: GetTimeoutResponder, + req: Request, + res: Response, + next: NextFunction, +) => Promise | typeof SkipResponse> + +export type TimeoutImplementation = { + getTimeout: GetTimeout +} + +export function createTimeoutRouter( + implementation: TimeoutImplementation, + options: {middleware?: RequestHandler[]} = {}, +): Router { + const router = Router() + + if (options.middleware?.length) { + router.use(...options.middleware) + } + + const getTimeoutQuerySchema = z.object({ms: z.coerce.number()}) + + const getTimeoutResponseBodyValidator = responseValidationFactory( + [["200", s_GetTimeout200Response]], + undefined, + ) + + // getTimeout + router.get( + `/timeout`, + async (req: Request, res: Response, next: NextFunction) => { + try { + const input = { + params: undefined, + query: parseRequestInput( + getTimeoutQuerySchema, + req.query, + RequestInputType.QueryString, + ), + body: undefined, + headers: undefined, + } + + const responder = { + with200() { + return new ExpressRuntimeResponse(200) + }, + withStatus(status: StatusCode) { + return new ExpressRuntimeResponse(status) + }, + } + + await implementation + .getTimeout(input, responder, req, res, next) + .catch(handleImplementationError) + .then(handleResponse(res, getTimeoutResponseBodyValidator)) + } catch (error) { + next(error) + } + }, + ) + + return router +} + +export {createTimeoutRouter as createRouter} +export type {TimeoutImplementation as Implementation} diff --git a/e2e/src/generated/server/express/schemas.ts b/e2e/src/generated/server/express/schemas.ts index e7fda34a..cbc3a07a 100644 --- a/e2e/src/generated/server/express/schemas.ts +++ b/e2e/src/generated/server/express/schemas.ts @@ -80,3 +80,7 @@ export const s_GetResponsesDefault200Response = z.object({ export const s_GetResponsesDefaultdefaultResponse = z.object({ error: z.string().optional(), }) + +export const s_GetTimeout200Response = z.object({ + ms: z.coerce.number().optional(), +}) diff --git a/e2e/src/generated/server/koa/models.ts b/e2e/src/generated/server/koa/models.ts index 306f5af7..95fa917c 100644 --- a/e2e/src/generated/server/koa/models.ts +++ b/e2e/src/generated/server/koa/models.ts @@ -107,6 +107,14 @@ export type t_GetResponsesDefaultdefaultResponse = { error?: string | undefined } +export type t_GetTimeout200Response = { + ms?: number | undefined +} + +export type t_GetTimeoutQuerySchema = { + ms: number +} + export type t_GetValidationNumbersRandomNumberQuerySchema = { forbidden?: number[] | undefined max?: number | undefined diff --git a/e2e/src/generated/server/koa/routes/timeout.ts b/e2e/src/generated/server/koa/routes/timeout.ts new file mode 100644 index 00000000..928fc746 --- /dev/null +++ b/e2e/src/generated/server/koa/routes/timeout.ts @@ -0,0 +1,94 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import KoaRouter, {type RouterContext, type RouterMiddleware} 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 {z} from "zod/v4" +import type { + t_GetTimeout200Response, + t_GetTimeoutQuerySchema, +} from "../models.ts" +import {s_GetTimeout200Response} from "../schemas.ts" + +export type GetTimeoutResponder = { + with200(): KoaRuntimeResponse +} & KoaRuntimeResponder + +export type GetTimeout = ( + params: Params, + respond: GetTimeoutResponder, + ctx: RouterContext, +) => Promise< + | KoaRuntimeResponse + | Res<200, t_GetTimeout200Response> + | typeof SkipResponse +> + +export type TimeoutImplementation = { + getTimeout: GetTimeout +} + +export function createTimeoutRouter( + implementation: TimeoutImplementation, + options: {middleware?: RouterMiddleware[]} = {}, +): KoaRouter { + const router = new KoaRouter() + + if (options.middleware?.length) { + router.use(...options.middleware) + } + + const getTimeoutQuerySchema = z.object({ms: z.coerce.number()}) + + const getTimeoutResponseValidator = responseValidationFactory( + [["200", s_GetTimeout200Response]], + undefined, + ) + + router.get("getTimeout", "/timeout", async (ctx) => { + const input = { + params: undefined, + query: parseRequestInput( + getTimeoutQuerySchema, + ctx.query, + RequestInputType.QueryString, + ), + body: undefined, + headers: undefined, + } + + const responder = { + with200() { + return new KoaRuntimeResponse(200) + }, + withStatus(status: StatusCode) { + return new KoaRuntimeResponse(status) + }, + } + + await implementation + .getTimeout(input, responder, ctx) + .catch(handleImplementationError) + .then(handleResponse(ctx, getTimeoutResponseValidator)) + }) + + return router +} + +export {createTimeoutRouter as createRouter} +export type {TimeoutImplementation as Implementation} diff --git a/e2e/src/generated/server/koa/schemas.ts b/e2e/src/generated/server/koa/schemas.ts index e7fda34a..cbc3a07a 100644 --- a/e2e/src/generated/server/koa/schemas.ts +++ b/e2e/src/generated/server/koa/schemas.ts @@ -80,3 +80,7 @@ export const s_GetResponsesDefault200Response = z.object({ export const s_GetResponsesDefaultdefaultResponse = z.object({ error: z.string().optional(), }) + +export const s_GetTimeout200Response = z.object({ + ms: z.coerce.number().optional(), +}) diff --git a/e2e/src/index.axios.spec.ts b/e2e/src/index.axios.spec.ts index 61ed3404..3c53bc75 100644 --- a/e2e/src/index.axios.spec.ts +++ b/e2e/src/index.axios.spec.ts @@ -14,15 +14,17 @@ describe.each( )("e2e - typescript-axios client against $name server", ({startServer}) => { let server: Server | undefined let client: ApiClient + let basePath: string beforeAll(async () => { const args = await startServer() + basePath = E2ETestClientServers.server("{protocol}://{host}:{port}").build( + undefined, + undefined, + args.address.port.toString(), + ) client = new ApiClient({ - basePath: E2ETestClientServers.server("{protocol}://{host}:{port}").build( - undefined, - undefined, - args.address.port.toString(), - ), + basePath, defaultHeaders: { Authorization: "Bearer default-header", }, @@ -532,4 +534,35 @@ describe.each( expect(data).toEqual({matched: "id", id: "123"}) }) }) + + describe("timeouts", () => { + it("should respect the default timeout", async () => { + const clientWithTimeout = new ApiClient({ + basePath, + defaultTimeout: 200, + }) + + const err = await clientWithTimeout.getTimeout({ms: 500}).then( + () => { + throw new Error("expected request to fail") + }, + (err: AxiosError) => err, + ) + + expect(err.code).toBe("ECONNABORTED") + expect(err.message).toMatch(/timeout of 200ms exceeded/) + }) + + it("should respect the request level timeout", async () => { + const err = await client.getTimeout({ms: 150}, 100).then( + () => { + throw new Error("expected request to fail") + }, + (err: AxiosError) => err, + ) + + expect(err.code).toBe("ECONNABORTED") + expect(err.message).toMatch(/timeout of 100ms exceeded/) + }) + }) }) diff --git a/e2e/src/index.fetch.spec.ts b/e2e/src/index.fetch.spec.ts index 6027fd51..f2d38ea6 100644 --- a/e2e/src/index.fetch.spec.ts +++ b/e2e/src/index.fetch.spec.ts @@ -22,15 +22,17 @@ describe.each( )("e2e - typescript-fetch client against $name server", ({startServer}) => { let server: Server | undefined let client: ApiClient + let basePath: string beforeAll(async () => { const args = await startServer() + basePath = E2ETestClientServers.server("{protocol}://{host}:{port}").build( + undefined, + undefined, + args.address.port.toString(), + ) client = new ApiClient({ - basePath: E2ETestClientServers.server("{protocol}://{host}:{port}").build( - undefined, - undefined, - args.address.port.toString(), - ), + basePath, defaultHeaders: { Authorization: "Bearer default-header", }, @@ -569,4 +571,25 @@ describe.each( ) }) }) + + describe("timeouts", () => { + it("should respect the default timeout", async () => { + const clientWithTimeout = new ApiClient({ + basePath, + defaultTimeout: 200, + }) + + const wait = clientWithTimeout.getTimeout({ms: 500}) + await expect(wait).rejects.toThrow( + "The operation was aborted due to timeout", + ) + }) + + it("should respect the request level timeout", async () => { + const wait = client.getTimeout({ms: 150}, 100) + await expect(wait).rejects.toThrow( + "The operation was aborted due to timeout", + ) + }) + }) }) diff --git a/e2e/src/koa.entrypoint.ts b/e2e/src/koa.entrypoint.ts index 18833396..983c34b5 100644 --- a/e2e/src/koa.entrypoint.ts +++ b/e2e/src/koa.entrypoint.ts @@ -5,6 +5,7 @@ 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 {createTimeoutRouter} from "./routes/koa/timeout.ts" import {createValidationRouter} from "./routes/koa/validation.ts" import {createErrorResponse} from "./shared.ts" @@ -17,6 +18,7 @@ function createRouter() { const mediaTypesRouter = createMediaTypesRouter() const queryParametersRouter = createQueryParametersRouter() const routeMatchingRouter = createRouteMatchingRouter() + const timeoutRouter = createTimeoutRouter() router.use( requestHeadersRouter.routes(), @@ -30,6 +32,7 @@ function createRouter() { queryParametersRouter.allowedMethods(), ) router.use(routeMatchingRouter.routes(), routeMatchingRouter.allowedMethods()) + router.use(timeoutRouter.routes(), timeoutRouter.allowedMethods()) return router } diff --git a/e2e/src/routes/express/timeout.ts b/e2e/src/routes/express/timeout.ts new file mode 100644 index 00000000..6d364429 --- /dev/null +++ b/e2e/src/routes/express/timeout.ts @@ -0,0 +1,16 @@ +import {scheduler} from "node:timers/promises" +import { + createRouter, + type GetTimeout, +} from "../../generated/server/express/routes/timeout.ts" + +const getTimeout: GetTimeout = async ({query}, respond) => { + await scheduler.wait(query.ms) + return respond.with200().body({ms: query.ms}) +} + +export function createTimeoutRouter() { + return createRouter({ + getTimeout, + }) +} diff --git a/e2e/src/routes/koa/timeout.ts b/e2e/src/routes/koa/timeout.ts new file mode 100644 index 00000000..71f74b91 --- /dev/null +++ b/e2e/src/routes/koa/timeout.ts @@ -0,0 +1,16 @@ +import {scheduler} from "node:timers/promises" +import { + createRouter, + type GetTimeout, +} from "../../generated/server/koa/routes/timeout.ts" + +const getTimeout: GetTimeout = async ({query}, respond) => { + await scheduler.wait(query.ms) + return respond.with200().body({ms: query.ms}) +} + +export function createTimeoutRouter() { + return createRouter({ + getTimeout, + }) +} diff --git a/packages/typescript-axios-runtime/src/main.ts b/packages/typescript-axios-runtime/src/main.ts index 81af6a00..3ff44729 100644 --- a/packages/typescript-axios-runtime/src/main.ts +++ b/packages/typescript-axios-runtime/src/main.ts @@ -21,10 +21,10 @@ export type { } from "@nahkies/typescript-common-runtime/types" export interface AbstractAxiosConfig { - axios?: AxiosInstance + axios?: AxiosInstance | undefined basePath: string - defaultHeaders?: Record - defaultTimeout?: number + defaultHeaders?: Record | undefined + defaultTimeout?: number | undefined } export abstract class AbstractAxiosClient { @@ -44,10 +44,12 @@ export abstract class AbstractAxiosClient { opts: AxiosRequestConfig, ): Promise { const headers = opts.headers ?? this._headers() + const timeout = opts.timeout ?? this.defaultTimeout return this.axios.request({ baseURL: this.basePath, ...opts, + ...(timeout !== undefined ? {timeout} : {}), headers, }) }