feat(client,sdk): improve api client (#425)
* feat(client,sdk): improve api client * feat: add test cases * docs: update doc * fix(sdk): cannot destructure property 'authClass' of 'instance' as it is undefined
This commit is contained in:
		
							parent
							
								
									735581d20d
								
							
						
					
					
						commit
						4412efc145
					
				| @ -34,7 +34,6 @@ Request Body | ||||
| Response 200 (application/json) | ||||
| { | ||||
|   "data": {}, | ||||
|   "meta": {} | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| @ -51,7 +50,6 @@ Request Body | ||||
| Response 200 (application/json) | ||||
| { | ||||
|   "data": {}, | ||||
|   "meta": {} | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| @ -75,8 +73,7 @@ Response 200 (application/json) | ||||
|     "user": { | ||||
|       "id": 1 | ||||
|     } | ||||
|   }, | ||||
|   "meta": {} | ||||
|   } | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| @ -117,8 +114,7 @@ Response 200 (application/json) | ||||
|         "id": 2 | ||||
|       } | ||||
|     } | ||||
|   ], | ||||
|   "meta": {} | ||||
|   ] | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
|  | ||||
| @ -37,8 +37,8 @@ | ||||
| 
 | ||||
| ## boolean | ||||
| 
 | ||||
| - $truthy | ||||
| - $falsy | ||||
| - $isTruthy | ||||
| - $isFalsy | ||||
| 
 | ||||
| ## date | ||||
| 
 | ||||
|  | ||||
| @ -280,13 +280,7 @@ Response 200 (application/json) | ||||
| { | ||||
|   data: { | ||||
|     id: 1 | ||||
|   }, | ||||
|   meta: { | ||||
|     count: 1 | ||||
|     page: 1, | ||||
|     pageSize: 1, | ||||
|     totalPage: 1 | ||||
|   }, | ||||
|   } | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
|  | ||||
| @ -52,15 +52,67 @@ mock.onGet('users:get').reply(200, { | ||||
| await api.request({ url: 'users:get' }); | ||||
| ``` | ||||
| 
 | ||||
| ## Storage | ||||
| 
 | ||||
| APIClient uses localStorage by default, you can also custom storage. | ||||
| 
 | ||||
| ```ts | ||||
| import { Storage } from '@nocobase/sdk'; | ||||
| 
 | ||||
| class MemoryStorage extends Storage { | ||||
|   items = new Map(); | ||||
| 
 | ||||
|   clear() { | ||||
|     this.items.clear(); | ||||
|   } | ||||
| 
 | ||||
|   getItem(key: string) { | ||||
|     return this.items.get(key); | ||||
|   } | ||||
| 
 | ||||
|   setItem(key: string, value: string) { | ||||
|     return this.items.set(key, value); | ||||
|   } | ||||
| 
 | ||||
|   removeItem(key: string) { | ||||
|     return this.items.delete(key); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const api = new APIClient({ | ||||
|   baseURL: 'https://localhost:8000/api', | ||||
|   storageClass: CustomStorage, | ||||
| }); | ||||
| ``` | ||||
| 
 | ||||
| ## Auth | ||||
| 
 | ||||
| ```ts | ||||
| // Pass token directly | ||||
| api.auth.token = '123'; | ||||
| // Or sign in via signIn | ||||
| api.auth.signIn(); | ||||
| // Log out and delete the token cache | ||||
| // sign in and remember the current token | ||||
| api.auth.signIn({ email, password }); | ||||
| // sign out and delete the token | ||||
| api.auth.signOut(); | ||||
| // set the token | ||||
| api.auth.setToken('123'); | ||||
| // set the role (multiple roles) | ||||
| api.auth.setRole('admin'); | ||||
| // set the locale (multiple languages) | ||||
| api.auth.setLocale('zh-CN'); | ||||
| ``` | ||||
| 
 | ||||
| Custom Auth | ||||
| 
 | ||||
| ```ts | ||||
| import { Auth } from '@nocobase/sdk'; | ||||
| 
 | ||||
| class CustomAuth extends Auth { | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| const api = new APIClient({ | ||||
|   baseURL: 'https://localhost:8000/api', | ||||
|   authClass: CustomAuth, | ||||
| }); | ||||
| ``` | ||||
| 
 | ||||
| ## Request | ||||
|  | ||||
| @ -1,5 +1,31 @@ | ||||
| # Release Notes | ||||
| 
 | ||||
| ## 2022/05/26 ~ v0.7.0-alpha.82 | ||||
| 
 | ||||
| - feat(client,sdk): improve api client | ||||
| 
 | ||||
| ### Breaking Change | ||||
| 
 | ||||
| There are major changes to the `@nocobase/sdk` API, see details [JavaScript SDK](./development/http-api/javascript-sdk.md) | ||||
| 
 | ||||
| ## 2022/05/25 ~ v0.7.0-alpha.81 | ||||
| 
 | ||||
| - feat: add create-plugin command (#423) | ||||
| - fix: "typescript": "4.5.5" | ||||
| - docs: update documentation | ||||
| - fix(client): filter menu item schema by permissions | ||||
| - fix(database): cannot read properties of null (reading 'substring') | ||||
| - fix(client): add description | ||||
| - fix(client): clone schema before insert | ||||
| - feat(client): add a description to the junction collection field | ||||
| - fix(devtools): unexpected token '.' | ||||
| 
 | ||||
| ## 2022/05/24 ~ v0.7.0-alpha.78 | ||||
| 
 | ||||
| - fix(client): add RemoteDocumentTitleProvider | ||||
| - fix(client): incomplete calendar events | ||||
| - fix(plugin-users): add translations (#416) | ||||
| 
 | ||||
| ## 2022/05/23 ~ v0.7.0-alpha.59 | ||||
| 
 | ||||
| - feat(docs): add alert message | ||||
|  | ||||
| @ -34,7 +34,6 @@ Request Body | ||||
| Response 200 (application/json) | ||||
| { | ||||
|   "data": {}, | ||||
|   "meta": {} | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| @ -50,8 +49,7 @@ Request Body | ||||
| 
 | ||||
| Response 200 (application/json) | ||||
| { | ||||
|   "data": {}, | ||||
|   "meta": {} | ||||
|   "data": {} | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| @ -75,8 +73,7 @@ Response 200 (application/json) | ||||
|     "user": { | ||||
|       "id": 1 | ||||
|     } | ||||
|   }, | ||||
|   "meta": {} | ||||
|   } | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| @ -117,8 +114,7 @@ Response 200 (application/json) | ||||
|         "id": 2 | ||||
|       } | ||||
|     } | ||||
|   ], | ||||
|   "meta": {} | ||||
|   ] | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| @ -141,6 +137,3 @@ Response 200 (application/json) | ||||
| ### `remove` | ||||
| 
 | ||||
| ### `toggle` | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|  | ||||
| @ -37,8 +37,8 @@ | ||||
| 
 | ||||
| ## boolean | ||||
| 
 | ||||
| - $truthy | ||||
| - $falsy | ||||
| - $isTruly | ||||
| - $isFalsy | ||||
| 
 | ||||
| ## date | ||||
| 
 | ||||
| @ -56,4 +56,4 @@ | ||||
| - $startsWith | ||||
| - $notStartsWith | ||||
| - $endWith | ||||
| - $notEndWith | ||||
| - $notEndWith | ||||
|  | ||||
| @ -281,12 +281,6 @@ Response 200 (application/json) | ||||
|   data: { | ||||
|     id: 1 | ||||
|   }, | ||||
|   meta: { | ||||
|     count: 1 | ||||
|     page: 1, | ||||
|     pageSize: 1, | ||||
|     totalPage: 1 | ||||
|   }, | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
|  | ||||
| @ -52,15 +52,67 @@ mock.onGet('users:get').reply(200, { | ||||
| await api.request({ url: 'users:get' }); | ||||
| ``` | ||||
| 
 | ||||
| ## Storage | ||||
| 
 | ||||
| APIClient 默认使用的 localStorage,你也可以自定义 Storage,如: | ||||
| 
 | ||||
| ```ts | ||||
| import { Storage } from '@nocobase/sdk'; | ||||
| 
 | ||||
| class MemoryStorage extends Storage { | ||||
|   items = new Map(); | ||||
| 
 | ||||
|   clear() { | ||||
|     this.items.clear(); | ||||
|   } | ||||
| 
 | ||||
|   getItem(key: string) { | ||||
|     return this.items.get(key); | ||||
|   } | ||||
| 
 | ||||
|   setItem(key: string, value: string) { | ||||
|     return this.items.set(key, value); | ||||
|   } | ||||
| 
 | ||||
|   removeItem(key: string) { | ||||
|     return this.items.delete(key); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const api = new APIClient({ | ||||
|   baseURL: 'https://localhost:8000/api', | ||||
|   storageClass: CustomStorage, | ||||
| }); | ||||
| ``` | ||||
| 
 | ||||
| ## Auth | ||||
| 
 | ||||
| ```ts | ||||
| // 直接传 token | ||||
| api.auth.token = '123'; | ||||
| // 或者通过 signIn 登录 | ||||
| api.auth.signIn(); | ||||
| // 注销并删除 token 缓存 | ||||
| // 登录并记录 token | ||||
| api.auth.signIn({ email, password }); | ||||
| // 注销并删除 token | ||||
| api.auth.signOut(); | ||||
| // 设置 token | ||||
| api.auth.setToken('123'); | ||||
| // 设置 role(当需要多角色时) | ||||
| api.auth.setRole('admin'); | ||||
| // 设置 locale(当需要多语言时) | ||||
| api.auth.setLocale('zh-CN'); | ||||
| ``` | ||||
| 
 | ||||
| 自定义 Auth | ||||
| 
 | ||||
| ```ts | ||||
| import { Auth } from '@nocobase/sdk'; | ||||
| 
 | ||||
| class CustomAuth extends Auth { | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| const api = new APIClient({ | ||||
|   baseURL: 'https://localhost:8000/api', | ||||
|   authClass: CustomAuth, | ||||
| }); | ||||
| ``` | ||||
| 
 | ||||
| ## Request | ||||
|  | ||||
| @ -1,5 +1,31 @@ | ||||
| # 更新日志 | ||||
| 
 | ||||
| ## 2022/05/26 ~ v0.7.0-alpha.82 | ||||
| 
 | ||||
| - feat(client,sdk): improve api client | ||||
| 
 | ||||
| ### Breaking Change | ||||
| 
 | ||||
| There are major changes to the `APIClient` API, see details [JavaScript SDK](./development/http-api/javascript-sdk.md) | ||||
| 
 | ||||
| ## 2022/05/25 ~ v0.7.0-alpha.81 | ||||
| 
 | ||||
| - feat: add create-plugin command (#423) | ||||
| - fix: "typescript": "4.5.5" | ||||
| - docs: update documentation | ||||
| - fix(client): filter menu item schema by permissions | ||||
| - fix(database): cannot read properties of null (reading 'substring') | ||||
| - fix(client): add description | ||||
| - fix(client): clone schema before insert | ||||
| - feat(client): add a description to the junction collection field | ||||
| - fix(devtools): unexpected token '.' | ||||
| 
 | ||||
| ## 2022/05/24 ~ v0.7.0-alpha.78 | ||||
| 
 | ||||
| - fix(client): add RemoteDocumentTitleProvider | ||||
| - fix(client): incomplete calendar events | ||||
| - fix(plugin-users): add translations (#416) | ||||
| 
 | ||||
| ## 2022/05/23 ~ v0.7.0-alpha.62 | ||||
| 
 | ||||
| - feat(docs): add alert message | ||||
|  | ||||
| @ -2,6 +2,9 @@ import { APIClient } from '@nocobase/client'; | ||||
| 
 | ||||
| const apiClient = new APIClient({ | ||||
|   baseURL: process.env.API_BASE_URL, | ||||
|   headers: { | ||||
|     'X-Hostname': window?.location?.hostname, | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| export default apiClient; | ||||
|  | ||||
| @ -18,6 +18,7 @@ | ||||
|     "@formily/antd": "2.0.20", | ||||
|     "@formily/core": "2.0.20", | ||||
|     "@formily/react": "2.0.20", | ||||
|     "@nocobase/sdk": "0.7.0-alpha.81", | ||||
|     "@nocobase/utils": "0.7.0-alpha.81", | ||||
|     "ahooks": "^3.0.5", | ||||
|     "antd": "~4.19.5", | ||||
|  | ||||
| @ -1,9 +1,8 @@ | ||||
| import { useFieldSchema } from '@formily/react'; | ||||
| import { useCookieState } from 'ahooks'; | ||||
| import { Spin } from 'antd'; | ||||
| import React, { createContext, useContext } from 'react'; | ||||
| import { Redirect } from 'react-router-dom'; | ||||
| import { useRequest } from '../api-client'; | ||||
| import { useAPIClient, useRequest } from '../api-client'; | ||||
| import { useCollection } from '../collection-manager'; | ||||
| import { useRecordIsOwn } from '../record-provider'; | ||||
| import { SchemaComponentOptions, useDesignable } from '../schema-component'; | ||||
| @ -22,7 +21,7 @@ export const ACLProvider = (props) => { | ||||
| 
 | ||||
| export const ACLRolesCheckProvider = (props) => { | ||||
|   const { setDesignable } = useDesignable(); | ||||
|   const [roleName, setRoleName] = useCookieState('currentRoleName'); | ||||
|   const api = useAPIClient(); | ||||
|   const result = useRequest( | ||||
|     { | ||||
|       url: 'roles:check', | ||||
| @ -32,8 +31,8 @@ export const ACLRolesCheckProvider = (props) => { | ||||
|         if (!data?.data?.allowConfigure && !data?.data?.allowAll) { | ||||
|           setDesignable(false); | ||||
|         } | ||||
|         if (data?.data?.role !== roleName) { | ||||
|           setRoleName(data?.data?.role); | ||||
|         if (data?.data?.role !== api.auth.role) { | ||||
|           api.auth.setRole(data?.data?.role); | ||||
|         } | ||||
|       }, | ||||
|     }, | ||||
|  | ||||
| @ -3,10 +3,11 @@ import enUS from 'antd/lib/locale/en_US'; | ||||
| import zhCN from 'antd/lib/locale/zh_CN'; | ||||
| import React from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { useRequest } from '../api-client'; | ||||
| import { useAPIClient, useRequest } from '../api-client'; | ||||
| 
 | ||||
| export function AntdConfigProvider(props) { | ||||
|   const { remoteLocale, ...others } = props; | ||||
|   const api = useAPIClient(); | ||||
|   const { i18n } = useTranslation(); | ||||
|   const { loading } = useRequest( | ||||
|     { | ||||
| @ -14,8 +15,9 @@ export function AntdConfigProvider(props) { | ||||
|     }, | ||||
|     { | ||||
|       onSuccess(data) { | ||||
|         const locale = localStorage.getItem('NOCOBASE_LANG'); | ||||
|         const locale = api.auth.locale; | ||||
|         if (data?.data?.lang && !locale) { | ||||
|           api.auth.setLocale(data?.data?.lang); | ||||
|           i18n.changeLanguage(data?.data?.lang); | ||||
|         } | ||||
|       }, | ||||
|  | ||||
| @ -1,124 +1,6 @@ | ||||
| import { observable } from '@formily/reactive'; | ||||
| import { APIClient as APIClientSDK } from '@nocobase/sdk'; | ||||
| import { Result } from 'ahooks/lib/useRequest/src/types'; | ||||
| import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; | ||||
| import Cookies from 'js-cookie'; | ||||
| import qs from 'qs'; | ||||
| 
 | ||||
| export interface ActionParams { | ||||
|   filterByTk?: any; | ||||
|   [key: string]: any; | ||||
| } | ||||
| 
 | ||||
| type ResourceActionOptions<P = any> = { | ||||
|   resource?: string; | ||||
|   resourceOf?: any; | ||||
|   action?: string; | ||||
|   params?: P; | ||||
| }; | ||||
| 
 | ||||
| export interface IResource { | ||||
|   list?: (params?: ActionParams) => Promise<any>; | ||||
|   get?: (params?: ActionParams) => Promise<any>; | ||||
|   create?: (params?: ActionParams) => Promise<any>; | ||||
|   update?: (params?: ActionParams) => Promise<any>; | ||||
|   destroy?: (params?: ActionParams) => Promise<any>; | ||||
|   [key: string]: (params?: ActionParams) => Promise<any>; | ||||
| } | ||||
| 
 | ||||
| export class APIClient { | ||||
|   axios: AxiosInstance; | ||||
| 
 | ||||
| export class APIClient extends APIClientSDK { | ||||
|   services: Record<string, Result<any, any>>; | ||||
| 
 | ||||
|   tokenKey = 'NOCOBASE_TOKEN'; | ||||
| 
 | ||||
|   constructor(instance?: AxiosInstance | AxiosRequestConfig) { | ||||
|     this.services = observable({}); | ||||
|     if (typeof instance === 'function') { | ||||
|       this.axios = instance; | ||||
|     } else { | ||||
|       this.axios = axios.create(instance); | ||||
|     } | ||||
|     this.qsMiddleware(); | ||||
|     this.authMiddleware(); | ||||
|   } | ||||
| 
 | ||||
|   qsMiddleware() { | ||||
|     this.axios.interceptors.request.use((config) => { | ||||
|       config.paramsSerializer = (params) => { | ||||
|         return qs.stringify(params, { | ||||
|           strictNullHandling: true, | ||||
|           arrayFormat: 'brackets', | ||||
|         }); | ||||
|       }; | ||||
|       return config; | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   // TODO
 | ||||
|   authMiddleware() { | ||||
|     this.axios.interceptors.request.use((config) => { | ||||
|       const token = localStorage.getItem(this.tokenKey); | ||||
|       config.headers['X-Locale'] = localStorage.getItem('NOCOBASE_LANG'); | ||||
|       config.headers['X-Hostname'] = window.location.hostname; | ||||
|       if (token) { | ||||
|         config.headers['Authorization'] = `Bearer ${token}`; | ||||
|       } | ||||
|       const currentRoleName = Cookies.get('currentRoleName'); | ||||
|       if (currentRoleName) { | ||||
|         config.headers['X-Role'] = currentRoleName; | ||||
|       } | ||||
|       return config; | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   // TODO
 | ||||
|   setBearerToken(token: any) { | ||||
|     localStorage.setItem(this.tokenKey, token || ''); | ||||
|     Cookies.remove('currentRoleName'); | ||||
|   } | ||||
| 
 | ||||
|   service(uid: string): Result<any, any> { | ||||
|     return this.services[uid]; | ||||
|   } | ||||
| 
 | ||||
|   request<T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig<D> | ResourceActionOptions): Promise<R> { | ||||
|     const { resource, resourceOf, action, params } = config as any; | ||||
|     if (resource) { | ||||
|       return this.resource(resource, resourceOf)[action](params); | ||||
|     } | ||||
|     return this.axios.request<T, R, D>(config); | ||||
|   } | ||||
| 
 | ||||
|   resource(name: string, of?: any): IResource { | ||||
|     const target = {}; | ||||
|     const handler = { | ||||
|       get: (_: any, actionName: string) => { | ||||
|         let url = name.split('.').join(`/${of || '_'}/`); | ||||
|         url += `:${actionName}`; | ||||
|         const config: AxiosRequestConfig = { url }; | ||||
|         if (['get', 'list'].includes(actionName)) { | ||||
|           config['method'] = 'get'; | ||||
|         } else { | ||||
|           config['method'] = 'post'; | ||||
|         } | ||||
|         return async (params?: ActionParams) => { | ||||
|           const { values, filter, ...others } = params || {}; | ||||
|           config['params'] = others; | ||||
|           if (filter) { | ||||
|             if (typeof filter === 'string') { | ||||
|               config['params']['filter'] = filter; | ||||
|             } else { | ||||
|               config['params']['filter'] = JSON.stringify(filter); | ||||
|             } | ||||
|           } | ||||
|           if (config.method !== 'get') { | ||||
|             config['data'] = values || {}; | ||||
|           } | ||||
|           return await this.request(config); | ||||
|         }; | ||||
|       }, | ||||
|     }; | ||||
|     return new Proxy(target, handler); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -7,7 +7,7 @@ const log = require('debug')('i18next'); | ||||
| export const i18n = i18next.createInstance(); | ||||
| 
 | ||||
| i18n.use(initReactI18next).init({ | ||||
|   lng: localStorage.getItem('NOCOBASE_LANG') || 'en-US', | ||||
|   lng: localStorage.getItem('NOCOBASE_LOCALE') || 'en-US', | ||||
|   // debug: true,
 | ||||
|   defaultNS: 'client', | ||||
|   // parseMissingKeyHandler: (key) => {
 | ||||
| @ -29,9 +29,9 @@ function setMomentLng(language) { | ||||
|   moment.locale(lng); | ||||
| } | ||||
| 
 | ||||
| setMomentLng(localStorage.getItem('NOCOBASE_LANG')); | ||||
| setMomentLng(localStorage.getItem('NOCOBASE_LOCALE')); | ||||
| 
 | ||||
| i18n.on('languageChanged', (lng) => { | ||||
|   localStorage.setItem('NOCOBASE_LANG', lng); | ||||
|   localStorage.setItem('NOCOBASE_LOCALE', lng); | ||||
|   setMomentLng(lng); | ||||
| }); | ||||
|  | ||||
| @ -36,7 +36,7 @@ export const CurrentUser = () => { | ||||
|               <Menu.Item | ||||
|                 onClick={async () => { | ||||
|                   await api.resource('users').signout(); | ||||
|                   api.setBearerToken(null); | ||||
|                   api.auth.setToken(null); | ||||
|                   history.push('/signin'); | ||||
|                 }} | ||||
|               > | ||||
|  | ||||
| @ -34,6 +34,7 @@ export const LanguageSettings = () => { | ||||
|               appLang: lang, | ||||
|             }, | ||||
|           }); | ||||
|           api.auth.setLocale(lang); | ||||
|           await i18n.changeLanguage(lang); | ||||
|           window.location.reload(); | ||||
|         }} | ||||
|  | ||||
| @ -68,13 +68,8 @@ const useSignin = () => { | ||||
|   return { | ||||
|     async run() { | ||||
|       await form.submit(); | ||||
|       const response = await api.resource('users').signin({ | ||||
|         values: form.values, | ||||
|       }); | ||||
|       if (response?.data?.data?.token) { | ||||
|         api.setBearerToken(response?.data?.data?.token); | ||||
|         history.push(redirect || '/admin'); | ||||
|       } | ||||
|       await api.auth.signIn(form.values); | ||||
|       history.push(redirect || '/admin'); | ||||
|     }, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| @ -1,4 +1,3 @@ | ||||
| import { useCookieState } from 'ahooks'; | ||||
| import { Menu, Select } from 'antd'; | ||||
| import React from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| @ -30,9 +29,6 @@ export const SwitchRole = () => { | ||||
|   const api = useAPIClient(); | ||||
|   const roles = useCurrentRoles(); | ||||
|   const { t } = useTranslation(); | ||||
|   const [roleName, setRoleName] = useCookieState('currentRoleName', { | ||||
|     defaultValue: roles?.find((role) => role.default)?.name, | ||||
|   }); | ||||
|   if (roles.length <= 1) { | ||||
|     return null; | ||||
|   } | ||||
| @ -47,9 +43,9 @@ export const SwitchRole = () => { | ||||
|           value: 'name', | ||||
|         }} | ||||
|         options={roles} | ||||
|         value={roleName} | ||||
|         value={api.auth.role} | ||||
|         onChange={async (roleName) => { | ||||
|           setRoleName(roleName); | ||||
|           api.auth.setRole(roleName); | ||||
|           await api.resource('users').setDefaultRole({ values: { roleName } }); | ||||
|           window.location.href = '/'; | ||||
|         }} | ||||
|  | ||||
| @ -46,11 +46,14 @@ function getUmiConfig() { | ||||
| function resolveNocobasePackagesAlias(config) { | ||||
|   const clientSrc = resolve(process.cwd(), './packages/core/client/src'); | ||||
|   const utilsSrc = resolve(process.cwd(), './packages/core/utils/src'); | ||||
|   const sdkSrc = resolve(process.cwd(), './packages/core/sdk/src'); | ||||
|   if (existsSync(clientSrc)) { | ||||
|     config.module.rules.get('ts-in-node_modules').include.add(clientSrc); | ||||
|     config.resolve.alias.set('@nocobase/client', clientSrc); | ||||
|     config.module.rules.get('ts-in-node_modules').include.add(utilsSrc); | ||||
|     config.resolve.alias.set('@nocobase/utils', utilsSrc); | ||||
|     config.module.rules.get('ts-in-node_modules').include.add(sdkSrc); | ||||
|     config.resolve.alias.set('@nocobase/sdk', sdkSrc); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -12,7 +12,8 @@ | ||||
|   "module": "es/index.js", | ||||
|   "typings": "es/index.d.ts", | ||||
|   "dependencies": { | ||||
|     "axios": "^0.26.1" | ||||
|     "axios": "^0.26.1", | ||||
|     "qs": "^6.10.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "axios-mock-adapter": "^1.20.0" | ||||
|  | ||||
| @ -22,42 +22,100 @@ export interface IResource { | ||||
|   [key: string]: (params?: ActionParams) => Promise<any>; | ||||
| } | ||||
| 
 | ||||
| class Auth { | ||||
| export class Auth { | ||||
|   protected api: APIClient; | ||||
|   protected token: string; | ||||
|   protected role: string; | ||||
| 
 | ||||
|   protected options = { | ||||
|     token: null, | ||||
|     locale: null, | ||||
|     role: null, | ||||
|   }; | ||||
| 
 | ||||
|   constructor(api: APIClient) { | ||||
|     this.api = api; | ||||
|     this.api.axios.interceptors.request.use((config) => { | ||||
|       config.headers['X-Hostname'] = window.location.hostname; | ||||
|       if (this.role) { | ||||
|         config.headers['X-Role'] = this.role; | ||||
|       } | ||||
|       if (this.token) { | ||||
|         config.headers['Authorization'] = `Bearer ${this.token}`; | ||||
|       } | ||||
|       return config; | ||||
|     }); | ||||
|     this.locale = this.getLocale(); | ||||
|     this.role = this.getRole(); | ||||
|     this.token = this.getToken(); | ||||
|     this.api.axios.interceptors.request.use(this.middleware.bind(this)); | ||||
|   } | ||||
| 
 | ||||
|   get locale() { | ||||
|     return this.getLocale(); | ||||
|   } | ||||
| 
 | ||||
|   get role() { | ||||
|     return this.getRole(); | ||||
|   } | ||||
| 
 | ||||
|   get token() { | ||||
|     return this.getToken(); | ||||
|   } | ||||
| 
 | ||||
|   set locale(value) { | ||||
|     this.setLocale(value); | ||||
|   } | ||||
| 
 | ||||
|   set role(value) { | ||||
|     this.setRole(value); | ||||
|   } | ||||
| 
 | ||||
|   set token(value) { | ||||
|     this.setToken(value); | ||||
|   } | ||||
| 
 | ||||
|   middleware(config: AxiosRequestConfig) { | ||||
|     if (this.locale) { | ||||
|       config.headers['X-Locale'] = this.locale; | ||||
|     } | ||||
|     if (this.role) { | ||||
|       config.headers['X-Role'] = this.role; | ||||
|     } | ||||
|     if (this.token) { | ||||
|       config.headers['Authorization'] = `Bearer ${this.token}`; | ||||
|     } | ||||
|     return config; | ||||
|   } | ||||
| 
 | ||||
|   getLocale() { | ||||
|     return this.api.storage.getItem('NOCOBASE_LOCALE'); | ||||
|   } | ||||
| 
 | ||||
|   setLocale(locale: string) { | ||||
|     this.options.locale = locale; | ||||
|     this.api.storage.setItem('NOCOBASE_LOCALE', locale || ''); | ||||
|   } | ||||
| 
 | ||||
|   getToken() { | ||||
|     return this.api.storage.getItem('NOCOBASE_TOKEN'); | ||||
|   } | ||||
| 
 | ||||
|   setToken(token: string) { | ||||
|     this.token = token; | ||||
|     this.options.token = token; | ||||
|     this.api.storage.setItem('NOCOBASE_TOKEN', token || ''); | ||||
|     if (!token) { | ||||
|       this.setRole(null); | ||||
|       this.setLocale(null); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   getRole() { | ||||
|     return this.api.storage.getItem('NOCOBASE_ROLE'); | ||||
|   } | ||||
| 
 | ||||
|   setRole(role: string) { | ||||
|     this.role = role; | ||||
|     this.options.role = role; | ||||
|     this.api.storage.setItem('NOCOBASE_ROLE', role || ''); | ||||
|   } | ||||
| 
 | ||||
|   async signIn(values) { | ||||
|   async signIn(values): Promise<AxiosResponse<any>> { | ||||
|     const response = await this.api.request({ | ||||
|       method: 'post', | ||||
|       url: 'users:signin', | ||||
|       data: values, | ||||
|     }); | ||||
|     const data = response?.data?.data; | ||||
|     this.token = data; | ||||
|     return data; | ||||
|     this.setToken(data?.token); | ||||
|     return response; | ||||
|   } | ||||
| 
 | ||||
|   async signOut() { | ||||
| @ -65,25 +123,78 @@ class Auth { | ||||
|       method: 'post', | ||||
|       url: 'users:signout', | ||||
|     }); | ||||
|     this.token = null; | ||||
|     this.setToken(null); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export abstract class Storage { | ||||
|   abstract clear(): void; | ||||
|   abstract getItem(key: string): string | null; | ||||
|   abstract removeItem(key: string): void; | ||||
|   abstract setItem(key: string, value: string): void; | ||||
| } | ||||
| 
 | ||||
| export class MemoryStorage extends Storage { | ||||
|   items = new Map(); | ||||
| 
 | ||||
|   clear() { | ||||
|     this.items.clear(); | ||||
|   } | ||||
| 
 | ||||
|   getItem(key: string) { | ||||
|     return this.items.get(key); | ||||
|   } | ||||
| 
 | ||||
|   setItem(key: string, value: string) { | ||||
|     return this.items.set(key, value); | ||||
|   } | ||||
| 
 | ||||
|   removeItem(key: string) { | ||||
|     return this.items.delete(key); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| interface ExtendedOptions { | ||||
|   authClass?: any; | ||||
|   storageClass?: any; | ||||
| } | ||||
| 
 | ||||
| export class APIClient { | ||||
|   axios: AxiosInstance; | ||||
|   auth: Auth; | ||||
|   storage: Storage; | ||||
| 
 | ||||
|   constructor(instance?: AxiosInstance | AxiosRequestConfig) { | ||||
|   constructor(instance?: AxiosInstance | (AxiosRequestConfig & ExtendedOptions)) { | ||||
|     if (typeof instance === 'function') { | ||||
|       this.axios = instance; | ||||
|     } else { | ||||
|       this.axios = axios.create(instance); | ||||
|       const { authClass, storageClass, ...others } = instance || {}; | ||||
|       this.axios = axios.create(others); | ||||
|       this.initStorage(storageClass); | ||||
|       if (authClass) { | ||||
|         this.auth = new authClass(this); | ||||
|       } | ||||
|     } | ||||
|     this.auth = new Auth(this); | ||||
|     this.qsMiddleware(); | ||||
|     if (!this.storage) { | ||||
|       this.initStorage(); | ||||
|     } | ||||
|     if (!this.auth) { | ||||
|       this.auth = new Auth(this); | ||||
|     } | ||||
|     this.paramsSerializer(); | ||||
|   } | ||||
| 
 | ||||
|   qsMiddleware() { | ||||
|   private initStorage(storage?: any) { | ||||
|     if (storage) { | ||||
|       this.storage = new storage(this); | ||||
|     } else if (localStorage) { | ||||
|       this.storage = localStorage; | ||||
|     } else { | ||||
|       this.storage = new MemoryStorage(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   paramsSerializer() { | ||||
|     this.axios.interceptors.request.use((config) => { | ||||
|       config.paramsSerializer = (params) => { | ||||
|         return qs.stringify(params, { | ||||
|  | ||||
							
								
								
									
										116
									
								
								packages/core/sdk/src/__tests__/api-client.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								packages/core/sdk/src/__tests__/api-client.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,116 @@ | ||||
| import { AxiosResponse } from 'axios'; | ||||
| import MockAdapter from 'axios-mock-adapter'; | ||||
| import { APIClient, Storage } from '../'; | ||||
| import { Auth } from '../APIClient'; | ||||
| 
 | ||||
| describe('api-client', () => { | ||||
|   test('instance', async () => { | ||||
|     const api = new APIClient({ | ||||
|       baseURL: 'https://localhost:8000/api', | ||||
|     }); | ||||
|     const mock = new MockAdapter(api.axios); | ||||
|     mock.onGet('users:get').reply(200, { | ||||
|       data: { id: 1, name: 'John Smith' }, | ||||
|     }); | ||||
|     const response = await api.request({ url: 'users:get' }); | ||||
|     expect(response.status).toBe(200); | ||||
|     expect(response.data).toMatchObject({ | ||||
|       data: { id: 1, name: 'John Smith' }, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   test('signIn', async () => { | ||||
|     const api = new APIClient({ | ||||
|       baseURL: 'https://localhost:8000/api', | ||||
|     }); | ||||
|     const mock = new MockAdapter(api.axios); | ||||
|     mock.onPost('users:signin').reply(200, { | ||||
|       data: { id: 1, name: 'John Smith', token: '123' }, | ||||
|     }); | ||||
|     const response = await api.auth.signIn({}); | ||||
|     expect(response.status).toBe(200); | ||||
|     expect(api.auth.token).toBe('123'); | ||||
|     expect(localStorage.getItem('NOCOBASE_TOKEN')).toBe('123'); | ||||
|   }); | ||||
| 
 | ||||
|   test('resource action', async () => { | ||||
|     const api = new APIClient({ | ||||
|       baseURL: 'https://localhost:8000/api', | ||||
|     }); | ||||
|     const mock = new MockAdapter(api.axios); | ||||
|     mock.onPost('users:test').reply(200, { | ||||
|       data: { id: 1, name: 'John Smith', token: '123' }, | ||||
|     }); | ||||
|     const response = await api.resource('users').test(); | ||||
|     expect(response.status).toBe(200); | ||||
|     expect(response.data).toMatchObject({ | ||||
|       data: { id: 1, name: 'John Smith', token: '123' }, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   test('custom storage', async () => { | ||||
|     const items = new Map(); | ||||
| 
 | ||||
|     class TestStorage extends Storage { | ||||
|       clear() { | ||||
|         items.clear(); | ||||
|       } | ||||
|       getItem(key: string) { | ||||
|         return items.get(key); | ||||
|       } | ||||
|       setItem(key: string, value: string) { | ||||
|         return items.set(key, value); | ||||
|       } | ||||
|       removeItem(key: string) { | ||||
|         return items.delete(key); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const api = new APIClient({ | ||||
|       baseURL: 'https://localhost:8000/api', | ||||
|       storageClass: TestStorage, | ||||
|     }); | ||||
| 
 | ||||
|     const mock = new MockAdapter(api.axios); | ||||
|     mock.onPost('users:signin').reply(200, { | ||||
|       data: { id: 1, name: 'John Smith', token: '123' }, | ||||
|     }); | ||||
| 
 | ||||
|     const response = await api.auth.signIn({}); | ||||
|     expect(response.status).toBe(200); | ||||
|     expect(api.auth.token).toBe('123'); | ||||
|     expect(items.get('NOCOBASE_TOKEN')).toBe('123'); | ||||
|   }); | ||||
| 
 | ||||
|   test('custom auth', async () => { | ||||
|     class TestAuth extends Auth { | ||||
|       async signIn(values: any): Promise<AxiosResponse<any, any>> { | ||||
|         const response = await this.api.request({ | ||||
|           method: 'post', | ||||
|           url: 'users:test', | ||||
|           data: values, | ||||
|         }); | ||||
|         const data = response?.data?.data; | ||||
|         this.setToken(data?.token); | ||||
|         return response; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const api = new APIClient({ | ||||
|       baseURL: 'https://localhost:8000/api', | ||||
|       authClass: TestAuth, | ||||
|     }); | ||||
| 
 | ||||
|     expect(api.auth).toBeInstanceOf(TestAuth); | ||||
| 
 | ||||
|     const mock = new MockAdapter(api.axios); | ||||
|     mock.onPost('users:test').reply(200, { | ||||
|       data: { id: 1, name: 'John Smith', token: '123' }, | ||||
|     }); | ||||
| 
 | ||||
|     const response = await api.auth.signIn({}); | ||||
|     expect(response.status).toBe(200); | ||||
|     expect(api.auth.token).toBe('123'); | ||||
|     expect(localStorage.getItem('NOCOBASE_TOKEN')).toBe('123'); | ||||
|   }); | ||||
| }); | ||||
| @ -17596,6 +17596,13 @@ q@^1.1.2, q@^1.5.1: | ||||
|   resolved "https://registry.npmjs.org/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" | ||||
|   integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= | ||||
| 
 | ||||
| qs@^6.10.1: | ||||
|   version "6.10.3" | ||||
|   resolved "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e" | ||||
|   integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ== | ||||
|   dependencies: | ||||
|     side-channel "^1.0.4" | ||||
| 
 | ||||
| qs@^6.4.0, qs@^6.5.2, qs@^6.9.4: | ||||
|   version "6.10.1" | ||||
|   resolved "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a" | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user