@beignet/core
Version:
Core framework primitives for Beignet
475 lines (429 loc) • 13.3 kB
text/typescript
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,
}),
};
}