Skip to content

Instantly share code, notes, and snippets.

@rafaelcorreiapoli
Last active September 13, 2019 13:20
Show Gist options
  • Save rafaelcorreiapoli/c32741d9919931c63562a69fb0f8bb0d to your computer and use it in GitHub Desktop.
Save rafaelcorreiapoli/c32741d9919931c63562a69fb0f8bb0d to your computer and use it in GitHub Desktop.
HttPClient
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 { token, refreshToken } = await this.getRefreshedTokens()
this.logger.debug(`Setting new newToken from refresh`)
this.token = token
this.refreshToken = refreshToken
} 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<{
token: string
refreshToken: string
}> {
this.logger.debug('getRefreshedTokens')
return this.request<{ token: string; refreshToken: string }>({
service: 'auth',
url: this.refreshTokenEndpoint,
method: 'GET',
headers: {
authorization: `Bearer ${this.refreshToken}`,
},
disableRefreshTokenInterceptor: true,
disableAuthorizationHeaderInterceptor: true,
}).then(response => response.data)
}
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