From 4412efc145a8c8684c1d32d1768727f4c6a537c7 Mon Sep 17 00:00:00 2001 From: chenos Date: Fri, 27 May 2022 00:00:59 +0800 Subject: [PATCH] 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 --- docs/en-US/development/http-api/action-api.md | 8 +- .../development/http-api/filter-operators.md | 4 +- docs/en-US/development/http-api/index.md | 8 +- .../development/http-api/javascript-sdk.md | 62 ++++++- docs/en-US/release-notes.md | 26 +++ docs/zh-CN/development/http-api/action-api.md | 13 +- .../development/http-api/filter-operators.md | 6 +- docs/zh-CN/development/http-api/index.md | 6 - .../development/http-api/javascript-sdk.md | 62 ++++++- docs/zh-CN/release-notes.md | 26 +++ packages/app/client/src/pages/apiClient.ts | 3 + packages/core/client/package.json | 1 + packages/core/client/src/acl/ACLProvider.tsx | 9 +- .../client/src/antd-config-provider/index.tsx | 6 +- .../core/client/src/api-client/APIClient.ts | 122 +------------- packages/core/client/src/i18n/i18n.ts | 6 +- packages/core/client/src/user/CurrentUser.tsx | 2 +- .../core/client/src/user/LanguageSettings.tsx | 1 + packages/core/client/src/user/SigninPage.tsx | 9 +- packages/core/client/src/user/SwitchRole.tsx | 8 +- packages/core/devtools/umiConfig.js | 3 + packages/core/sdk/package.json | 3 +- packages/core/sdk/src/APIClient.ts | 159 +++++++++++++++--- .../core/sdk/src/__tests__/api-client.test.ts | 116 +++++++++++++ yarn.lock | 7 + 25 files changed, 463 insertions(+), 213 deletions(-) create mode 100644 packages/core/sdk/src/__tests__/api-client.test.ts diff --git a/docs/en-US/development/http-api/action-api.md b/docs/en-US/development/http-api/action-api.md index d71f3a6f9..1445d05e5 100644 --- a/docs/en-US/development/http-api/action-api.md +++ b/docs/en-US/development/http-api/action-api.md @@ -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": {} + ] } ``` diff --git a/docs/en-US/development/http-api/filter-operators.md b/docs/en-US/development/http-api/filter-operators.md index 13e9f7beb..a31112c5c 100644 --- a/docs/en-US/development/http-api/filter-operators.md +++ b/docs/en-US/development/http-api/filter-operators.md @@ -37,8 +37,8 @@ ## boolean -- $truthy -- $falsy +- $isTruthy +- $isFalsy ## date diff --git a/docs/en-US/development/http-api/index.md b/docs/en-US/development/http-api/index.md index ace76a369..79fc43e6c 100644 --- a/docs/en-US/development/http-api/index.md +++ b/docs/en-US/development/http-api/index.md @@ -280,13 +280,7 @@ Response 200 (application/json) { data: { id: 1 - }, - meta: { - count: 1 - page: 1, - pageSize: 1, - totalPage: 1 - }, + } } ``` diff --git a/docs/en-US/development/http-api/javascript-sdk.md b/docs/en-US/development/http-api/javascript-sdk.md index 764510834..329441c51 100644 --- a/docs/en-US/development/http-api/javascript-sdk.md +++ b/docs/en-US/development/http-api/javascript-sdk.md @@ -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 diff --git a/docs/en-US/release-notes.md b/docs/en-US/release-notes.md index 36e152603..b28f24831 100644 --- a/docs/en-US/release-notes.md +++ b/docs/en-US/release-notes.md @@ -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 diff --git a/docs/zh-CN/development/http-api/action-api.md b/docs/zh-CN/development/http-api/action-api.md index a2208e2c3..26fd5e1b8 100644 --- a/docs/zh-CN/development/http-api/action-api.md +++ b/docs/zh-CN/development/http-api/action-api.md @@ -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` - - - diff --git a/docs/zh-CN/development/http-api/filter-operators.md b/docs/zh-CN/development/http-api/filter-operators.md index b74cc80ae..332f28a50 100644 --- a/docs/zh-CN/development/http-api/filter-operators.md +++ b/docs/zh-CN/development/http-api/filter-operators.md @@ -37,8 +37,8 @@ ## boolean -- $truthy -- $falsy +- $isTruly +- $isFalsy ## date @@ -56,4 +56,4 @@ - $startsWith - $notStartsWith - $endWith -- $notEndWith \ No newline at end of file +- $notEndWith diff --git a/docs/zh-CN/development/http-api/index.md b/docs/zh-CN/development/http-api/index.md index 328400898..a3117cf49 100644 --- a/docs/zh-CN/development/http-api/index.md +++ b/docs/zh-CN/development/http-api/index.md @@ -281,12 +281,6 @@ Response 200 (application/json) data: { id: 1 }, - meta: { - count: 1 - page: 1, - pageSize: 1, - totalPage: 1 - }, } ``` diff --git a/docs/zh-CN/development/http-api/javascript-sdk.md b/docs/zh-CN/development/http-api/javascript-sdk.md index d14cf9cff..34737c1b5 100644 --- a/docs/zh-CN/development/http-api/javascript-sdk.md +++ b/docs/zh-CN/development/http-api/javascript-sdk.md @@ -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 diff --git a/docs/zh-CN/release-notes.md b/docs/zh-CN/release-notes.md index 5ab8061ea..d3f9fc273 100644 --- a/docs/zh-CN/release-notes.md +++ b/docs/zh-CN/release-notes.md @@ -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 diff --git a/packages/app/client/src/pages/apiClient.ts b/packages/app/client/src/pages/apiClient.ts index 082319619..e80c0fa8e 100644 --- a/packages/app/client/src/pages/apiClient.ts +++ b/packages/app/client/src/pages/apiClient.ts @@ -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; diff --git a/packages/core/client/package.json b/packages/core/client/package.json index 24ae05c86..3f0f66fe2 100644 --- a/packages/core/client/package.json +++ b/packages/core/client/package.json @@ -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", diff --git a/packages/core/client/src/acl/ACLProvider.tsx b/packages/core/client/src/acl/ACLProvider.tsx index efea6de0f..f7d822623 100644 --- a/packages/core/client/src/acl/ACLProvider.tsx +++ b/packages/core/client/src/acl/ACLProvider.tsx @@ -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); } }, }, diff --git a/packages/core/client/src/antd-config-provider/index.tsx b/packages/core/client/src/antd-config-provider/index.tsx index 07084837e..dd0a7d468 100644 --- a/packages/core/client/src/antd-config-provider/index.tsx +++ b/packages/core/client/src/antd-config-provider/index.tsx @@ -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); } }, diff --git a/packages/core/client/src/api-client/APIClient.ts b/packages/core/client/src/api-client/APIClient.ts index 7735e731e..f154176d9 100644 --- a/packages/core/client/src/api-client/APIClient.ts +++ b/packages/core/client/src/api-client/APIClient.ts @@ -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

= { - resource?: string; - resourceOf?: any; - action?: string; - params?: P; -}; - -export interface IResource { - list?: (params?: ActionParams) => Promise; - get?: (params?: ActionParams) => Promise; - create?: (params?: ActionParams) => Promise; - update?: (params?: ActionParams) => Promise; - destroy?: (params?: ActionParams) => Promise; - [key: string]: (params?: ActionParams) => Promise; -} - -export class APIClient { - axios: AxiosInstance; +export class APIClient extends APIClientSDK { services: Record>; - - 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 { - return this.services[uid]; - } - - request, D = any>(config: AxiosRequestConfig | ResourceActionOptions): Promise { - const { resource, resourceOf, action, params } = config as any; - if (resource) { - return this.resource(resource, resourceOf)[action](params); - } - return this.axios.request(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); - } } diff --git a/packages/core/client/src/i18n/i18n.ts b/packages/core/client/src/i18n/i18n.ts index 3d90456f4..91df45724 100644 --- a/packages/core/client/src/i18n/i18n.ts +++ b/packages/core/client/src/i18n/i18n.ts @@ -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); }); diff --git a/packages/core/client/src/user/CurrentUser.tsx b/packages/core/client/src/user/CurrentUser.tsx index 1566a6247..c52173110 100644 --- a/packages/core/client/src/user/CurrentUser.tsx +++ b/packages/core/client/src/user/CurrentUser.tsx @@ -36,7 +36,7 @@ export const CurrentUser = () => { { await api.resource('users').signout(); - api.setBearerToken(null); + api.auth.setToken(null); history.push('/signin'); }} > diff --git a/packages/core/client/src/user/LanguageSettings.tsx b/packages/core/client/src/user/LanguageSettings.tsx index c66ae3194..e1a289be2 100644 --- a/packages/core/client/src/user/LanguageSettings.tsx +++ b/packages/core/client/src/user/LanguageSettings.tsx @@ -34,6 +34,7 @@ export const LanguageSettings = () => { appLang: lang, }, }); + api.auth.setLocale(lang); await i18n.changeLanguage(lang); window.location.reload(); }} diff --git a/packages/core/client/src/user/SigninPage.tsx b/packages/core/client/src/user/SigninPage.tsx index 698dd6f1f..8cb87cb2f 100644 --- a/packages/core/client/src/user/SigninPage.tsx +++ b/packages/core/client/src/user/SigninPage.tsx @@ -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'); }, }; }; diff --git a/packages/core/client/src/user/SwitchRole.tsx b/packages/core/client/src/user/SwitchRole.tsx index c2a3fc39e..a8716f1b2 100644 --- a/packages/core/client/src/user/SwitchRole.tsx +++ b/packages/core/client/src/user/SwitchRole.tsx @@ -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 = '/'; }} diff --git a/packages/core/devtools/umiConfig.js b/packages/core/devtools/umiConfig.js index 0ee0f77e5..ff08758eb 100644 --- a/packages/core/devtools/umiConfig.js +++ b/packages/core/devtools/umiConfig.js @@ -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); } } diff --git a/packages/core/sdk/package.json b/packages/core/sdk/package.json index d75f14022..4fcc313b8 100644 --- a/packages/core/sdk/package.json +++ b/packages/core/sdk/package.json @@ -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" diff --git a/packages/core/sdk/src/APIClient.ts b/packages/core/sdk/src/APIClient.ts index ea67141db..a921c88d4 100644 --- a/packages/core/sdk/src/APIClient.ts +++ b/packages/core/sdk/src/APIClient.ts @@ -22,42 +22,100 @@ export interface IResource { [key: string]: (params?: ActionParams) => Promise; } -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> { 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, { diff --git a/packages/core/sdk/src/__tests__/api-client.test.ts b/packages/core/sdk/src/__tests__/api-client.test.ts new file mode 100644 index 000000000..d15e346ca --- /dev/null +++ b/packages/core/sdk/src/__tests__/api-client.test.ts @@ -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> { + 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'); + }); +}); diff --git a/yarn.lock b/yarn.lock index b1eb352b8..65ccab160 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"