1
This commit is contained in:
199
packages/shared/src/http.ts
Normal file
199
packages/shared/src/http.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
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(value.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;
|
||||
};
|
||||
message?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user