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:
chenos 2022-05-27 00:00:59 +08:00 committed by GitHub
parent 735581d20d
commit 4412efc145
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 463 additions and 213 deletions

View File

@ -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": {}
]
}
```

View File

@ -37,8 +37,8 @@
## boolean
- $truthy
- $falsy
- $isTruthy
- $isFalsy
## date

View File

@ -280,13 +280,7 @@ Response 200 (application/json)
{
data: {
id: 1
},
meta: {
count: 1
page: 1,
pageSize: 1,
totalPage: 1
},
}
}
```

View File

@ -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

View File

@ -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

View File

@ -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`

View File

@ -37,8 +37,8 @@
## boolean
- $truthy
- $falsy
- $isTruly
- $isFalsy
## date
@ -56,4 +56,4 @@
- $startsWith
- $notStartsWith
- $endWith
- $notEndWith
- $notEndWith

View File

@ -281,12 +281,6 @@ Response 200 (application/json)
data: {
id: 1
},
meta: {
count: 1
page: 1,
pageSize: 1,
totalPage: 1
},
}
```

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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",

View File

@ -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);
}
},
},

View File

@ -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);
}
},

View File

@ -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);
}
}

View File

@ -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);
});

View File

@ -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');
}}
>

View File

@ -34,6 +34,7 @@ export const LanguageSettings = () => {
appLang: lang,
},
});
api.auth.setLocale(lang);
await i18n.changeLanguage(lang);
window.location.reload();
}}

View File

@ -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');
},
};
};

View File

@ -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 = '/';
}}

View File

@ -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);
}
}

View File

@ -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"

View File

@ -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, {

View 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');
});
});

View File

@ -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"