feat(GOV-009): 建立前端 OpenAPI 客户端边界
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { components } from '../generated/schema';
|
||||
import { toHealthViewModel } from './health.adapter';
|
||||
|
||||
type HealthResponseDto = components['schemas']['HealthResponseDto'];
|
||||
|
||||
const healthyDto: HealthResponseDto = {
|
||||
status: 'healthy',
|
||||
process: 'api',
|
||||
checkedAt: '2026-08-13T09:30:00.000Z',
|
||||
dependencies: {
|
||||
mysql: { status: 'healthy', detail: 'MySQL 5.7 ready' },
|
||||
redis: { status: 'healthy', detail: 'PONG' },
|
||||
rocketMq: { status: 'healthy', detail: 'RocketMQ ready' },
|
||||
},
|
||||
};
|
||||
|
||||
describe('健康响应页面适配器', () => {
|
||||
it('把全部可用的传输状态映射为健康页面模型', () => {
|
||||
expect(toHealthViewModel(healthyDto)).toEqual({
|
||||
state: 'healthy',
|
||||
stateLabel: '运行正常',
|
||||
checkedAt: new Date('2026-08-13T09:30:00.000Z'),
|
||||
services: [
|
||||
{ key: 'redis', name: 'Redis', available: true, note: 'PONG' },
|
||||
{ key: 'mysql', name: 'MySQL', available: true, note: 'MySQL 5.7 ready' },
|
||||
{ key: 'rocketMq', name: 'RocketMQ', available: true, note: 'RocketMQ ready' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('把可选能力停用映射为降级而不是后端枚举', () => {
|
||||
const dto: HealthResponseDto = {
|
||||
...healthyDto,
|
||||
dependencies: {
|
||||
...healthyDto.dependencies,
|
||||
rocketMq: { status: 'disabled', detail: '当前版本未启用' },
|
||||
},
|
||||
};
|
||||
|
||||
const viewModel = toHealthViewModel(dto);
|
||||
|
||||
expect(viewModel.state).toBe('degraded');
|
||||
expect(viewModel.stateLabel).toBe('部分能力停用');
|
||||
expect(viewModel.services[2]).toEqual({
|
||||
key: 'rocketMq',
|
||||
name: 'RocketMQ',
|
||||
available: false,
|
||||
note: '当前版本未启用',
|
||||
});
|
||||
});
|
||||
|
||||
it('把任一依赖不可用映射为不可用', () => {
|
||||
const dto: HealthResponseDto = {
|
||||
...healthyDto,
|
||||
status: 'unavailable',
|
||||
dependencies: {
|
||||
...healthyDto.dependencies,
|
||||
redis: { status: 'unavailable', detail: '检查失败' },
|
||||
},
|
||||
};
|
||||
|
||||
expect(toHealthViewModel(dto).state).toBe('unavailable');
|
||||
expect(toHealthViewModel(dto).stateLabel).toBe('服务异常');
|
||||
});
|
||||
|
||||
it('拒绝运行时出现的未知依赖状态', () => {
|
||||
const malformedDto = structuredClone(healthyDto) as unknown as {
|
||||
dependencies: { redis: { status: string } };
|
||||
};
|
||||
malformedDto.dependencies.redis.status = 'unknown';
|
||||
|
||||
expect(() => toHealthViewModel(malformedDto as never)).toThrow(/未知依赖状态/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { components } from '../generated/schema';
|
||||
|
||||
type HealthResponseDto = components['schemas']['HealthResponseDto'];
|
||||
type DependencyHealthDto = components['schemas']['DependencyHealthDto'];
|
||||
|
||||
export type HealthViewState = 'healthy' | 'degraded' | 'unavailable';
|
||||
|
||||
export interface HealthServiceViewModel {
|
||||
key: 'redis' | 'mysql' | 'rocketMq';
|
||||
name: 'Redis' | 'MySQL' | 'RocketMQ';
|
||||
available: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface HealthViewModel {
|
||||
state: HealthViewState;
|
||||
stateLabel: '运行正常' | '部分能力停用' | '服务异常';
|
||||
checkedAt: Date;
|
||||
services: HealthServiceViewModel[];
|
||||
}
|
||||
|
||||
function assertDependencyStatus(status: string): asserts status is DependencyHealthDto['status'] {
|
||||
if (status !== 'healthy' && status !== 'unavailable' && status !== 'disabled') {
|
||||
throw new Error(`未知依赖状态:${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toServiceViewModel(
|
||||
key: HealthServiceViewModel['key'],
|
||||
name: HealthServiceViewModel['name'],
|
||||
dependency: DependencyHealthDto,
|
||||
): HealthServiceViewModel {
|
||||
assertDependencyStatus(dependency.status);
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
available: dependency.status === 'healthy',
|
||||
note: dependency.detail,
|
||||
};
|
||||
}
|
||||
|
||||
export function toHealthViewModel(dto: HealthResponseDto): HealthViewModel {
|
||||
const dependencies = Object.values(dto.dependencies);
|
||||
for (const dependency of dependencies) assertDependencyStatus(dependency.status);
|
||||
|
||||
const hasUnavailable = dependencies.some((dependency) => dependency.status === 'unavailable');
|
||||
const hasDisabled = dependencies.some((dependency) => dependency.status === 'disabled');
|
||||
const state: HealthViewState =
|
||||
dto.status === 'unavailable' || hasUnavailable
|
||||
? 'unavailable'
|
||||
: hasDisabled
|
||||
? 'degraded'
|
||||
: 'healthy';
|
||||
|
||||
const stateLabel = {
|
||||
healthy: '运行正常',
|
||||
degraded: '部分能力停用',
|
||||
unavailable: '服务异常',
|
||||
} as const;
|
||||
|
||||
// 页面只消费稳定的展示模型,后端枚举和依赖对象结构不会直接扩散到 Vue 组件。
|
||||
return {
|
||||
state,
|
||||
stateLabel: stateLabel[state],
|
||||
checkedAt: new Date(dto.checkedAt),
|
||||
services: [
|
||||
toServiceViewModel('redis', 'Redis', dto.dependencies.redis),
|
||||
toServiceViewModel('mysql', 'MySQL', dto.dependencies.mysql),
|
||||
toServiceViewModel('rocketMq', 'RocketMQ', dto.dependencies.rocketMq),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ApiRequestError, createApiClient } from './client';
|
||||
|
||||
describe('OpenAPI 请求客户端', () => {
|
||||
it('使用调用方提供的 API 根地址请求公开路径', async () => {
|
||||
let requestedUrl = '';
|
||||
const client = createApiClient('http://127.0.0.1:3000', async (request) => {
|
||||
requestedUrl = request.url;
|
||||
return Response.json({
|
||||
status: 'healthy',
|
||||
process: 'api',
|
||||
checkedAt: '2026-08-13T09:30:00.000Z',
|
||||
dependencies: {
|
||||
mysql: { status: 'healthy', detail: 'ready' },
|
||||
redis: { status: 'healthy', detail: 'ready' },
|
||||
rocketMq: { status: 'disabled', detail: 'disabled' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await client.GET('/api/v1/health');
|
||||
|
||||
expect(requestedUrl).toBe('http://127.0.0.1:3000/api/v1/health');
|
||||
});
|
||||
|
||||
it('把网络错误归一化为不泄漏底层连接信息的错误', async () => {
|
||||
const client = createApiClient('http://127.0.0.1:3000', async () => {
|
||||
throw new Error('connect ECONNREFUSED mysql://root:secret@internal');
|
||||
});
|
||||
|
||||
await expect(client.GET('/api/v1/health')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'ApiRequestError',
|
||||
message: 'API 请求失败,请稍后重试',
|
||||
}),
|
||||
);
|
||||
await expect(client.GET('/api/v1/health')).rejects.toBeInstanceOf(ApiRequestError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import createClient, { type Client } from 'openapi-fetch';
|
||||
import type { paths } from './generated/schema';
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor() {
|
||||
super('API 请求失败,请稍后重试');
|
||||
this.name = 'ApiRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiClient(
|
||||
baseUrl: string,
|
||||
fetchImplementation: (request: Request) => Promise<Response> = globalThis.fetch,
|
||||
): Client<paths> {
|
||||
const client = createClient<paths>({
|
||||
baseUrl: baseUrl.replace(/\/$/, ''),
|
||||
fetch: fetchImplementation,
|
||||
});
|
||||
|
||||
client.use({
|
||||
onError() {
|
||||
// 浏览器错误可能带出内网主机、代理或底层连接细节,统一转换为安全的用户提示。
|
||||
return new ApiRequestError();
|
||||
},
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
'/api/v1/health': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations['HealthController_getHealth'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
DependencyHealthDto: {
|
||||
detail: string;
|
||||
/** @enum {string} */
|
||||
status: 'healthy' | 'unavailable' | 'disabled';
|
||||
};
|
||||
HealthDependenciesDto: {
|
||||
mysql: components['schemas']['DependencyHealthDto'];
|
||||
redis: components['schemas']['DependencyHealthDto'];
|
||||
rocketMq: components['schemas']['DependencyHealthDto'];
|
||||
};
|
||||
HealthResponseDto: {
|
||||
/** Format: date-time */
|
||||
checkedAt: string;
|
||||
dependencies: components['schemas']['HealthDependenciesDto'];
|
||||
/** @enum {string} */
|
||||
process: 'api';
|
||||
/** @enum {string} */
|
||||
status: 'healthy' | 'unavailable';
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
HealthController_getHealth: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HealthResponseDto'];
|
||||
};
|
||||
};
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HealthResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user