import sqliteDriver from './drivers/sqlite' ; import * as _mapValues from 'lodash.mapvalues' ; import * as _merge from 'lodash.merge' ; import * as sha from 'jssha' ; import { IAPIOptions , IAPIService , IAPIServices , IFetchOptions , IFetchResponse , ICachedData , ICacheDictionary , IAPICacheDriver , APIMiddleware , IMiddlewarePaths , IHTTPMethods } from './interfaces' ; const DEFAULT_API_OPTIONS = { debugAPI : false , prefixes : { default : '/' } , encodeParameters : false , printNetworkRequests : false , disableCache : false , cacheExpiration : 5 * 60 * 1000 , cachePrefix : 'APIPelineCache' , ignoreHeadersWhenCaching : false , capServices : false , capLimit : 50 } ; const DEFAULT_SERVICE_OPTIONS = { method : 'GET' , domain : 'default' , prefix : 'default' } ; const HTTP_METHODS = [ 'GET' , 'HEAD' , 'POST' , 'PUT' , 'DELETE' , 'CONNECT' , 'OPTIONS' , 'TRACE' ] ; export const drivers = { sqliteDriver } ; export default class APIpeline { private _APIOptions : IAPIOptions ; private _APIServices : IAPIServices = { } ; private _APICacheDriver : IAPICacheDriver ; private _warnedAboutMissingDriver : boolean = false ; constructor ( options , services , driver ? ) { options && this . setOptions ( options ) ; services && this . setServices ( services ) ; driver && this . setCacheDriver ( driver ) ; this . _createHTTPMethods ( ) ; if ( ! this . _APIOptions . fetchMethod ) { throw new Error ( 'Your fetch method is undefined. make sure to set `fetchMethod` in your API options. ' + 'Check out the documentation to find setting examples for the browser / node / react-native' ) ; } } public async fetch ( service , options ? , forcedHTTPMethod ? ) { const serviceDefinition = this . _APIServices [ service ] ; if ( ! serviceDefinition ) { throw new Error ( `Cannot fetch data from unregistered service '${ service }'` ) ; } if ( ! this . _APICacheDriver && ! this . _warnedAboutMissingDriver ) { this . _log ( 'No caching driver set. Remember set it as the 3rd argument of APIPeline ' + 'or use the `setCacheDriver` method before firing requests. Nothing will be cached for now.' ) ; this . _warnedAboutMissingDriver = true ; } const { fullPath , withoutQueryParams } = this . _constructPath ( serviceDefinition , options ) ; try { const middlewares = await this . _applyMiddlewares ( serviceDefinition , { fullPath , withoutQueryParams } , options ) ; const fetchOptions = _merge ( middlewares , ( options && options . fetchOptions ) || { } , { method : forcedHTTPMethod || serviceDefinition . method } , { headers : ( options && options . headers ) || { } } ) ; const fetchHeaders = options && options . fetchHeaders ; const shouldUseCache = this . _shouldUseCache ( serviceDefinition , options ) ; let expiration ; let requestId ; if ( shouldUseCache ) { requestId = this . _buildRequestId ( serviceDefinition , fullPath , fetchHeaders , fetchOptions , options ) ; const expirationDelay = ( options && options . expiration ) || serviceDefinition . expiration || this . _APIOptions . cacheExpiration ; expiration = Date . now ( ) + expirationDelay ; } const cachedData = await this . _getCachedData ( service , requestId , fullPath , shouldUseCache ) ; if ( cachedData . success && cachedData . fresh ) { this . _log ( `Using fresh cache for ${ fullPath }` ) ; return cachedData . data ; } this . _logNetwork ( serviceDefinition , fullPath , fetchHeaders , options , forcedHTTPMethod ) ; this . _log ( 'full URL for request' , fullPath ) ; this . _log ( 'full fetch options for request' , fetchOptions ) ; let parsedResponseData ; const res = await this . _fetch ( fullPath , fetchOptions ) ; if ( ! res . success ) { if ( cachedData . success && cachedData . data ) { this . _log ( `Using stale cache for ${ fullPath } (network request failed)` ) ; return cachedData . data ; } else { throw new Error ( `Cannot fetch data for ${ service } online, no cache either.` ) ; } } this . _log ( 'raw network response' , res ) ; if ( fetchHeaders ) { parsedResponseData = res . data . headers && res . data . headers . map ? res . data . headers . map : { } ; } else { parsedResponseData = ( options && options . rawData ) || serviceDefinition . rawData ? res . data : await res . data . json ( ) ; const responseMiddleware = ( options && options . responseMiddleware ) || serviceDefinition . responseMiddleware || this . _APIOptions . responseMiddleware ; if ( responseMiddleware ) { parsedResponseData = await responseMiddleware ( parsedResponseData ) ; } } if ( res . data . ok && shouldUseCache ) { this . _cache ( serviceDefinition , service , requestId , parsedResponseData , expiration ) ; } this . _log ( 'parsed network response' , parsedResponseData ) ; return parsedResponseData ; } catch ( err ) { throw new Error ( err ) ; } } public async fetchHeaders ( service , options ? ) { try { return await this . fetch ( service , { ... options , fetchHeaders : true } ) ; } catch ( err ) { throw new Error ( err ) ; } } public async clearCache ( service ? ) { this . _log ( `clearing ${ service ? `cache for ${ service }` : 'all cache' }...` ) ; try { let keysToRemove = [ ] ; if ( ! service ) { for ( let serviceName in this . _APIServices ) { let keysForService = await this . _getAllKeysForService ( serviceName ) ; keysToRemove = [ ... keysToRemove , ... keysForService ] ; } } else { if ( ! this . _APIServices [ service ] ) { throw new Error ( `Cannot clear cache for unregistered service : '${ service }'` ) ; } keysToRemove = await this . _getAllKeysForService ( service ) ; } this . _log ( 'keys to be removed' , keysToRemove ) ; await Promise . all ( keysToRemove . map ( ( key ) => this . _APICacheDriver . removeItem ( key ) ) ) ; return ; } catch ( err ) { throw new Error ( err ) ; } } public setOptions ( options ) { this . _APIOptions = this . _mergeAPIOptionsWithDefaultValues ( options ) ; this . _log ( 'options set to ' , this . _APIOptions ) ; if ( ! this . _APIOptions . domains . default ) { throw new Error ( "You didn't set your default domain URL in your options. \n " + "new APIpeline({ domains: { default: 'http://myApi.net' } }, ...)" ) ; } } public setServices ( services ) { this . _APIServices = this . _mergeServicesWithDefaultValues ( services ) ; this . _log ( 'services set to' , this . _APIServices ) ; } public setCacheDriver ( driver ) { this . _APICacheDriver = driver ; this . _log ( 'custom driver set' ) ; } private _createHTTPMethods ( ) { HTTP_METHODS . forEach ( ( method ) => { this [ method . toLocaleLowerCase ( ) ] = async ( ... args ) => this . fetch ( args [ 0 ] , args [ 1 ] , method ) ; } ) ; } private param { string } url param { * } options returns { Promise < IFetchResponse > } memberof private async _fetch ( url , options ? ) { try { return { success : true , data : await this . _APIOptions . fetchMethod ( url , options ) } ; } catch ( err ) { console . warn ( err ) ; return { success : false } ; } } private param { string } service param { string } requestId param { * } response param { number } expiration returns { ( Promise < void | boolean > ) } memberof private async _cache ( serviceDefinition , service , requestId , response , expiration ) { const shouldCap = typeof serviceDefinition . capService !== 'undefined' ? serviceDefinition . capService : this . _APIOptions . capServices ; try { this . _log ( `Caching ${ requestId } ...` ) ; await this . _addKeyToServiceDictionary ( service , requestId , expiration ) ; await this . _APICacheDriver . setItem ( this . _getCacheObjectKey ( requestId ) , JSON . stringify ( response ) ) ; this . _log ( `Updated cache for request ${ requestId }` ) ; if ( shouldCap ) { const capLimit = serviceDefinition . capLimit || this . _APIOptions . capLimit ; const serviceDictionaryKey = this . _getServiceDictionaryKey ( service ) ; let dictionary = await this . _APICacheDriver . getItem ( serviceDictionaryKey ) ; if ( dictionary ) { dictionary = JSON . parse ( dictionary ) ; const cachedItemsCount = Object . keys ( dictionary ) . length ; if ( cachedItemsCount > capLimit ) { this . _log ( `service ${ service } cap reached (${ cachedItemsCount } / ${ capLimit })` + ', removing the oldest cached item...' ) ; const { key } = this . _getOldestCachedItem ( dictionary ) ; delete dictionary [ key ] ; await this . _APICacheDriver . removeItem ( this . _getCacheObjectKey ( key ) ) ; this . _APICacheDriver . setItem ( serviceDictionaryKey , JSON . stringify ( dictionary ) ) ; } } } return true ; } catch ( err ) { throw new Error ( `Error while caching API response for ${ requestId }` ) ; } } private param { string } service param { string } requestId param { string } fullPath returns { Promise < ICachedData > } memberof private async _getCachedData ( service , requestId , fullPath , shouldUseCache ) { if ( ! this . _APICacheDriver || ! shouldUseCache ) { return { success : false } ; } let serviceDictionary = await this . _APICacheDriver . getItem ( this . _getServiceDictionaryKey ( service ) ) ; serviceDictionary = JSON . parse ( serviceDictionary ) || { } ; const expiration = serviceDictionary [ requestId ] ; if ( expiration ) { this . _log ( `${ fullPath } already cached, expiring at : ${ expiration }` ) ; try { const rawCachedData = await this . _APICacheDriver . getItem ( this . _getCacheObjectKey ( requestId ) ) ; const parsedCachedData = JSON . parse ( rawCachedData ) ; if ( expiration > Date . now ( ) ) { return { success : true , fresh : true , data : parsedCachedData } ; } else { return { success : true , fresh : false , data : parsedCachedData } ; } } catch ( err ) { throw new Error ( err ) ; } } else { this . _log ( `${ fullPath } not yet cached` ) ; return { success : false } ; } } private param { IAPIService } serviceDefinition param { IFetchOptions } options returns { boolean } memberof private _shouldUseCache ( serviceDefinition , options ) { if ( ! this . _APICacheDriver ) { return false ; } else if ( options && typeof options . disableCache !== 'undefined' ) { return ! options . disableCache ; } else if ( serviceDefinition && typeof serviceDefinition . disableCache !== 'undefined' ) { return ! serviceDefinition . disableCache ; } else { return ! this . _APIOptions . disableCache ; } } private param { string } service param { string } requestId param { number } expiration returns { Promise < boolean > } memberof private async _addKeyToServiceDictionary ( service , requestId , expiration ) { try { const serviceDictionaryKey = this . _getServiceDictionaryKey ( service ) ; let dictionary = await this . _APICacheDriver . getItem ( serviceDictionaryKey ) ; if ( ! dictionary ) { dictionary = { } ; } else { dictionary = JSON . parse ( dictionary ) ; } dictionary [ requestId ] = expiration ; this . _APICacheDriver . setItem ( serviceDictionaryKey , JSON . stringify ( dictionary ) ) ; return true ; } catch ( err ) { throw new Error ( err ) ; } } private param { ICacheDictionary } dictionary returns { * } memberof private _getOldestCachedItem ( dictionary ) { let oldest ; for ( let key in dictionary ) { const keyExpiration = dictionary [ key ] ; if ( oldest ) { if ( keyExpiration < oldest . expiration ) { oldest = { key , expiration : keyExpiration } ; } } else { oldest = { key , expiration : keyExpiration } ; } } return oldest ; } private param { string } service returns { Promise < string [ ] > } memberof private async _getAllKeysForService ( service ) { try { let keys = [ ] ; const serviceDictionaryKey = this . _getServiceDictionaryKey ( service ) ; keys . push ( serviceDictionaryKey ) ; let dictionary = await this . _APICacheDriver . getItem ( serviceDictionaryKey ) ; if ( dictionary ) { dictionary = JSON . parse ( dictionary ) ; const dictionaryKeys = Object . keys ( dictionary ) . map ( ( key ) => `${ this . _APIOptions . cachePrefix }:${ key }` ) ; keys = [ ... keys , ... dictionaryKeys ] ; } return keys ; } catch ( err ) { throw new Error ( err ) ; } } private param { string } service returns { string } memberof private _getServiceDictionaryKey ( service ) { return `${ this . _APIOptions . cachePrefix }:dictionary:${ service }` ; } private param { string } requestId returns { string } memberof private _getCacheObjectKey ( requestId ) { return `${ this . _APIOptions . cachePrefix }:${ requestId }` ; } private param { IAPIService } serviceDefinition param { IFetchOptions } options returns { Promise < any > } memberof private async _applyMiddlewares ( serviceDefinition , paths , options ? ) { let middlewares = ( options && options . middlewares ) || serviceDefinition . middlewares || this . _APIOptions . middlewares ; if ( middlewares && middlewares . length ) { try { middlewares = middlewares . map ( ( middleware ) => middleware ( serviceDefinition , paths , options ) ) ; const resolvedMiddlewares = await Promise . all ( middlewares ) ; return _merge ( ... resolvedMiddlewares ) ; } catch ( err ) { throw new Error ( `Error while applying middlewares for ${ serviceDefinition . path } : ${ err }` ) ; } } else { return { } ; } } private _buildRequestId ( serviceDefinition , fullPath , fetchHeaders , mergedOptions ? , fetchOptions ? ) { const ignoreHeadersWhenCaching = this . _APIOptions . ignoreHeadersWhenCaching || serviceDefinition . ignoreHeadersWhenCaching || ( fetchOptions && fetchOptions . ignoreHeadersWhenCaching ) ; const _sha = new sha ( 'SHA-1' , 'TEXT' ) ; let requestStringId = `${ fullPath }:${ fetchHeaders ? 'headersOnly' : '' }` ; Object . keys ( mergedOptions ) . forEach ( ( key ) => { if ( ! ignoreHeadersWhenCaching || key !== 'headers' ) { requestStringId += JSON . stringify ( mergedOptions [ key ] ) ; } } ) ; _sha . update ( requestStringId ) ; return _sha . getHash ( 'HEX' ) ; } private param { IAPIService } serviceDefinition param { IFetchOptions } options returns { string } memberof private _constructPath ( serviceDefinition , options ? ) { const domainKey = ( options && options . domain ) || serviceDefinition . domain ; const domainURL = this . _APIOptions . domains [ domainKey ] ; const prefixKey = ( options && options . prefix ) || serviceDefinition . prefix ; const prefix = this . _APIOptions . prefixes [ prefixKey ] ; const { fullyParsed , withoutQueryParams } = this . _parsePath ( serviceDefinition , options ) ; const urlRoot = domainURL + prefix + '/' ; return { fullPath : urlRoot + fullyParsed , withoutQueryParams : urlRoot + withoutQueryParams } ; } private param { IAPIService } serviceDefinition param { IFetchOptions } options returns { string } memberof private _parsePath ( serviceDefinition , options ? ) { const { encodeParameters } = this . _APIOptions ; let path = serviceDefinition . path ; let parsedQueryParameters = '' ; if ( options && options . pathParameters ) { const { pathParameters } = options ; for ( let i in pathParameters ) { if ( typeof pathParameters [ i ] === 'undefined' ) { continue ; } if ( encodeParameters ) { pathParameters [ i ] = encodeURIComponent ( pathParameters [ i ] ) ; } path = path . replace ( `:${ i }` , pathParameters [ i ] ) ; } } if ( options && options . queryParameters ) { const { queryParameters } = options ; let insertedQueryParameters = 0 ; for ( let i in queryParameters ) { if ( typeof queryParameters [ i ] === 'undefined' ) { continue ; } if ( encodeParameters ) { queryParameters [ i ] = encodeURIComponent ( queryParameters [ i ] ) ; } parsedQueryParameters += insertedQueryParameters === 0 ? `?${ i }=${ queryParameters [ i ] }` : `&${ i }=${ queryParameters [ i ] }` ; insertedQueryParameters ++ ; } } return { fullyParsed : path + parsedQueryParameters , withoutQueryParams : path } ; } private param { IAPIOptions } options returns { IAPIOptions } memberof private _mergeAPIOptionsWithDefaultValues ( options ) { return { ... DEFAULT_API_OPTIONS , ... options , prefixes : { ... DEFAULT_API_OPTIONS . prefixes , ... ( options . prefixes || { } ) } } ; } private param { IAPIServices } services returns { IAPIServices } memberof private _mergeServicesWithDefaultValues ( services ) { return _mapValues ( services , ( service , serviceName ) => { if ( service . domain && typeof this . _APIOptions . domains [ service . domain ] === 'undefined' ) { throw new Error ( `Domain key ${ service . domain } specified for service ${ serviceName } hasn't been declared. \n` + 'Please provide it in your APIpeline parameters or leave it blank to use the default one.' ) ; } if ( service . prefix && typeof this . _APIOptions . prefixes [ service . prefix ] === 'undefined' ) { throw new Error ( `Prefix key ${ service . domain } specified for service ${ serviceName } hasn't been declared. \n` + 'Please provide it in your APIpeline parameters or leave it blank to use the default one.' ) ; } return { ... DEFAULT_SERVICE_OPTIONS , ... service } ; } ) ; } private param { IAPIService } serviceDefinition param { boolean } fetchHeaders param { IFetchOptions } options memberof private _logNetwork ( serviceDefinition , fullPath , fetchHeaders , options ? , forcedHTTPMethod ? ) { if ( this . _APIOptions . printNetworkRequests ) { console . log ( `%c Network request ${ fetchHeaders ? '(headers only)' : '' } for ${ fullPath } ` + `(${ forcedHTTPMethod || ( options && options . method ) || serviceDefinition . method })` , 'font-weight: bold; color: blue' ) ; } } private param { string } msg param { * } value memberof private _log ( msg , value ? ) { if ( this . _APIOptions . debugAPI ) { if ( value ) { console . log ( `APIpeline | ${ msg }` , value ) ; } else { console . log ( `APIpeline | ${ msg }` ) ; } } } }