UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

475 lines (429 loc) 13.3 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, 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: Record<string, string>, 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; } /** 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 = { ...(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; setRecordHeader( headers, name, existing ? `${existing}, ${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): Record<string, string> { const record: Record<string, string> = {}; headers.forEach((value, key) => { record[key] = value; }); 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: Record<string, string>, finalHeaders: Record<string, string>, ): Response { const originalByLowerKey = new Map<string, 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 (originalByLowerKey.get(lowerKey) === value) continue; changed = true; if (lowerKey === "set-cookie") { merged.append(lowerKey, value); } else { merged.set(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 validateCatalogErrorResponse<C extends HttpContractConfig>( contract: C, res: HttpResponseLike, ): Promise<void> { const body = res.body; if (res.status < 400 || !isErrorResponseBody(body)) return; const declaredErrors = getDeclaredCatalogErrorsForStatus( contract, res.status, ); if (declaredErrors.length === 0) return; 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 { await parseStandardSchema(matchingError.details, body.details); } 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; } } } async function validateResponseAgainstContract<C extends HttpContractConfig>( contract: C, res: HttpResponseLike, responseValidationExemptStatus?: number, ): Promise<void> { const statusKey = String(res.status); const hasDeclaredStatus = Object.hasOwn(contract.responses, statusKey); if (!hasDeclaredStatus) { if (Object.keys(contract.responses).length === 0) return; 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; } if (!responseSchema) return; // 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; try { await parseStandardSchema(responseSchema, res.body); await validateCatalogErrorResponse(contract, res); } 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) { await validateResponseAgainstContract( 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); return { status: 500, body: createErrorResponseBody({ code: "INTERNAL_SERVER_ERROR", message: "Internal server error", requestId, details: process.env.NODE_ENV !== "production" && err instanceof Error ? { error: { message: err.message, stack: err.stack, }, } : undefined, }), }; }