@beignet/core
Version:
Core framework primitives for Beignet
529 lines (479 loc) • 14.8 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,
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,
}),
};
}