import { IncomingHttpHeaders } from 'http' ; import { OperationDefinitionNode , ListTypeNode , VariableDefinitionNode , parse , print , getOperationAST , NonNullTypeNode , DocumentNode , } from 'graphql' ; import axios , { AxiosTransformer , AxiosInstance , AxiosRequestConfig } from 'axios' ; import * as express from 'express' ; import { createHash } from 'crypto' ; import { IMountableItem , IConstructorRouteOptions , IRouteOptions , LogLevel , ILogger , IOperationVariableMap , IOperationVariable , IResponse , ICacheEngine , } from './types' ; import Logger from './Logger' ; const PATH_VARIABLES_REGEX = /:([A-Za-z]+)/g ; function optionsDeprecationWarning ( methodName ) { console . warn ( `Deprecated method ${ methodName }() called. This function will be removed in a later version, please use withOption() or withOptions() instead.` ) ; } function isVariableArray ( node ) { if ( node . type . kind === 'NonNullType' ) { return isVariableArray ( node . type ) ; } return node . type . kind === 'ListType' ; } function translateVariableType ( node ) { if ( node . type . kind === 'NonNullType' || node . type . kind === 'ListType' ) { return translateVariableType ( node . type ) ; } return node . type . name . value ; } function cleanPath ( path ) { if ( path [ 0 ] === '/' ) { return path ; } return `/${ path }` ; } function getDefaultValue ( node ) { if ( ! node . defaultValue || node . defaultValue . kind === 'Variable' || node . defaultValue . kind === 'ListValue' || node . defaultValue . kind === 'ObjectValue' ) { return undefined ; } if ( node . defaultValue . kind === 'NullValue' ) { return null ; } return node . defaultValue . value ; } function attemptJSONParse ( variable ) { try { return JSON . parse ( variable ) ; } catch ( e ) { return variable ; } } function typecastVariable ( variable , variableDefinition ) { switch ( variableDefinition && variableDefinition . type ) { case 'Int' : return parseInt ( variable , 10 ) ; case 'Boolean' : return Boolean ( variable ) ; case 'String' : return variable ; default : return attemptJSONParse ( variable ) ; } } export default class Route implements IMountableItem { public path ! : string ; public httpMethod = 'get' ; public passThroughHeaders : string [ ] = [ ] ; public operationVariables ! : IOperationVariableMap ; public operationName ? : string ; private configurationIsFrozen = false ; private axios ! : AxiosInstance ; private schema ! : DocumentNode ; private logger ! : Logger ; private transformRequestFn : AxiosTransformer [ ] = [ ] ; private transformResponseFn : AxiosTransformer [ ] = [ ] ; private staticVariables : Record < string , unknown > = { } ; private defaultVariables : Record < string , unknown > = { } ; private cacheTimeInMs = 0 ; private cacheEngine ? : ICacheEngine ; private cacheKeyIncludedHeaders : Set < string > = new Set ( ) ; constructor ( configuration ) { this . configureRoute ( configuration ) ; } private setDefaultTransforms ( ) { const defaultTransformResponse = Array . isArray ( axios . defaults . transformResponse ) && axios . defaults . transformResponse [ 0 ] ; const defaultTransformRequest = Array . isArray ( axios . defaults . transformRequest ) && axios . defaults . transformRequest [ 0 ] ; if ( defaultTransformResponse ) { this . transformResponseFn . push ( defaultTransformResponse ) ; } if ( defaultTransformRequest ) { this . transformRequestFn . push ( defaultTransformRequest ) ; } } private configureRoute ( configuration ) { const { schema , operationName , axios , ... options } = configuration ; if ( ! schema ) { throw new Error ( 'A valid schema is required to initialize a Route' ) ; } this . setDefaultTransforms ( ) ; this . schema = typeof schema === 'string' ? parse ( schema ) : schema ; this . axios = axios ; this . logger = new Logger ( ) ; this . setOperationName ( operationName ) ; if ( ! options . path ) { options . path = operationName ; } this . withOptions ( options ) ; } private filterHeadersForPassThrough ( headers ) { const passThroughHeaders = { } ; this . passThroughHeaders . forEach ( ( header ) => { if ( headers . hasOwnProperty ( header ) ) { passThroughHeaders [ header ] = headers [ header ] ; } } ) ; return passThroughHeaders ; } private getOperationVariables ( operation ) { const variableMap = { } ; operation . variableDefinitions ?. forEach ( ( node ) : void => { const variable = { name : node . variable . name . value , required : node . type . kind === 'NonNullType' , type : translateVariableType ( node ) , array : isVariableArray ( node ) , defaultValue : getDefaultValue ( node ) } ; variableMap [ variable . name ] = variable ; } ) ; return variableMap ; } private setOperationName ( operationName ? ) { const operation = getOperationAST ( this . schema , operationName ) ; if ( ! operation ) { throw new Error ( `The named query "${ operationName }" does not exist in the Schema provided` ) ; } this . operationName = operationName ; this . operationVariables = this . getOperationVariables ( operation ) ; } private get requiredVariables ( ) : string [ ] { return Object . values ( this . operationVariables ) . filter ( ( { required , defaultValue } ) => required && ! defaultValue ) . map ( ( { name } ) => name ) ; } private warnForUsageOfStaticVariables ( params ) { const staticVariablesAsKeys = Object . keys ( this . staticVariables ) ; const unassignableVariables = Object . keys ( params ) . filter ( param => staticVariablesAsKeys . includes ( param ) ) ; if ( unassignableVariables . length ) { console . warn ( `
        ${ this . path } received the following restricted variables with
        the request that will be ignored:
        ${ unassignableVariables . join ( ', ' ) }.
      ` ) ; } } private assembleVariables ( params ) { const { staticVariables , defaultVariables } = this ; return { ... defaultVariables , ... params , ... staticVariables } ; } private missingVariables ( variables ) { const variablesAsKeys = Object . keys ( variables ) ; return this . requiredVariables . filter ( requiredVariable => ! variablesAsKeys . includes ( requiredVariable ) ) ; } private typecastVariables ( variables ) { const parsedVariables = { } ; Object . entries ( variables ) . forEach ( ( [ variableName , value ] ) => { const variableDefinition = this . operationVariables [ variableName ] ; parsedVariables [ variableName ] = typecastVariable ( value , variableDefinition ) ; } ) ; return parsedVariables ; } private addPassThroughHeaders ( headers ) { if ( Array . isArray ( headers ) ) { this . passThroughHeaders = this . passThroughHeaders . concat ( headers . map ( value => value . toLowerCase ( ) ) ) ; } else { this . passThroughHeaders . push ( headers . toLowerCase ( ) ) ; } return this ; } private addCacheKeyHeaders ( headers ) { if ( Array . isArray ( headers ) ) { headers . forEach ( v => this . cacheKeyIncludedHeaders . add ( v . toLowerCase ( ) ) ) ; } else { this . cacheKeyIncludedHeaders . add ( headers . toLowerCase ( ) ) ; } return this ; } asExpressRoute ( ) { return async ( req , res ) : Promise < unknown > => { const { query , params , body } = req ; const parsedQueryVariables = this . typecastVariables ( query as Record < string , string > ) ; const parsedPathVariables = this . typecastVariables ( params ) ; const providedVariables = { ... parsedQueryVariables , ... parsedPathVariables , ... body } ; const assembledVariables = this . assembleVariables ( providedVariables ) ; const missingVariables = this . missingVariables ( assembledVariables ) ; if ( missingVariables . length ) { res . json ( { error : 'Missing Variables' , } ) ; return ; } const { statusCode , body : responseBody } = await this . makeRequest ( assembledVariables , req . headers ) ; res . status ( statusCode ) . json ( responseBody ) ; } ; } asKoaRoute ( ) { throw new Error ( 'Not available! Submit PR' ) ; } asMetal ( ) { throw new Error ( 'Not available! Submit PR' ) ; } withOption ( option , value ) { if ( ! value || ! option ) { return this ; } switch ( option ) { case 'path' : return this . at ( value as string ) ; case 'httpMethod' : case 'method' : return this . as ( value as string ) ; case 'passThroughHeaders' : return this . addPassThroughHeaders ( value as string ) ; case 'logger' : this . logger . setLoggerObject ( value as ILogger ) ; return this ; case 'logLevel' : this . logger . setLogLevel ( value as LogLevel ) ; return this ; case 'cacheKeyIncludedHeaders' : return this . addCacheKeyHeaders ( value as string ) ; case 'transformRequest' : this . transformRequestFn . push ( value as AxiosTransformer ) ; return this ; case 'transformResponse' : this . transformResponseFn . push ( value as AxiosTransformer ) ; return this ; case 'cacheTimeInMs' : this . cacheTimeInMs = value as number ; return this ; case 'cacheEngine' : this . cacheEngine = value as ICacheEngine ; return this ; case 'staticVariables' : this . staticVariables = value as Record < string , unknown > ; return this ; case 'defaultVariables' : this . defaultVariables = value as Record < string , unknown > ; return this ; default : throw new Error ( `Invalid option: ${ option }` ) ; } } withOptions ( options ) { Object . entries ( options ) . forEach ( ( [ k , v ] ) => { this . withOption ( k , v ) ; } ) ; return this ; } at ( path ) { this . path = cleanPath ( path ) ; return this ; } as ( httpMethod ) { this . httpMethod = httpMethod . toLowerCase ( ) ; return this ; } addCacheKeyHeader ( header ) { optionsDeprecationWarning ( 'addCacheKeyHeader' ) ; this . withOption ( 'cacheKeyIncludedHeaders' , header ) ; return this ; } whitelistHeaderForPassThrough ( header ) { optionsDeprecationWarning ( 'whitelistHeaderForPassThrough' ) ; this . withOption ( 'passThroughHeaders' , header ) ; return this ; } setLogLevel ( logLevel ) { optionsDeprecationWarning ( 'setLogLevel' ) ; this . withOption ( 'logLevel' , logLevel ) ; return this ; } transformRequest ( fn ) { optionsDeprecationWarning ( 'transformRequest' ) ; this . withOption ( 'transformRequest' , fn ) ; return this ; } transformResponse ( fn ) { optionsDeprecationWarning ( 'transformResponse' ) ; this . withOption ( 'transformResponse' , fn ) ; return this ; } setCacheTimeInMs ( cacheTimeInMs ) { optionsDeprecationWarning ( 'setCacheTimeInMs' ) ; this . withOption ( 'cacheTimeInMs' , cacheTimeInMs ) ; return this ; } disableCache ( ) { optionsDeprecationWarning ( 'disableCache' ) ; this . withOption ( 'cacheTimeInMs' , 0 ) ; return this ; } get queryVariables ( ) : IOperationVariable [ ] { if ( this . httpMethod === 'post' ) { return [ ] ; } return this . nonPathVariables ; } get bodyVariables ( ) : IOperationVariable [ ] { if ( this . httpMethod === 'get' ) { return [ ] ; } return this . nonPathVariables ; } get pathVariables ( ) : IOperationVariable [ ] { const matches = this . path . match ( PATH_VARIABLES_REGEX ) ; if ( ! matches ) { return [ ] ; } const pathVariableNames = matches . map ( match => match . substr ( 1 ) ) ; return Object . values ( this . operationVariables ) . filter ( ( { name } ) => pathVariableNames . includes ( name ) ) ; } get nonPathVariables ( ) : IOperationVariable [ ] { const pathVariableNames = this . pathVariables . map ( ( { name } ) => name ) ; return Object . values ( this . operationVariables ) . filter ( ( { name } ) => ! pathVariableNames . includes ( name ) ) ; } private getRequestFingerprint ( path , variables , headers ) { const hash = createHash ( 'sha1' ) ; hash . update ( path ) ; Object . entries ( variables ) . forEach ( ( [ k , v ] ) => hash . update ( `${ k }-${ v }` ) ) ; Object . entries ( headers ) . forEach ( ( [ k , v ] ) => { if ( this . cacheKeyIncludedHeaders . has ( k ) ) { hash . update ( `${ k }-${ v }` ) ; } } ) ; return hash . digest ( 'hex' ) ; } private async checkCache ( fingerprint ) { if ( this . cacheEngine && this . cacheTimeInMs !== 0 ) { const cachedResult = await this . cacheEngine . get ( fingerprint ) ; return cachedResult ? { statusCode : 200 , body : JSON . parse ( cachedResult ) , } : null ; } } private async makeRequest ( variables , headers = { } ) { const { axios , schema , operationName , path } = this ; const headersForPassThrough = this . filterHeadersForPassThrough ( headers ) ; const fingerprint = this . getRequestFingerprint ( path , variables , headers ) ; this . logger . info ( `Incoming request on ${ operationName } at ${ path }, request variables: ${ JSON . stringify ( variables ) }` ) ; const cachedResult = await this . checkCache ( fingerprint ) ; if ( cachedResult ) { this . logger . debug ( 'Cache hit' ) ; return cachedResult ; } const config = { data : { query : print ( schema ) , variables , operationName , } , headers : headersForPassThrough , } ; if ( this . transformRequestFn . length ) { config . transformRequest = this . transformRequestFn ; } if ( this . transformResponseFn . length ) { config . transformResponse = this . transformResponseFn ; } try { const { data , status } = await axios ( config ) ; data . errors ?. forEach ( ( error ) => { this . logger . error ( `Error in GraphQL response: ${ JSON . stringify ( error ) }` ) ; } ) ; if ( this . cacheEngine && this . cacheTimeInMs !== 0 ) { this . logger . debug ( 'Cache miss, setting results' ) ; this . cacheEngine . set ( fingerprint , JSON . stringify ( data ) , this . cacheTimeInMs ) ; } return < IResponse > { body : data , statusCode : status } ; } catch ( error ) { this . logger . error ( error . stack ) ; if ( error . response ) { return < IResponse > { body : error . response . data , statusCode : error . response . status , } ; } if ( error . message . indexOf ( 'timeout' ) >= 0 ) { return < IResponse > { statusCode : 504 , body : { error : error . message , } , } ; } return < IResponse > { statusCode : 500 , body : { error : error . message , } , } ; } } }