UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

372 lines 13.7 kB
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