222 lines
5.2 KiB
TypeScript
222 lines
5.2 KiB
TypeScript
export const API_VERSION = '2026-04-08';
|
||
export const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
|
||
export const API_RESPONSE_ENVELOPE_VERSION = 'v1';
|
||
|
||
export type ApiErrorCode =
|
||
| 'BAD_REQUEST'
|
||
| 'INVALID_REQUEST'
|
||
| 'VALIDATION_ERROR'
|
||
| 'UNAUTHORIZED'
|
||
| 'FORBIDDEN'
|
||
| 'NOT_FOUND'
|
||
| 'CONFLICT'
|
||
| 'UPSTREAM_ERROR'
|
||
| 'INTERNAL_SERVER_ERROR'
|
||
| 'bad_request'
|
||
| 'validation_error'
|
||
| 'unauthorized'
|
||
| 'forbidden'
|
||
| 'not_found'
|
||
| 'conflict'
|
||
| 'upstream_error'
|
||
| 'internal_error'
|
||
| (string & {});
|
||
|
||
export type ApiErrorPayload = {
|
||
code: ApiErrorCode;
|
||
message: string;
|
||
details?: Record<string, unknown> | null;
|
||
};
|
||
|
||
export type ApiMeta = {
|
||
apiVersion: string;
|
||
requestId?: string;
|
||
routeVersion?: string;
|
||
operation?: string | null;
|
||
latencyMs?: number;
|
||
timestamp?: string;
|
||
};
|
||
|
||
export type ApiSuccessResponse<T> = {
|
||
ok: true;
|
||
data: T;
|
||
error: null;
|
||
meta: ApiMeta;
|
||
};
|
||
|
||
export type ApiErrorResponse = {
|
||
ok: false;
|
||
data: null;
|
||
error: ApiErrorPayload;
|
||
meta: ApiMeta;
|
||
};
|
||
|
||
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === 'object' && value !== null;
|
||
}
|
||
|
||
function buildApiMeta(meta: Partial<ApiMeta> = {}): ApiMeta {
|
||
return {
|
||
apiVersion: meta.apiVersion ?? API_VERSION,
|
||
requestId:
|
||
typeof meta.requestId === 'string' && meta.requestId.trim()
|
||
? meta.requestId.trim()
|
||
: undefined,
|
||
routeVersion:
|
||
typeof meta.routeVersion === 'string' && meta.routeVersion.trim()
|
||
? meta.routeVersion.trim()
|
||
: undefined,
|
||
operation:
|
||
typeof meta.operation === 'string' && meta.operation.trim()
|
||
? meta.operation.trim()
|
||
: meta.operation === null
|
||
? null
|
||
: undefined,
|
||
latencyMs:
|
||
typeof meta.latencyMs === 'number' && Number.isFinite(meta.latencyMs)
|
||
? meta.latencyMs
|
||
: undefined,
|
||
timestamp:
|
||
typeof meta.timestamp === 'string' && meta.timestamp.trim()
|
||
? meta.timestamp.trim()
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
export function createApiSuccess<T>(
|
||
data: T,
|
||
meta: Partial<ApiMeta> = {},
|
||
): ApiSuccessResponse<T> {
|
||
return {
|
||
ok: true,
|
||
data,
|
||
error: null,
|
||
meta: buildApiMeta(meta),
|
||
};
|
||
}
|
||
|
||
export function createApiError(
|
||
error: ApiErrorPayload,
|
||
meta: Partial<ApiMeta> = {},
|
||
): ApiErrorResponse {
|
||
return {
|
||
ok: false,
|
||
data: null,
|
||
error: {
|
||
code: error.code,
|
||
message: error.message,
|
||
details: error.details ?? null,
|
||
},
|
||
meta: buildApiMeta(meta),
|
||
};
|
||
}
|
||
|
||
export function isApiResponse<T>(value: unknown): value is ApiResponse<T> {
|
||
if (!isRecord(value) || typeof value.ok !== 'boolean' || !('meta' in value)) {
|
||
return false;
|
||
}
|
||
|
||
if (!isRecord(value.meta) || typeof value.meta.apiVersion !== 'string') {
|
||
return false;
|
||
}
|
||
|
||
if (value.ok) {
|
||
return 'data' in value && value.error === null;
|
||
}
|
||
|
||
return (
|
||
value.data === null &&
|
||
isRecord(value.error) &&
|
||
typeof value.error.code === 'string' &&
|
||
typeof value.error.message === 'string'
|
||
);
|
||
}
|
||
|
||
export function unwrapApiResponse<T>(value: ApiResponse<T> | T): T {
|
||
if (!isApiResponse<T>(value)) {
|
||
return value as T;
|
||
}
|
||
|
||
if (value.ok) {
|
||
return value.data;
|
||
}
|
||
|
||
throw new Error(getApiErrorDisplayMessage(value.error) || '请求失败');
|
||
}
|
||
|
||
function readTrimmedMessage(value: unknown) {
|
||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||
}
|
||
|
||
export function getApiErrorDisplayMessage(error: ApiErrorPayload) {
|
||
// 后端通用 message 常用于错误分类,details.message 才是给用户定位问题的业务原因。
|
||
const detailMessage = isRecord(error.details)
|
||
? readTrimmedMessage(error.details.message)
|
||
: '';
|
||
|
||
return detailMessage || readTrimmedMessage(error.message);
|
||
}
|
||
|
||
export function parseApiErrorMessage(rawText: string, fallbackMessage: string) {
|
||
if (!rawText.trim()) {
|
||
return fallbackMessage;
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(rawText) as
|
||
| ApiErrorResponse
|
||
| {
|
||
error?: {
|
||
message?: string;
|
||
code?: string;
|
||
details?: Record<string, unknown> | null;
|
||
};
|
||
message?: string;
|
||
code?: string;
|
||
};
|
||
|
||
const detailMessage = isRecord(parsed.error?.details)
|
||
? readTrimmedMessage(parsed.error.details.message)
|
||
: '';
|
||
|
||
if (detailMessage) {
|
||
return detailMessage;
|
||
}
|
||
|
||
if (
|
||
typeof parsed.error?.message === 'string' &&
|
||
parsed.error.message.trim()
|
||
) {
|
||
return parsed.error.message.trim();
|
||
}
|
||
|
||
const topLevelMessage =
|
||
'message' in parsed && typeof parsed.message === 'string'
|
||
? parsed.message.trim()
|
||
: '';
|
||
|
||
if (topLevelMessage) {
|
||
return topLevelMessage;
|
||
}
|
||
|
||
const errorCode =
|
||
typeof parsed.error?.code === 'string' && parsed.error.code.trim()
|
||
? parsed.error.code.trim()
|
||
: 'code' in parsed &&
|
||
typeof parsed.code === 'string' &&
|
||
parsed.code.trim()
|
||
? parsed.code.trim()
|
||
: '';
|
||
|
||
if (errorCode) {
|
||
return `${fallbackMessage}(${errorCode})`;
|
||
}
|
||
} catch {
|
||
// Ignore malformed json responses.
|
||
}
|
||
|
||
return rawText.trim() || fallbackMessage;
|
||
}
|