refactor: ky
This commit is contained in:
+20
-8
@@ -1,24 +1,24 @@
|
||||
# Services
|
||||
|
||||
`src/services` 是資料存取與 HTTP 邊界,負責封裝 axios client、interceptor、token/session、錯誤處理與 API 模組。
|
||||
`src/services` 是資料存取與 HTTP 邊界,負責封裝 ky client、hooks、token/session、錯誤處理與 API 模組。
|
||||
|
||||
## 目前資料流
|
||||
|
||||
```txt
|
||||
component/view -> store/composable -> service module -> httpClient -> interceptor
|
||||
component/view -> store/composable -> service module -> httpClient -> hooks
|
||||
```
|
||||
|
||||
原則:
|
||||
|
||||
- component 不直接處理底層 HTTP client、token、interceptor 或錯誤正規化。
|
||||
- component 不直接處理底層 HTTP client、token、hooks 或錯誤正規化。
|
||||
- store 或 composable 負責協調 UI 狀態與呼叫 service。
|
||||
- service 回傳資料,不持有 UI 狀態。
|
||||
- service 不 import component、view 或 store。
|
||||
|
||||
## 目前檔案
|
||||
|
||||
- `client.ts`:建立單一 axios instance,設定 `baseURL`、timeout、credentials 與 interceptor。
|
||||
- `interceptors.ts`:集中處理 request token 注入與 response 錯誤。
|
||||
- `client.ts`:建立單一 ky instance,設定 `prefix`、timeout、credentials 與 hooks。
|
||||
- `interceptors.ts`:集中提供 ky hooks,處理 request token 注入與 response 錯誤。
|
||||
- `error.ts`:提供 `normalizeError()` 與統一錯誤型別。
|
||||
- `http-error.ts`:提供全域 HTTP 錯誤事件。
|
||||
- `http-toast.ts`:提供 HTTP 錯誤提示相關流程。
|
||||
@@ -38,6 +38,18 @@ API module 應:
|
||||
- 定義與該 module 相關的 request/response 型別。
|
||||
- 接收 `AbortSignal` 等 request option,但不管理頁面 loading 或 controller 狀態。
|
||||
|
||||
## ky 使用注意事項
|
||||
|
||||
本專案使用 ky,不使用 axios。新增或調整 API module 時注意:
|
||||
|
||||
- ky 不回傳 axios 的 `{ data, status, headers }` 物件。需要 JSON 時使用 `.json<T>()`。
|
||||
- 若呼叫端已經依賴 `{ data }` 形狀,請在 API module 內包回 `{ data: await ... }`,不要讓 store 或 component 混用多種 response 形狀。
|
||||
- ky 的錯誤型別是 `HTTPError`、`TimeoutError` 等,不是 `AxiosError`。錯誤一律交給 `normalizeError()`,呼叫端不要直接判斷 ky error。
|
||||
- ky 基於 Fetch API,取消請求使用原生 `AbortController` 與 `signal`。
|
||||
- token 注入、401 force logout、HTTP 錯誤導頁與 toast 都集中在 ky hooks。不要在單一 service module 裡重複實作。
|
||||
- FormData 請用 `body: formData`;JSON payload 請用 `json: payload`。
|
||||
- 如果需求需要 upload progress、request/response transform、或其他 axios 專屬行為,先確認 ky/fetch 是否有等價做法,再決定是否擴充 service layer。
|
||||
|
||||
## HTTP Client 設定
|
||||
|
||||
`client.ts` 的 `baseURL` 優先使用 `VITE_API_BASE_URL`,否則使用 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。
|
||||
@@ -64,10 +76,10 @@ production 不應沿用 template 內的示範後端位址,應由使用專案
|
||||
token 由 `tokenService` 作為單一來源:
|
||||
|
||||
- store 負責登入成功後寫入 token,以及登出時清除 token。
|
||||
- interceptor 只讀取 token 並附加到 request。
|
||||
- 401 或 HTTP 錯誤由 interceptor 與錯誤事件流程集中處理。
|
||||
- hooks 只讀取 token 並附加到 request。
|
||||
- 401 或 HTTP 錯誤由 hooks 與錯誤事件流程集中處理。
|
||||
|
||||
錯誤透過 `normalizeError()` 轉成 UI 可理解的格式。UI 或 store 不需要直接理解 AxiosError。
|
||||
錯誤透過 `normalizeError()` 轉成 UI 可理解的格式。UI 或 store 不需要直接理解 ky 的 HTTPError。
|
||||
|
||||
## 請求取消
|
||||
|
||||
|
||||
+7
-14
@@ -1,21 +1,14 @@
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { setupInterceptors } from './interceptors'
|
||||
import ky, { type KyInstance } from 'ky'
|
||||
import { createHooks } from './interceptors'
|
||||
|
||||
// HTTP Client(Axios instance)
|
||||
//
|
||||
// 設計重點:
|
||||
// - 透過單一 axios instance 統一管理 baseURL、timeout、headers 與攔截器
|
||||
// - 預設 baseURL 使用 `/api`:搭配 Vite proxy 轉送到後端 dev server
|
||||
// - 攔截器集中在 `interceptors.ts`,避免 client.ts 變得難維護
|
||||
function createClient(): AxiosInstance {
|
||||
function createClient(): KyInstance {
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || '/service/api'
|
||||
const client = axios.create({
|
||||
baseURL,
|
||||
return ky.create({
|
||||
prefix: baseURL,
|
||||
timeout: 10_000,
|
||||
withCredentials: true,
|
||||
credentials: 'include',
|
||||
hooks: createHooks(),
|
||||
})
|
||||
setupInterceptors(client)
|
||||
return client
|
||||
}
|
||||
|
||||
export const httpClient = createClient()
|
||||
|
||||
+11
-13
@@ -1,5 +1,5 @@
|
||||
import type { ApiError } from '@/types/api'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { isHTTPError, isTimeoutError } from 'ky'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
@@ -51,12 +51,6 @@ export function extractErrorMessage(data: unknown): string | undefined {
|
||||
return firstString(data)
|
||||
}
|
||||
|
||||
// 統一錯誤格式
|
||||
//
|
||||
// 設計重點:
|
||||
// - 將 AxiosError 與非預期錯誤統一轉成 ApiRequestError
|
||||
// - Store 只需要處理 message/code/status,不需理解 Axios 結構
|
||||
// - 取消請求(AbortController)會轉成 CanceledRequestError
|
||||
export class ApiRequestError extends Error {
|
||||
code?: number
|
||||
status?: number
|
||||
@@ -87,9 +81,6 @@ export class CanceledRequestError extends ApiRequestError {
|
||||
}
|
||||
|
||||
export function isRequestCanceled(error: unknown): boolean {
|
||||
if (isAxiosError(error)) {
|
||||
return error.code === 'ERR_CANCELED'
|
||||
}
|
||||
return error instanceof DOMException && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
@@ -102,9 +93,9 @@ export function normalizeError(error: unknown): ApiRequestError {
|
||||
return new CanceledRequestError()
|
||||
}
|
||||
|
||||
if (isAxiosError(error)) {
|
||||
const status = error.response?.status
|
||||
const data = error.response?.data as unknown
|
||||
if (isHTTPError(error)) {
|
||||
const status = error.response.status
|
||||
const data = error.data
|
||||
const message = extractErrorMessage(data) || error.message || '請求失敗'
|
||||
const apiError = isRecord(data) ? (data as Partial<ApiError>) : undefined
|
||||
const code = apiError?.code ?? status
|
||||
@@ -117,6 +108,13 @@ export function normalizeError(error: unknown): ApiRequestError {
|
||||
})
|
||||
}
|
||||
|
||||
if (isTimeoutError(error)) {
|
||||
return new ApiRequestError({
|
||||
message: '請求逾時',
|
||||
raw: error,
|
||||
})
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new ApiRequestError({ message: error.message, raw: error })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 全域 HTTP 錯誤事件(Playground 專用)
|
||||
//
|
||||
// 目的:
|
||||
// - 避免 axios interceptor 直接 import router 造成耦合
|
||||
// - 避免 HTTP hooks 直接 import router 造成耦合
|
||||
// - 由 router 層或 App 層決定要導到哪個錯誤頁與顯示哪些訊息
|
||||
|
||||
export const HTTP_ERROR_EVENT = 'sk-playground:http-error'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 全域 HTTP Toast 事件(Playground 專用)
|
||||
//
|
||||
// 目的:
|
||||
// - 讓 axios interceptor 能用「事件」通知 UI 顯示錯誤提示,不需直接依賴 Pinia
|
||||
// - 讓 HTTP hooks 能用「事件」通知 UI 顯示錯誤提示,不需直接依賴 Pinia
|
||||
// - 預設只用於「非阻斷」錯誤(例如 500 / 網路中斷),避免導頁打斷使用者
|
||||
|
||||
export const HTTP_TOAST_EVENT = 'sk-playground:http-toast'
|
||||
|
||||
+65
-102
@@ -1,122 +1,85 @@
|
||||
import { type AxiosError, AxiosHeaders, type AxiosInstance } from 'axios'
|
||||
import type { Hooks } from 'ky'
|
||||
import { isHTTPError } from 'ky'
|
||||
import { extractErrorMessage, normalizeError } from './error'
|
||||
import { emitHttpError } from './http-error'
|
||||
import { emitHttpToast } from './http-toast'
|
||||
import { emitForceLogout } from './session'
|
||||
import { tokenService } from './token'
|
||||
|
||||
// Axios 攔截器
|
||||
//
|
||||
// 設計重點:
|
||||
// - Request:自動注入 token(從 localStorage 讀取)
|
||||
// - 使用 tokenService 作為單一來源,避免 interceptor 直接 import Pinia store 造成循環依賴
|
||||
// store(auth) -> services(authApi/menuApi) -> httpClient -> interceptors -> store(auth)
|
||||
// - Response:統一處理 HTTP 錯誤(目前示範 401/403/500)
|
||||
// - 使用 normalizeError 將錯誤轉成 ApiRequestError
|
||||
//
|
||||
// 注意:
|
||||
// - Store 仍然是唯一負責「寫入/清除 token」的地方(login/logout)
|
||||
// - Interceptor 只負責「讀取 token 並附加到 request」
|
||||
export function setupInterceptors(client: AxiosInstance) {
|
||||
// Request: 自動注入 token
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = tokenService.getToken()
|
||||
const url = config.url ?? ''
|
||||
const shouldAttachToken = !url.includes('/Auth/login')
|
||||
export function createHooks(): Hooks {
|
||||
return {
|
||||
beforeRequest: [
|
||||
({ request }) => {
|
||||
const token = tokenService.getToken()
|
||||
const url = request.url
|
||||
const shouldAttachToken = !url.includes('/Auth/login')
|
||||
|
||||
if (token && shouldAttachToken) {
|
||||
const headers = AxiosHeaders.from(config.headers ?? {})
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
config.headers = headers
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
if (token && shouldAttachToken) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeError: [
|
||||
({ request, options, error }) => {
|
||||
const normalized = normalizeError(error)
|
||||
|
||||
// Response: 統一錯誤處理
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
const normalized = normalizeError(error)
|
||||
if (normalized.name === 'CanceledRequestError') {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// 取消請求不做全域錯誤導頁
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
return Promise.reject(normalized)
|
||||
}
|
||||
const requestUrl = request.url
|
||||
const isLoginRequest = requestUrl.includes('/Auth/login')
|
||||
const silentToast = Boolean(options.context.silentToast)
|
||||
const status = isHTTPError(error) ? error.response.status : undefined
|
||||
|
||||
const requestUrl = error.config?.url ?? ''
|
||||
const isLoginRequest = requestUrl.includes('/Auth/login')
|
||||
const silentToast = Boolean(error.config?.meta?.silentToast)
|
||||
switch (status) {
|
||||
case 401: {
|
||||
if (requestUrl.includes('/Auth/login')) break
|
||||
|
||||
// 統一處理 HTTP 狀態碼錯誤
|
||||
const status = error.response?.status
|
||||
switch (status) {
|
||||
// 401 Unauthorized
|
||||
case 401: {
|
||||
// 不是所有 401 都代表「token 過期」:
|
||||
// - 登入失敗通常也會是 401,但不應觸發全域登出流程
|
||||
// 這裡以「是否帶 Authorization header」作為判斷依據
|
||||
{
|
||||
const url = error.config?.url ?? ''
|
||||
if (url.includes('/Auth/login')) break
|
||||
|
||||
const requestHeaders = AxiosHeaders.from(error.config?.headers ?? {})
|
||||
const hasAuthHeader = Boolean(requestHeaders.get('Authorization'))
|
||||
const hasAuthHeader = request.headers.has('Authorization')
|
||||
if (hasAuthHeader) {
|
||||
tokenService.clearToken()
|
||||
const backendMessage = extractErrorMessage(error.response?.data)
|
||||
const backendMessage = isHTTPError(error) ? extractErrorMessage(error.data) : undefined
|
||||
emitForceLogout({ message: backendMessage })
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
case 403: {
|
||||
emitHttpError({ status, message: normalized.message })
|
||||
break
|
||||
}
|
||||
case 404: {
|
||||
emitHttpError({ status, message: normalized.message })
|
||||
break
|
||||
}
|
||||
case 500: {
|
||||
break
|
||||
}
|
||||
case 503: {
|
||||
emitHttpError({ status, message: normalized.message })
|
||||
break
|
||||
}
|
||||
default:
|
||||
}
|
||||
// 403 Forbidden
|
||||
case 403: {
|
||||
emitHttpError({ status, message: normalized.message })
|
||||
break
|
||||
}
|
||||
// 404 Not Found(API 端點不存在/被移除)
|
||||
case 404: {
|
||||
emitHttpError({ status, message: normalized.message })
|
||||
break
|
||||
}
|
||||
// 500 Internal Server Error
|
||||
case 500: {
|
||||
// 500 通常是「單一 API 失敗」:交由呼叫端決定 UI(snackbar/區塊錯誤/重試)
|
||||
// 避免同一頁多支 API 時,其中一支 500 就把整個頁面導走
|
||||
break
|
||||
}
|
||||
// 503 Service Unavailable
|
||||
case 503: {
|
||||
// 503 通常對使用者來說就是「系統維護/暫時無法使用」
|
||||
emitHttpError({ status, message: normalized.message })
|
||||
break
|
||||
}
|
||||
default:
|
||||
// 無 response status 時,多半是網路/跨網域/連線問題:
|
||||
// 交由呼叫端決定 UI(snackbar/區塊錯誤/重試),避免全域導頁打斷使用者操作
|
||||
}
|
||||
|
||||
const shouldToast =
|
||||
!silentToast &&
|
||||
!isLoginRequest &&
|
||||
status !== 401 &&
|
||||
status !== 403 &&
|
||||
status !== 404 &&
|
||||
status !== 503 &&
|
||||
(status === 500 || !status || (typeof status === 'number' && status >= 500))
|
||||
const shouldToast =
|
||||
!silentToast &&
|
||||
!isLoginRequest &&
|
||||
status !== 401 &&
|
||||
status !== 403 &&
|
||||
status !== 404 &&
|
||||
status !== 503 &&
|
||||
(status === 500 || !status || (typeof status === 'number' && status >= 500))
|
||||
|
||||
if (shouldToast) {
|
||||
emitHttpToast({
|
||||
level: status ? 'error' : 'warning',
|
||||
message: normalized.message,
|
||||
})
|
||||
}
|
||||
return Promise.reject(normalized)
|
||||
}
|
||||
)
|
||||
if (shouldToast) {
|
||||
emitHttpToast({
|
||||
level: status ? 'error' : 'warning',
|
||||
message: normalized.message,
|
||||
})
|
||||
}
|
||||
|
||||
return normalized
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,15 @@ export interface RequestOptions {
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
getCaptcha: () => httpClient.get<CaptchaResponse>('/Auth/get-captcha'),
|
||||
login: (payload: FormData, options?: RequestOptions) =>
|
||||
httpClient.post<unknown>('/Auth/login', payload, {
|
||||
getCaptcha: async () => ({
|
||||
data: await httpClient.get('Auth/get-captcha').json<CaptchaResponse>(),
|
||||
}),
|
||||
login: async (payload: FormData, options?: RequestOptions) => ({
|
||||
data: await httpClient
|
||||
.post('Auth/login', {
|
||||
body: payload,
|
||||
signal: options?.signal,
|
||||
}),
|
||||
})
|
||||
.json<unknown>(),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -18,12 +18,20 @@ export interface MenuOuterResponse {
|
||||
}
|
||||
|
||||
export const menuApi = {
|
||||
getMenu: (payload: MenuPayload, options?: RequestOptions) =>
|
||||
httpClient.post<MenuOuterResponse>('/Menu/GetMenu', payload, {
|
||||
getMenu: async (payload: MenuPayload, options?: RequestOptions) => ({
|
||||
data: await httpClient
|
||||
.post('Menu/GetMenu', {
|
||||
json: payload,
|
||||
signal: options?.signal,
|
||||
}),
|
||||
getFavorite: (payload: MenuPayload, options?: RequestOptions) =>
|
||||
httpClient.post<MenuOuterResponse>('/Menu/GetFavorite', payload, {
|
||||
})
|
||||
.json<MenuOuterResponse>(),
|
||||
}),
|
||||
getFavorite: async (payload: MenuPayload, options?: RequestOptions) => ({
|
||||
data: await httpClient
|
||||
.post('Menu/GetFavorite', {
|
||||
json: payload,
|
||||
signal: options?.signal,
|
||||
}),
|
||||
})
|
||||
.json<MenuOuterResponse>(),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 全域 Session 事件(Playground 專用)
|
||||
//
|
||||
// 目的:
|
||||
// - 避免 axios interceptor 直接 import Pinia store / router 造成循環依賴
|
||||
// - 避免 HTTP hooks 直接 import Pinia store / router 造成循環依賴
|
||||
// - 由 App.vue 在 UI 層統一處理登出流程(清狀態、導頁、提示訊息)
|
||||
|
||||
export const SESSION_FORCE_LOGOUT_EVENT = 'sk-playground:session-force-logout'
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import { useMenuStore } from '@/stores/menu'
|
||||
// - Component 不直接呼叫 API,避免狀態散落
|
||||
// - token 單一來源:透過 tokenService 同步 ref + localStorage
|
||||
// - store 負責寫入/清除 token(login/logout)
|
||||
// - axios interceptor 只讀 tokenService
|
||||
// - HTTP hooks 只讀 tokenService
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const token = tokenService.token
|
||||
|
||||
Reference in New Issue
Block a user