import { EventEmitter } from 'events' ; import { PriorityQueue } from '@wireapp/priority-queue' ; import { TimeUtil } from '@wireapp/commons' ; import axios , { AxiosError , AxiosInstance , AxiosRequestConfig , AxiosResponse } from 'axios' ; import logdown from 'logdown' ; import { AccessTokenData , AccessTokenStore , AuthAPI , InvalidTokenError , MissingCookieError , TokenExpiredError , } from '../auth/' ; import { BackendError , BackendErrorMapper , ConnectionState , ContentType , StatusCode } from '../http/' ; import { ObfuscationUtil } from '../obfuscation/' ; import { sendRequestWithCookie } from '../shims/node/cookie' ; import { Config } from '../Config' ; import axiosRetry , { isNetworkOrIdempotentRequestError } from 'axios-retry' ; enum TOPIC { ON_CONNECTION_STATE_CHANGE = 'HttpClient.TOPIC.ON_CONNECTION_STATE_CHANGE' , ON_INVALID_TOKEN = 'HttpClient.TOPIC.ON_INVALID_TOKEN' } export interface HttpClient { on ( event , listener ) : this ; on ( event , listener ) : this ; } const FILE_SIZE_100_MB = 104857600 ; export class HttpClient extends EventEmitter { private readonly client : AxiosInstance ; private readonly logger : logdown . Logger ; private connectionState : ConnectionState ; private readonly requestQueue : PriorityQueue ; public static readonly TOPIC = TOPIC ; constructor ( private readonly config , public accessTokenStore ) { super ( ) ; this . client = axios . create ( { baseURL : this . config . urls . rest , } ) ; axiosRetry ( this . client , { retries : Infinity , retryDelay : axiosRetry . exponentialDelay , retryCondition : ( error ) => { const { response , request } = error ; const isNetworkError = ! response && request && ! Object . keys ( request ) . length ; if ( isNetworkError ) { this . logger . warn ( 'Disconnected from backend' ) ; this . updateConnectionState ( ConnectionState . DISCONNECTED ) ; return true ; } return isNetworkOrIdempotentRequestError ( error ) ; } , shouldResetTimeout : true , } ) ; this . connectionState = ConnectionState . UNDEFINED ; this . logger = logdown ( '@wireapp/api-client/http/HttpClient' , { logger : console , markdown : false , } ) ; this . requestQueue = new PriorityQueue ( { maxRetries : 0 , retryDelay : TimeUtil . TimeInMillis . SECOND , } ) ; } private updateConnectionState ( state ) { if ( this . connectionState !== state ) { this . connectionState = state ; this . emit ( HttpClient . TOPIC . ON_CONNECTION_STATE_CHANGE , this . connectionState ) ; } } public async _sendRequest < T > ( config , tokenAsParam = false , isFirstTry = true ) { if ( this . accessTokenStore . accessToken ) { const { token_type , access_token } = this . accessTokenStore . accessToken ; if ( tokenAsParam ) { config . params = { ... config . params , access_token , } ; } else { config . headers = { ... config . headers , Authorization : `${ token_type } ${ access_token }` , } ; } } try { const response = await this . client . request < T > ( { ... config , maxBodyLength : FILE_SIZE_100_MB , maxContentLength : FILE_SIZE_100_MB , } ) ; this . updateConnectionState ( ConnectionState . CONNECTED ) ; return response ; } catch ( error ) { const retryWithTokenRefresh = async ( ) => { this . logger . warn ( `Access token refresh triggered for "${ config . method }" request to "${ config . url }".` ) ; await this . refreshAccessToken ( ) ; config [ 'axios-retry' ] = { retries : 0 , } ; return this . _sendRequest < T > ( config , tokenAsParam , false ) ; } ; const hasAccessToken = ! ! this . accessTokenStore ?. accessToken ; if ( HttpClient . isAxiosError ( error ) && error . response ?. status === StatusCode . UNAUTHORIZED ) { return retryWithTokenRefresh ( ) ; } if ( HttpClient . isBackendError ( error ) ) { const mappedError = BackendErrorMapper . map ( new BackendError ( error . response . data . message , error . response . data . label , error . response . data . code ) ) ; const isUnauthorized = mappedError . code === StatusCode . UNAUTHORIZED ; const isExpiredTokenError = mappedError instanceof TokenExpiredError ; if ( ( isExpiredTokenError || isUnauthorized ) && hasAccessToken && isFirstTry ) { return retryWithTokenRefresh ( ) ; } if ( mappedError instanceof InvalidTokenError || mappedError instanceof MissingCookieError ) { this . logger . warn ( `Cannot renew access token for "${ config . method }" request to "${ config . url }" because cookie/token is invalid: ${ mappedError . message }` , mappedError ) ; this . emit ( HttpClient . TOPIC . ON_INVALID_TOKEN , mappedError ) ; } throw mappedError ; } throw error ; } } static isAxiosError ( errorCandidate ) { return errorCandidate . isAxiosError === true ; } static isBackendError ( errorCandidate ) { if ( errorCandidate . response ) { const { data } = errorCandidate . response ; return ! ! data ?. code && ! ! data ?. label && ! ! data ?. message ; } return false ; } public async refreshAccessToken ( ) { let expiredAccessToken ; if ( this . accessTokenStore . accessToken ?. access_token ) { expiredAccessToken = this . accessTokenStore . accessToken ; } const accessToken = await this . postAccess ( expiredAccessToken ) ; this . logger . info ( `Received updated access token. It will expire in "${ accessToken . expires_in }" seconds.` , ObfuscationUtil . obfuscateAccessToken ( accessToken ) ) ; return this . accessTokenStore . updateToken ( accessToken ) ; } public async postAccess ( expiredAccessToken ? ) { const config = { headers : { } , method : 'post' , url : `${ AuthAPI . URL . ACCESS }` , withCredentials : true , } ; if ( expiredAccessToken ?. access_token ) { config . headers . Authorization = `${ expiredAccessToken . token_type } ${ decodeURIComponent ( expiredAccessToken . access_token ) }` ; } const response = await sendRequestWithCookie < AccessTokenData > ( this , config ) ; return response . data ; } public async sendRequest < T > ( config , tokenAsParam = false , isSynchronousRequest = false ) { return isSynchronousRequest ? this . requestQueue . add ( ( ) => this . _sendRequest < T > ( config , tokenAsParam ) ) : this . _sendRequest < T > ( config , tokenAsParam ) ; } public sendJSON < T > ( config , isSynchronousRequest = false ) { config . headers = { ... config . headers , 'Content-Type' : ContentType . APPLICATION_JSON , } ; return this . sendRequest < T > ( config , false , isSynchronousRequest ) ; } public sendXML < T > ( config ) { config . headers = { ... config . headers , 'Content-Type' : ContentType . APPLICATION_XML , } ; return this . sendRequest < T > ( config , false , false ) ; } public sendProtocolBuffer < T > ( config , isSynchronousRequest = false ) { config . headers = { ... config . headers , 'Content-Type' : ContentType . APPLICATION_PROTOBUF , } ; return this . sendRequest < T > ( config , false , isSynchronousRequest ) ; } }