diff --git a/examples/complex/contracts/index.ts b/examples/complex/contracts/index.ts index 3ce5bee..92387c2 100644 --- a/examples/complex/contracts/index.ts +++ b/examples/complex/contracts/index.ts @@ -20,7 +20,7 @@ export const contract = createContract({ responses: { 200: { 'application/json': { - body: z.object({ spec: z.any() }), + body: z.any(), }, }, }, diff --git a/examples/complex/index.ts b/examples/complex/index.ts index 3b49015..b562d91 100644 --- a/examples/complex/index.ts +++ b/examples/complex/index.ts @@ -70,7 +70,11 @@ const router = createRouter({ updateOrderStatus: orderHandlers.updateOrderStatus, getUserOrders: orderHandlers.getUserOrders, getSpec: async (request) => { - return request.respond({ status: 200, body: openApiSpecification }); + return request.respond({ + status: 200, + contentType: 'application/json', + body: openApiSpecification, + }); }, getDocs: async (request) => { return request.respond({ diff --git a/examples/simple/index.ts b/examples/simple/index.ts index f9905de..6c98332 100644 --- a/examples/simple/index.ts +++ b/examples/simple/index.ts @@ -6,9 +6,7 @@ import { createRouter } from '../../src/index.ts'; import { createSpotlightElementsHtml, formatCalculateResponseXML, - formatCalculateErrorXML, formatCalculateResponseHTML, - formatCalculateErrorHTML, } from './utils.ts'; import { IRequest } from 'itty-router'; @@ -35,29 +33,6 @@ const router = createRouter({ // Headers are normalized to lowercase in types and runtime, regardless of how they're defined in the schema const contentType = request.validatedHeaders.get('content-type'); - if (result > 100) { - const errorMessage = 'Invalid request'; - if (contentType === 'text/html') { - return request.respond({ - status: 400, - contentType: 'text/html', - body: formatCalculateErrorHTML(errorMessage), - }); - } - if (contentType === 'application/xml') { - return request.respond({ - status: 400, - contentType: 'application/xml', - body: formatCalculateErrorXML(errorMessage), - }); - } - return request.respond({ - status: 400, - contentType: 'application/json', - body: { error: errorMessage }, - }); - } - if (contentType === 'text/html') { return request.respond({ status: 200, diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 7d2550a..687b412 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -3,3 +3,4 @@ export * from './withSpecValidation.js'; export * from './withResponseHelpers.js'; export * from './withContractFormat.js'; export * from './withContractErrorHandler.js'; +export * from './withMissingHandler.js'; diff --git a/src/middleware/withContractErrorHandler.ts b/src/middleware/withContractErrorHandler.ts index 135f4ab..71958de 100644 --- a/src/middleware/withContractErrorHandler.ts +++ b/src/middleware/withContractErrorHandler.ts @@ -23,25 +23,36 @@ export function withContractErrorHandler< RequestType extends IRequest = IRequest, Args extends any[] = any[], >(): (err: unknown, request: RequestType, ...args: Args) => Response { - return (err: unknown, request: RequestType, ..._args: Args): Response => { + return (err: unknown, _request: RequestType, ..._args: Args): Response => { + // Handle validation errors with issues array if (err instanceof Error && 'issues' in err) { + const issues = (err as Error & { issues: unknown }).issues; return new Response( JSON.stringify({ error: 'Validation failed', - details: (err as Error & { issues: unknown }).issues, + details: Array.isArray(issues) ? issues : [issues], }), { status: 400, headers: { 'content-type': 'application/json' } } ); } - // Handle other errors - return error message without circular reference issues + // Handle other errors - ensure all errors conform to { error: string, details: [...] } const errorMessage = err instanceof Error ? err.message : 'Internal server error'; const statusCode = err && typeof err === 'object' && 'status' in err ? (err as any).status : 500; + // Format error message as a details array for consistency with validation errors + // Details array contains objects with message property (and optionally other fields) + const details = [ + { + message: errorMessage, + }, + ]; + return new Response( JSON.stringify({ error: errorMessage, + details, }), { status: statusCode, headers: { 'content-type': 'application/json' } } ); diff --git a/src/middleware/withMissingHandler.ts b/src/middleware/withMissingHandler.ts new file mode 100644 index 0000000..596cbc1 --- /dev/null +++ b/src/middleware/withMissingHandler.ts @@ -0,0 +1,47 @@ +import type { IRequest, ResponseHandler } from 'itty-router'; +import { error } from 'itty-router'; +import { createBasicResponseHelpers } from '../utils'; + +/** + * Middleware for handling missing routes + * + * This middleware checks if a response has been set. If not, it calls the provided + * missing handler (if available) or returns a 404 error. The missing handler receives + * the request with basic response helpers attached. + * + * @typeParam RequestType - The request type (extends IRequest) + * @typeParam Args - Additional arguments passed to handlers + * + * @param missing - Optional handler for missing routes + * @returns A ResponseHandler function that handles missing routes + * + * @example + * ```typescript + * const router = Router({ + * finally: [ + * withMissingHandler(options.missing), + * ], + * }); + * ``` + */ +export function withMissingHandler< + RequestType extends IRequest = IRequest, + Args extends any[] = any[], +>( + missing?: ( + request: RequestType & ReturnType, + ...args: Args + ) => Response | Promise +): ResponseHandler { + return (response: Response, request: IRequest, ...args: Args) => { + if (response != null) return response as Response; + if (missing) { + return missing( + { ...(request as RequestType), ...createBasicResponseHelpers() } as RequestType & + ReturnType, + ...(args as Args) + ); + } + return error(404); + }; +} diff --git a/src/middleware/withSpecValidation.ts b/src/middleware/withSpecValidation.ts index 812bc52..78ffaa6 100644 --- a/src/middleware/withSpecValidation.ts +++ b/src/middleware/withSpecValidation.ts @@ -1,111 +1,142 @@ import type { IRequest, RequestHandler } from 'itty-router'; import { error } from 'itty-router'; import type { StandardSchemaV1 } from '@standard-schema/spec'; -import type { ContractAugmentedRequest } from '../types.js'; +import type { + ContractAugmentedRequest, + ContractOperationParameters, + ContractOperationQuery, +} from '../types.js'; import { validateSchema, defineProp } from '../utils.js'; import { extractPathParamsFromUrl, - extractQueryParamsFromUrl, getContentType, parseBodyByContentType, normalizeHeaders, validateHeadersWithFallback, } from './utils.js'; -/** - * Global middleware: Validates path parameters, query parameters, headers, and body - * using the operation from request. This reads from __contractOperation set by - * withMatchingContractOperation. - * - * This middleware combines the functionality of: - * - withPathParams - * - withQueryParams - * - withHeaders - * - withBody - */ +type ContractOperation = NonNullable; + export const withSpecValidation: RequestHandler = async (request: IRequest) => { const operation = (request as ContractAugmentedRequest).__contractOperation; if (!operation) return; - // Validate path parameters - let requestParams = (request.params as Record | undefined) || {}; - if (!Object.keys(requestParams).length && request.url) { - requestParams = extractPathParamsFromUrl(operation.path, request.url); - } - const params = operation.pathParams - ? await validateSchema>(operation.pathParams, requestParams) - : requestParams; - defineProp(request, 'params', params); + // Path params + const params = await resolveAndValidatePathParams(request, operation); + defineProp(request, 'validatedParams', params); - // Validate query parameters - let requestQuery = (request.query as Record | undefined) || {}; - if (!Object.keys(requestQuery).length && request.url) { - requestQuery = extractQueryParamsFromUrl(request.url); - } - const query = operation.query - ? await validateSchema>(operation.query, requestQuery) - : requestQuery; + // Query params + const query = await resolveAndValidateQuery(request, operation); defineProp(request, 'validatedQuery', query); - defineProp(request, 'query', query); - - // Validate headers - const requestHeaders = normalizeHeaders(request.headers); - const validatedHeadersObject = operation.headers - ? await validateHeadersWithFallback(operation.headers, requestHeaders) - : requestHeaders; - // Convert to Headers object to align with Web API Request standard - const headers = new Headers(); - for (const [key, value] of Object.entries(validatedHeadersObject)) { - headers.set(key, String(value)); + + // Headers + const validatedHeaders = await resolveAndValidateHeaders(request, operation); + defineProp(request, 'validatedHeaders', validatedHeaders); + + // Body + const validatedBody = await resolveAndValidateBody(request, operation); + defineProp(request, 'validatedBody', validatedBody); +}; + +async function resolveAndValidatePathParams(request: IRequest, operation: ContractOperation) { + const requestParams = extractPathParamsFromUrl(operation.path, request.url); + + return operation.pathParams + ? await validateSchema>( + operation.pathParams, + requestParams + ) + : requestParams; +} + +async function resolveAndValidateQuery( + request: IRequest, + operation: ContractOperation +): Promise> { + if (operation.query) { + return validateSchema>( + operation.query, + request.query + ); } - defineProp(request, 'validatedHeaders', headers); - // Validate body - // If no request schemas defined, set empty body and return - if (!operation.requests) { - defineProp(request, 'validatedBody', {}); - return; + return {}; +} + +async function resolveAndValidateHeaders( + request: IRequest, + operation: ContractOperation +): Promise { + if (operation.headers) { + const normalizedHeaders = normalizeHeaders(request.headers); + const validatedHeaders = await validateHeadersWithFallback( + operation.headers, + normalizedHeaders + ); + return new Headers(validatedHeaders as Record); } - let bodyData: unknown = {}; - let bodyReadSuccessfully = false; - let bodyText = ''; + return undefined; +} +async function tryReadRequestText( + request: IRequest +): Promise<{ ok: true; text: string } | { ok: false }> { try { - bodyText = await request.text(); - bodyReadSuccessfully = true; + const text = await request.text(); + return { ok: true, text }; } catch { - bodyData = {}; + return { ok: false }; } +} + +function findRequestSchemaEntry( + requests: Record, + contentType: string +): [normalizedContentType: string, schema: unknown] | undefined { + // Slightly more robust than the original comment implied: + // - normalizes the incoming content type for matching (adds acceptance, doesn’t remove) + const normalized = contentType.toLowerCase(); - if (bodyReadSuccessfully && bodyText.trim()) { - // Check if request is a content-type map - const contentType = getContentType(request); - if (!contentType) { - throw error(400, 'Content-Type header is required'); - } - - // Find matching schema (case-insensitive) - const matchingEntry = Object.entries(operation.requests).find(([key]) => { - return key.toLowerCase() === contentType; - }); - - if (!matchingEntry) { - throw error( - 400, - `Unsupported Content-Type: ${contentType}. Supported types: ${Object.keys(operation.requests).join(', ')}` - ); - } - - const [, requestSchema] = matchingEntry; - if (!requestSchema || typeof requestSchema !== 'object' || !('body' in requestSchema)) { - throw error(500, 'Invalid request schema configuration'); - } - bodyData = parseBodyByContentType(contentType, bodyText); - const body = await validateSchema((requestSchema as { body: StandardSchemaV1 }).body, bodyData); - defineProp(request, 'validatedBody', body); - } else { - // Empty body - defineProp(request, 'validatedBody', bodyData); + const entry = Object.entries(requests).find(([key]) => key.toLowerCase() === normalized); + if (!entry) return; + + return [normalized, entry[1]]; +} + +async function resolveAndValidateBody( + request: IRequest, + operation: ContractOperation +): Promise { + // Preserve existing behavior: if no request schemas defined, set empty body. + if (!operation.requests) return {}; + + // Preserve existing behavior: body read failures become empty body. + const read = await tryReadRequestText(request); + if (!read.ok) return {}; + + const bodyText = read.text; + if (!bodyText.trim()) return {}; + + const contentType = getContentType(request); + if (!contentType) { + throw error(400, 'Content-Type header is required'); } -}; + + const entry = findRequestSchemaEntry(operation.requests, contentType); + if (!entry) { + throw error( + 400, + `Unsupported Content-Type: ${contentType}. Supported types: ${Object.keys(operation.requests).join(', ')}` + ); + } + + const [normalizedContentType, requestSchema] = entry; + + if (!requestSchema || typeof requestSchema !== 'object' || !('body' in requestSchema)) { + throw error(500, 'Invalid request schema configuration'); + } + + const bodyData = parseBodyByContentType(normalizedContentType, bodyText); + return await validateSchema((requestSchema as { body: StandardSchemaV1 }).body, bodyData); +} diff --git a/src/router.ts b/src/router.ts index c98f2f4..a595426 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,24 +1,17 @@ -import { - Router, - type RouterType, - type IRequest, - withParams, - error, - type ResponseHandler, -} from 'itty-router'; +import { Router, type RouterType, type IRequest, withParams } from 'itty-router'; import type { ContractDefinition, ContractRouterOptions, ContractRequest, HttpMethod, } from './types'; -import { createBasicResponseHelpers } from './utils'; import { withMatchingContractOperation, withSpecValidation, withResponseHelpers, withContractFormat, withContractErrorHandler, + withMissingHandler, } from './middleware'; /** @@ -68,55 +61,35 @@ export const createRouter = < >( options: ContractRouterOptions ): RouterType => { - const missingHandler: ResponseHandler = ( - response: unknown, - request: unknown, - ...args: unknown[] - ) => { - if (response != null) return response as Response; - if (options.missing) { - return options.missing( - { ...(request as RequestType), ...createBasicResponseHelpers() } as RequestType & - ReturnType, - ...(args as Args) - ); - } - return error(404); - }; - - const before = [ - withParams as unknown as (request: RequestType, ...args: Args) => void, - withMatchingContractOperation(options.contract, options.base), - withSpecValidation, - withResponseHelpers, - ]; - if (options.before) before.push(...options.before); - - const finally_ = [missingHandler, withContractFormat(options.format)]; - if (options.finally) finally_.push(...options.finally); - + /** + * Define the router + */ const router = Router({ base: options.base, - before, + before: [ + (request: RequestType, ..._other: Args) => withParams(request), + withMatchingContractOperation(options.contract, options.base), + withSpecValidation, + withResponseHelpers, + ...(options.before || []), + ], catch: withContractErrorHandler(), - finally: finally_, + finally: [ + withMissingHandler(options.missing), + withContractFormat(options.format), + ...(options.finally || []), + ], }); for (const [contractKey, operation] of Object.entries(options.contract)) { const handler = options.handlers[contractKey as keyof TContract]; + const method = operation.method.toLowerCase() as Lowercase; if (!handler) continue; - if (!operation.method) { - throw new Error( - `Contract operation "${contractKey}" must explicitly specify a method. ` + - `Found: undefined. Please add method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'` - ); - } - router[operation.method.toLowerCase() as Lowercase]( - operation.path, - async (request: IRequest, ...args: Args) => - handler(request as ContractRequest, ...args) + + router[method](operation.path, async (request: IRequest, ...args: Args) => + handler(request as ContractRequest, ...args) ); } - return router as RouterType; + return router; }; diff --git a/src/types.ts b/src/types.ts index 7150335..1b30724 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,13 +42,13 @@ export type RawQuery = Record; * * Note: `headers` being optional already expresses "no headers schema". */ -export type ResponseSchema< +export interface ResponseSchema< TBody extends StandardSchemaV1 = StandardSchemaV1, THeaders extends StandardSchemaV1 = StandardSchemaV1, -> = { +> { body: TBody; headers?: THeaders; -}; +} /** * Response schemas mapped by content type. @@ -141,14 +141,14 @@ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | * - `method` is optional - if omitted, defaults to 'GET' * - `requests` must be a RequestByContentType map (content-type keyed object) */ -export type ContractOperation< +export interface ContractOperation< TPathParams extends StandardSchemaV1 | undefined = undefined, TQuery extends StandardSchemaV1 | undefined = undefined, TRequests extends RequestByContentType | undefined = undefined, THeaders extends StandardSchemaV1 | undefined = undefined, TResponses extends ResponseByStatusCode = ResponseByStatusCode, TPath extends string = string, -> = { +> { operationId?: string; description?: string; summary?: string; @@ -161,7 +161,7 @@ export type ContractOperation< requests?: RequestSchemas; headers?: THeaders; responses: ResponseSchemas; -}; +} /** * Type constraint for any contract operation. @@ -191,13 +191,15 @@ type ContractOperationKeys = * If the operation has extra keys (like 'request' instead of 'requests'), * those keys are mapped to 'never', which will cause a type error. * - * This works by intersecting the input type with a type that maps all - * invalid keys to 'never'. TypeScript will error when trying to assign - * an object with invalid keys because those keys would need to be 'never'. + * To reduce type pressure, we only apply the intersection if extra keys are detected. + * This prevents unnecessary type expansions for valid operations. */ -type ValidateOperation = T & { - [K in Exclude]: never; -}; +type ValidateOperation = + Exclude extends never + ? T + : T & { + [K in Exclude]: never; + }; /** * Contract definition - a record of operation IDs to operations @@ -451,13 +453,12 @@ export type ContractOperationHeaders = * IRequest's native `body` (ReadableStream) and `headers` (Headers object) properties. * The `params` property is kept as-is since it's standard in itty-router. */ -export type ContractOperationRequest = IRequest & { - params: ContractOperationParameters; - query: RawQuery; +export interface ContractOperationRequest extends IRequest { + validatedParams: ContractOperationParameters; validatedQuery: ContractOperationQuery; validatedBody: ContractOperationBody; validatedHeaders: ContractOperationHeaders; -}; +} /** * Extract body type from a response (content-type map). @@ -561,7 +562,7 @@ export type RespondOptions< /** * Typed response helper method attached to the request object */ -export type ContractOperationResponseHelpers = { +export interface ContractOperationResponseHelpers { /** * Create a response with typed body, status code, and content type * Validates that the status code and content type exist in the contract @@ -570,22 +571,22 @@ export type ContractOperationResponseHelpers = { respond, C extends ExtractContentTypes>( options: RespondOptions ): ResponseVariant; -}; +} /** * Contract request that extends ContractOperationRequest with typed response helpers * This is the primary request type that handlers receive */ -export type ContractRequest = ContractOperationRequest & - ContractOperationResponseHelpers; +export interface ContractRequest + extends ContractOperationRequest, ContractOperationResponseHelpers {} /** * Handler function type for a contract operation * Receives a typed request with response helpers */ -export type ContractOperationHandler = ( +export type ContractOperationHandler = ( request: ContractRequest, - ...args: any[] + ...args: Args ) => Promise>; /** @@ -601,16 +602,16 @@ export type ContractRouterType = { /** * Options for ContractRouter */ -export type ContractRouterOptions< +export interface ContractRouterOptions< TContract extends ContractDefinition, RequestType extends IRequest = IRequest, Args extends any[] = any[], -> = { +> { /** Contract definition */ contract: TContract; /** Handlers mapped by operation ID */ handlers: { - [K in keyof TContract]?: ContractOperationHandler; + [K in keyof TContract]?: ContractOperationHandler; }; /** Response formatter (defaults to contract-aware JSON formatter) */ format?: ResponseHandler; @@ -632,7 +633,7 @@ export type ContractRouterOptions< finally?: ResponseHandler[]; /** Base path for all routes */ base?: string; -}; +} /** * Internal type for request augmentation with contract operation metadata diff --git a/tests/integration/router.integration.test.ts b/tests/integration/router.integration.test.ts index c227760..4dd53bd 100644 --- a/tests/integration/router.integration.test.ts +++ b/tests/integration/router.integration.test.ts @@ -1,7 +1,7 @@ import { test, expect } from 'vitest'; import { createRouter } from '../../src/router.js'; import { createContract } from '../../src/contract.js'; -import { z } from 'zod/v4'; +import * as v from 'valibot'; test('GET request with query parameters should handle validated query parameters', async () => { const contract = createContract({ @@ -9,13 +9,19 @@ test('GET request with query parameters should handle validated query parameters operationId: 'getCalculate', path: '/calculate', method: 'GET', - query: z.object({ - a: z.string().transform((val) => parseInt(val, 10)), - b: z.string().transform((val) => parseInt(val, 10)), + query: v.object({ + a: v.pipe( + v.string(), + v.transform((val) => parseInt(val, 10)) + ), + b: v.pipe( + v.string(), + v.transform((val) => parseInt(val, 10)) + ), }), responses: { - 200: { 'application/json': { body: z.object({ result: z.number() }) } }, - 400: { 'application/json': { body: z.object({ error: z.string() }) } }, + 200: { 'application/json': { body: v.object({ result: v.number() }) } }, + 400: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -50,13 +56,19 @@ test('GET request with query parameters should return 400 for invalid query para operationId: 'getCalculate', path: '/calculate', method: 'GET', - query: z.object({ - a: z.string().transform((val) => parseInt(val, 10)), - b: z.string().transform((val) => parseInt(val, 10)), + query: v.object({ + a: v.pipe( + v.string(), + v.transform((val) => parseInt(val, 10)) + ), + b: v.pipe( + v.string(), + v.transform((val) => parseInt(val, 10)) + ), }), responses: { - 200: { 'application/json': { body: z.object({ result: z.number() }) } }, - 400: { 'application/json': { body: z.object({ error: z.string() }) } }, + 200: { 'application/json': { body: v.object({ result: v.number() }) } }, + 400: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -93,15 +105,15 @@ test('POST request with body should handle validated body', async () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - a: z.number().min(0).max(100), - b: z.number().min(0).max(100), + body: v.object({ + a: v.pipe(v.number(), v.minValue(0), v.maxValue(100)), + b: v.pipe(v.number(), v.minValue(0), v.maxValue(100)), }), }, }, responses: { - 200: { 'application/json': { body: z.object({ result: z.number() }) } }, - 400: { 'application/json': { body: z.object({ error: z.string() }) } }, + 200: { 'application/json': { body: v.object({ result: v.number() }) } }, + 400: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -142,15 +154,15 @@ test('POST request with body should return 400 for invalid body', async () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - a: z.number().min(0).max(100), - b: z.number().min(0).max(100), + body: v.object({ + a: v.pipe(v.number(), v.minValue(0), v.maxValue(100)), + b: v.pipe(v.number(), v.minValue(0), v.maxValue(100)), }), }, }, responses: { - 200: { 'application/json': { body: z.object({ result: z.number() }) } }, - 400: { 'application/json': { body: z.object({ error: z.string() }) } }, + 200: { 'application/json': { body: v.object({ result: v.number() }) } }, + 400: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -190,8 +202,8 @@ test('GET request with path parameters should handle path parameters', async () path: '/users/:id', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, - 404: { 'application/json': { body: z.object({ error: z.string() }) } }, + 200: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, + 404: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -232,8 +244,8 @@ test('GET request with path parameters should handle 404 for non-existent user', path: '/users/:id', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, - 404: { 'application/json': { body: z.object({ error: z.string() }) } }, + 200: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, + 404: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -274,8 +286,8 @@ test('DELETE request with 204 No Content should handle DELETE request returning path: '/users/:id', method: 'DELETE', responses: { - 204: { 'application/json': { body: z.never() } }, - 404: { 'application/json': { body: z.object({ error: z.string() }) } }, + 204: { 'application/json': { body: v.never() } }, + 404: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -312,12 +324,12 @@ test('Request with headers validation should handle request with validated heade operationId: 'getProtected', path: '/protected', method: 'GET', - headers: z.object({ - authorization: z.string(), + headers: v.object({ + authorization: v.string(), }), responses: { - 200: { 'application/json': { body: z.object({ message: z.string() }) } }, - 401: { 'application/json': { body: z.object({ error: z.string() }) } }, + 200: { 'application/json': { body: v.object({ message: v.string() }) } }, + 401: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -361,15 +373,15 @@ test('Error handling should handle validation errors gracefully', async () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - name: z.string().min(1), - email: z.email(), + body: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + email: v.pipe(v.string(), v.email()), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, - 400: { 'application/json': { body: z.object({ error: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, + 400: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -408,14 +420,17 @@ test('Query parameter validation should return 400 with error details for missin search: { path: '/search', method: 'GET', - query: z.object({ - q: z.string().min(1), - page: z.string().transform((val) => parseInt(val, 10)), + query: v.object({ + q: v.pipe(v.string(), v.minLength(1)), + page: v.pipe( + v.string(), + v.transform((val) => parseInt(val, 10)) + ), }), responses: { - 200: { 'application/json': { body: z.object({ results: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ results: v.array(v.string()) }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -450,20 +465,22 @@ test('Query parameter validation should return 400 for invalid type', async () = getItems: { path: '/items', method: 'GET', - query: z.object({ - page: z - .string() - .regex(/^\d+$/) - .transform((val) => parseInt(val, 10)), - limit: z - .string() - .regex(/^\d+$/) - .transform((val) => parseInt(val, 10)), + query: v.object({ + page: v.pipe( + v.string(), + v.regex(/^\d+$/), + v.transform((val) => parseInt(val, 10)) + ), + limit: v.pipe( + v.string(), + v.regex(/^\d+$/), + v.transform((val) => parseInt(val, 10)) + ), }), responses: { - 200: { 'application/json': { body: z.object({ items: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ items: v.array(v.string()) }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -499,17 +516,17 @@ test('Body validation should return 400 with error details for missing required method: 'POST', requests: { 'application/json': { - body: z.object({ - name: z.string().min(1), - email: z.string().email(), - age: z.number().min(18), + body: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + email: v.pipe(v.string(), v.email()), + age: v.pipe(v.number(), v.minValue(18)), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -550,16 +567,16 @@ test('Body validation should return 400 for invalid email format', async () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - name: z.string().min(1), - email: z.string().email(), + body: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + email: v.pipe(v.string(), v.email()), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -599,16 +616,16 @@ test('Body validation should return 400 for value below minimum constraint', asy method: 'POST', requests: { 'application/json': { - body: z.object({ - name: z.string().min(1), - age: z.number().min(18).max(120), + body: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + age: v.pipe(v.number(), v.minValue(18), v.maxValue(120)), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -648,17 +665,17 @@ test('Body validation should return 400 for value above maximum constraint', asy method: 'POST', requests: { 'application/json': { - body: z.object({ - name: z.string().min(1), - price: z.number().min(0).max(10000), - quantity: z.number().int().min(0).max(1000), + body: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + price: v.pipe(v.number(), v.minValue(0), v.maxValue(10000)), + quantity: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(1000)), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -698,24 +715,24 @@ test('Body validation should return 400 for invalid nested object structure', as method: 'POST', requests: { 'application/json': { - body: z.object({ - customer: z.object({ - name: z.string().min(1), - email: z.string().email(), + body: v.object({ + customer: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + email: v.pipe(v.string(), v.email()), }), - items: z.array( - z.object({ - productId: z.string(), - quantity: z.number().min(1), + items: v.array( + v.object({ + productId: v.string(), + quantity: v.pipe(v.number(), v.minValue(1)), }) ), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ orderId: z.string() }) } }, + 201: { 'application/json': { body: v.object({ orderId: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -761,20 +778,20 @@ test('Body validation should return 400 for invalid array item', async () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - items: z.array( - z.object({ - productId: z.string().min(1), - quantity: z.number().min(1), + body: v.object({ + items: v.array( + v.object({ + productId: v.pipe(v.string(), v.minLength(1)), + quantity: v.pipe(v.number(), v.minValue(1)), }) ), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ orderId: z.string() }) } }, + 201: { 'application/json': { body: v.object({ orderId: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -819,16 +836,16 @@ test('Body validation should return 400 for invalid JSON body', async () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - name: z.string().min(1), - email: z.string().email(), + body: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + email: v.pipe(v.string(), v.email()), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -866,16 +883,16 @@ test('Body validation should return 400 for empty body when body is required', a method: 'POST', requests: { 'application/json': { - body: z.object({ - name: z.string().min(1), - email: z.string().email(), + body: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + email: v.pipe(v.string(), v.email()), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -913,14 +930,14 @@ test('Header validation should return 400 for missing required headers', async ( getProtected: { path: '/protected', method: 'GET', - headers: z.object({ - authorization: z.string().min(1), - 'x-api-key': z.string().min(1), + headers: v.object({ + authorization: v.pipe(v.string(), v.minLength(1)), + 'x-api-key': v.pipe(v.string(), v.minLength(1)), }), responses: { - 200: { 'application/json': { body: z.object({ message: z.string() }) } }, + 200: { 'application/json': { body: v.object({ message: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -954,13 +971,13 @@ test('Header validation should return 400 for invalid header format', async () = getProtected: { path: '/protected', method: 'GET', - headers: z.object({ - authorization: z.string().regex(/^Bearer .+$/), + headers: v.object({ + authorization: v.pipe(v.string(), v.regex(/^Bearer .+$/)), }), responses: { - 200: { 'application/json': { body: z.object({ message: z.string() }) } }, + 200: { 'application/json': { body: v.object({ message: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -1000,18 +1017,18 @@ test('Multiple validation errors should return all error details', async () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - name: z.string().min(3), - email: z.string().email(), - age: z.number().min(18).max(120), - password: z.string().min(8), + body: v.object({ + name: v.pipe(v.string(), v.minLength(3)), + email: v.pipe(v.string(), v.email()), + age: v.pipe(v.number(), v.minValue(18), v.maxValue(120)), + password: v.pipe(v.string(), v.minLength(8)), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -1057,14 +1074,14 @@ test('Query parameter validation should return 400 for invalid enum value', asyn listItems: { path: '/items', method: 'GET', - query: z.object({ - sort: z.enum(['asc', 'desc', 'name']), - filter: z.enum(['active', 'inactive', 'all']).optional(), + query: v.object({ + sort: v.picklist(['asc', 'desc', 'name']), + filter: v.optional(v.picklist(['active', 'inactive', 'all'])), }), responses: { - 200: { 'application/json': { body: z.object({ items: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ items: v.array(v.string()) }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -1100,16 +1117,16 @@ test('Body validation should return 400 for invalid URL format', async () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - title: z.string().min(1), - url: z.string().url(), + body: v.object({ + title: v.pipe(v.string(), v.minLength(1)), + url: v.pipe(v.string(), v.url()), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), title: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), title: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -1152,16 +1169,16 @@ test('Body validation should return 400 for invalid UUID format', async () => { method: 'PUT', requests: { 'application/json': { - body: z.object({ - name: z.string().min(1), - referenceId: z.string().uuid(), + body: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + referenceId: v.pipe(v.string(), v.uuid()), }), }, }, responses: { - 200: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 200: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -1204,16 +1221,16 @@ test('Validation error response should have correct content-type header', async method: 'POST', requests: { 'application/json': { - body: z.object({ - name: z.string().min(1), - email: z.string().email(), + body: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + email: v.pipe(v.string(), v.email()), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, 400: { - 'application/json': { body: z.object({ error: z.string(), details: z.unknown() }) }, + 'application/json': { body: v.object({ error: v.string(), details: v.unknown() }) }, }, }, }, @@ -1254,7 +1271,7 @@ test('Multiple operations should handle multiple operations in same router', asy path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, getUser: { @@ -1262,7 +1279,7 @@ test('Multiple operations should handle multiple operations in same router', asy path: '/users/:id', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 200: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, }, }, createUser: { @@ -1271,11 +1288,11 @@ test('Multiple operations should handle multiple operations in same router', asy method: 'POST', requests: { 'application/json': { - body: z.object({ name: z.string() }), + body: v.object({ name: v.string() }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, }, }, }); @@ -1339,7 +1356,7 @@ test('Optional operationId should default to contract key', async () => { path: '/users/:id/profile', method: 'GET', // method is now required responses: { - 200: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 200: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, }, }, }); @@ -1372,7 +1389,7 @@ test('Method is required and must be explicitly specified', async () => { path: '/items', method: 'GET', // method is now required responses: { - 200: { 'application/json': { body: z.object({ items: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ items: v.array(v.string()) }) } }, }, }, }); @@ -1405,7 +1422,7 @@ test('Explicit operationId should override contract key', async () => { method: 'GET', // method is now required path: '/users/:id/profile', responses: { - 200: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 200: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, }, }, }); @@ -1439,11 +1456,11 @@ test('Explicit method should override default GET', async () => { method: 'POST', // Explicit method requests: { 'application/json': { - body: z.object({ name: z.string() }), + body: v.object({ name: v.string() }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, }, }, }); @@ -1485,10 +1502,10 @@ test('Nested routes with multiple path parameters should handle complex paths', responses: { 200: { 'application/json': { - body: z.object({ userId: z.string(), postId: z.string(), title: z.string() }), + body: v.object({ userId: v.string(), postId: v.string(), title: v.string() }), }, }, - 404: { 'application/json': { body: z.object({ error: z.string() }) } }, + 404: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -1523,17 +1540,20 @@ test('Complex query parameters with arrays should handle array query params', as searchItems: { path: '/items/search', method: 'GET', - query: z.object({ - tags: z.array(z.string()).optional(), - categories: z.array(z.string()).default([]), - page: z - .string() - .transform((val) => parseInt(val, 10)) - .default(1), + query: v.object({ + tags: v.optional(v.array(v.string())), + categories: v.fallback(v.array(v.string()), []), + page: v.fallback( + v.pipe( + v.string(), + v.transform((val) => parseInt(val, 10)) + ), + 1 + ), }), responses: { 200: { - 'application/json': { body: z.object({ items: z.array(z.string()), total: z.number() }) }, + 'application/json': { body: v.object({ items: v.array(v.string()), total: v.number() }) }, }, }, }, @@ -1556,7 +1576,6 @@ test('Complex query parameters with arrays should handle array query params', as const request = new Request('http://localhost:3000/items/search?tags=tag1&tags=tag2&page=2'); const response = await router.fetch(request); - expect(response.status).toBe(200); const body = await response.json(); expect(body.items).toContain('tag1'); @@ -1571,19 +1590,19 @@ test('PUT request should handle update operations', async () => { method: 'PUT', requests: { 'application/json': { - body: z.object({ - name: z.string().min(1), - email: z.email(), + body: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + email: v.pipe(v.string(), v.email()), }), }, }, responses: { 200: { 'application/json': { - body: z.object({ id: z.string(), name: z.string(), email: z.string() }), + body: v.object({ id: v.string(), name: v.string(), email: v.string() }), }, }, - 404: { 'application/json': { body: z.object({ error: z.string() }) } }, + 404: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -1628,19 +1647,19 @@ test('PATCH request should handle partial updates', async () => { method: 'PATCH', requests: { 'application/json': { - body: z.object({ - name: z.string().optional(), - email: z.email().optional(), + body: v.object({ + name: v.optional(v.string()), + email: v.optional(v.pipe(v.string(), v.email())), }), }, }, responses: { 200: { 'application/json': { - body: z.object({ - id: z.string(), - name: z.string().optional(), - email: z.string().optional(), + body: v.object({ + id: v.string(), + name: v.optional(v.string()), + email: v.optional(v.string()), }), }, }, @@ -1684,7 +1703,7 @@ test('Base path should prefix all routes', async () => { path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, }); @@ -1723,7 +1742,7 @@ test('Custom missing handler should handle 404 routes', async () => { path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, }); @@ -1773,7 +1792,7 @@ test('Before middleware should run before handlers', async () => { responses: { 200: { 'application/json': { - body: z.object({ users: z.array(z.string()), requestId: z.string() }), + body: v.object({ users: v.array(v.string()), requestId: v.string() }), }, }, }, @@ -1818,7 +1837,7 @@ test('Finally middleware should run after handlers', async () => { path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, }); @@ -1858,21 +1877,21 @@ test('Route precedence should match specific routes before general ones', async path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ type: z.literal('list') }) } }, + 200: { 'application/json': { body: v.object({ type: v.literal('list') }) } }, }, }, getUser: { path: '/users/:id', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ type: z.literal('single') }) } }, + 200: { 'application/json': { body: v.object({ type: v.literal('single') }) } }, }, }, getUserPosts: { path: '/users/:id/posts', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ type: z.literal('posts') }) } }, + 200: { 'application/json': { body: v.object({ type: v.literal('posts') }) } }, }, }, }); @@ -1928,7 +1947,7 @@ test('URL encoding should handle special characters in paths', async () => { getUser: { path: '/users/:id', method: 'GET', - responses: { 200: { 'application/json': { body: z.object({ id: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ id: v.string() }) } } }, }, }); @@ -1969,14 +1988,14 @@ test('Query parameters with special characters should be handled correctly', asy search: { path: '/search', method: 'GET', - query: z.object({ - q: z.string(), - filter: z.string().optional(), + query: v.object({ + q: v.string(), + filter: v.optional(v.string()), }), responses: { 200: { 'application/json': { - body: z.object({ query: z.string(), filter: z.string().optional() }), + body: v.object({ query: v.string(), filter: v.optional(v.string()) }), }, }, }, @@ -2016,24 +2035,24 @@ test('Complex nested body should handle nested objects and arrays', async () => method: 'POST', requests: { 'application/json': { - body: z.object({ - customer: z.object({ - name: z.string(), - email: z.email(), + body: v.object({ + customer: v.object({ + name: v.string(), + email: v.pipe(v.string(), v.email()), }), - items: z.array( - z.object({ - productId: z.string(), - quantity: z.number().min(1), - price: z.number(), + items: v.array( + v.object({ + productId: v.string(), + quantity: v.pipe(v.number(), v.minValue(1)), + price: v.number(), }) ), - metadata: z.record(z.string(), z.unknown()).optional(), + metadata: v.optional(v.record(v.string(), v.unknown())), }), }, }, responses: { - 201: { 'application/json': { body: z.object({ orderId: z.string(), total: z.number() }) } }, + 201: { 'application/json': { body: v.object({ orderId: v.string(), total: v.number() }) } }, }, }, }); @@ -2088,8 +2107,8 @@ test('Handler throwing errors should be handled gracefully', async () => { method: 'GET', responses: { 200: { - 'application/json': { body: z.object({ users: z.array(z.string()) }) }, - 500: { 'application/json': { body: z.object({ error: z.string() }) } }, + 'application/json': { body: v.object({ users: v.array(v.string()) }) }, + 500: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }, @@ -2126,16 +2145,16 @@ test('Multiple headers validation should handle case-insensitive headers', async getProtected: { path: '/protected', method: 'GET', - headers: z.object({ - authorization: z.string(), - 'x-api-key': z.string(), - 'x-request-id': z.string().optional(), + headers: v.object({ + authorization: v.string(), + 'x-api-key': v.string(), + 'x-request-id': v.optional(v.string()), }), responses: { 200: { - 'application/json': { body: z.object({ message: z.string() }) }, + 'application/json': { body: v.object({ message: v.string() }) }, }, - 401: { 'application/json': { body: z.object({ error: z.string() }) } }, + 401: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -2171,7 +2190,6 @@ test('Multiple headers validation should handle case-insensitive headers', async }, }); const response = await router.fetch(request); - expect(response.status).toBe(200); const body = await response.json(); expect(body.message).toBe('Access granted'); @@ -2185,10 +2203,10 @@ test('Response headers should be set correctly', async () => { responses: { 200: { 'application/json': { - body: z.object({ users: z.array(z.string()) }), - headers: z.object({ - 'x-total-count': z.string().optional(), - 'cache-control': z.string().optional(), + body: v.object({ users: v.array(v.string()) }), + headers: v.object({ + 'x-total-count': v.optional(v.string()), + 'cache-control': v.optional(v.string()), }), }, }, @@ -2228,15 +2246,15 @@ test('Empty query parameters should handle optional params', async () => { listItems: { path: '/items', method: 'GET', - query: z.object({ - page: z.string().optional(), - limit: z.string().optional(), - sort: z.enum(['asc', 'desc']).optional(), + query: v.object({ + page: v.optional(v.string()), + limit: v.optional(v.string()), + sort: v.optional(v.picklist(['asc', 'desc'])), }), responses: { 200: { 'application/json': { - body: z.object({ items: z.array(z.string()), page: z.number().optional() }), + body: v.object({ items: v.array(v.string()), page: v.optional(v.number()) }), }, }, }, @@ -2281,14 +2299,14 @@ test('Empty body should handle optional body validation', async () => { method: 'PATCH', requests: { 'application/json': { - body: z.object({ - theme: z.string().optional(), - notifications: z.boolean().optional(), + body: v.object({ + theme: v.optional(v.string()), + notifications: v.optional(v.boolean()), }), }, }, responses: { - 200: { 'application/json': { body: z.object({ updated: z.boolean() }) } }, + 200: { 'application/json': { body: v.object({ updated: v.boolean() }) } }, }, }, }); @@ -2325,7 +2343,7 @@ test('Same path different methods should handle route conflicts correctly', asyn path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, createUser: { @@ -2333,11 +2351,11 @@ test('Same path different methods should handle route conflicts correctly', asyn method: 'POST', requests: { 'application/json': { - body: z.object({ name: z.string() }), + body: v.object({ name: v.string() }), }, }, responses: { - 201: { 'application/json': { body: z.object({ id: z.string(), name: z.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.string(), name: v.string() }) } }, }, }, updateUsers: { @@ -2345,18 +2363,18 @@ test('Same path different methods should handle route conflicts correctly', asyn method: 'PUT', requests: { 'application/json': { - body: z.object({ users: z.array(z.string()) }), + body: v.object({ users: v.array(v.string()) }), }, }, responses: { - 200: { 'application/json': { body: z.object({ updated: z.number() }) } }, + 200: { 'application/json': { body: v.object({ updated: v.number() }) } }, }, }, deleteUsers: { path: '/users', method: 'DELETE', responses: { - 204: { 'application/json': { body: z.undefined() } }, + 204: { 'application/json': { body: v.undefined() } }, }, }, }); @@ -2426,7 +2444,7 @@ test('Path parameters with special characters should be preserved', async () => path: '/users/:id', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ id: z.string() }) } }, + 200: { 'application/json': { body: v.object({ id: v.string() }) } }, }, }, }); @@ -2466,18 +2484,18 @@ test('Large payload should handle big request bodies', async () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - items: z.array( - z.object({ - name: z.string(), - description: z.string(), - tags: z.array(z.string()), + body: v.object({ + items: v.array( + v.object({ + name: v.string(), + description: v.string(), + tags: v.array(v.string()), }) ), }), }, }, - responses: { 201: { 'application/json': { body: z.object({ created: z.number() }) } } }, + responses: { 201: { 'application/json': { body: v.object({ created: v.number() }) } } }, }, }); @@ -2518,27 +2536,35 @@ test('Query parameter transformations should handle complex transforms', async ( search: { path: '/search', method: 'GET', - query: z.object({ - page: z - .string() - .transform((val) => parseInt(val, 10)) - .default(1), - limit: z - .string() - .transform((val) => Math.min(parseInt(val, 10), 100)) - .default(10), - includeDeleted: z - .string() - .transform((val) => val === 'true') - .optional(), + query: v.object({ + page: v.fallback( + v.pipe( + v.string(), + v.transform((val) => parseInt(val, 10)) + ), + 1 + ), + limit: v.fallback( + v.pipe( + v.string(), + v.transform((val) => Math.min(parseInt(val, 10), 100)) + ), + 10 + ), + includeDeleted: v.optional( + v.pipe( + v.string(), + v.transform((val) => val === 'true') + ) + ), }), responses: { 200: { 'application/json': { - body: z.object({ - page: z.number(), - limit: z.number(), - includeDeleted: z.boolean().optional(), + body: v.object({ + page: v.number(), + limit: v.number(), + includeDeleted: v.optional(v.boolean()), }), }, }, @@ -2580,22 +2606,22 @@ test('Union types in body should handle discriminated unions', async () => { method: 'POST', requests: { 'application/json': { - body: z.discriminatedUnion('type', [ - z.object({ - type: z.literal('user'), - userId: z.string(), - action: z.string(), + body: v.union([ + v.object({ + type: v.literal('user'), + userId: v.string(), + action: v.string(), }), - z.object({ - type: z.literal('system'), - systemId: z.string(), - event: z.string(), + v.object({ + type: v.literal('system'), + systemId: v.string(), + event: v.string(), }), ]), }, }, responses: { - 201: { 'application/json': { body: z.object({ eventId: z.string(), type: z.string() }) } }, + 201: { 'application/json': { body: v.object({ eventId: v.string(), type: v.string() }) } }, }, }, }); diff --git a/tests/unit/middleware.test.ts b/tests/unit/middleware.test.ts index 192fa13..9b1217d 100644 --- a/tests/unit/middleware.test.ts +++ b/tests/unit/middleware.test.ts @@ -8,7 +8,7 @@ import { withContractErrorHandler, } from '../../src/middleware'; import type { ContractOperation } from '../../src/types.js'; -import { z } from 'zod/v4'; +import * as v from 'valibot'; /** * Helper function to create a mock IRequest object that is compatible with itty-router's IRequest type. @@ -54,7 +54,7 @@ test('withMatchingContractOperation should attach contract operation to request' operationId: 'test', path: '/test', method: 'GET', - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const request = createMockRequest({ url: 'http://example.com/test' }); @@ -68,7 +68,7 @@ describe('withSpecValidation - path params', () => { operationId: 'test', path: '/users/:id', method: 'GET', - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const request = new Request('http://example.com/users/123') as IRequest; @@ -76,7 +76,7 @@ describe('withSpecValidation - path params', () => { await withSpecValidation(request); - expect((request as any).params).toEqual({ id: '123' }); + expect((request as any).validatedParams).toEqual({ id: '123' }); }); test('should handle empty params', async () => { @@ -84,7 +84,7 @@ describe('withSpecValidation - path params', () => { operationId: 'test', path: '/test', method: 'GET', - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const request = createMockRequest({ @@ -105,7 +105,7 @@ describe('withSpecValidation - query params', () => { operationId: 'test', path: '/test', method: 'GET', - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const request = createMockRequest({ @@ -116,7 +116,8 @@ describe('withSpecValidation - query params', () => { await withSpecValidation(request); - expect((request as any).validatedQuery).toEqual({ page: '1', limit: '10' }); + expect((request as any).query).toEqual({ page: '1', limit: '10' }); + expect((request as any).validatedQuery).toEqual({}); }); test('should handle empty query', async () => { @@ -124,7 +125,7 @@ describe('withSpecValidation - query params', () => { operationId: 'test', path: '/test', method: 'GET', - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const request = createMockRequest({ @@ -145,7 +146,7 @@ describe('withSpecValidation - headers', () => { operationId: 'test', path: '/test', method: 'GET', - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const headers = new Headers(); @@ -158,8 +159,8 @@ describe('withSpecValidation - headers', () => { await withSpecValidation(request); - expect(request.validatedHeaders).toBeInstanceOf(Headers); - expect(request.validatedHeaders.get('authorization')).toBe('Bearer token'); + expect(request.validatedHeaders).toBeUndefined(); + expect(request.headers.get('authorization')).toBe('Bearer token'); }); test('should validate headers against schema when provided', async () => { @@ -167,10 +168,10 @@ describe('withSpecValidation - headers', () => { operationId: 'test', path: '/test', method: 'GET', - headers: z.object({ - authorization: z.string(), + headers: v.object({ + authorization: v.string(), }), - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const headers = new Headers(); @@ -192,7 +193,7 @@ describe('withSpecValidation - headers', () => { operationId: 'test', path: '/test', method: 'GET', - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const request = createMockRequest({ @@ -203,8 +204,8 @@ describe('withSpecValidation - headers', () => { await withSpecValidation(request); - expect(request.validatedHeaders).toBeInstanceOf(Headers); - expect(request.validatedHeaders.get('authorization')).toBe('Bearer token'); + expect(request.validatedHeaders).toBeUndefined(); + expect(request.headers.get('authorization')).toBe('Bearer token'); }); test('should handle comma-separated Accept header with matching first value', async () => { @@ -212,14 +213,17 @@ describe('withSpecValidation - headers', () => { operationId: 'test', path: '/test', method: 'POST', - headers: z.object({ - accept: z.enum(['application/json', 'application/xml']), + headers: v.object({ + accept: v.picklist(['application/json', 'application/xml']), }), - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const headers = new Headers(); - headers.set('accept', 'application/json, text/html, application/xml'); + headers.append('accept', 'application/json'); + headers.append('accept', 'text/html'); + headers.append('accept', 'application/xml'); + const request = createMockRequest({ url: 'http://example.com/test', method: 'POST', @@ -238,14 +242,17 @@ describe('withSpecValidation - headers', () => { operationId: 'test', path: '/test', method: 'POST', - headers: z.object({ - accept: z.enum(['application/json', 'application/xml']), + headers: v.object({ + accept: v.picklist(['application/json', 'application/xml']), }), - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const headers = new Headers(); - headers.set('accept', 'text/html, application/xml, text/plain'); + // headers.set('accept', 'text/html, application/xml, text/plain'); + headers.append('accept', 'text/html'); + headers.append('accept', 'application/xml'); + headers.append('accept', 'text/plain'); const request = createMockRequest({ url: 'http://example.com/test', method: 'POST', @@ -264,10 +271,10 @@ describe('withSpecValidation - headers', () => { operationId: 'test', path: '/test', method: 'POST', - headers: z.object({ - accept: z.enum(['application/json', 'application/xml']), + headers: v.object({ + accept: v.picklist(['application/json', 'application/xml']), }), - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const headers = new Headers(); @@ -291,10 +298,10 @@ describe('withSpecValidation - body', () => { method: 'POST', requests: { 'application/json': { - body: z.object({ name: z.string(), email: z.string() }), + body: v.object({ name: v.string(), email: v.string() }), }, }, - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const bodyText = JSON.stringify({ name: 'John', email: 'john@example.com' }); @@ -317,7 +324,7 @@ describe('withSpecValidation - body', () => { operationId: 'test', path: '/test', method: 'GET', - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const request = createMockRequest({ @@ -336,7 +343,7 @@ describe('withSpecValidation - body', () => { path: '/test', method: 'POST', requests: { 'application/json': {} }, - responses: { 200: { 'application/json': { body: z.object({ message: z.string() }) } } }, + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, }; const request = createMockRequest({ @@ -361,8 +368,8 @@ describe('withResponseHelpers', () => { path: '/test', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ message: z.string() }) } }, - 400: { 'application/json': { body: z.object({ error: z.string() }) } }, + 200: { 'application/json': { body: v.object({ message: v.string() }) } }, + 400: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }; @@ -445,12 +452,655 @@ describe('withContractErrorHandler', () => { expect(response.status).toBe(400); }); - test('withContractErrorHandler should handle non-validation errors', () => { + test('withContractErrorHandler should handle non-validation errors', async () => { const errorHandler = withContractErrorHandler(); const error = new Error('Something went wrong'); const response = errorHandler(error, createMockRequest()); + const body = await response.json(); expect(response).toBeInstanceOf(Response); + expect(body).toMatchInlineSnapshot(` + { + "details": [ + { + "message": "Something went wrong", + }, + ], + "error": "Something went wrong", + } + `); + }); +}); + +describe('400 Validation Error Responses', () => { + describe('Path Parameter Validation Errors', () => { + test('should throw validation error for invalid path parameter type', async () => { + const operation: ContractOperation = { + operationId: 'getUser', + path: '/users/:id', + method: 'GET', + pathParams: v.object({ + id: v.pipe(v.string(), v.uuid()), + }), + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, + }; + + const request = new Request('http://example.com/users/not-a-uuid') as IRequest; + withMatchingContractOperation({ getUser: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + + test('should throw validation error for path parameter failing numeric constraint', async () => { + const operation: ContractOperation = { + operationId: 'getPost', + path: '/posts/:id', + method: 'GET', + pathParams: v.object({ + id: v.pipe( + v.string(), + v.transform((val) => parseInt(val, 10)), + v.number(), + v.minValue(1) + ), + }), + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, + }; + + const request = new Request('http://example.com/posts/0') as IRequest; + withMatchingContractOperation({ getPost: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + + test('should throw validation error for path parameter failing min length constraint', async () => { + const operation: ContractOperation = { + operationId: 'getUser', + path: '/users/:id', + method: 'GET', + pathParams: v.object({ + id: v.pipe(v.string(), v.minLength(3)), + }), + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, + }; + + const request = new Request('http://example.com/users/ab') as IRequest; + withMatchingContractOperation({ getUser: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + }); + + describe('Query Parameter Validation Errors', () => { + test('should throw validation error for invalid query parameter type', async () => { + const operation: ContractOperation = { + operationId: 'listItems', + path: '/items', + method: 'GET', + query: v.object({ + page: v.pipe( + v.string(), + v.transform((val) => parseInt(val, 10)), + v.number(), + v.minValue(1) + ), + }), + responses: { + 200: { 'application/json': { body: v.object({ items: v.array(v.string()) }) } }, + }, + }; + + const request = createMockRequest({ + url: 'http://example.com/items?page=0', + query: { page: '0' }, + }); + withMatchingContractOperation({ listItems: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + + test('should throw validation error for invalid enum value in query', async () => { + const operation: ContractOperation = { + operationId: 'search', + path: '/search', + method: 'GET', + query: v.object({ + sort: v.picklist(['asc', 'desc']), + }), + responses: { + 200: { 'application/json': { body: v.object({ results: v.array(v.string()) }) } }, + }, + }; + + const request = createMockRequest({ + url: 'http://example.com/search?sort=invalid', + query: { sort: 'invalid' }, + }); + withMatchingContractOperation({ search: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + + test('should throw validation error for missing required query parameter', async () => { + const operation: ContractOperation = { + operationId: 'listItems', + path: '/items', + method: 'GET', + query: v.object({ + page: v.pipe(v.string(), v.minLength(1)), + }), + responses: { + 200: { 'application/json': { body: v.object({ items: v.array(v.string()) }) } }, + }, + }; + + const request = createMockRequest({ + url: 'http://example.com/items', + query: {}, + }); + withMatchingContractOperation({ listItems: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + + test('should throw validation error for query parameter failing string constraint', async () => { + const operation: ContractOperation = { + operationId: 'search', + path: '/search', + method: 'GET', + query: v.object({ + q: v.pipe(v.string(), v.minLength(3)), + }), + responses: { + 200: { 'application/json': { body: v.object({ results: v.array(v.string()) }) } }, + }, + }; + + const request = createMockRequest({ + url: 'http://example.com/search?q=ab', + query: { q: 'ab' }, + }); + withMatchingContractOperation({ search: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + }); + + describe('Header Validation Errors', () => { + test('should throw validation error for missing required header', async () => { + const operation: ContractOperation = { + operationId: 'getProtected', + path: '/protected', + method: 'GET', + headers: v.object({ + authorization: v.pipe(v.string(), v.minLength(1)), + }), + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, + }; + + const request = createMockRequest({ + url: 'http://example.com/protected', + headers: {}, + }); + withMatchingContractOperation({ getProtected: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + + test('should throw validation error for header failing regex pattern', async () => { + const operation: ContractOperation = { + operationId: 'getProtected', + path: '/protected', + method: 'GET', + headers: v.object({ + authorization: v.pipe(v.string(), v.regex(/^Bearer .+$/)), + }), + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, + }; + + const request = createMockRequest({ + url: 'http://example.com/protected', + headers: { authorization: 'token123' }, + }); + withMatchingContractOperation({ getProtected: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + + test('should throw validation error for header failing enum constraint', async () => { + const operation: ContractOperation = { + operationId: 'getResource', + path: '/resource', + method: 'GET', + headers: v.object({ + accept: v.picklist(['application/json', 'application/xml']), + }), + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, + }; + + const request = createMockRequest({ + url: 'http://example.com/resource', + headers: { accept: 'text/html' }, + }); + withMatchingContractOperation({ getResource: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + + test('should throw validation error for header failing string length constraint', async () => { + const operation: ContractOperation = { + operationId: 'getResource', + path: '/resource', + method: 'GET', + headers: v.object({ + 'x-api-key': v.pipe(v.string(), v.minLength(10)), + }), + responses: { 200: { 'application/json': { body: v.object({ message: v.string() }) } } }, + }; + + const request = createMockRequest({ + url: 'http://example.com/resource', + headers: { 'x-api-key': 'short' }, + }); + withMatchingContractOperation({ getResource: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + }); + + describe('Body Validation Errors', () => { + test('should throw 400 error for missing Content-Type header when body schema is defined', async () => { + const operation: ContractOperation = { + operationId: 'createUser', + path: '/users', + method: 'POST', + requests: { + 'application/json': { + body: v.object({ name: v.string() }), + }, + }, + responses: { 201: { 'application/json': { body: v.object({ id: v.string() }) } } }, + }; + + const request = createMockRequest({ + url: 'http://example.com/users', + method: 'POST', + headers: {}, + text: async () => JSON.stringify({ name: 'John' }), + }); + withMatchingContractOperation({ createUser: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow(); + try { + await withSpecValidation(request); + } catch (err: any) { + expect(err.status).toBe(400); + // itty-router's error() may include message in different ways, check status is correct + expect(err.status).toBeDefined(); + } + }); + + test('should throw 400 error for unsupported Content-Type', async () => { + const operation: ContractOperation = { + operationId: 'createUser', + path: '/users', + method: 'POST', + requests: { + 'application/json': { + body: v.object({ name: v.string() }), + }, + }, + responses: { 201: { 'application/json': { body: v.object({ id: v.string() }) } } }, + }; + + const request = createMockRequest({ + url: 'http://example.com/users', + method: 'POST', + headers: { 'content-type': 'application/xml' }, + text: async () => 'John', + }); + withMatchingContractOperation({ createUser: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow(); + try { + await withSpecValidation(request); + } catch (err: any) { + expect(err.status).toBe(400); + } + }); + + test('should throw validation error for invalid body schema - missing required field', async () => { + const operation: ContractOperation = { + operationId: 'createUser', + path: '/users', + method: 'POST', + requests: { + 'application/json': { + body: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + email: v.pipe(v.string(), v.email()), + }), + }, + }, + responses: { 201: { 'application/json': { body: v.object({ id: v.string() }) } } }, + }; + + const request = createMockRequest({ + url: 'http://example.com/users', + method: 'POST', + headers: { 'content-type': 'application/json' }, + text: async () => JSON.stringify({ name: 'John' }), + }); + withMatchingContractOperation({ createUser: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + + test('should throw validation error for invalid body schema - invalid email format', async () => { + const operation: ContractOperation = { + operationId: 'createUser', + path: '/users', + method: 'POST', + requests: { + 'application/json': { + body: v.object({ + name: v.string(), + email: v.pipe(v.string(), v.email()), + }), + }, + }, + responses: { 201: { 'application/json': { body: v.object({ id: v.string() }) } } }, + }; + + const request = createMockRequest({ + url: 'http://example.com/users', + method: 'POST', + headers: { 'content-type': 'application/json' }, + text: async () => JSON.stringify({ name: 'John', email: 'invalid-email' }), + }); + withMatchingContractOperation({ createUser: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + + test('should throw validation error for invalid body schema - string length constraint', async () => { + const operation: ContractOperation = { + operationId: 'createUser', + path: '/users', + method: 'POST', + requests: { + 'application/json': { + body: v.object({ + name: v.pipe(v.string(), v.minLength(3), v.maxLength(50)), + password: v.pipe(v.string(), v.minLength(8)), + }), + }, + }, + responses: { 201: { 'application/json': { body: v.object({ id: v.string() }) } } }, + }; + + const request = createMockRequest({ + url: 'http://example.com/users', + method: 'POST', + headers: { 'content-type': 'application/json' }, + text: async () => JSON.stringify({ name: 'Jo', password: '123' }), + }); + withMatchingContractOperation({ createUser: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + + test('should throw validation error for invalid body schema - number range constraint', async () => { + const operation: ContractOperation = { + operationId: 'createProduct', + path: '/products', + method: 'POST', + requests: { + 'application/json': { + body: v.object({ + name: v.string(), + price: v.pipe(v.number(), v.minValue(0), v.maxValue(10000)), + }), + }, + }, + responses: { 201: { 'application/json': { body: v.object({ id: v.string() }) } } }, + }; + + const request = createMockRequest({ + url: 'http://example.com/products', + method: 'POST', + headers: { 'content-type': 'application/json' }, + text: async () => JSON.stringify({ name: 'Product', price: -10 }), + }); + withMatchingContractOperation({ createProduct: operation })(request); + + await expect(withSpecValidation(request)).rejects.toThrow('Validation failed'); + }); + + test('should throw validation error for invalid JSON body format', async () => { + const operation: ContractOperation = { + operationId: 'createUser', + path: '/users', + method: 'POST', + requests: { + 'application/json': { + body: v.object({ + name: v.string(), + }), + }, + }, + responses: { 201: { 'application/json': { body: v.object({ id: v.string() }) } } }, + }; + + const request = createMockRequest({ + url: 'http://example.com/users', + method: 'POST', + headers: { 'content-type': 'application/json' }, + text: async () => 'invalid json{', + }); + withMatchingContractOperation({ createUser: operation })(request); + + // Invalid JSON will be parsed as string, which will fail schema validation + await expect(withSpecValidation(request)).rejects.toThrow(); + }); + }); + + describe('Error Handler Response Format', () => { + test('should return 400 status code for validation errors', async () => { + const errorHandler = withContractErrorHandler(); + const validationError = new Error('Validation failed'); + (validationError as any).issues = [ + { path: ['name'], message: 'Required' }, + { path: ['email'], message: 'Invalid email format' }, + ]; + + const response = errorHandler(validationError, createMockRequest()); + + expect(response.status).toBe(400); + }); + + test('should return JSON response with error and details for validation errors', async () => { + const errorHandler = withContractErrorHandler(); + const validationError = new Error('Validation failed'); + const issues = [ + { path: ['name'], message: 'Required' }, + { path: ['email'], message: 'Invalid email format' }, + ]; + (validationError as any).issues = issues; + + const response = errorHandler(validationError, createMockRequest()); + const body = await response.json(); + + expect(body).toEqual({ + error: 'Validation failed', + details: issues, + }); + }); + + test('should return correct Content-Type header for validation error response', async () => { + const errorHandler = withContractErrorHandler(); + const validationError = new Error('Validation failed'); + (validationError as any).issues = [{ path: ['name'], message: 'Required' }]; + + const response = errorHandler(validationError, createMockRequest()); + + expect(response.headers.get('content-type')).toBe('application/json'); + }); + + test('should handle validation errors with empty issues array', async () => { + const errorHandler = withContractErrorHandler(); + const validationError = new Error('Validation failed'); + (validationError as any).issues = []; + + const response = errorHandler(validationError, createMockRequest()); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe('Validation failed'); + expect(body.details).toEqual([]); + }); + + test('should handle validation errors with complex nested path structures', async () => { + const errorHandler = withContractErrorHandler(); + const validationError = new Error('Validation failed'); + (validationError as any).issues = [ + { path: ['user', 'profile', 'email'], message: 'Invalid email' }, + { path: ['items', 0, 'quantity'], message: 'Must be positive' }, + ]; + + const response = errorHandler(validationError, createMockRequest()); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.details).toHaveLength(2); + expect(body.details[0].path).toEqual(['user', 'profile', 'email']); + expect(body.details[1].path).toEqual(['items', 0, 'quantity']); + }); + + test('should format non-validation errors with error and details array', async () => { + const errorHandler = withContractErrorHandler(); + const genericError = new Error('Something went wrong'); + + const response = errorHandler(genericError, createMockRequest()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + error: 'Something went wrong', + details: [{ message: 'Something went wrong' }], + }); + }); + + test('should format 400 errors with error and details array', async () => { + const errorHandler = withContractErrorHandler(); + const errorWithStatus = Object.assign(new Error('Content-Type header is required'), { + status: 400, + }); + + const response = errorHandler(errorWithStatus, createMockRequest()); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ + error: 'Content-Type header is required', + details: [{ message: 'Content-Type header is required' }], + }); + }); + + test('should format non-Error objects with error and details array', async () => { + const errorHandler = withContractErrorHandler(); + const stringError = 'An error occurred'; + + const response = errorHandler(stringError, createMockRequest()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + error: 'Internal server error', + details: [{ message: 'Internal server error' }], + }); + }); + + test('should ensure all error responses conform to { error: string, details: [...] }', async () => { + const errorHandler = withContractErrorHandler(); + + // Test validation error + const validationError = new Error('Validation failed'); + (validationError as any).issues = [{ path: ['field'], message: 'Invalid' }]; + const validationResponse = errorHandler(validationError, createMockRequest()); + const validationBody = await validationResponse.json(); + expect(validationBody).toHaveProperty('error'); + expect(validationBody).toHaveProperty('details'); + expect(Array.isArray(validationBody.details)).toBe(true); + + // Test generic error + const genericError = new Error('Generic error'); + const genericResponse = errorHandler(genericError, createMockRequest()); + const genericBody = await genericResponse.json(); + expect(genericBody).toHaveProperty('error'); + expect(genericBody).toHaveProperty('details'); + expect(Array.isArray(genericBody.details)).toBe(true); + }); + }); + + describe('Multiple Validation Errors', () => { + test('should throw validation error with multiple issues for path, query, and body', async () => { + const operation: ContractOperation = { + operationId: 'updateUser', + path: '/users/:id', + method: 'PUT', + pathParams: v.object({ + id: v.pipe(v.string(), v.uuid()), + }), + query: v.object({ + version: v.pipe( + v.string(), + v.transform((val) => parseInt(val, 10)), + v.number(), + v.minValue(1) + ), + }), + requests: { + 'application/json': { + body: v.object({ + name: v.pipe(v.string(), v.minLength(3)), + email: v.pipe(v.string(), v.email()), + }), + }, + }, + responses: { 200: { 'application/json': { body: v.object({ id: v.string() }) } } }, + }; + + const request = createMockRequest({ + url: 'http://example.com/users/not-uuid?version=0', + method: 'PUT', + headers: { 'content-type': 'application/json' }, + query: { version: '0' }, + text: async () => JSON.stringify({ name: 'Jo', email: 'invalid' }), + }); + withMatchingContractOperation({ updateUser: operation })(request); + + // Should throw validation error (may fail on first validation encountered) + await expect(withSpecValidation(request)).rejects.toThrow(); + }); + + test('error handler should include all validation issues in details array', async () => { + const errorHandler = withContractErrorHandler(); + const validationError = new Error('Validation failed'); + const issues = [ + { path: ['name'], message: 'String must contain at least 3 character(s)' }, + { path: ['email'], message: 'Invalid email' }, + { path: ['age'], message: 'Number must be greater than or equal to 18' }, + { path: ['password'], message: 'String must contain at least 8 character(s)' }, + ]; + (validationError as any).issues = issues; + + const response = errorHandler(validationError, createMockRequest()); + const body = await response.json(); + + expect(body.details).toHaveLength(4); + expect(body.details).toEqual(issues); + }); }); }); diff --git a/tests/unit/openapi.test.ts b/tests/unit/openapi.test.ts index 1716f7a..15dfa6a 100644 --- a/tests/unit/openapi.test.ts +++ b/tests/unit/openapi.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe } from 'vitest'; import { createContract } from '../../src/index.js'; import { createOpenApiSpecification } from '../../src/openapi/index.js'; import type { OpenAPIV3_1 } from '../../src/openapi/types.js'; -import { z } from 'zod/v4'; +import * as v from 'valibot'; describe('OpenAPI Specification Generation', () => { test('should generate basic OpenAPI spec with minimal contract', () => { @@ -11,7 +11,7 @@ describe('OpenAPI Specification Generation', () => { path: '/users', method: 'GET', responses: { - 200: { body: z.object({ users: z.array(z.string()) }) }, + 200: { body: v.object({ users: v.array(v.string()) }) }, }, }, }); @@ -34,7 +34,7 @@ describe('OpenAPI Specification Generation', () => { path: '/users/:id', method: 'GET', responses: { - 200: { body: z.object({ id: z.string() }) }, + 200: { body: v.object({ id: v.string() }) }, }, }, }); @@ -54,7 +54,7 @@ describe('OpenAPI Specification Generation', () => { path: '/users/:id', method: 'GET', responses: { - 200: { body: z.object({ id: z.string() }) }, + 200: { body: v.object({ id: v.string() }) }, }, }, }); @@ -81,11 +81,11 @@ describe('OpenAPI Specification Generation', () => { getUser: { path: '/users/:id', method: 'GET', - pathParams: z.object({ - id: z.string().uuid().describe('User UUID'), + pathParams: v.object({ + id: v.pipe(v.string(), v.uuid(), v.description('User UUID')), }), responses: { - 200: { body: z.object({ id: z.string() }) }, + 200: { body: v.object({ id: v.string() }) }, }, }, }); @@ -109,12 +109,12 @@ describe('OpenAPI Specification Generation', () => { getUserPost: { path: '/users/:userId/posts/:postId', method: 'GET', - pathParams: z.object({ - userId: z.string().uuid(), + pathParams: v.object({ + userId: v.pipe(v.string(), v.uuid()), // postId not in schema, should fallback to string }), responses: { - 200: { body: z.object({ userId: z.string(), postId: z.string() }) }, + 200: { body: v.object({ userId: v.string(), postId: v.string() }) }, }, }, }); @@ -139,13 +139,13 @@ describe('OpenAPI Specification Generation', () => { getUsers: { path: '/users', method: 'GET', - query: z.object({ - page: z.number().int().min(1).describe('Page number'), - limit: z.number().int().min(1).max(100).optional(), - search: z.string().optional(), + query: v.object({ + page: v.pipe(v.number(), v.integer(), v.minValue(1), v.description('Page number')), + limit: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))), + search: v.optional(v.string()), }), responses: { - 200: { body: z.object({ users: z.array(z.string()) }) }, + 200: { body: v.object({ users: v.array(v.string()) }) }, }, }, }); @@ -190,15 +190,15 @@ describe('OpenAPI Specification Generation', () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - name: z.string().min(1), - email: z.string().email(), - age: z.number().int().min(0).optional(), + body: v.object({ + name: v.pipe(v.string(), v.minLength(1)), + email: v.pipe(v.string(), v.email()), + age: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))), }), }, }, responses: { - 201: { body: z.object({ id: z.string(), name: z.string() }) }, + 201: { body: v.object({ id: v.string(), name: v.string() }) }, }, }, }); @@ -223,12 +223,12 @@ describe('OpenAPI Specification Generation', () => { getUsers: { path: '/users', method: 'GET', - headers: z.object({ - 'X-API-Key': z.string().describe('API key for authentication'), - 'X-Request-ID': z.string().uuid().optional(), + headers: v.object({ + 'X-API-Key': v.pipe(v.string(), v.description('API key for authentication')), + 'X-Request-ID': v.optional(v.pipe(v.string(), v.uuid())), }), responses: { - 200: { body: z.object({ users: z.array(z.string()) }) }, + 200: { body: v.object({ users: v.array(v.string()) }) }, }, }, }); @@ -266,20 +266,20 @@ describe('OpenAPI Specification Generation', () => { responses: { 200: { 'application/json': { - body: z.object({ - id: z.string(), - name: z.string(), - email: z.string().email(), + body: v.object({ + id: v.string(), + name: v.string(), + email: v.pipe(v.string(), v.email()), }), - headers: z.object({ - 'X-Request-ID': z.string().uuid(), - 'X-RateLimit-Remaining': z.number().int(), + headers: v.object({ + 'X-Request-ID': v.pipe(v.string(), v.uuid()), + 'X-RateLimit-Remaining': v.pipe(v.number(), v.integer()), }), }, }, 404: { 'application/json': { - body: z.object({ error: z.string() }), + body: v.object({ error: v.string() }), }, }, }, @@ -316,14 +316,14 @@ describe('OpenAPI Specification Generation', () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - name: z.string(), - email: z.string().email(), + body: v.object({ + name: v.string(), + email: v.pipe(v.string(), v.email()), }), }, }, responses: { - 201: { body: z.object({ id: z.string(), name: z.string() }) }, + 201: { body: v.object({ id: v.string(), name: v.string() }) }, }, }, }); @@ -338,10 +338,10 @@ describe('OpenAPI Specification Generation', () => { }); test('should deduplicate schemas when reused', () => { - const userSchema = z.object({ - id: z.string(), - name: z.string(), - email: z.string().email(), + const userSchema = v.object({ + id: v.string(), + name: v.string(), + email: v.pipe(v.string(), v.email()), }); const contract = createContract({ @@ -384,7 +384,7 @@ describe('OpenAPI Specification Generation', () => { path: '/users', method: 'GET', responses: { - 200: { body: z.object({ users: z.array(z.string()) }) }, + 200: { body: v.object({ users: v.array(v.string()) }) }, }, }, createUser: { @@ -392,11 +392,11 @@ describe('OpenAPI Specification Generation', () => { method: 'POST', requests: { 'application/json': { - body: z.object({ name: z.string() }), + body: v.object({ name: v.string() }), }, }, responses: { - 201: { body: z.object({ id: z.string() }) }, + 201: { body: v.object({ id: v.string() }) }, }, }, }); @@ -421,7 +421,7 @@ describe('OpenAPI Specification Generation', () => { description: 'Retrieves a single user by their unique identifier', tags: ['users'], responses: { - 200: { body: z.object({ id: z.string() }) }, + 200: { body: v.object({ id: v.string() }) }, }, }, }); @@ -445,31 +445,31 @@ describe('OpenAPI Specification Generation', () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - items: z.array( - z.object({ - productId: z.string(), - quantity: z.number().int().min(1), - price: z.number().positive(), + body: v.object({ + items: v.array( + v.object({ + productId: v.string(), + quantity: v.pipe(v.number(), v.integer(), v.minValue(1)), + price: v.pipe(v.number(), v.minValue(1)), }) ), - shippingAddress: z.object({ - street: z.string(), - city: z.string(), - zipCode: z.string(), + shippingAddress: v.object({ + street: v.string(), + city: v.string(), + zipCode: v.string(), }), }), }, }, responses: { 201: { - body: z.object({ - orderId: z.string(), - total: z.number(), - items: z.array( - z.object({ - productId: z.string(), - quantity: z.number(), + body: v.object({ + orderId: v.string(), + total: v.number(), + items: v.array( + v.object({ + productId: v.string(), + quantity: v.number(), }) ), }), @@ -496,15 +496,15 @@ describe('OpenAPI Specification Generation', () => { method: 'PATCH', requests: { 'application/json': { - body: z.object({ - name: z.string().optional(), - email: z.string().email().optional(), - age: z.number().int().optional(), + body: v.object({ + name: v.optional(v.string()), + email: v.optional(v.pipe(v.string(), v.email())), + age: v.optional(v.pipe(v.number(), v.integer())), }), }, }, responses: { - 200: { body: z.object({ id: z.string() }) }, + 200: { body: v.object({ id: v.string() }) }, }, }, }); @@ -528,13 +528,13 @@ describe('OpenAPI Specification Generation', () => { responses: { 200: { 'application/json': { - body: z.object({ result: z.number() }), + body: v.object({ result: v.number() }), }, 'text/html': { - body: z.string(), + body: v.string(), }, 'application/xml': { - body: z.string(), + body: v.string(), }, }, }, @@ -566,16 +566,16 @@ describe('OpenAPI Specification Generation', () => { responses: { 200: { 'application/json': { - body: z.object({ result: z.number() }), - headers: z.object({ - 'Content-Length': z.string(), - 'X-Request-ID': z.string(), + body: v.object({ result: v.number() }), + headers: v.object({ + 'Content-Length': v.string(), + 'X-Request-ID': v.string(), }), }, 'text/html': { - body: z.string(), - headers: z.object({ - 'Content-Length': z.string(), + body: v.string(), + headers: v.object({ + 'Content-Length': v.string(), }), }, }, @@ -597,9 +597,9 @@ describe('OpenAPI Specification Generation', () => { }); test('should register schemas for all content types', () => { - const jsonSchema = z.object({ result: z.number() }); - const htmlSchema = z.string(); - const xmlSchema = z.string(); + const jsonSchema = v.object({ result: v.number() }); + const htmlSchema = v.string(); + const xmlSchema = v.string(); const contract = createContract({ getData: { @@ -634,13 +634,13 @@ describe('OpenAPI Specification Generation', () => { method: 'GET', responses: { 200: { - 'application/json': { body: z.object({ result: z.number() }) }, + 'application/json': { body: v.object({ result: v.number() }) }, }, 400: { - 'application/json': { body: z.object({ error: z.string() }) }, + 'application/json': { body: v.object({ error: v.string() }) }, }, 500: { - 'application/json': { body: z.object({ error: z.string(), code: z.string() }) }, + 'application/json': { body: v.object({ error: v.string(), code: v.string() }) }, }, }, }, @@ -663,16 +663,16 @@ describe('OpenAPI Specification Generation', () => { method: 'POST', requests: { 'application/json': { - body: z.object({ - email: z.string().email(), - uri: z.string().url(), - uuid: z.string().uuid(), - date: z.string().date(), + body: v.object({ + email: v.pipe(v.string(), v.email()), + uri: v.pipe(v.string(), v.url()), + uuid: v.pipe(v.string(), v.uuid()), + date: v.pipe(v.string(), v.date()), }), }, }, responses: { - 201: { body: z.object({ id: z.string() }) }, + 201: { body: v.object({ id: v.string() }) }, }, }, }); @@ -719,14 +719,14 @@ describe('OpenAPI Specification Generation', () => { method: 'POST', requests: { 'application/json': { - body: z.object({ + body: v.object({ // Custom regex pattern without standard format - customField: z.string().regex(/^[A-Z]{3}-\d{4}$/), + customField: v.pipe(v.string(), v.regex(/^[A-Z]{3}-\d{4}$/)), }), }, }, responses: { - 201: { body: z.object({ id: z.string() }) }, + 201: { body: v.object({ id: v.string() }) }, }, }, }); diff --git a/tests/unit/router.test.ts b/tests/unit/router.test.ts index 0f48ca8..e80b147 100644 --- a/tests/unit/router.test.ts +++ b/tests/unit/router.test.ts @@ -2,7 +2,7 @@ import { test, expect } from 'vitest'; import { createRouter } from '../../src/router.js'; import { createContract } from '../../src/contract.js'; import type { ContractDefinition } from '../../src/types.js'; -import { z } from 'zod/v4'; +import * as v from 'valibot'; test('createContract should return contract definition as-is', () => { const definition: ContractDefinition = { @@ -11,7 +11,7 @@ test('createContract should return contract definition as-is', () => { path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, }; @@ -29,7 +29,7 @@ test('createContract should preserve type inference', () => { path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, }); @@ -46,7 +46,7 @@ test('createContract should handle multiple operations', () => { path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, getUser: { @@ -54,8 +54,8 @@ test('createContract should handle multiple operations', () => { path: '/users/:id', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ user: z.string() }) } }, - 404: { 'application/json': { body: z.object({ error: z.string() }) } }, + 200: { 'application/json': { body: v.object({ user: v.string() }) } }, + 404: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }, }); @@ -72,7 +72,7 @@ test('createRouter should create router with contract and handlers', () => { path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, }); @@ -101,7 +101,7 @@ test('createRouter should handle missing routes with default 404', async () => { path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, }); @@ -132,7 +132,7 @@ test('createRouter should use custom missing handler', async () => { path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, }); @@ -171,7 +171,7 @@ test('createRouter should handle base path', () => { path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, }); @@ -200,7 +200,7 @@ test('createRouter should skip operations without handlers', () => { path: '/users', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ users: z.array(z.string()) }) } }, + 200: { 'application/json': { body: v.object({ users: v.array(v.string()) }) } }, }, }, getUser: { @@ -208,7 +208,7 @@ test('createRouter should skip operations without handlers', () => { path: '/users/:id', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ user: z.string() }) } }, + 200: { 'application/json': { body: v.object({ user: v.string() }) } }, }, }, }); diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index aa53686..c3008a1 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -5,7 +5,7 @@ import { getResponseSchemaForContentType, } from '../../src/utils.js'; import type { ContractOperation, ResponseByContentType } from '../../src/types.js'; -import { z } from 'zod/v4'; +import * as v from 'valibot'; test('createBasicResponseHelpers should create respond helper', () => { const helpers = createBasicResponseHelpers(); @@ -58,7 +58,7 @@ test('createResponseHelpers should create respond helper', () => { path: '/test', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ message: z.string() }) } }, + 200: { 'application/json': { body: v.object({ message: v.string() }) } }, }, }; @@ -82,8 +82,8 @@ test('createResponseHelpers should create respond helper with explicit status', path: '/test', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ message: z.string() }) } }, - 201: { 'application/json': { body: z.object({ id: z.number() }) } }, + 200: { 'application/json': { body: v.object({ message: v.string() }) } }, + 201: { 'application/json': { body: v.object({ id: v.number() }) } }, }, }; @@ -109,8 +109,8 @@ test('createResponseHelpers should create respond helper with headers', () => { responses: { 200: { 'application/json': { - body: z.object({ message: z.string() }), - headers: z.object({ 'content-type': z.string() }), + body: v.object({ message: v.string() }), + headers: v.object({ 'content-type': v.string() }), }, }, }, @@ -137,7 +137,7 @@ test('createResponseHelpers should handle no content response', () => { path: '/test', method: 'DELETE', responses: { - 204: { 'application/json': { body: z.never() } }, + 204: { 'application/json': { body: v.never() } }, }, }; @@ -157,8 +157,8 @@ test('createResponseHelpers should create error response', () => { path: '/test', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ message: z.string() }) } }, - 400: { 'application/json': { body: z.object({ error: z.string() }) } }, + 200: { 'application/json': { body: v.object({ message: v.string() }) } }, + 400: { 'application/json': { body: v.object({ error: v.string() }) } }, }, }; @@ -182,11 +182,11 @@ test('createResponseHelpers should create error response with headers', () => { path: '/test', method: 'GET', responses: { - 200: { 'application/json': { body: z.object({ message: z.string() }) } }, + 200: { 'application/json': { body: v.object({ message: v.string() }) } }, 400: { 'application/json': { - body: z.object({ error: z.string() }), - headers: z.object({ 'X-Error-Code': z.string() }), + body: v.object({ error: v.string() }), + headers: v.object({ 'X-Error-Code': v.string() }), }, }, }, @@ -213,8 +213,8 @@ test('createResponseHelpers should create error response with headers', () => { describe('Content type helpers', () => { test('getResponseSchemaForContentType should extract schema for content type', () => { const contentTypeMap: ResponseByContentType = { - 'application/json': { body: z.object({ result: z.number() }) }, - 'text/html': { body: z.string() }, + 'application/json': { body: v.object({ result: v.number() }) }, + 'text/html': { body: v.string() }, }; const jsonSchema = getResponseSchemaForContentType(contentTypeMap, 'application/json'); @@ -228,7 +228,7 @@ describe('Content type helpers', () => { test('getResponseSchemaForContentType should return null for missing content type', () => { const contentTypeMap: ResponseByContentType = { - 'application/json': { body: z.object({ result: z.number() }) }, + 'application/json': { body: v.object({ result: v.number() }) }, }; const schema = getResponseSchemaForContentType(contentTypeMap, 'text/xml'); @@ -244,9 +244,9 @@ describe('Content-type-specific response helpers', () => { method: 'GET', responses: { 200: { - 'application/json': { body: z.object({ result: z.number() }) }, - 'text/html': { body: z.string() }, - 'application/xml': { body: z.string() }, + 'application/json': { body: v.object({ result: v.number() }) }, + 'text/html': { body: v.string() }, + 'application/xml': { body: v.string() }, }, }, }; @@ -282,7 +282,7 @@ describe('Content-type-specific response helpers', () => { method: 'GET', responses: { 200: { - 'application/xml': { body: z.string() }, + 'application/xml': { body: v.string() }, }, }, };