UNPKG

@lokalise/api-contracts

Version:
138 lines 6.2 kB
export const isJsonResponse = (value) => typeof value === 'object' && value !== null && !('content' in value) && !('allowNoBody' in value); export const blobBody = () => ({ _tag: 'BlobBody' }); export const isBlobBody = (value) => typeof value === 'object' && value !== null && '_tag' in value && value._tag === 'BlobBody'; export const sseBody = (schemaByEventName) => ({ _tag: 'SseBody', schemaByEventName, }); export const isSseBody = (value) => typeof value === 'object' && value !== null && '_tag' in value && value._tag === 'SseBody'; export const isJsonBody = (value) => typeof value === 'object' && value !== null && !('_tag' in value); export const isContentResponseEntry = (value) => typeof value === 'object' && value !== null && ('content' in value || 'allowNoBody' in value); /** * Declares a no-body response (e.g. `204`). */ export const noBodyResponse = (options) => ({ allowNoBody: true, ...(options?.description !== undefined && { description: options.description }), }); /** * Declares a binary/opaque response for a single media type. */ export const blobResponse = (contentType, options) => ({ // A computed property with a generic key widens to `{ [x: string]: ... }`, losing the literal // media type — assert the single-key record shape to keep `TContentType` in the entry type. content: { [contentType]: blobBody() }, ...(options?.description !== undefined && { description: options.description }), }); /** * Declares a Server-Sent Events response. */ export const sseResponse = (schemaByEventName, options) => ({ content: { 'text/event-stream': sseBody(schemaByEventName) }, ...(options?.description !== undefined && { description: options.description }), }); const normalizeMediaType = (contentType) => (contentType.split(';')[0] ?? contentType).trim().toLowerCase(); const matchTypedResponse = (entry, contentType) => normalizeMediaType(contentType) === 'application/json' ? { kind: 'json', schema: entry } : null; const resolveByKind = (entry) => ({ kind: 'json', schema: entry, }); const descriptorToKind = (descriptor) => { if (isBlobBody(descriptor)) { return { kind: 'blob' }; } if (isSseBody(descriptor)) { return { kind: 'sse', schemaByEventName: descriptor.schemaByEventName }; } return { kind: 'json', schema: descriptor }; }; /** * Resolves a content-map {@link ResponseEntry}. Body media types are matched by exact * (parameter-stripped, case-insensitive) content-type equality, so e.g. `application/json` * and `application/json+01` are kept distinct. */ const resolveContentEntry = (entry, contentType, strict) => { if (!entry.content) { return { kind: 'noContent' }; } const entries = Object.entries(entry.content); if (!contentType) { if (entry.allowNoBody) { return { kind: 'noContent' }; } } else { const target = normalizeMediaType(contentType); for (const [mediaType, descriptor] of entries) { if (normalizeMediaType(mediaType) === target) { return descriptorToKind(descriptor); } } } // No content-type (without allowNoBody), or no media type matched: in non-strict mode fall // back to the sole descriptor when the entry declares exactly one. const onlyDescriptor = entries.length === 1 ? entries[0]?.[1] : undefined; return !strict && onlyDescriptor ? descriptorToKind(onlyDescriptor) : null; }; /** * Resolves a contract's response entry for a given status code into a concrete `ResponseKind`, * taking the response `content-type` into account. * * Returns `null` when the content-type cannot be matched to any entry in the contract, * indicating the response is unexpected and should be treated as an error by the caller. * * @param schemaEntry - The contract entry for the matched status code (a Zod schema, * `noBodyResponse`, or a content-map entry). * @param contentType - The `content-type` header value from the actual HTTP response, * or `undefined` when the header is absent. * @param strict - When `true` (default), returns `null` if the `content-type` is absent or does * not match the contract entry. When `false`, falls back to the entry's declared kind instead of * returning `null` — only applies to single-entry responses. */ export const resolveContractResponse = (schemaEntry, contentType, strict = true) => { if (isContentResponseEntry(schemaEntry)) { return resolveContentEntry(schemaEntry, contentType, strict); } if (!contentType) { return strict ? null : resolveByKind(schemaEntry); } const matched = matchTypedResponse(schemaEntry, contentType); return matched ?? (strict ? null : resolveByKind(schemaEntry)); }; function getRangeKey(statusCode) { if (statusCode >= 100 && statusCode < 200) return '1xx'; if (statusCode >= 200 && statusCode < 300) return '2xx'; if (statusCode >= 300 && statusCode < 400) return '3xx'; if (statusCode >= 400 && statusCode < 500) return '4xx'; if (statusCode >= 500 && statusCode < 600) return '5xx'; return null; } /** * Combines status-code lookup and content-type resolution into a single call. * Lookup precedence: exact code → range key (e.g. `'4xx'`) → `'default'`. * Returns `null` when no entry matches or the content-type cannot be matched. */ export function resolveResponseEntry(responsesByStatusCode, statusCode, contentType, strictContentType) { const exactEntry = responsesByStatusCode[statusCode]; if (exactEntry) { return resolveContractResponse(exactEntry, contentType, strictContentType); } const rangeKey = getRangeKey(statusCode); if (rangeKey) { const rangeEntry = responsesByStatusCode[rangeKey]; if (rangeEntry) { return resolveContractResponse(rangeEntry, contentType, strictContentType); } } const defaultEntry = responsesByStatusCode.default; if (defaultEntry) { return resolveContractResponse(defaultEntry, contentType, strictContentType); } return null; } //# sourceMappingURL=contractResponse.js.map