UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

529 lines (479 loc) 14.8 kB
import { BEIGNET_ERROR_OWNER_HEADER, type ContractErrorDefinition, type HttpContractConfig, } from "../contracts/index.js"; import { contractLifecycleResponseHeaders } from "../contracts/lifecycle.js"; import { createErrorResponseBody, isErrorResponseBody, } from "../errors/index.js"; import { getRequestIdFromContext } from "./hooks/utils.js"; import type { HttpResponse, HttpResponseHeaders, HttpResponseLike, } from "./http.js"; import type { ResponseFinalizerResponseOwner } from "./internal-hooks.js"; import { parseStandardSchema, SchemaValidationError, } from "./providers/index.js"; export type ResponseOwner = ResponseFinalizerResponseOwner; export function errorResponse( status: number, code: string, message: string, details?: unknown, ): HttpResponseLike { return { status, body: createErrorResponseBody({ code, message, details }), }; } function contractDiagnostics(contract: HttpContractConfig) { return { contract: contract.name, method: contract.method, path: contract.path, }; } export function normalizeResponse(res: HttpResponseLike): HttpResponseLike { return { status: res.status, headers: res.headers, body: res.body, }; } export function isWebResponse(value: unknown): value is Response { return typeof Response !== "undefined" && value instanceof Response; } export function normalizeHttpResponse(res: HttpResponse): HttpResponse { return isWebResponse(res) ? res : normalizeResponse(res); } export function withFrameworkErrorOwnerHeader( res: HttpResponseLike, owner: ResponseOwner, ): HttpResponseLike { if ( owner !== "framework" || res.status < 400 || !isErrorResponseBody(res.body) ) { return res; } return { ...res, headers: { ...(res.headers ?? {}), [BEIGNET_ERROR_OWNER_HEADER]: "framework", }, }; } function setRecordHeader( headers: HttpResponseHeaders, name: string, value: string, ): void { const existingName = Object.keys(headers).find( (key) => key.toLowerCase() === name.toLowerCase(), ); if (existingName && existingName !== name) { delete headers[existingName]; } headers[name] = value; } function headerValues(value: string | readonly string[]): readonly string[] { return typeof value === "string" ? [value] : value; } function sameHeaderValue( left: string | readonly string[] | undefined, right: string | readonly string[], ): boolean { 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: Headers, name: string, value: string | readonly string[], ): void { 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: HttpResponse, contract: HttpContractConfig, ): HttpResponse { 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: HttpResponseHeaders = { ...(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: HttpResponse, owner?: ResponseOwner, ): ResponseOwner { if (isWebResponse(res)) return "transport"; return owner ?? "route"; } function headersToRecord(headers: Headers): HttpResponseHeaders { const record: HttpResponseHeaders = {}; headers.forEach((value, key) => { record[key] = value; }); const setCookies = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie?.call( headers, ) ?? []; if (setCookies.length > 0) { record["set-cookie"] = setCookies; } return record; } export function responseForHooks(res: HttpResponse): HttpResponseLike { 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: Response, originalHeaders: HttpResponseHeaders, finalHeaders: HttpResponseHeaders, ): Response { const originalByLowerKey = new Map<string, string | readonly string[]>(); 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: unknown): value is HttpResponseLike { return ( !isWebResponse(value) && typeof value === "object" && value !== null && "status" in value && typeof (value as { status?: unknown }).status === "number" ); } export class ResponseContractViolationError extends Error { readonly code: "RESPONSE_VALIDATION_ERROR" | "UNDECLARED_RESPONSE_STATUS"; readonly details?: unknown; constructor(args: { code: "RESPONSE_VALIDATION_ERROR" | "UNDECLARED_RESPONSE_STATUS"; message: string; details?: unknown; }) { super(args.message); this.name = "ResponseContractViolationError"; this.code = args.code; this.details = args.details; } } function responseContractViolationMessage( contract: HttpContractConfig, status: number, ): string { return ( `Response validation failed for ${contract.method} ${contract.path} ` + `(status ${status}, contract: ${contract.name})` ); } function declaredResponseStatuses(contract: HttpContractConfig): number[] { return Object.keys(contract.responses) .map((status) => Number(status)) .filter((status) => Number.isFinite(status)) .sort((a, b) => a - b); } function responseContractViolationDetails( contract: HttpContractConfig, status: number, details?: Record<string, unknown>, ) { return { ...contractDiagnostics(contract), location: "response", status, declaredStatuses: declaredResponseStatuses(contract), ...details, }; } function getDeclaredCatalogErrorsForStatus( contract: HttpContractConfig, status: number, ): ContractErrorDefinition[] { const errors = contract.metadata?.errors; if (typeof errors !== "object" || errors === null) return []; return Object.values(errors).filter( (error): error is ContractErrorDefinition => typeof error === "object" && error !== null && typeof (error as { code?: unknown }).code === "string" && typeof (error as { status?: unknown }).status === "number" && typeof (error as { message?: unknown }).message === "string" && (error as { status: number }).status === status, ); } async function parseCatalogErrorResponse<C extends HttpContractConfig>( contract: C, res: HttpResponseLike, ): Promise<HttpResponseLike> { 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<C extends HttpContractConfig>( contract: C, res: HttpResponseLike, responseValidationExemptStatus?: number, ): Promise<HttpResponseLike> { 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: HttpContractConfig, res: HttpResponseLike, ): void { 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<C extends HttpContractConfig>( contract: C, res: HttpResponseLike, responseValidationExemptStatus?: number, options: { validateContract?: boolean } = {}, ): Promise<HttpResponseLike> { const normalized = normalizeResponse(res); validateHttpResponseSemantics(contract, normalized); if (options.validateContract ?? true) { return parseResponseAgainstContract( contract, normalized, responseValidationExemptStatus, ); } return normalized; } export function toContractViolationResponse( error: ResponseContractViolationError, ): HttpResponseLike { return { status: 500, body: createErrorResponseBody({ code: error.code, message: error.message, details: error.details, }), }; } export function defaultErrorResponse( err: unknown, ctx?: unknown, ): HttpResponseLike { 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, }), }; }