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 = { 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( request: Request, response: Response, data: T, ): T | ApiSuccessEnvelope { const meta = applyApiResponseHeaders(request, response); if (!wantsApiEnvelope(request)) { return data; } return createApiSuccess(data, buildSharedApiMeta(meta)); } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } export function isStandardApiSuccessEnvelope( body: unknown, ): body is ApiSuccessEnvelope { 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( response: Response, data: T, statusCode = 200, ) { response.status(statusCode); response.json(data); } export function prepareApiResponse( request: Request, response: Response, options: { statusCode?: number; headers?: Record; 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; } = {}, ) { 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, ): 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(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }