|
1 | 1 | import type { IRequest, RequestHandler } from 'itty-router'; |
2 | 2 | import { error } from 'itty-router'; |
3 | 3 | 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'; |
5 | 9 | import { validateSchema, defineProp } from '../utils.js'; |
6 | 10 | import { |
7 | 11 | extractPathParamsFromUrl, |
8 | | - extractQueryParamsFromUrl, |
9 | 12 | getContentType, |
10 | 13 | parseBodyByContentType, |
11 | 14 | normalizeHeaders, |
12 | 15 | validateHeadersWithFallback, |
13 | 16 | } from './utils.js'; |
14 | 17 |
|
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 | + |
26 | 20 | export const withSpecValidation: RequestHandler<IRequest> = async (request: IRequest) => { |
27 | 21 | const operation = (request as ContractAugmentedRequest).__contractOperation; |
28 | 22 | if (!operation) return; |
29 | 23 |
|
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); |
39 | 27 |
|
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); |
48 | 30 | 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 | + ); |
60 | 61 | } |
61 | | - defineProp(request, 'validatedHeaders', headers); |
62 | 62 |
|
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>); |
68 | 77 | } |
69 | 78 |
|
70 | | - let bodyData: unknown = {}; |
71 | | - let bodyReadSuccessfully = false; |
72 | | - let bodyText = ''; |
| 79 | + return undefined; |
| 80 | +} |
73 | 81 |
|
| 82 | +async function tryReadRequestText( |
| 83 | + request: IRequest |
| 84 | +): Promise<{ ok: true; text: string } | { ok: false }> { |
74 | 85 | try { |
75 | | - bodyText = await request.text(); |
76 | | - bodyReadSuccessfully = true; |
| 86 | + const text = await request.text(); |
| 87 | + return { ok: true, text }; |
77 | 88 | } catch { |
78 | | - bodyData = {}; |
| 89 | + return { ok: false }; |
79 | 90 | } |
| 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(); |
80 | 100 |
|
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'); |
110 | 124 | } |
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