在 Day 8 我們建立了全面的效能監控系統,今天我們要將模擬 API 升級為真實的後端整合。在我們的 Kyo 系統中,前端目前使用模擬 API,而後端已經有完整的 Fastify 實作。
透過將 React Query 與真實 API 結合,我們能實現更好的快取策略、錯誤處理,並結合 Day 8 的效能監控做出最佳化。
Day 8 簡單 API 設計:
// 基本的 API 端點,缺乏統一規範
app.post('/send-otp', async (req, res) => {
const { phone } = req.body;
// 簡單處理...
res.json({ success: true });
});
app.get('/templates', async (req, res) => {
const templates = await getTemplates();
res.json(templates);
});
Day 9 標準化 API 設計:
// 統一的響應格式與錯誤處理
interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: ApiError;
meta?: ResponseMeta;
}
interface ApiError {
code: string;
message: string;
details?: any;
}
interface ResponseMeta {
timestamp: string;
requestId: string;
version: string;
}
// shared/api-types.ts
export interface StandardApiResponse<T = any> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, any>;
};
meta: {
timestamp: string;
requestId: string;
version: string;
pagination?: {
page: number;
limit: number;
total: number;
hasNext: boolean;
};
};
}
// 成功響應
export const createSuccessResponse = <T>(
data: T,
meta: Partial<ResponseMeta> = {}
): StandardApiResponse<T> => ({
success: true,
data,
meta: {
timestamp: new Date().toISOString(),
requestId: generateRequestId(),
version: process.env.API_VERSION || '1.0',
...meta
}
});
// 錯誤響應
export const createErrorResponse = (
code: string,
message: string,
details?: any
): StandardApiResponse => ({
success: false,
error: { code, message, details },
meta: {
timestamp: new Date().toISOString(),
requestId: generateRequestId(),
version: process.env.API_VERSION || '1.0'
}
});
// api/routes/templates.ts - RESTful 模板管理
export const templateRoutes = (app: FastifyInstance) => {
// GET /api/v1/templates - 獲取模板列表
app.get('/api/v1/templates', {
schema: {
querystring: {
type: 'object',
properties: {
page: { type: 'number', minimum: 1, default: 1 },
limit: { type: 'number', minimum: 1, maximum: 100, default: 20 },
status: { type: 'string', enum: ['active', 'inactive', 'all'], default: 'all' },
search: { type: 'string', maxLength: 100 }
}
},
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
data: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
content: { type: 'string' },
isActive: { type: 'boolean' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' }
}
}
},
meta: { type: 'object' }
}
}
}
},
handler: async (request, reply) => {
const { page, limit, status, search } = request.query as any;
const filters = {
...(status !== 'all' && { isActive: status === 'active' }),
...(search && { name: { contains: search } })
};
const [templates, total] = await Promise.all([
templateService.findMany({
where: filters,
skip: (page - 1) * limit,
take: limit,
orderBy: { updatedAt: 'desc' }
}),
templateService.count({ where: filters })
]);
return createSuccessResponse(templates, {
pagination: {
page,
limit,
total,
hasNext: page * limit < total
}
});
}
});
// POST /api/v1/templates - 創建新模板
app.post('/api/v1/templates', {
schema: {
body: {
type: 'object',
required: ['name', 'content'],
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
content: { type: 'string', minLength: 1, maxLength: 500 },
isActive: { type: 'boolean', default: true }
}
},
response: {
201: {
type: 'object',
properties: {
success: { type: 'boolean' },
data: {
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
content: { type: 'string' },
isActive: { type: 'boolean' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' }
}
}
}
}
}
},
handler: async (request, reply) => {
const templateData = request.body as CreateTemplateDto;
// 檢查名稱是否已存在
const existingTemplate = await templateService.findByName(templateData.name);
if (existingTemplate) {
return reply.code(409).send(createErrorResponse(
'TEMPLATE_NAME_EXISTS',
'Template with this name already exists'
));
}
const template = await templateService.create(templateData);
reply.code(201);
return createSuccessResponse(template);
}
});
// PUT /api/v1/templates/:id - 更新模板
app.put('/api/v1/templates/:id', {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'number', minimum: 1 }
},
required: ['id']
},
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
content: { type: 'string', minLength: 1, maxLength: 500 },
isActive: { type: 'boolean' }
},
additionalProperties: false
}
},
handler: async (request, reply) => {
const { id } = request.params as { id: number };
const updateData = request.body as Partial<CreateTemplateDto>;
const template = await templateService.findById(id);
if (!template) {
return reply.code(404).send(createErrorResponse(
'TEMPLATE_NOT_FOUND',
'Template not found'
));
}
const updatedTemplate = await templateService.update(id, updateData);
return createSuccessResponse(updatedTemplate);
}
});
// DELETE /api/v1/templates/:id - 刪除模板
app.delete('/api/v1/templates/:id', {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'number', minimum: 1 }
},
required: ['id']
}
},
handler: async (request, reply) => {
const { id } = request.params as { id: number };
const template = await templateService.findById(id);
if (!template) {
return reply.code(404).send(createErrorResponse(
'TEMPLATE_NOT_FOUND',
'Template not found'
));
}
await templateService.delete(id);
reply.code(204);
return;
}
});
};
// api/routes/otp.ts - 改進的 OTP API
export const otpRoutes = (app: FastifyInstance) => {
// POST /api/v1/otp/send - 發送 OTP
app.post('/api/v1/otp/send', {
schema: {
body: {
type: 'object',
required: ['phone'],
properties: {
phone: {
type: 'string',
pattern: '^09\\d{8}$',
description: '台灣手機號碼格式:09XXXXXXXX'
},
templateId: {
type: 'number',
minimum: 1,
description: '可選的簡訊模板 ID'
},
purpose: {
type: 'string',
enum: ['login', 'register', 'reset_password', 'verify_phone'],
default: 'login',
description: 'OTP 用途分類'
}
}
},
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
data: {
type: 'object',
properties: {
messageId: { type: 'string' },
phone: { type: 'string' },
expiresAt: { type: 'string', format: 'date-time' },
attemptsLeft: { type: 'number' }
}
}
}
}
}
},
preHandler: [rateLimitMiddleware], // 速率限制
handler: async (request, reply) => {
const { phone, templateId, purpose } = request.body as SendOtpDto;
// 檢查發送頻率限制
const lastSent = await otpService.getLastSentTime(phone);
if (lastSent && Date.now() - lastSent < 60000) { // 1分鐘限制
return reply.code(429).send(createErrorResponse(
'TOO_FREQUENT',
'Please wait before requesting another OTP'
));
}
const result = await otpService.send({
phone,
templateId,
purpose,
ttl: 300 // 5分鐘有效期
});
return createSuccessResponse({
messageId: result.messageId,
phone: result.phone,
expiresAt: new Date(Date.now() + 300000).toISOString(),
attemptsLeft: 3
});
}
});
// POST /api/v1/otp/verify - 驗證 OTP
app.post('/api/v1/otp/verify', {
schema: {
body: {
type: 'object',
required: ['phone', 'code'],
properties: {
phone: { type: 'string', pattern: '^09\\d{8}$' },
code: {
type: 'string',
pattern: '^\\d{6}$',
description: '6位數驗證碼'
},
purpose: {
type: 'string',
enum: ['login', 'register', 'reset_password', 'verify_phone'],
default: 'login'
}
}
}
},
handler: async (request, reply) => {
const { phone, code, purpose } = request.body as VerifyOtpDto;
const result = await otpService.verify(phone, code, purpose);
if (!result.success) {
const statusCode = result.reason === 'EXPIRED' ? 410 :
result.reason === 'INVALID' ? 400 : 429;
return reply.code(statusCode).send(createErrorResponse(
result.reason,
result.message,
{ attemptsLeft: result.attemptsLeft }
));
}
return createSuccessResponse({
verified: true,
token: result.token // JWT token for authenticated session
});
}
});
};
// api/swagger.ts - Swagger 配置
import { FastifyInstance } from 'fastify';
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUi from '@fastify/swagger-ui';
export const setupSwagger = async (app: FastifyInstance) => {
await app.register(fastifySwagger, {
openapi: {
openapi: '3.0.0',
info: {
title: 'Kyo OTP Service API',
description: 'OTP 驗證服務的 RESTful API 文件',
version: '1.0.0',
contact: {
name: 'Kyo Development Team',
email: 'dev@kyo.com'
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
},
servers: [
{
url: 'http://localhost:3000',
description: 'Development server'
},
{
url: 'https://api.kyo.com',
description: 'Production server'
}
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT'
},
apiKey: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key'
}
},
schemas: {
Template: {
type: 'object',
properties: {
id: { type: 'number', example: 1 },
name: { type: 'string', example: 'default' },
content: { type: 'string', example: '您的驗證碼:{code}' },
isActive: { type: 'boolean', example: true },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' }
}
},
ApiError: {
type: 'object',
properties: {
success: { type: 'boolean', example: false },
error: {
type: 'object',
properties: {
code: { type: 'string', example: 'VALIDATION_ERROR' },
message: { type: 'string', example: 'Invalid input data' },
details: { type: 'object' }
}
},
meta: {
type: 'object',
properties: {
timestamp: { type: 'string', format: 'date-time' },
requestId: { type: 'string' },
version: { type: 'string', example: '1.0' }
}
}
}
}
}
},
tags: [
{
name: 'Templates',
description: '簡訊模板管理'
},
{
name: 'OTP',
description: 'OTP 發送與驗證'
},
{
name: 'Health',
description: '系統健康檢查'
}
]
}
});
await app.register(fastifySwaggerUi, {
routePrefix: '/docs',
uiConfig: {
docExpansion: 'list',
deepLinking: false
},
staticCSP: true,
transformStaticCSP: (header) => header,
transformSpecification: (swaggerObject) => {
return swaggerObject;
},
transformSpecificationClone: true
});
};
// types/api.ts - 與 API schema 同步的型別定義
export interface Template {
id: number;
name: string;
content: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateTemplateDto {
name: string;
content: string;
isActive?: boolean;
}
export interface UpdateTemplateDto extends Partial<CreateTemplateDto> {}
export interface SendOtpDto {
phone: string;
templateId?: number;
purpose?: 'login' | 'register' | 'reset_password' | 'verify_phone';
}
export interface VerifyOtpDto {
phone: string;
code: string;
purpose?: 'login' | 'register' | 'reset_password' | 'verify_phone';
}
export interface OtpSendResponse {
messageId: string;
phone: string;
expiresAt: string;
attemptsLeft: number;
}
export interface OtpVerifyResponse {
verified: boolean;
token?: string;
}
// API Client 型別定義
export interface ApiClient {
// Template operations
getTemplates(params?: {
page?: number;
limit?: number;
status?: 'active' | 'inactive' | 'all';
search?: string;
}): Promise<StandardApiResponse<Template[]>>;
createTemplate(data: CreateTemplateDto): Promise<StandardApiResponse<Template>>;
updateTemplate(id: number, data: UpdateTemplateDto): Promise<StandardApiResponse<Template>>;
deleteTemplate(id: number): Promise<void>;
// OTP operations
sendOtp(data: SendOtpDto): Promise<StandardApiResponse<OtpSendResponse>>;
verifyOtp(data: VerifyOtpDto): Promise<StandardApiResponse<OtpVerifyResponse>>;
}
// scripts/generate-docs.ts - 文件生成腳本
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
const execAsync = promisify(exec);
export class DocumentationGenerator {
async generateApiDocs(): Promise<void> {
console.log('Generating API documentation...');
// 1. 從 Swagger 生成 OpenAPI spec
const { stdout: swaggerJson } = await execAsync(
'curl -s http://localhost:3000/docs/json'
);
await fs.writeFile('docs/openapi.json', swaggerJson);
// 2. 生成 Markdown 文件
await execAsync(
'npx swagger-markdown -i docs/openapi.json -o docs/api.md'
);
// 3. 生成客戶端 SDK
await this.generateClientSDK();
// 4. 生成 Postman Collection
await this.generatePostmanCollection();
console.log('Documentation generated successfully!');
}
private async generateClientSDK(): Promise<void> {
const configs = [
{
generator: 'typescript-fetch',
output: 'clients/typescript',
package: '@kyo/api-client'
},
{
generator: 'python',
output: 'clients/python',
package: 'kyo-api-client'
}
];
for (const config of configs) {
await execAsync(`
npx @openapitools/openapi-generator-cli generate \
-i docs/openapi.json \
-g ${config.generator} \
-o ${config.output} \
--additional-properties packageName=${config.package}
`);
}
}
private async generatePostmanCollection(): Promise<void> {
await execAsync(
'npx openapi-to-postman -s docs/openapi.json -o docs/postman-collection.json'
);
}
async validateDocumentation(): Promise<boolean> {
try {
// 驗證 OpenAPI spec
await execAsync('npx swagger-codegen validate -i docs/openapi.json');
// 運行 API 測試確保文件與實際 API 一致
const { stdout } = await execAsync('npm run test:api');
return !stdout.includes('FAIL');
} catch (error) {
console.error('Documentation validation failed:', error);
return false;
}
}
}
// GitHub Actions 整合
export const setupCIDocumentation = `
# .github/workflows/docs.yml
name: API Documentation
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Start API server
run: pnpm run dev &
- name: Wait for server
run: sleep 10
- name: Generate documentation
run: pnpm run docs:generate
- name: Validate documentation
run: pnpm run docs:validate
- name: Deploy to GitHub Pages
if: github.ref == 'refs/heads/main'
uses: peaceiris/actions-gh-pages@v3
with:
github_token: \${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs
`;
// api/versioning.ts - API 版本管理
export class ApiVersionManager {
private supportedVersions = ['1.0', '1.1', '2.0'];
private defaultVersion = '2.0';
getVersion(request: FastifyRequest): string {
// 1. 從 Accept header 獲取版本
const acceptHeader = request.headers.accept;
const versionMatch = acceptHeader?.match(/application\/vnd\.kyo\.v(\d+\.\d+)\+json/);
if (versionMatch) {
const version = versionMatch[1];
if (this.supportedVersions.includes(version)) {
return version;
}
}
// 2. 從 URL path 獲取版本 (/api/v1.1/...)
const pathVersion = request.url.match(/\/api\/v(\d+\.\d+)\//)?.[1];
if (pathVersion && this.supportedVersions.includes(pathVersion)) {
return pathVersion;
}
// 3. 從 X-API-Version header 獲取
const headerVersion = request.headers['x-api-version'] as string;
if (headerVersion && this.supportedVersions.includes(headerVersion)) {
return headerVersion;
}
return this.defaultVersion;
}
isDeprecated(version: string): boolean {
const deprecatedVersions = ['1.0'];
return deprecatedVersions.includes(version);
}
async handleVersionedRequest(
request: FastifyRequest,
reply: FastifyReply,
handlers: Record<string, Function>
): Promise<any> {
const version = this.getVersion(request);
// 添加版本信息到響應 header
reply.header('X-API-Version', version);
// 檢查是否為棄用版本
if (this.isDeprecated(version)) {
reply.header('X-API-Deprecated', 'true');
reply.header('X-API-Sunset', '2024-12-31'); // 停用日期
}
const handler = handlers[version] || handlers[this.defaultVersion];
return handler(request, reply);
}
}
// 使用範例
const versionManager = new ApiVersionManager();
app.get('/api/*/templates', async (request, reply) => {
return versionManager.handleVersionedRequest(request, reply, {
'1.0': handleTemplatesV1,
'1.1': handleTemplatesV1_1,
'2.0': handleTemplatesV2
});
});
// frontend/src/api/client.ts - 改進的 API 客戶端
import { StandardApiResponse, Template, CreateTemplateDto } from '@kyo/shared-types';
class KyoApiClient {
private baseURL: string;
private version: string;
constructor(baseURL: string, version = '2.0') {
this.baseURL = baseURL;
this.version = version;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<StandardApiResponse<T>> {
const url = `${this.baseURL}/api/v${this.version}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'Accept': `application/vnd.kyo.v${this.version}+json`,
...options.headers
}
});
if (!response.ok) {
const errorData = await response.json();
throw new ApiError(response.status, errorData);
}
return response.json();
}
// Template API methods
async getTemplates(params?: GetTemplatesParams): Promise<StandardApiResponse<Template[]>> {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', params.page.toString());
if (params?.limit) searchParams.set('limit', params.limit.toString());
if (params?.status) searchParams.set('status', params.status);
if (params?.search) searchParams.set('search', params.search);
const queryString = searchParams.toString();
return this.request<Template[]>(`/templates${queryString ? `?${queryString}` : ''}`);
}
async createTemplate(data: CreateTemplateDto): Promise<StandardApiResponse<Template>> {
return this.request<Template>('/templates', {
method: 'POST',
body: JSON.stringify(data)
});
}
async updateTemplate(id: number, data: Partial<CreateTemplateDto>): Promise<StandardApiResponse<Template>> {
return this.request<Template>(`/templates/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
async deleteTemplate(id: number): Promise<void> {
await this.request(`/templates/${id}`, { method: 'DELETE' });
}
}
export const apiClient = new KyoApiClient(
process.env.REACT_APP_API_URL || 'http://localhost:3000',
process.env.REACT_APP_API_VERSION || '2.0'
);
// 錯誤處理類
export class ApiError extends Error {
constructor(
public status: number,
public response: StandardApiResponse
) {
super(response.error?.message || 'API Error');
this.name = 'ApiError';
}
}
// frontend/src/hooks/api/useTemplates.ts - 型別安全的 hooks
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
export const useTemplates = (params?: GetTemplatesParams) => {
return useQuery({
queryKey: ['templates', params],
queryFn: () => apiClient.getTemplates(params),
select: (response) => ({
templates: response.data || [],
pagination: response.meta?.pagination
}),
staleTime: 5 * 60 * 1000, // 5 minutes
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
return false; // Don't retry client errors
}
return failureCount < 3;
}
});
};
export const useCreateTemplate = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: apiClient.createTemplate,
onSuccess: (response) => {
// Invalidate and refetch templates
queryClient.invalidateQueries({ queryKey: ['templates'] });
// Optimistically add to cache
queryClient.setQueryData(['templates'], (old: any) => {
if (!old) return old;
return {
...old,
data: [...(old.data || []), response.data]
};
});
},
onError: (error: ApiError) => {
console.error('Failed to create template:', error);
// Error handling is done in the UI layer
}
});
};
// monitoring/api-metrics.ts - API 效能監控
export class ApiMetrics {
private metrics = new Map<string, Array<number>>();
recordResponseTime(endpoint: string, duration: number): void {
if (!this.metrics.has(endpoint)) {
this.metrics.set(endpoint, []);
}
const times = this.metrics.get(endpoint)!;
times.push(duration);
// Keep only last 100 requests
if (times.length > 100) {
times.shift();
}
}
getAverageResponseTime(endpoint: string): number {
const times = this.metrics.get(endpoint) || [];
return times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0;
}
getP95ResponseTime(endpoint: string): number {
const times = this.metrics.get(endpoint) || [];
if (times.length === 0) return 0;
const sorted = [...times].sort((a, b) => a - b);
const index = Math.ceil(sorted.length * 0.95) - 1;
return sorted[index];
}
generateReport(): Record<string, any> {
const report: Record<string, any> = {};
for (const [endpoint, times] of this.metrics.entries()) {
report[endpoint] = {
requestCount: times.length,
averageTime: this.getAverageResponseTime(endpoint),
p95Time: this.getP95ResponseTime(endpoint),
minTime: Math.min(...times),
maxTime: Math.max(...times)
};
}
return report;
}
}
本日成果:
✅ RESTful API 標準化設計
✅ 統一響應格式與錯誤處理
✅ OpenAPI/Swagger 自動化文件
✅ TypeScript 型別安全整合
✅ API 版本管理策略
✅ 前端客戶端實作