@lokalise/api-contracts
Version:
138 lines • 6.2 kB
JavaScript
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