Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/complex/contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const contract = createContract({
responses: {
200: {
'application/json': {
body: z.object({ spec: z.any() }),
body: z.any(),
},
},
},
Expand Down
6 changes: 5 additions & 1 deletion examples/complex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
25 changes: 0 additions & 25 deletions examples/simple/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ import { createRouter } from '../../src/index.ts';
import {
createSpotlightElementsHtml,
formatCalculateResponseXML,
formatCalculateErrorXML,
formatCalculateResponseHTML,
formatCalculateErrorHTML,
} from './utils.ts';
import { IRequest } from 'itty-router';

Expand All @@ -35,29 +33,6 @@ const router = createRouter<typeof contract, IRequest, [ExampleContext]>({
// 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,
Expand Down
1 change: 1 addition & 0 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './withSpecValidation.js';
export * from './withResponseHelpers.js';
export * from './withContractFormat.js';
export * from './withContractErrorHandler.js';
export * from './withMissingHandler.js';
17 changes: 14 additions & 3 deletions src/middleware/withContractErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }
);
Expand Down
47 changes: 47 additions & 0 deletions src/middleware/withMissingHandler.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createBasicResponseHelpers>,
...args: Args
) => Response | Promise<Response>
): 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<typeof createBasicResponseHelpers>,
...(args as Args)
);
}
return error(404);
};
}
197 changes: 114 additions & 83 deletions src/middleware/withSpecValidation.ts
Original file line number Diff line number Diff line change
@@ -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<ContractAugmentedRequest['__contractOperation']>;

export const withSpecValidation: RequestHandler<IRequest> = async (request: IRequest) => {
const operation = (request as ContractAugmentedRequest).__contractOperation;
if (!operation) return;

// Validate path parameters
let requestParams = (request.params as Record<string, string> | undefined) || {};
if (!Object.keys(requestParams).length && request.url) {
requestParams = extractPathParamsFromUrl(operation.path, request.url);
}
const params = operation.pathParams
? await validateSchema<Record<string, string>>(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<string, unknown> | undefined) || {};
if (!Object.keys(requestQuery).length && request.url) {
requestQuery = extractQueryParamsFromUrl(request.url);
}
const query = operation.query
? await validateSchema<Record<string, unknown>>(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<ContractOperationParameters<ContractOperation>>(
operation.pathParams,
requestParams
)
: requestParams;
}

async function resolveAndValidateQuery(
request: IRequest,
operation: ContractOperation
): Promise<Record<string, unknown>> {
if (operation.query) {
return validateSchema<ContractOperationQuery<ContractOperation>>(
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<Headers | undefined> {
if (operation.headers) {
const normalizedHeaders = normalizeHeaders(request.headers);
const validatedHeaders = await validateHeadersWithFallback(
operation.headers,
normalizedHeaders
);
return new Headers(validatedHeaders as Record<string, string>);
}

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<string, unknown>,
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<unknown> {
// 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);
}
Loading