@cloudbase/app
Version:
cloudbase javascript sdk core
443 lines (396 loc) • 13.2 kB
text/typescript
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]
}