UNPKG

@cloudbase/app

Version:
443 lines (396 loc) 13.2 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 { ICustomReqOpts } from '@cloudbase/types/functions' 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 IGateWayOptions { name: string data?: any path: string method: string header?: {} } 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) if (this.config.endPointMode !== 'GATEWAY') { 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 async getAccessToken(token = null) { // eslint-disable-next-line eqeqeq 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 } public getDefaultHeaders() { return { [this.config.i18n?.LANG_HEADER_KEY]: this.config.i18n?.lang, 'X-SDK-Version': `@cloudbase/js-sdk/${getSdkVersion()}`, } } public async post(options: IRequestOptions, customReqOpts?: ICustomReqOpts): Promise<ResponseObject> { const res = await this.reqClass.post({ ...options, headers: { ...options.headers, ...this.getDefaultHeaders() }, customReqOpts, }) 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> }, customReqOpts?: ICustomReqOpts, ): 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 endPointMode = this.config.endPointMode || 'CLOUD_API' const url = getEndPointInfo(this.config.env, endPointMode) let BASE_URL = url.baseUrl const PROTOCOL = url.protocol if (endPointMode === 'GATEWAY') { // opts.headers.Authorization = `Bearer ${await this.getAccessToken()}` if (action === 'functions.invokeFunction' || /^(storage|database)\./.test(action)) { BASE_URL = `${BASE_URL.match(/\/\/([^/?#]*)/)[0]}/web` } } // 生成请求 url let newUrl if (options.pathname) { newUrl = formatUrl( PROTOCOL, `${getBaseEndPoint(this.config.env, endPointMode)?.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, }, customReqOpts, ) // 保存 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; customReqOpts?: ICustomReqOpts },): Promise<ResponseObject> { const { token, headers = {}, ...restOptions } = options 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 this.getAccessToken(token)}`, ...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> = {}, customReqOpts?: ICustomReqOpts, ): Promise<any> { const response = await this.request( action, data, { ...options, onUploadProgress: data.onUploadProgress }, customReqOpts, ) 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 } public async gateWay(options: IGateWayOptions, customReqOpts?: ICustomReqOpts) { const { name, data, path = '', method, header = {} } = options if (!name || !path) { throw new Error(JSON.stringify({ code: ERRORS.INVALID_PARAMS, msg: '[gateWay] invalid function name or path', }),) } let jsonData try { jsonData = data ? JSON.stringify(data) : '' } catch (e) { throw new Error(JSON.stringify({ code: ERRORS.INVALID_PARAMS, msg: '[gateWay] invalid data', }),) } const requestId = utils.generateRequestId() const { baseUrl, protocol } = getEndPointInfo(this.config.env, 'GATEWAY') const endpoint = `${protocol}${baseUrl}/${path}/${name}` const response = await this.fetch({ method: method || 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8', 'X-Request-Id': requestId, ...header, }, body: jsonData, url: endpoint, customReqOpts, }) return { requestId, ...response, data: await 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] }