Files
Genarrative/server-node/src/http.ts
2026-04-10 15:37:02 +08:00

319 lines
7.8 KiB
TypeScript

import type { NextFunction, Request, RequestHandler, Response } from 'express';
import {
API_VERSION,
createApiError,
createApiSuccess,
parseApiErrorMessage,
} from '../../packages/shared/src/http.js';
import { resolveHttpErrorCode, resolveHttpErrorMessage } from './errors.js';
export const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
export const API_VERSION_HEADER = 'x-api-version';
export const ROUTE_VERSION_HEADER = 'x-route-version';
export const RESPONSE_TIME_HEADER = 'x-response-time-ms';
const DEFAULT_API_VERSION = API_VERSION;
const DEFAULT_ROUTE_VERSION = DEFAULT_API_VERSION;
export type ApiRouteMeta = {
operation?: string;
routeVersion?: string;
};
export type ApiResponseMeta = {
requestId: string;
apiVersion: string;
routeVersion: string;
operation: string | null;
latencyMs: number;
timestamp: string;
};
export type ApiSuccessEnvelope<T> = {
ok: true;
data: T;
error: null;
meta: ApiResponseMeta;
};
type LegacyApiErrorBody = {
error?: {
code?: string;
message?: string;
details?: unknown;
} | null;
message?: string;
code?: string;
details?: unknown;
meta?: unknown;
};
function readRouteMeta(response: Response): ApiRouteMeta {
const routeMeta = response.locals.apiRouteMeta;
if (!routeMeta || typeof routeMeta !== 'object') {
return {};
}
return routeMeta as ApiRouteMeta;
}
function inferOperation(request: Request) {
if (request.originalUrl) {
return `${request.method} ${request.originalUrl}`;
}
if (request.route?.path) {
return `${request.method} ${request.baseUrl}${request.route.path}`;
}
return `${request.method} ${request.originalUrl || request.url}`;
}
export function setRouteMeta(response: Response, meta: ApiRouteMeta) {
response.locals.apiRouteMeta = {
...readRouteMeta(response),
...meta,
};
}
export function withRouteMeta(meta: ApiRouteMeta): RequestHandler {
return (_request, response, next) => {
setRouteMeta(response, meta);
next();
};
}
export function wantsApiEnvelope(request: Request) {
const value =
request.header(API_RESPONSE_ENVELOPE_HEADER)?.trim().toLowerCase() || '';
return (
value === '1' || value === 'true' || value === 'v1' || value === 'envelope'
);
}
export function buildApiResponseMeta(
request: Request,
response: Response,
): ApiResponseMeta {
const routeMeta = readRouteMeta(response);
return {
requestId: request.requestId,
apiVersion: DEFAULT_API_VERSION,
routeVersion: routeMeta.routeVersion || DEFAULT_ROUTE_VERSION,
operation: routeMeta.operation || inferOperation(request),
latencyMs: Math.max(0, Date.now() - request.requestStartedAt),
timestamp: new Date().toISOString(),
};
}
export function applyApiResponseHeaders(request: Request, response: Response) {
const meta = buildApiResponseMeta(request, response);
response.setHeader('X-Request-Id', meta.requestId);
response.setHeader(API_VERSION_HEADER, meta.apiVersion);
response.setHeader(ROUTE_VERSION_HEADER, meta.routeVersion);
response.setHeader(RESPONSE_TIME_HEADER, String(meta.latencyMs));
return meta;
}
function buildSharedApiMeta(meta: ApiResponseMeta) {
return {
requestId: meta.requestId,
apiVersion: meta.apiVersion,
routeVersion: meta.routeVersion,
operation: meta.operation,
latencyMs: meta.latencyMs,
timestamp: meta.timestamp,
};
}
export function buildApiLogContext(request: Request, response: Response) {
const meta = buildApiResponseMeta(request, response);
return {
request_id: meta.requestId,
api_version: meta.apiVersion,
route_version: meta.routeVersion,
operation: meta.operation,
};
}
export function toApiSuccessBody<T>(
request: Request,
response: Response,
data: T,
): T | ApiSuccessEnvelope<T> {
const meta = applyApiResponseHeaders(request, response);
if (!wantsApiEnvelope(request)) {
return data;
}
return createApiSuccess(data, buildSharedApiMeta(meta));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function isStandardApiSuccessEnvelope(
body: unknown,
): body is ApiSuccessEnvelope<unknown> {
return (
isRecord(body) &&
body.ok === true &&
'data' in body &&
'error' in body &&
body.error === null &&
isRecord(body.meta) &&
typeof body.meta.apiVersion === 'string'
);
}
export function isStandardApiErrorResponse(body: unknown) {
if (
!isRecord(body) ||
!isRecord(body.meta) ||
typeof body.meta.apiVersion !== 'string'
) {
return false;
}
if (body.ok === false) {
return (
body.data === null &&
isRecord(body.error) &&
typeof body.error.code === 'string' &&
typeof body.error.message === 'string'
);
}
return (
'error' in body &&
isRecord(body.error) &&
typeof body.error.code === 'string' &&
typeof body.error.message === 'string'
);
}
function normalizeLegacyApiErrorBody(body: unknown, statusCode: number) {
const legacyBody = isRecord(body) ? (body as LegacyApiErrorBody) : {};
const nestedError = isRecord(legacyBody.error) ? legacyBody.error : null;
const code =
(typeof nestedError?.code === 'string' && nestedError.code.trim()) ||
(typeof legacyBody.code === 'string' && legacyBody.code.trim()) ||
resolveHttpErrorCode(statusCode);
const message =
(typeof nestedError?.message === 'string' && nestedError.message.trim()) ||
(typeof legacyBody.message === 'string' && legacyBody.message.trim()) ||
resolveHttpErrorMessage(statusCode);
const details = nestedError?.details ?? legacyBody.details;
return {
code,
message,
...(details !== undefined ? { details } : {}),
};
}
export function toApiErrorBody(
request: Request,
response: Response,
body: unknown,
) {
const meta = applyApiResponseHeaders(request, response);
const error = normalizeLegacyApiErrorBody(body, response.statusCode || 500);
if (wantsApiEnvelope(request)) {
return createApiError(error, buildSharedApiMeta(meta));
}
return {
error,
meta,
};
}
export function sendApiResponse<T>(
response: Response,
data: T,
statusCode = 200,
) {
response.status(statusCode);
response.json(data);
}
export function prepareApiResponse(
request: Request,
response: Response,
options: {
statusCode?: number;
headers?: Record<string, string>;
routeMeta?: ApiRouteMeta;
} = {},
) {
if (options.routeMeta) {
setRouteMeta(response, options.routeMeta);
}
if (typeof options.statusCode === 'number') {
response.status(options.statusCode);
}
const meta = applyApiResponseHeaders(request, response);
for (const [name, value] of Object.entries(options.headers ?? {})) {
response.setHeader(name, value);
}
return meta;
}
export function prepareEventStreamResponse(
request: Request,
response: Response,
options: {
statusCode?: number;
routeMeta?: ApiRouteMeta;
headers?: Record<string, string>;
} = {},
) {
return prepareApiResponse(request, response, {
statusCode: options.statusCode ?? 200,
routeMeta: options.routeMeta,
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
...(options.headers ?? {}),
},
});
}
export function asyncHandler(
handler: (
request: Request,
response: Response,
next: NextFunction,
) => Promise<unknown> | unknown,
): RequestHandler {
return (request, response, next) => {
Promise.resolve(handler(request, response, next)).catch(next);
};
}
export function extractApiErrorMessage(
rawText: string,
fallbackMessage: string,
) {
return parseApiErrorMessage(rawText, fallbackMessage);
}
export function jsonClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}