@beignet/core
Version:
Core framework primitives for Beignet
372 lines • 13.7 kB
JavaScript
import { BEIGNET_ERROR_OWNER_HEADER, } from "../contracts/index.js";
import { contractLifecycleResponseHeaders } from "../contracts/lifecycle.js";
import { createErrorResponseBody, isErrorResponseBody, } from "../errors/index.js";
import { getRequestIdFromContext } from "./hooks/utils.js";
import { parseStandardSchema, SchemaValidationError, } from "./providers/index.js";
export function errorResponse(status, code, message, details) {
return {
status,
body: createErrorResponseBody({ code, message, details }),
};
}
function contractDiagnostics(contract) {
return {
contract: contract.name,
method: contract.method,
path: contract.path,
};
}
export function normalizeResponse(res) {
return {
status: res.status,
headers: res.headers,
body: res.body,
};
}
export function isWebResponse(value) {
return typeof Response !== "undefined" && value instanceof Response;
}
export function normalizeHttpResponse(res) {
return isWebResponse(res) ? res : normalizeResponse(res);
}
export function withFrameworkErrorOwnerHeader(res, owner) {
if (owner !== "framework" ||
res.status < 400 ||
!isErrorResponseBody(res.body)) {
return res;
}
return {
...res,
headers: {
...(res.headers ?? {}),
[BEIGNET_ERROR_OWNER_HEADER]: "framework",
},
};
}
function setRecordHeader(headers, name, value) {
const existingName = Object.keys(headers).find((key) => key.toLowerCase() === name.toLowerCase());
if (existingName && existingName !== name) {
delete headers[existingName];
}
headers[name] = value;
}
function headerValues(value) {
return typeof value === "string" ? [value] : value;
}
function sameHeaderValue(left, right) {
if (left === undefined)
return false;
const leftValues = headerValues(left);
const rightValues = headerValues(right);
return (leftValues.length === rightValues.length &&
leftValues.every((value, index) => value === rightValues[index]));
}
function appendHeaderValues(headers, name, value) {
headers.delete(name);
for (const item of headerValues(value)) {
headers.append(name, item);
}
}
/** Apply contract-owned deprecation headers to any response representation. */
export function withContractLifecycleHeaders(res, contract) {
const lifecycleHeaders = contractLifecycleResponseHeaders(contract);
if (Object.keys(lifecycleHeaders).length === 0)
return res;
if (isWebResponse(res)) {
const headers = new Headers(res.headers);
for (const [name, value] of Object.entries(lifecycleHeaders)) {
if (name.toLowerCase() === "link" && headers.has(name)) {
headers.append(name, value);
}
else {
headers.set(name, value);
}
}
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}
const headers = { ...(res.headers ?? {}) };
for (const [name, value] of Object.entries(lifecycleHeaders)) {
if (name.toLowerCase() === "link") {
const existingName = Object.keys(headers).find((key) => key.toLowerCase() === "link");
const existing = existingName ? headers[existingName] : undefined;
const existingValue = existing ? headerValues(existing).join(", ") : "";
setRecordHeader(headers, name, existingValue ? `${existingValue}, ${value}` : value);
}
else {
setRecordHeader(headers, name, value);
}
}
return { ...res, headers };
}
export function responseOwnerFor(res, owner) {
if (isWebResponse(res))
return "transport";
return owner ?? "route";
}
function headersToRecord(headers) {
const record = {};
headers.forEach((value, key) => {
record[key] = value;
});
const setCookies = headers.getSetCookie?.call(headers) ?? [];
if (setCookies.length > 0) {
record["set-cookie"] = setCookies;
}
return record;
}
export function responseForHooks(res) {
if (!isWebResponse(res)) {
return normalizeResponse(res);
}
return {
status: res.status,
headers: headersToRecord(res.headers),
};
}
/**
* Merge hook-applied header changes onto a native web Response.
*
* Starts from the native response's `Headers` so `set-cookie` multiplicity is
* preserved, then applies headers the beforeSend chain added or changed
* relative to the original headers-only view. The body stream passes through
* untouched; status and statusText are preserved.
*/
export function mergeNativeResponseHeaders(nativeResponse, originalHeaders, finalHeaders) {
const originalByLowerKey = new Map();
for (const [key, value] of Object.entries(originalHeaders)) {
originalByLowerKey.set(key.toLowerCase(), value);
}
let changed = false;
const merged = new Headers(nativeResponse.headers);
for (const [key, value] of Object.entries(finalHeaders)) {
const lowerKey = key.toLowerCase();
if (sameHeaderValue(originalByLowerKey.get(lowerKey), value))
continue;
changed = true;
appendHeaderValues(merged, key, value);
}
if (!changed) {
return nativeResponse;
}
return new Response(nativeResponse.body, {
status: nativeResponse.status,
statusText: nativeResponse.statusText,
headers: merged,
});
}
export function isHttpResponseLike(value) {
return (!isWebResponse(value) &&
typeof value === "object" &&
value !== null &&
"status" in value &&
typeof value.status === "number");
}
export class ResponseContractViolationError extends Error {
code;
details;
constructor(args) {
super(args.message);
this.name = "ResponseContractViolationError";
this.code = args.code;
this.details = args.details;
}
}
function responseContractViolationMessage(contract, status) {
return (`Response validation failed for ${contract.method} ${contract.path} ` +
`(status ${status}, contract: ${contract.name})`);
}
function declaredResponseStatuses(contract) {
return Object.keys(contract.responses)
.map((status) => Number(status))
.filter((status) => Number.isFinite(status))
.sort((a, b) => a - b);
}
function responseContractViolationDetails(contract, status, details) {
return {
...contractDiagnostics(contract),
location: "response",
status,
declaredStatuses: declaredResponseStatuses(contract),
...details,
};
}
function getDeclaredCatalogErrorsForStatus(contract, status) {
const errors = contract.metadata?.errors;
if (typeof errors !== "object" || errors === null)
return [];
return Object.values(errors).filter((error) => typeof error === "object" &&
error !== null &&
typeof error.code === "string" &&
typeof error.status === "number" &&
typeof error.message === "string" &&
error.status === status);
}
async function parseCatalogErrorResponse(contract, res) {
const body = res.body;
if (res.status < 400 || !isErrorResponseBody(body))
return res;
const declaredErrors = getDeclaredCatalogErrorsForStatus(contract, res.status);
if (declaredErrors.length === 0)
return res;
const matchingError = declaredErrors.find((error) => error.code === body.code);
if (!matchingError) {
throw new ResponseContractViolationError({
code: "RESPONSE_VALIDATION_ERROR",
message: responseContractViolationMessage(contract, res.status),
details: responseContractViolationDetails(contract, res.status, {
issues: [
{
message: `Error response code "${body.code}" is not declared for status ${res.status}. ` +
`Expected one of: ${declaredErrors.map((error) => error.code).join(", ")}.`,
},
],
}),
});
}
if (matchingError.details && body.details !== undefined) {
try {
const parsedDetails = await parseStandardSchema(matchingError.details, body.details);
const { details: _details, ...bodyWithoutDetails } = body;
return {
...res,
body: parsedDetails === undefined
? bodyWithoutDetails
: { ...bodyWithoutDetails, details: parsedDetails },
};
}
catch (error) {
if (error instanceof SchemaValidationError) {
throw new ResponseContractViolationError({
code: "RESPONSE_VALIDATION_ERROR",
message: responseContractViolationMessage(contract, res.status),
details: responseContractViolationDetails(contract, res.status, {
issues: error.issues,
}),
});
}
throw error;
}
}
return res;
}
async function parseResponseAgainstContract(contract, res, responseValidationExemptStatus) {
const statusKey = String(res.status);
const hasDeclaredStatus = Object.hasOwn(contract.responses, statusKey);
if (!hasDeclaredStatus) {
if (Object.keys(contract.responses).length === 0)
return res;
throw new ResponseContractViolationError({
code: "UNDECLARED_RESPONSE_STATUS",
message: `Handler returned undeclared status ${res.status} for ` +
`${contract.method} ${contract.path} (contract: ${contract.name})`,
details: responseContractViolationDetails(contract, res.status, {
returnedStatus: res.status,
}),
});
}
const responseSchema = contract.responses[res.status];
if (responseSchema === null) {
if (res.body !== undefined && res.body !== null) {
throw new ResponseContractViolationError({
code: "RESPONSE_VALIDATION_ERROR",
message: responseContractViolationMessage(contract, res.status),
details: responseContractViolationDetails(contract, res.status, {
issues: [
{
message: "Response body must be empty for a null response schema.",
},
],
}),
});
}
return res;
}
if (!responseSchema)
return res;
// Binder routes whose use case output schema is the same object as the
// declared success response schema skip the redundant success-status parse.
// Error statuses and undeclared statuses are validated unchanged.
if (res.status === responseValidationExemptStatus)
return res;
try {
const parsed = {
...res,
body: await parseStandardSchema(responseSchema, res.body),
};
return await parseCatalogErrorResponse(contract, parsed);
}
catch (error) {
if (error instanceof SchemaValidationError) {
throw new ResponseContractViolationError({
code: "RESPONSE_VALIDATION_ERROR",
message: responseContractViolationMessage(contract, res.status),
details: responseContractViolationDetails(contract, res.status, {
issues: error.issues,
}),
});
}
throw error;
}
}
const BODYLESS_RESPONSE_STATUSES = new Set([204, 205, 304]);
function validateHttpResponseSemantics(contract, res) {
if (!BODYLESS_RESPONSE_STATUSES.has(res.status) ||
res.body === undefined ||
res.body === null) {
return;
}
throw new ResponseContractViolationError({
code: "RESPONSE_VALIDATION_ERROR",
message: responseContractViolationMessage(contract, res.status),
details: responseContractViolationDetails(contract, res.status, {
issues: [
{
message: `HTTP status ${res.status} must not include a response body.`,
},
],
}),
});
}
export async function finalizeResponse(contract, res, responseValidationExemptStatus, options = {}) {
const normalized = normalizeResponse(res);
validateHttpResponseSemantics(contract, normalized);
if (options.validateContract ?? true) {
return parseResponseAgainstContract(contract, normalized, responseValidationExemptStatus);
}
return normalized;
}
export function toContractViolationResponse(error) {
return {
status: 500,
body: createErrorResponseBody({
code: error.code,
message: error.message,
details: error.details,
}),
};
}
export function defaultErrorResponse(err, ctx) {
const requestId = getRequestIdFromContext(ctx);
const exposeErrorDetails = process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
return {
status: 500,
body: createErrorResponseBody({
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
requestId,
details: exposeErrorDetails && err instanceof Error
? {
error: {
message: err.message,
stack: err.stack,
},
}
: undefined,
}),
};
}
//# sourceMappingURL=response-finalization.js.map