import axios, { AxiosRequestConfig, AxiosResponse, Method } from 'axios'; import { RestClientType } from '../types'; import { signMessage } from './node-support'; import { RestClientOptions, serializeParams, getRestBaseUrl, } from './requestUtils'; import { neverGuard } from './websocket-util'; interface SignedRequest { originalParams: T; paramsWithSign?: T & { sign: string }; serializedParams: string; sign: string; queryParamsWithSign: string; timestamp: number; recvWindow: number; } interface UnsignedRequest { originalParams: T; paramsWithSign: T; } type SignMethod = 'bitget'; const ENABLE_HTTP_TRACE = typeof process === 'object' && typeof process.env === 'object' && process.env.BITGETTRACE; if (ENABLE_HTTP_TRACE) { axios.interceptors.request.use((request) => { console.log( new Date(), 'Starting Request', JSON.stringify( { url: request.url, method: request.method, params: request.params, data: request.data, }, null, 2, ), ); return request; }); axios.interceptors.response.use((response) => { console.log(new Date(), 'Response:', { // request: { // url: response.config.url, // method: response.config.method, // data: response.config.data, // headers: response.config.headers, // }, response: { status: response.status, statusText: response.statusText, headers: response.headers, data: response.data, }, }); return response; }); } export default abstract class BaseRestClient { private options: RestClientOptions; private baseUrl: string; private globalRequestOptions: AxiosRequestConfig; private apiKey: string | undefined; private apiSecret: string | undefined; private apiPass: string | undefined; /** Defines the client type (affecting how requests & signatures behave) */ abstract getClientType(): RestClientType; /** * Create an instance of the REST client. Pass API credentials in the object in the first parameter. * @param {RestClientOptions} [restClientOptions={}] options to configure REST API connectivity * @param {AxiosRequestConfig} [networkOptions={}] HTTP networking options for axios */ constructor( restOptions: RestClientOptions = {}, networkOptions: AxiosRequestConfig = {}, ) { this.options = { recvWindow: 5000, /** Throw errors if any request params are empty */ strictParamValidation: false, encodeQueryStringValues: true, ...restOptions, }; this.globalRequestOptions = { /** in ms == 5 minutes by default */ timeout: 1000 * 60 * 5, /** inject custom rquest options based on axios specs - see axios docs for more guidance on AxiosRequestConfig: https://github.com/axios/axios#request-config */ ...networkOptions, headers: { 'X-CHANNEL-API-CODE': 'hbnni', 'Content-Type': 'application/json', locale: 'en-US', }, }; this.baseUrl = getRestBaseUrl(false, restOptions); this.apiKey = this.options.apiKey; this.apiSecret = this.options.apiSecret; this.apiPass = this.options.apiPass; // Throw if one of the 3 values is missing, but at least one of them is set const credentials = [this.apiKey, this.apiSecret, this.apiPass]; if ( credentials.includes(undefined) && credentials.some((v) => typeof v === 'string') ) { throw new Error( 'API Key, Secret & Passphrase are ALL required to use the authenticated REST client', ); } } get(endpoint: string, params?: any) { return this._call('GET', endpoint, params, true); } getPrivate(endpoint: string, params?: any) { return this._call('GET', endpoint, params, false); } post(endpoint: string, params?: any) { return this._call('POST', endpoint, params, true); } postPrivate(endpoint: string, params?: any) { return this._call('POST', endpoint, params, false); } deletePrivate(endpoint: string, params?: any) { return this._call('DELETE', endpoint, params, false); } /** * @private Make a HTTP request to a specific endpoint. Private endpoint API calls are automatically signed. */ private async _call( method: Method, endpoint: string, params?: any, isPublicApi?: boolean, ): Promise { // Sanity check to make sure it's only ever prefixed by one forward slash const requestUrl = [this.baseUrl, endpoint].join( endpoint.startsWith('/') ? '' : '/', ); // Build a request and handle signature process const options = await this.buildRequest( method, endpoint, requestUrl, params, isPublicApi, ); if (ENABLE_HTTP_TRACE) { console.log('full request: ', options); } // Dispatch request return axios(options) .then((response) => { if (response.status == 200) { if ( typeof response.data?.code === 'string' && response.data?.code !== '00000' ) { throw { response }; } return response.data; } throw { response }; }) .catch((e) => this.parseException(e)); } /** * @private generic handler to parse request exceptions */ parseException(e: any): unknown { if (this.options.parseExceptions === false) { throw e; } // Something happened in setting up the request that triggered an error if (!e.response) { if (!e.request) { throw e.message; } // request made but no response received throw e; } // The request was made and the server responded with a status code // that falls out of the range of 2xx const response: AxiosResponse = e.response; // console.error('err: ', response?.data); throw { code: response.status, message: response.statusText, body: response.data, headers: response.headers, requestOptions: { ...this.options, // Prevent credentials from leaking into error messages apiPass: 'omittedFromError', apiSecret: 'omittedFromError', }, }; } /** * @private sign request and set recv window */ private async signRequest( data: T, endpoint: string, method: Method, signMethod: SignMethod, ): Promise> { const timestamp = Date.now(); const res: SignedRequest = { originalParams: { ...data, }, sign: '', timestamp, recvWindow: 0, serializedParams: '', queryParamsWithSign: '', }; if (!this.apiKey || !this.apiSecret) { return res; } // It's possible to override the recv window on a per rquest level const strictParamValidation = this.options.strictParamValidation; const encodeQueryStringValues = this.options.encodeQueryStringValues; if (signMethod === 'bitget') { const signRequestParams = method === 'GET' ? serializeParams( data, strictParamValidation, encodeQueryStringValues, '?', ) : JSON.stringify(data) || ''; const paramsStr = timestamp + method.toUpperCase() + endpoint + signRequestParams; // console.log('sign params: ', paramsStr); res.sign = await signMessage(paramsStr, this.apiSecret, 'base64'); res.queryParamsWithSign = signRequestParams; return res; } console.error( new Date(), neverGuard(signMethod, `Unhandled sign method: "${signMessage}"`), ); return res; } private async prepareSignParams( method: Method, endpoint: string, signMethod: SignMethod, params?: TParams, isPublicApi?: true, ): Promise>; private async prepareSignParams( method: Method, endpoint: string, signMethod: SignMethod, params?: TParams, isPublicApi?: false | undefined, ): Promise>; private async prepareSignParams( method: Method, endpoint: string, signMethod: SignMethod, params?: TParams, isPublicApi?: boolean, ) { if (isPublicApi) { return { originalParams: params, paramsWithSign: params, }; } if (!this.apiKey || !this.apiSecret) { throw new Error('Private endpoints require api and private keys set'); } return this.signRequest(params, endpoint, method, signMethod); } /** Returns an axios request object. Handles signing process automatically if this is a private API call */ private async buildRequest( method: Method, endpoint: string, url: string, params?: any, isPublicApi?: boolean, ): Promise { const options: AxiosRequestConfig = { ...this.globalRequestOptions, url: url, method: method, }; for (const key in params) { if (typeof params[key] === 'undefined') { delete params[key]; } } if (isPublicApi || !this.apiKey || !this.apiPass) { return { ...options, params: params, }; } const signResult = await this.prepareSignParams( method, endpoint, 'bitget', params, isPublicApi, ); const authHeaders = { 'ACCESS-KEY': this.apiKey, 'ACCESS-PASSPHRASE': this.apiPass, 'ACCESS-TIMESTAMP': signResult.timestamp, 'ACCESS-SIGN': signResult.sign, }; if (method === 'GET') { return { ...options, headers: { ...authHeaders, ...options.headers, }, url: options.url + signResult.queryParamsWithSign, }; } return { ...options, headers: { ...authHeaders, ...options.headers, }, data: params, }; } }