1
This commit is contained in:
@@ -1,5 +1,299 @@
|
||||
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,
|
||||
@@ -16,31 +310,7 @@ export function extractApiErrorMessage(
|
||||
rawText: string,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
if (!rawText.trim()) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as {
|
||||
error?: { message?: string };
|
||||
message?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
if (typeof parsed.error?.message === 'string' && parsed.error.message.trim()) {
|
||||
return parsed.error.message.trim();
|
||||
}
|
||||
if (typeof parsed.message === 'string' && parsed.message.trim()) {
|
||||
return parsed.message.trim();
|
||||
}
|
||||
if (typeof parsed.code === 'string' && parsed.code.trim()) {
|
||||
return `${fallbackMessage}(${parsed.code.trim()})`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed json responses.
|
||||
}
|
||||
|
||||
return rawText.trim() || fallbackMessage;
|
||||
return parseApiErrorMessage(rawText, fallbackMessage);
|
||||
}
|
||||
|
||||
export function jsonClone<T>(value: T): T {
|
||||
|
||||
Reference in New Issue
Block a user