skills/openapi-client/SKILL.md
前後端 API 契約規範:OpenAPI Client 產生策略、Axios 封裝、型別同步與錯誤處理。
npx skillsauth add CloudyWing/ai-dotfiles openapi-clientInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
當撰寫或審查前端 API 呼叫層,或處理前後端型別同步時,請自動套用以下規範。
前端的 API Client 與型別定義應盡可能從後端的 OpenAPI(Swagger)規格檔自動產生,而非手動撰寫。
推薦工具鏈:
| 工具 | 用途 |
| --- | --- |
| openapi-typescript | 從 OpenAPI spec 產生 TypeScript 型別定義 |
| openapi-fetch | 搭配 openapi-typescript 的型別安全 Fetch Client |
| NSwag | 從 ASP.NET Core 產生 TypeScript Client(含型別與方法) |
// lib/axios.ts
import axios from 'axios';
import type { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
});
// Request Interceptor
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error: AxiosError) => Promise.reject(error)
);
// Response Interceptor
apiClient.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
// Token 過期,導向登入頁
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default apiClient;
import axios from 'axios'。統一透過封裝的 apiClient 存取。// api/order.ts
import apiClient from '@/lib/axios';
import type { Order, CreateOrderInput, UpdateOrderInput } from '@/types/order';
import type { PaginatedResponse } from '@/types/api';
export const orderApi = {
async getAll(params?: { status?: string; page?: number }): Promise<PaginatedResponse<Order>> {
const response = await apiClient.get<PaginatedResponse<Order>>('/orders', { params });
return response.data;
},
async getById(id: number): Promise<Order> {
const response = await apiClient.get<Order>(`/orders/${id}`);
return response.data;
},
async create(input: CreateOrderInput): Promise<Order> {
const response = await apiClient.post<Order>('/orders', input);
return response.data;
},
async update(id: number, input: UpdateOrderInput): Promise<Order> {
const response = await apiClient.put<Order>(`/orders/${id}`, input);
return response.data;
},
async delete(id: number): Promise<void> {
await apiClient.delete(`/orders/${id}`);
}
};
order.ts、customer.ts)。response.data,呼叫端不需要處理 Axios 的 Response 結構。// types/api.ts
/** API 標準回應包裝 */
interface ApiResponse<T> {
data: T;
message: string;
}
/** 分頁回應 */
interface PaginatedResponse<T> {
data: ReadonlyArray<T>;
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/** ProblemDetails(RFC 9457)對應 */
interface ProblemDetails {
type?: string;
title: string;
status: number;
detail?: string;
instance?: string;
errors?: Record<string, string[]>;
}
// lib/apiError.ts
import type { AxiosError } from 'axios';
import type { ProblemDetails } from '@/types/api';
export function isApiError(error: unknown): error is AxiosError<ProblemDetails> {
return axios.isAxiosError(error) && error.response?.data?.title !== undefined;
}
export function getErrorMessage(error: unknown): string {
if (isApiError(error)) {
return error.response!.data.detail ?? error.response!.data.title;
}
if (error instanceof Error) {
return error.message;
}
return '發生未預期的錯誤';
}
export function getValidationErrors(error: unknown): Record<string, string[]> {
if (isApiError(error) && error.response?.status === 422) {
return error.response.data.errors ?? {};
}
return {};
}
<script setup lang="ts">
import { getErrorMessage, getValidationErrors } from '@/lib/apiError';
async function handleSubmit() {
try {
await orderStore.createOrder(formData.value);
router.push({ name: 'OrderList' });
} catch (error) {
const validationErrors = getValidationErrors(error);
if (Object.keys(validationErrors).length > 0) {
fieldErrors.value = validationErrors;
} else {
toast.error(getErrorMessage(error));
}
}
}
</script>
// Composable 中管理請求取消
export function useOrderDetail(id: Ref<number>) {
const order = ref<Order | null>(null);
let abortController: AbortController | null = null;
async function fetchDetail() {
// 取消前一次請求
abortController?.abort();
abortController = new AbortController();
try {
const response = await apiClient.get<Order>(`/orders/${id.value}`, {
signal: abortController.signal
});
order.value = response.data;
} catch (error) {
if (!axios.isCancel(error)) {
throw error;
}
}
}
watch(id, fetchDetail, { immediate: true });
onUnmounted(() => {
abortController?.abort();
});
return { order };
}
# 從後端 Swagger endpoint 產生型別
npx openapi-typescript https://localhost:5001/swagger/v1/swagger.json -o src/types/generated/api.d.ts
package.json 的 scripts:"api:types": "openapi-typescript ..."。若採用手動維護型別,遵循以下原則:
OrderDto → 前端 Order,去掉 Dto 後綴)。interface 定義 API 回應型別,方便擴展。async function uploadFile(file: File): Promise<UploadResult> {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<UploadResult>('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
const percent = Math.round(
(progressEvent.loaded * 100) / (progressEvent.total ?? 1)
);
uploadProgress.value = percent;
}
});
return response.data;
}
FormData,設定 Content-Type: multipart/form-data。onUploadProgress)給 UI 層。tools
產生或補齊 .gitattributes,統一行尾處理、二進位識別與 lock files 標記,保留既有自訂偏好。
development
產生或補齊前端 Lint 設定(Prettier + ESLint Flat Config),統一格式化與程式碼品質規則,保留既有自訂偏好。
testing
依據事實校閱報告修改技術文件:以事實層為不可違反的約束,由改檔者負責表達層的措辭與行文連貫。Use when the user asks to apply fact-check results to a document, or to edit a document based on a previously produced fact-check-report.md.
data-ai
多份資料檔整合流程。當需要將兩份以上的資料檔(如 JSON、CSV)合併、補齊闕漏欄位或去重成單一檔案時使用。以 dry-run、筆數核對與抽樣比對降低整合錯誤。