Created
September 13, 2019 13:17
-
-
Save rafaelcorreiapoli/4bcae95893824afcf781a4b2b7dd914e to your computer and use it in GitHub Desktop.
HttpClient
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import async_hooks from 'async_hooks' | |
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' | |
import { inject, injectable, optional } from 'inversify' | |
import { IClock, IClockTypes } from '../../../clock' | |
import { IHashMap } from '../../../common' | |
import { IJsonConfig, IJsonConfigTypes } from '../../../config' | |
import { ContextManagerTypes, IContextManager } from '../../../context' | |
import { ILogger, ILoggerTypes } from '../../../logs' | |
import { | |
HttpClientMode, | |
HttpClientTypes, | |
HttpCodes, | |
IHttpClient, | |
IHttpClientConfig, | |
IHttpClientOptions, | |
IRequest, | |
IResponse, | |
ISignInServiceResponse, | |
OnFulfilled, | |
OnRejected, | |
} from '../interfaces/IHttpClient' | |
import { memoizedGetJwtPayload } from '../logic/getJwtPayload' | |
import { isTokenAboutToExpire } from '../logic/isTokenAboutToExpire' | |
import { defaultOptions } from './consts' | |
import { makeErrorSerializable } from './helpers' | |
export interface IInterceptorMap { | |
request: Array<[OnFulfilled<IRequest>, OnRejected]> | |
response: Array<[OnFulfilled<AxiosResponse>, OnRejected]> | |
} | |
@injectable() | |
export class HttpClient implements IHttpClient { | |
private services: IHashMap<string> | |
private axiosInstance: AxiosInstance | |
private serviceName: string | |
private servicePassword: string | |
private token: string | |
private refreshToken: string | |
private maxRetriesForAuth: number | |
private expireThresholdSeconds: number | |
private mode: HttpClientMode | |
private signInEndpoint: string | |
private refreshTokenEndpoint: string | |
constructor( | |
@inject(ILoggerTypes.ILogger) | |
private readonly logger: ILogger, | |
@inject(IJsonConfigTypes.IJsonConfig) | |
private readonly config: IJsonConfig<IHttpClientConfig>, | |
@inject(IClockTypes.IClock) | |
private readonly clock: IClock, | |
@inject(ContextManagerTypes.IContextManager) | |
private readonly contextManager: IContextManager<{ correlationId: string }>, | |
@inject(HttpClientTypes.options) | |
@optional() | |
readonly options: IHttpClientOptions | |
) { | |
const mergedOptions = { | |
...defaultOptions, | |
...options, | |
} | |
this.maxRetriesForAuth = mergedOptions.maxRetriesForAuth | |
this.expireThresholdSeconds = mergedOptions.expireThresholdSeconds | |
this.mode = mergedOptions.mode | |
this.signInEndpoint = mergedOptions.signInEndpoint | |
this.refreshTokenEndpoint = mergedOptions.refreshTokenEndpoint | |
this.setupAxios() | |
} | |
private setupAxios() { | |
this.logger.debug('starting http component') | |
const { services, name, authPassword } = this.config.getConfig() | |
this.services = services | |
this.serviceName = name | |
this.servicePassword = authPassword | |
this.axiosInstance = axios.create() | |
const webInterceptors: IInterceptorMap = { | |
request: [ | |
[this.authorizationHeaderInterceptor, null], | |
[this.refreshTokenInterceptor, null], | |
], | |
response: [], | |
} | |
const serviceInterceptors: IInterceptorMap = { | |
request: [[this.authorizationHeaderInterceptor, null]], | |
response: [[null, this.obtainTokenOnUnauthorized]], | |
} | |
switch (this.mode) { | |
case HttpClientMode.web: | |
this.applyInterceptors(webInterceptors) | |
break | |
case HttpClientMode.service: | |
this.logger.debug('applying service interceptors') | |
this.applyInterceptors(serviceInterceptors) | |
break | |
} | |
} | |
private axiosRequest = <T>(reqConfig: AxiosRequestConfig) => { | |
return this.axiosInstance.request<T>(reqConfig).catch(error => { | |
// axios 0.18.0 errors are not serialiazible because they have circular references | |
// This method overrides .toJSON method to make it serialiazible | |
makeErrorSerializable(error) | |
throw error | |
}) | |
} | |
requestRaw<T>( | |
config: AxiosRequestConfig & { service?: string } | |
): Promise<IResponse<T>> { | |
const serviceUrl = config.service && this.services[config.service] | |
return this.axiosRequest<T>({ | |
// We dont want to send our tokens to external services :P | |
disableAuthorizationHeaderInterceptor: true, | |
baseURL: serviceUrl, | |
...config, | |
} as IRequest) | |
} | |
getCorrelationIdHeaders = () => { | |
const eid = async_hooks.executionAsyncId() | |
const contextInfo = this.contextManager.getContext(eid) | |
return { | |
'x-cid': contextInfo.correlationId, | |
} | |
} | |
async request<T>(reqConfig: IRequest): Promise<IResponse<T>> { | |
const { service, headers: originalHeaders, ...other } = reqConfig | |
const serviceUrl = this.services[service] | |
const correlationIdHeaders = this.getCorrelationIdHeaders() | |
return this.axiosRequest<T>({ | |
service, | |
baseURL: serviceUrl, | |
headers: { | |
...originalHeaders, | |
...correlationIdHeaders, | |
}, | |
...other, | |
} as IRequest).then(axiosResponse => ({ | |
status: axiosResponse.status, | |
data: axiosResponse.data, | |
headers: axiosResponse.headers, | |
statusText: axiosResponse.statusText, | |
request: axiosResponse.request, | |
})) | |
} | |
addRequestInterceptor( | |
onFulfilled?: OnFulfilled<IRequest>, | |
onRejected?: OnRejected | |
): number { | |
return this.axiosInstance.interceptors.request.use(onFulfilled, onRejected) | |
} | |
addResponseInterceptor( | |
onFulfilled?: OnFulfilled<AxiosResponse>, | |
onRejected?: OnRejected | |
): number { | |
return this.axiosInstance.interceptors.response.use(onFulfilled, onRejected) | |
} | |
private applyInterceptors = ({ request, response }: IInterceptorMap) => { | |
request.forEach(requestInterceptor => { | |
this.addRequestInterceptor(...requestInterceptor) | |
}) | |
response.forEach(responseInterceptor => { | |
this.addResponseInterceptor(...responseInterceptor) | |
}) | |
} | |
private obtainTokenOnUnauthorized = (error: any) => { | |
this.logger.debug('obtainTokenOnUnauthorized') | |
const config = error.config as IRequest | |
// The host wasnt able to respond OR | |
// We dont care about obtaining authentications OR | |
// We are above the maxRetriesForAuth limit | |
if ( | |
!error.response || | |
config.disableObtainTokenOnUnauthorizationInterceptor || | |
config.retries > this.maxRetriesForAuth | |
) { | |
throw error | |
} | |
this.logger.debug( | |
`Service ${config.service} failed with ${error.response.status}` | |
) | |
switch (error.response.status) { | |
case HttpCodes.Forbidden: | |
case HttpCodes.Unauthorized: | |
return this.obtainTokenForInternalServices() | |
.catch(authError => { | |
// If we are not able to obtain a token on auth, | |
// just throw the original error to the caller | |
error.metadata = authError | |
throw error | |
}) | |
.then(({ token, refreshToken }) => { | |
this.logger.debug( | |
`Setting new token from signin: ${token.split('.')[0]}` | |
) | |
this.token = token | |
this.refreshToken = refreshToken | |
return this.request({ | |
...config, | |
retries: (config.retries || 0) + 1, | |
}) | |
}) | |
default: | |
// We are not dealing with authentication/authroization errors | |
// just throw the error to the caller | |
throw error | |
} | |
} | |
private refreshTokenInterceptor = async (config: IRequest) => { | |
if (this.refreshToken && !config.disableRefreshTokenInterceptor) { | |
const payload = memoizedGetJwtPayload(this.token) as { exp: number } | |
if (!payload) { | |
return config | |
} | |
const tokenAboutToExpire = isTokenAboutToExpire({ | |
expirationSeconds: payload.exp, | |
thresholdSeconds: this.expireThresholdSeconds, | |
now: this.clock.getDate(), | |
}) | |
if (tokenAboutToExpire) { | |
try { | |
const newToken = await this.getRefreshedTokens() | |
this.logger.debug( | |
`Setting new newToken from refresh: ${newToken.split('.')[0]}` | |
) | |
this.token = newToken | |
} catch (err) { | |
this.logger.debug('Refresh token failed!') | |
this.logger.error(err) | |
} | |
} | |
} | |
return config | |
} | |
private authorizationHeaderInterceptor = (config: IRequest) => { | |
if (this.token && !config.disableAuthorizationHeaderInterceptor) { | |
this.logger.debug( | |
`Sending token to ${config.url}: ${this.token.split('.')[0]}` | |
) | |
config.headers.authorization = `Bearer ${this.token}` | |
} | |
return config | |
} | |
private getRefreshedTokens(): Promise<string> { | |
this.logger.debug('getRefreshedTokens') | |
return this.request<{ token: string }>({ | |
service: 'auth', | |
url: this.refreshTokenEndpoint, | |
method: 'GET', | |
headers: { | |
authorization: `Bearer ${this.refreshToken}`, | |
}, | |
disableRefreshTokenInterceptor: true, | |
disableAuthorizationHeaderInterceptor: true, | |
}).then(response => response.data.token) | |
} | |
private obtainTokenForInternalServices(): Promise<{ | |
token: string | |
refreshToken: string | |
}> { | |
this.logger.debug('obtainTokenForInternalService') | |
return this.request<ISignInServiceResponse>({ | |
service: 'auth', | |
url: this.signInEndpoint, | |
method: 'POST', | |
disableObtainTokenOnUnauthorizationInterceptor: true, | |
disableRefreshTokenInterceptor: true, | |
data: { | |
name: this.serviceName, | |
password: this.servicePassword, | |
}, | |
}).then(response => response.data) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment