UNPKG

@cloudbase/app

Version:
356 lines (319 loc) 11 kB
import { DATA_VERSION, getSdkVersion, getBaseEndPoint, getEndPointInfo } from '../constants/common' import { IRequestOptions, SDKRequestInterface, ResponseObject, IUploadRequestOptions, IRequestConfig, IFetchOptions, } from '@cloudbase/adapter-interface' import { utils, constants, langEvent } from '@cloudbase/utilities' import { EndPointKey, KV } from '@cloudbase/types' import { IGetAccessTokenResult, ICloudbaseRequestConfig, IAppendedRequestInfo, IRequestBeforeHook, } from '@cloudbase/types/request' import { ICloudbaseCache } from '@cloudbase/types/cache' import { getLocalCache } from './cache' import { Platform } from './adapter' const { ERRORS } = constants const { genSeqId, isFormData, formatUrl } = utils // 下面几种 action 不需要 access token const ACTIONS_WITHOUT_ACCESSTOKEN = [ 'auth.getJwt', 'auth.logout', 'auth.signInWithTicket', 'auth.signInAnonymously', 'auth.signIn', 'auth.fetchAccessTokenWithRefreshToken', 'auth.signUpWithEmailAndPassword', 'auth.activateEndUserMail', 'auth.sendPasswordResetEmail', 'auth.resetPasswordWithToken', 'auth.isUsernameRegistered', ] function bindHooks(instance: SDKRequestInterface, name: string, hooks: IRequestBeforeHook[]) { const originMethod = instance[name] instance[name] = function (options: IRequestOptions) { const data = {} const headers = {} hooks.forEach((hook) => { const { data: appendedData, headers: appendedHeaders } = hook.call(instance, options) Object.assign(data, appendedData) Object.assign(headers, appendedHeaders) }) const originData = options.data originData && (() => { if (isFormData(originData)) { Object.keys(data).forEach((key) => { (originData as FormData).append(key, data[key]) }) return } options.data = { ...originData, ...data, } })() options.headers = { ...(options.headers || {}), ...headers, } return (originMethod as Function).call(instance, options) } } function beforeEach(): IAppendedRequestInfo { const seqId = genSeqId() return { data: { seqId, }, headers: { 'X-SDK-Version': `@cloudbase/js-sdk/${getSdkVersion()}`, 'x-seqid': seqId, }, } } export interface ICloudbaseRequest { post: (options: IRequestOptions) => Promise<ResponseObject> upload: (options: IUploadRequestOptions) => Promise<ResponseObject> download: (options: IRequestOptions) => Promise<ResponseObject> request: (action: string, params: KV<any>, options?: KV<any>) => Promise<ResponseObject> send: (action: string, data: KV<any>) => Promise<any> fetch: (options: IFetchOptions) => Promise<ResponseObject> } /** * @class CloudbaseRequest */ export class CloudbaseRequest implements ICloudbaseRequest { config: ICloudbaseRequestConfig private reqClass: SDKRequestInterface // 请求失败是否抛出Error private throwWhenRequestFail = false // 持久化本地存储 private localCache: ICloudbaseCache /** * 初始化 * @param config */ constructor(config: ICloudbaseRequestConfig & { throw?: boolean }) { this.config = config const reqConfig: IRequestConfig = { timeout: this.config.timeout, timeoutMsg: `[@cloudbase/js-sdk] 请求在${this.config.timeout / 1000}s内未完成,已中断`, restrictedMethods: ['post', 'put'], } this.reqClass = new Platform.adapter.reqClass(reqConfig) this.throwWhenRequestFail = config.throw || false this.localCache = getLocalCache(this.config.env) bindHooks(this.reqClass, 'post', [beforeEach]) bindHooks(this.reqClass, 'upload', [beforeEach]) bindHooks(this.reqClass, 'download', [beforeEach]) langEvent.bus.on(langEvent.LANG_CHANGE_EVENT, (params) => { this.config.i18n = params.data?.i18n || this.config.i18n }) } public getDefaultHeaders() { return { [this.config.i18n?.LANG_HEADER_KEY]: this.config.i18n?.lang } } public async post(options: IRequestOptions): Promise<ResponseObject> { const res = await this.reqClass.post({ ...options, headers: { ...options.headers, ...this.getDefaultHeaders() } }) return res } public async upload(options: IUploadRequestOptions): Promise<ResponseObject> { const res = await this.reqClass.upload({ ...options, headers: { ...options.headers, ...this.getDefaultHeaders() } }) return res } public async download(options: IRequestOptions): Promise<ResponseObject> { const res = await this.reqClass.download({ ...options, headers: { ...options.headers, ...this.getDefaultHeaders() } }) return res } public getBaseEndPoint(endPointKey: EndPointKey = 'CLOUD_API') { return getBaseEndPoint(this.config.env, endPointKey) } public async getOauthAccessTokenV2(oauthClient: any): Promise<IGetAccessTokenResult> { const validAccessToken = await oauthClient.getAccessToken() const credentials = await oauthClient.getCredentials() return { accessToken: validAccessToken, accessTokenExpire: new Date(credentials.expires_at).getTime(), } } /* eslint-disable complexity */ public async request( action: string, params: KV<any>, options?: { onUploadProgress?: Function pathname?: string parse?: boolean inQuery?: KV<any> search?: string defaultQuery?: KV<any> }, ): Promise<ResponseObject> { const tcbTraceKey = `x-tcb-trace_${this.config.env}` let contentType = 'application/x-www-form-urlencoded' const tmpObj: KV<any> = { action, dataVersion: DATA_VERSION, env: this.config.env, ...params, } if (ACTIONS_WITHOUT_ACCESSTOKEN.indexOf(action) === -1) { const app = this.config._fromApp if (!app.oauthInstance) { throw new Error('you can\'t request without auth') } const { oauthInstance } = app const oauthClient = oauthInstance.oauth2client tmpObj.access_token = (await this.getOauthAccessTokenV2(oauthClient)).accessToken } // 拼body和content-type let payload if (action === 'storage.uploadFile') { payload = new FormData() Object.keys(payload).forEach((key) => { if (Object.prototype.hasOwnProperty.call(payload, key) && payload[key] !== undefined) { payload.append(key, tmpObj[key]) } }) contentType = 'multipart/form-data' } else { contentType = 'application/json;charset=UTF-8' payload = {} Object.keys(tmpObj).forEach((key) => { if (tmpObj[key] !== undefined) { payload[key] = tmpObj[key] } }) } const opts: any = { headers: { 'content-type': contentType, ...this.getDefaultHeaders(), }, } if (options?.onUploadProgress) { opts.onUploadProgress = options.onUploadProgress } if (this.config.region) { opts.headers['X-TCB-Region'] = this.config.region } const traceHeader = this.localCache.getStore(tcbTraceKey) if (traceHeader) { opts.headers['X-TCB-Trace'] = traceHeader } // 发出请求 // 新的 url 需要携带 env 参数进行 CORS 校验 // 请求链接支持添加动态 query 参数,方便用户调试定位请求 const parse = options?.parse !== undefined ? options.parse : params.parse const inQuery = options?.inQuery !== undefined ? options.inQuery : params.inQuery const search = options?.search !== undefined ? options.search : params.search let formatQuery: Record<string, any> = { ...(options?.defaultQuery || {}), env: this.config.env, } // 尝试解析响应数据为 JSON parse && (formatQuery.parse = true) inQuery && (formatQuery = { ...inQuery, ...formatQuery, }) const { baseUrl: BASE_URL, protocol: PROTOCOL } = getEndPointInfo(this.config.env, 'CLOUD_API') // 生成请求 url let newUrl if (options.pathname) { newUrl = formatUrl( PROTOCOL, `${getBaseEndPoint(this.config.env)?.replace(/^https?:/, '')}/${options.pathname}`, formatQuery, ) } else { newUrl = formatUrl(PROTOCOL, BASE_URL, formatQuery) } if (search) { newUrl += search } const res: ResponseObject = await this.post({ url: newUrl, data: payload, ...opts, }) // 保存 trace header const resTraceHeader = res.header?.['x-tcb-trace'] if (resTraceHeader) { this.localCache.setStore(tcbTraceKey, resTraceHeader) } if ((Number(res.status) !== 200 && Number(res.statusCode) !== 200) || !res.data) { throw new Error('network request error') } return res } public async fetch(options: IFetchOptions & { token?: string }): Promise<ResponseObject> { const { token, headers = {}, ...restOptions } = options const getAccessToken = async () => { if (token != null) { return token } const app = this.config._fromApp if (!app.oauthInstance) { throw new Error('you can\'t request without auth') } const { oauthInstance } = app const oauthClient = oauthInstance.oauth2client return (await this.getOauthAccessTokenV2(oauthClient)).accessToken } const doFetch = async () => this.reqClass.fetch({ headers: { // 'Content-Type': 'application/json', // 'X-Request-Id': `${utils.generateRequestId()}`, // 'X-Request-Timestamp': `${Date.now()}`, 'X-SDK-Version': `@cloudbase/js-sdk/${getSdkVersion()}`, Authorization: `Bearer ${await getAccessToken()}`, ...this.getDefaultHeaders(), ...headers, }, ...restOptions, }) try { const result = await doFetch() return result } catch (err) { if (err?.code === 'ACCESS_TOKEN_EXPIRED') { // 如果是因为 token 过期失败,刷 token 后再试一次 if (typeof this.config?._fromApp?.oauthInstance?.authApi?.refreshTokenForce !== 'function') { throw err } await this.config?._fromApp?.oauthInstance?.authApi?.refreshTokenForce() return doFetch() } // 其他原因向上抛出 throw err } } public async send(action: string, data: KV<any> = {}, options: KV<any> = {}): Promise<any> { const response = await this.request(action, data, { ...options, onUploadProgress: data.onUploadProgress }) if (response.data.code && this.throwWhenRequestFail) { throw new Error(JSON.stringify({ code: ERRORS.OPERATION_FAIL, msg: `[${response.data.code}] ${response.data.message}`, }),) } return response.data } } const requestMap: KV<CloudbaseRequest> = {} export function initRequest(config: ICloudbaseRequestConfig) { requestMap[config.env] = new CloudbaseRequest({ ...config, throw: true, }) } export function getRequestByEnvId(env: string): CloudbaseRequest { return requestMap[env] }