管理request api 減少錯誤發生
主要目的是:透過增加API定義文件,讓我們減少呼叫api時所發生的錯誤,會幫我檢查query、body、response是否正確(包含型別確認)。
主要會分為四個部分:
- 定義文件:先定義好各個api的method、url、response…等。
- class:分析定義文件,並推導型別,綁定HTTP 請求工具(Axios),建構api context,核心功能建置。
- 物件: instance request class 並傳入文件。
- 方法:封裝物件,讓我們方便使用,未來可以隨時抽換方法。
可以參考下面簡易流程圖:
1. 定義文件 (路徑檔名:request-types/user)
export type PrivateDefinitions = {
// method@path 格式可以在request中自行調整
"get@user": {
body: null;
query: null;
response: {
name: string;
age: string;
gender: string;
};
contentType: null;
};
}
2. class (路徑檔名:utils/request)
import { lastMatch } from './modules/index'
import { serviceException } from '../utils/index'
// RouteParameters Tpye
type RouteParameters<Route extends string, ST = string> = Route extends `${string}:${infer Rest}`
? (
GetRouteParameter<Rest> extends never
? null
: GetRouteParameter<Rest> extends `${infer ParamName}?`
? { [P in ParamName]?: ST }
: { [P in GetRouteParameter<Rest>]: ST }
) &
(Rest extends `${GetRouteParameter<Rest>}${infer Next}`
? RouteParameters<Next> : unknown)
: {}
type GetRouteParameter<S extends string> = RemoveTail<
RemoveTail<
RemoveTail<RemoveTail<S, `/${string}`>,
`-${string}`>,
`.${string}`>,
`#${string}`
>
type RemoveTail<S extends string, Tail extends string> = S extends `${infer P}${Tail}` ? P : S
// url format
type ToFormat = `${'get' | 'post' | 'put' | 'delete'}@${string}`
// multipart/form-data#json : 要使用formdata格式 但header帶的是json
type ContentTypes = 'application/json' | 'form' | 'x-www-form-urlencoded' | 'multipart/form-data' | 'multipart/form-data#json'
// 例外處理
const exception = serviceException.checkout('request')
// RequestContext Type
export type RequestContext<T extends string = string> = {
name: T
path: string
form: HTMLFormElement
body: Record<string, any>
query: Record<string, any>
state: Record<string, any>
method: string
headers: Record<string, string>
contentType: ContentTypes
responseType?: 'arraybuffer' | 'application/json'
}
type StringParams<
T extends string,
P = RouteParameters<T>
> = P extends Record<string, never> ? { params?: any } : {
params: {
[K in keyof P]: K | string | number
}
}
type DefinedFormat = {
body?: any
query?: any
contentType?: ContentTypes | null
response: any
}
// Request QueryParams 參數 Type
type QueryParams<To extends string, Api extends DefinedFormat> = StringParams<To> &
(Api['body'] extends null ? { body?: Record<string, any> } : { body: Api['body'] }) &
(Api['query'] extends null ? { query?: Record<string, number | string> } : { query: Api['query'] }) &
(Api['contentType'] extends null ? { contentType?: Api['contentType'] } : { contentType: Api['contentType'] }) &
(Api['response'] extends ArrayBuffer ? { responseType: 'arraybuffer' } : { responseType?: 'arraybuffer' }) & {
headers?: Record<string, string>
}
// 建構時參數Type
type ModuleParams<R extends Request<any>> = {
// Request name
name: string
// 建構Request http method 及 header
http: (_context: RequestContext<R['__names']>) => Promise<any>
// 安裝(初始化) HTTP 請求工具及初始header context...等
install?: (_request: R) => any
}
// 定義 文件的 Type
export type ApisDefinition<T extends Record<ToFormat, DefinedFormat>> = T
// 泛型 instance 要符合ApisDefinition Type
export class Request<
ApisDefinition extends Record<ToFormat, DefinedFormat>
> {
// Extract<Type, Union>:從原本的 Type 中取出符合規則的
// type OnlyString = Extract<'a' | 'b' | 1 | 2, string>; // "a" | "b"
__names: Extract<keyof ApisDefinition, string> = null as any
// 用來存一些狀態 store axios 之類的
state: Record<string, any> = {}
private params: ModuleParams<this>
private installed = false
// instance 要建構符合 ModuleParams
constructor(params: ModuleParams<Request<ApisDefinition>>) {
this.params = params
}
get name() {
return this.params.name
}
// 靜態方法 不能在instance時使用 只能用在該class Request.AxiosRequest
// 建立Request 執行方法 並回傳 result
static async AxiosRequest(params: {
axios: any
context: RequestContext
}) {
let { axios, context } = params
let { method, path, query, headers, responseType, body } = context
let result = null
if (method === 'get' || method === 'delete') {
result = await axios[method](path, {
params: query,
headers,
responseType
})
}
if (method === 'post' || method === 'put') {
result = await axios[method](path, body, {
params: query,
headers,
responseType
})
}
return result
}
// 解析 method, path
private parseUrl(url: string, params?: Record<string, string | number>) {
let substrings = url.split('@')
let method = substrings[0]
let path = substrings.slice(1).join('@')
if (params) {
for (let key in params) {
if (params[key] == null) {
throw exception.create(`Url ${url} param ${key} is null.`)
}
path = path.replace(':' + key, params[key].toString())
}
}
return {
path: path.split('#')[0],
method
}
}
// 建置header, context
async http<T extends keyof ApisDefinition>(
to: T,
params: QueryParams<Extract<T, ToFormat>, Extract<ApisDefinition[T], DefinedFormat>>
): Promise<Extract<ApisDefinition[T], DefinedFormat>['response']> {
if (this.installed === false && this.params.install) {
// 傳入自己(request) 取得 state 等資料
this.params.install(this)
}
this.installed = true
let parsed = this.parseUrl(to as string, params.params)
let headers = params.headers || {}
let context: RequestContext<any> = {
name: to,
path: parsed.path,
form: document.createElement('form'),
body: (params.body || {}) as any,
query: (params.query || {}) as any,
state: this.state,
headers,
contentType: params.contentType || 'application/json',
responseType: params.responseType,
method: parsed.method
}
// 根據不同Content Type 建立body
if (context.contentType === 'x-www-form-urlencoded') {
let body: any = {}
for (let [key, value] of Object.entries(context.body)) {
if (lastMatch(key, '[]')) {
body[key.slice(0, -2)] = value
} else {
body[key] = value
}
}
// qs.stringify({ a: ['b', 'c'] }, { arrayFormat: 'brackets' })
// 'a[]=b&a[]=c'
context.body = stringify(body, {
arrayFormat: 'brackets'
}) as any
headers.contentType = 'application/x-www-form-urlencoded'
}
if (context.contentType === 'multipart/form-data') {
let formData = new FormData()
for (let key in context.body) {
formData.append(key, context.body[key])
}
context.body = formData
headers.contentType = 'multipart/form-data'
}
if (context.contentType === 'multipart/form-data#json') {
let formData = new FormData()
for (let key in context.body) {
formData.append(key, context.body[key])
}
context.body = formData
}
if (context.contentType === 'form') {
context.form.setAttribute('method', context.method.toUpperCase())
for (let key in context.body) {
const value = context.body[key]
const field = document.createElement('input')
field.setAttribute('type', 'hidden')
field.setAttribute('name', key)
field.setAttribute('value', typeof value === 'string' ? value : JSON.stringify(value))
context.form.appendChild(field)
}
context.form.style.opacity = '0'
context.form.style.position = 'fixed'
document.body.appendChild(context.form)
}
// Send
// 執行instance 時的http 並帶入上面的context
// context 就會包含 instance 時的 state
let response = await this.params.http(context)
if (context.contentType === 'form') {
context.form.remove()
}
return response
}
export(): Request<ApisDefinition>['http'] {
// 確保this 指向的是這裡的this
return this.http.bind(this)
}
}
下面特別介紹一下這寫法 this.http.bind(this):
沒用bind(this)時,實例後 export()後對應的showAge() 會抓不到this.age,因為這時候他的this並不是指向這個class。
透過bind(this)後,實例後完呼叫也是指向原本的class。
3. 物件 (路徑檔名:requests/private.ts)
import Axios, { AxiosInstance } from 'axios'
import { Request } from '@/utils/request'
import { PrivateDefinitions } from '@/request-types/user'
// 定義 State Type
type State = {
axios: AxiosInstance
}
// instance class and export
export const privateRequests = new Request<PrivateDefinitions>({
// 可以根據不同文件去命名 公開的api就可以叫public
name: 'private',
// 傳入http method
http: async(context) => {
const state = context.state as State
// 可以在這邊定義 headers
// context.headers.Authorization = `Bearer ${token}`
// 使用class 靜態方法
let result: any = await Request.AxiosRequest({
axios: context.state.axios
// 定義 headers 透過context 傳入
// context
})
return result.data
},
// 安裝 axios 方法
// state 也可以傳入 store 供後續使用
// 雖然在這邊也可以宣告header
// 但只適合沒有時間週期性的(一開始就存在的資料)
// Axios.create 建立後 header值就不會改變
install: (request) => {
request.state.axios = Axios.create({
baseURL: `${env.ApiBaseUrl}`
})
}
})
4. 方法: 封裝成 useApis (路徑檔名:requests/index.ts)
import { privateRequests } from './private'
export const useApis = () => {
return {
private: privateRequests.export()
}
}
5. 使用
import { useApis } from '@/requests'
const apis = useApis()
apis.private('get@user', {})