Skip to content

Commit e284ce1

Browse files
committed
Improvements
1 parent b22c82f commit e284ce1

12 files changed

Lines changed: 1287 additions & 581 deletions

File tree

examples/simple/index.ts

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ import { createRouter } from '../../src/index.ts';
66
import {
77
createSpotlightElementsHtml,
88
formatCalculateResponseXML,
9-
formatCalculateErrorXML,
109
formatCalculateResponseHTML,
11-
formatCalculateErrorHTML,
1210
} from './utils.ts';
1311
import { IRequest } from 'itty-router';
1412

@@ -35,29 +33,6 @@ const router = createRouter<typeof contract, IRequest, [ExampleContext]>({
3533
// Headers are normalized to lowercase in types and runtime, regardless of how they're defined in the schema
3634
const contentType = request.validatedHeaders.get('content-type');
3735

38-
if (result > 100) {
39-
const errorMessage = 'Invalid request';
40-
if (contentType === 'text/html') {
41-
return request.respond({
42-
status: 400,
43-
contentType: 'text/html',
44-
body: formatCalculateErrorHTML(errorMessage),
45-
});
46-
}
47-
if (contentType === 'application/xml') {
48-
return request.respond({
49-
status: 400,
50-
contentType: 'application/xml',
51-
body: formatCalculateErrorXML(errorMessage),
52-
});
53-
}
54-
return request.respond({
55-
status: 400,
56-
contentType: 'application/json',
57-
body: { error: errorMessage },
58-
});
59-
}
60-
6136
if (contentType === 'text/html') {
6237
return request.respond({
6338
status: 200,

src/middleware/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './withSpecValidation.js';
33
export * from './withResponseHelpers.js';
44
export * from './withContractFormat.js';
55
export * from './withContractErrorHandler.js';
6+
export * from './withMissingHandler.js';

src/middleware/withContractErrorHandler.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,25 +23,36 @@ export function withContractErrorHandler<
2323
RequestType extends IRequest = IRequest,
2424
Args extends any[] = any[],
2525
>(): (err: unknown, request: RequestType, ...args: Args) => Response {
26-
return (err: unknown, request: RequestType, ..._args: Args): Response => {
26+
return (err: unknown, _request: RequestType, ..._args: Args): Response => {
27+
// Handle validation errors with issues array
2728
if (err instanceof Error && 'issues' in err) {
29+
const issues = (err as Error & { issues: unknown }).issues;
2830
return new Response(
2931
JSON.stringify({
3032
error: 'Validation failed',
31-
details: (err as Error & { issues: unknown }).issues,
33+
details: Array.isArray(issues) ? issues : [issues],
3234
}),
3335
{ status: 400, headers: { 'content-type': 'application/json' } }
3436
);
3537
}
3638

37-
// Handle other errors - return error message without circular reference issues
39+
// Handle other errors - ensure all errors conform to { error: string, details: [...] }
3840
const errorMessage = err instanceof Error ? err.message : 'Internal server error';
3941
const statusCode =
4042
err && typeof err === 'object' && 'status' in err ? (err as any).status : 500;
4143

44+
// Format error message as a details array for consistency with validation errors
45+
// Details array contains objects with message property (and optionally other fields)
46+
const details = [
47+
{
48+
message: errorMessage,
49+
},
50+
];
51+
4252
return new Response(
4353
JSON.stringify({
4454
error: errorMessage,
55+
details,
4556
}),
4657
{ status: statusCode, headers: { 'content-type': 'application/json' } }
4758
);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { IRequest, ResponseHandler } from 'itty-router';
2+
import { error } from 'itty-router';
3+
import { createBasicResponseHelpers } from '../utils';
4+
5+
/**
6+
* Middleware for handling missing routes
7+
*
8+
* This middleware checks if a response has been set. If not, it calls the provided
9+
* missing handler (if available) or returns a 404 error. The missing handler receives
10+
* the request with basic response helpers attached.
11+
*
12+
* @typeParam RequestType - The request type (extends IRequest)
13+
* @typeParam Args - Additional arguments passed to handlers
14+
*
15+
* @param missing - Optional handler for missing routes
16+
* @returns A ResponseHandler function that handles missing routes
17+
*
18+
* @example
19+
* ```typescript
20+
* const router = Router({
21+
* finally: [
22+
* withMissingHandler(options.missing),
23+
* ],
24+
* });
25+
* ```
26+
*/
27+
export function withMissingHandler<
28+
RequestType extends IRequest = IRequest,
29+
Args extends any[] = any[],
30+
>(
31+
missing?: (
32+
request: RequestType & ReturnType<typeof createBasicResponseHelpers>,
33+
...args: Args
34+
) => Response | Promise<Response>
35+
): ResponseHandler {
36+
return (response: Response, request: IRequest, ...args: Args) => {
37+
if (response != null) return response as Response;
38+
if (missing) {
39+
return missing(
40+
{ ...(request as RequestType), ...createBasicResponseHelpers() } as RequestType &
41+
ReturnType<typeof createBasicResponseHelpers>,
42+
...(args as Args)
43+
);
44+
}
45+
return error(404);
46+
};
47+
}
Lines changed: 114 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,142 @@
11
import type { IRequest, RequestHandler } from 'itty-router';
22
import { error } from 'itty-router';
33
import type { StandardSchemaV1 } from '@standard-schema/spec';
4-
import type { ContractAugmentedRequest } from '../types.js';
4+
import type {
5+
ContractAugmentedRequest,
6+
ContractOperationParameters,
7+
ContractOperationQuery,
8+
} from '../types.js';
59
import { validateSchema, defineProp } from '../utils.js';
610
import {
711
extractPathParamsFromUrl,
8-
extractQueryParamsFromUrl,
912
getContentType,
1013
parseBodyByContentType,
1114
normalizeHeaders,
1215
validateHeadersWithFallback,
1316
} from './utils.js';
1417

15-
/**
16-
* Global middleware: Validates path parameters, query parameters, headers, and body
17-
* using the operation from request. This reads from __contractOperation set by
18-
* withMatchingContractOperation.
19-
*
20-
* This middleware combines the functionality of:
21-
* - withPathParams
22-
* - withQueryParams
23-
* - withHeaders
24-
* - withBody
25-
*/
18+
type ContractOperation = NonNullable<ContractAugmentedRequest['__contractOperation']>;
19+
2620
export const withSpecValidation: RequestHandler<IRequest> = async (request: IRequest) => {
2721
const operation = (request as ContractAugmentedRequest).__contractOperation;
2822
if (!operation) return;
2923

30-
// Validate path parameters
31-
let requestParams = (request.params as Record<string, string> | undefined) || {};
32-
if (!Object.keys(requestParams).length && request.url) {
33-
requestParams = extractPathParamsFromUrl(operation.path, request.url);
34-
}
35-
const params = operation.pathParams
36-
? await validateSchema<Record<string, string>>(operation.pathParams, requestParams)
37-
: requestParams;
38-
defineProp(request, 'params', params);
24+
// Path params
25+
const params = await resolveAndValidatePathParams(request, operation);
26+
defineProp(request, 'validatedParams', params);
3927

40-
// Validate query parameters
41-
let requestQuery = (request.query as Record<string, unknown> | undefined) || {};
42-
if (!Object.keys(requestQuery).length && request.url) {
43-
requestQuery = extractQueryParamsFromUrl(request.url);
44-
}
45-
const query = operation.query
46-
? await validateSchema<Record<string, unknown>>(operation.query, requestQuery)
47-
: requestQuery;
28+
// Query params
29+
const query = await resolveAndValidateQuery(request, operation);
4830
defineProp(request, 'validatedQuery', query);
49-
defineProp(request, 'query', query);
50-
51-
// Validate headers
52-
const requestHeaders = normalizeHeaders(request.headers);
53-
const validatedHeadersObject = operation.headers
54-
? await validateHeadersWithFallback(operation.headers, requestHeaders)
55-
: requestHeaders;
56-
// Convert to Headers object to align with Web API Request standard
57-
const headers = new Headers();
58-
for (const [key, value] of Object.entries(validatedHeadersObject)) {
59-
headers.set(key, String(value));
31+
32+
// Headers
33+
const validatedHeaders = await resolveAndValidateHeaders(request, operation);
34+
defineProp(request, 'validatedHeaders', validatedHeaders);
35+
36+
// Body
37+
const validatedBody = await resolveAndValidateBody(request, operation);
38+
defineProp(request, 'validatedBody', validatedBody);
39+
};
40+
41+
async function resolveAndValidatePathParams(request: IRequest, operation: ContractOperation) {
42+
const requestParams = extractPathParamsFromUrl(operation.path, request.url);
43+
44+
return operation.pathParams
45+
? await validateSchema<ContractOperationParameters<ContractOperation>>(
46+
operation.pathParams,
47+
requestParams
48+
)
49+
: requestParams;
50+
}
51+
52+
async function resolveAndValidateQuery(
53+
request: IRequest,
54+
operation: ContractOperation
55+
): Promise<Record<string, unknown>> {
56+
if (operation.query) {
57+
return validateSchema<ContractOperationQuery<ContractOperation>>(
58+
operation.query,
59+
request.query
60+
);
6061
}
61-
defineProp(request, 'validatedHeaders', headers);
6262

63-
// Validate body
64-
// If no request schemas defined, set empty body and return
65-
if (!operation.requests) {
66-
defineProp(request, 'validatedBody', {});
67-
return;
63+
return {};
64+
}
65+
66+
async function resolveAndValidateHeaders(
67+
request: IRequest,
68+
operation: ContractOperation
69+
): Promise<Headers | undefined> {
70+
if (operation.headers) {
71+
const normalizedHeaders = normalizeHeaders(request.headers);
72+
const validatedHeaders = await validateHeadersWithFallback(
73+
operation.headers,
74+
normalizedHeaders
75+
);
76+
return new Headers(validatedHeaders as Record<string, string>);
6877
}
6978

70-
let bodyData: unknown = {};
71-
let bodyReadSuccessfully = false;
72-
let bodyText = '';
79+
return undefined;
80+
}
7381

82+
async function tryReadRequestText(
83+
request: IRequest
84+
): Promise<{ ok: true; text: string } | { ok: false }> {
7485
try {
75-
bodyText = await request.text();
76-
bodyReadSuccessfully = true;
86+
const text = await request.text();
87+
return { ok: true, text };
7788
} catch {
78-
bodyData = {};
89+
return { ok: false };
7990
}
91+
}
92+
93+
function findRequestSchemaEntry(
94+
requests: Record<string, unknown>,
95+
contentType: string
96+
): [normalizedContentType: string, schema: unknown] | undefined {
97+
// Slightly more robust than the original comment implied:
98+
// - normalizes the incoming content type for matching (adds acceptance, doesn’t remove)
99+
const normalized = contentType.toLowerCase();
80100

81-
if (bodyReadSuccessfully && bodyText.trim()) {
82-
// Check if request is a content-type map
83-
const contentType = getContentType(request);
84-
if (!contentType) {
85-
throw error(400, 'Content-Type header is required');
86-
}
87-
88-
// Find matching schema (case-insensitive)
89-
const matchingEntry = Object.entries(operation.requests).find(([key]) => {
90-
return key.toLowerCase() === contentType;
91-
});
92-
93-
if (!matchingEntry) {
94-
throw error(
95-
400,
96-
`Unsupported Content-Type: ${contentType}. Supported types: ${Object.keys(operation.requests).join(', ')}`
97-
);
98-
}
99-
100-
const [, requestSchema] = matchingEntry;
101-
if (!requestSchema || typeof requestSchema !== 'object' || !('body' in requestSchema)) {
102-
throw error(500, 'Invalid request schema configuration');
103-
}
104-
bodyData = parseBodyByContentType(contentType, bodyText);
105-
const body = await validateSchema((requestSchema as { body: StandardSchemaV1 }).body, bodyData);
106-
defineProp(request, 'validatedBody', body);
107-
} else {
108-
// Empty body
109-
defineProp(request, 'validatedBody', bodyData);
101+
const entry = Object.entries(requests).find(([key]) => key.toLowerCase() === normalized);
102+
if (!entry) return;
103+
104+
return [normalized, entry[1]];
105+
}
106+
107+
async function resolveAndValidateBody(
108+
request: IRequest,
109+
operation: ContractOperation
110+
): Promise<unknown> {
111+
// Preserve existing behavior: if no request schemas defined, set empty body.
112+
if (!operation.requests) return {};
113+
114+
// Preserve existing behavior: body read failures become empty body.
115+
const read = await tryReadRequestText(request);
116+
if (!read.ok) return {};
117+
118+
const bodyText = read.text;
119+
if (!bodyText.trim()) return {};
120+
121+
const contentType = getContentType(request);
122+
if (!contentType) {
123+
throw error(400, 'Content-Type header is required');
110124
}
111-
};
125+
126+
const entry = findRequestSchemaEntry(operation.requests, contentType);
127+
if (!entry) {
128+
throw error(
129+
400,
130+
`Unsupported Content-Type: ${contentType}. Supported types: ${Object.keys(operation.requests).join(', ')}`
131+
);
132+
}
133+
134+
const [normalizedContentType, requestSchema] = entry;
135+
136+
if (!requestSchema || typeof requestSchema !== 'object' || !('body' in requestSchema)) {
137+
throw error(500, 'Invalid request schema configuration');
138+
}
139+
140+
const bodyData = parseBodyByContentType(normalizedContentType, bodyText);
141+
return await validateSchema((requestSchema as { body: StandardSchemaV1 }).body, bodyData);
142+
}

0 commit comments

Comments
 (0)