import { stringify } from 'querystring' ; import { DefaultClient , RequestConfig , ResourceResponse } from './client' ; import { AxiosResponse } from 'axios' ; import { uuidWeak } from './util' ; import RelatedManager from './related' ; import { BaseNormalizer , NormalizerDict , ValidNormalizer } from './helpers/normalization' ; import * as exceptions from './exceptions' ; import assert from 'assert' ; const _ = require ( 'lodash' ) ; export default class Resource { static endpoint : string = '' ; static cacheMaxAge : number = 10 ; static client : DefaultClient = new DefaultClient ( '/' ) ; static queued : Record < string , any > = { } ; static uniqueKey : string = 'id' ; static defaults : Record < string , any > = { } ; static RelatedManagerClass : typeof RelatedManager = RelatedManager ; static validation : ValidatorDictOrFunction = { } ; static normalization : NormalizerDict = { } ; static fields : string [ ] = [ ] ; static related : RelatedDictOrFunction = { } ; static _cache : any = { } ; static _uuid : string ; _attributes : Record < string , any > = { } ; uuid : string ; attributes : Record < string , any > = { } ; managers : Record < string , RelatedManager > = { } ; changes : Record < string , any > = { } ; constructor ( attributes = { } , options = { } ) { const Ctor = this . getConstructor ( ) ; if ( typeof Ctor . client !== 'object' ) { throw new exceptions . ImproperlyConfiguredError ( "Resource can't be used without Client class instance" ) ; } this . _attributes = { } ; let _attrKeys = Object . keys ( attributes ) ; let _defaults = Ctor . makeDefaultsObject ( ) ; this . attributes = new Proxy ( this . _attributes , { set : ( receiver , key , value ) => { receiver [ key ] = this . toInternalValue ( key , value ) ; return true ; } , get : ( receiver , key ) => { return receiver [ key ] ; } , defineProperty : ( target , prop , descriptor ) => { return Reflect . defineProperty ( target , prop , descriptor ) ; } , } ) ; for ( let defaultsKey in _defaults ) { if ( _attrKeys . includes ( defaultsKey ) ) { continue ; } this . attributes [ defaultsKey ] = _defaults [ defaultsKey ] ; } for ( let attrKey in attributes ) { this . attributes [ attrKey ] = attributes [ attrKey ] ; } this . changes = { } ; for ( let relAttrKey in Ctor . getRelatedClasses ( ) ) { this . managers [ relAttrKey ] = this . createManagerFor ( relAttrKey ) ; } if ( this . id ) { Ctor . cacheResource ( this ) ; } } /**
     * Cache getter
     */ static get cache ( ) { let ParentClass = Object . getPrototypeOf ( this ) ; if ( ! this . _cache || this . _cache === ParentClass . _cache ) { this . _cache = { } ; } return new Proxy ( this . _cache , { set : ( receiver , key , value ) => { receiver [ key ] = value ; return true ; } , get : ( receiver , key ) => { return receiver [ key ] ; } , defineProperty : ( target , prop , descriptor ) => { return Reflect . defineProperty ( target , prop , descriptor ) ; } , } ) ; } static get uuid ( ) { if ( ! this . _uuid ) { this . _uuid = uuidWeak ( ) ; return this . _uuid ; } return this . _uuid ; } param resource param replace static cacheResource < T extends typeof Resource > ( this , resource , replace = false ) { if ( ! resource . id ) { throw new exceptions . CacheError ( `Can't cache ${ resource . toResourceName ( ) } resource without ${ resource . getConstructor ( ) . uniqueKey } field` ) ; } else if ( replace ) { try { return this . replaceCache < InstanceType < T > > ( resource ) ; } catch ( e ) { } } this . cache [ resource . id ] = { resource , expires : this . cacheDeltaSeconds ( ) , } ; } param resource static replaceCache < T extends Resource > ( resource ) { if ( ! this . cache [ resource . id ] ) { throw new exceptions . CacheError ( "Can't replace cache: resource doesn't exist" ) ; } Object . assign ( this . cache [ resource . id ] . resource . attributes , resource . attributes ) ; this . cache [ resource . id ] . expires = this . cacheDeltaSeconds ( ) ; } static clearCache ( ) { this . _cache = { } ; } /**
     * Get time delta in seconds of cache expiry
     */ static cacheDeltaSeconds ( ) { return Date . now ( ) + this . cacheMaxAge * 1000 ; } param id static getCached < T extends typeof Resource > ( this , id ) { const cached = this . cache [ String ( id ) ] ; if ( cached && cached . expires > Date . now ( ) ) { return cached as CachedResource < InstanceType < T > > ; } return undefined ; } static getCachedAll < T extends typeof Resource > ( this ) { return Object . keys ( this . cache ) . map ( ( cacheKey ) => this . getCached ( cacheKey ) ) . filter ( ( valid ) => ! ! valid ) ; } /**
     * Backwards compatibility
     * Remove in next major release @todo
     */ static get validators ( ) { return this . validation ; } /**
     * Backwards compatibility
     * Remove in next major release @todo
     */ static set validators ( value ) { this . validation = value ; } param query static getListRoutePath ( query ? ) { if ( query && Object . keys ( query ) . length ) { let qs = stringify ( query ) ; return `${ this . endpoint }?${ qs }` ; } return this . endpoint ; } param id param query static getDetailRoutePath ( id , query ? ) { let qs = stringify ( query ) ; return `${ this . endpoint }/${ String ( id ) }${ query && Object . keys ( query ) . length ? '?' : '' }${ qs }` ; } param options returns static list < T extends typeof Resource > ( this , options = { } ) { return this . client . list < T > ( this as T , options ) . then ( ( result ) => { if ( options . resolveRelated || options . resolveRelatedDeep ) { let deep = ! ! options . resolveRelatedDeep ; const promises = [ ] ; result . resources . forEach ( ( resource ) => { promises . push ( resource . resolveRelated ( { deep } ) ) ; } ) ; return Promise . all ( promises ) . then ( ( ) => result ) ; } return result ; } ) ; } static detail < T extends typeof Resource > ( this , id , options = { } ) { const cached = this . getCached ( String ( id ) ) ; return new Promise ( ( resolve , reject ) => { if ( ! cached || options . useCache === false ) { const queueHashKey = this . getResourceHashKey ( String ( id ) ) ; if ( ! this . queued [ queueHashKey ] ) { this . queued [ queueHashKey ] = [ ] ; this . client . detail ( this , String ( id ) , options ) . then ( async ( result ) => { const correctResource = < InstanceType < T > > result . resources . pop ( ) ; if ( options . resolveRelated || options . resolveRelatedDeep ) { let deep = ! ! options . resolveRelatedDeep ; await correctResource . resolveRelated ( { deep } ) ; } setTimeout ( ( ) => resolve ( correctResource ) , 0 ) ; this . queued [ queueHashKey ] . forEach ( ( deferred ) => { deferred ( correctResource ) ; } ) ; } ) . catch ( ( e ) => { reject ( e ) ; } ) . finally ( ( ) => { delete this . queued [ queueHashKey ] ; } ) ; } else { const deferredPromise = new Promise ( ( resolveDeferred ) => { this . queued [ queueHashKey ] . push ( resolveDeferred ) ; } ) ; resolve ( deferredPromise ) ; } } else { const cachedResource = cached . resource ; if ( options . resolveRelated || options . resolveRelatedDeep ) { let deep = ! ! options . resolveRelatedDeep ; cached . resource . resolveRelated ( { deep } ) . then ( ( ) => resolve ( cachedResource ) ) ; } else { resolve ( cachedResource ) ; } } } ) ; } static wrap ( relativePath , query ? ) { assert ( relativePath && relativePath [ 0 ] === '/' , `Relative path "${ relativePath }" must start with a "/"` ) ; let relEndpoint = this . endpoint + relativePath ; if ( query && Object . keys ( query ) . length ) { let qs = stringify ( query ) ; relEndpoint = `${ relEndpoint }?${ qs }` ; } return this . client . bindMethodsToPath ( relEndpoint ) ; } static toResourceName ( ) { if ( this . name . match ( /^class_/ ) ) { return `ResourceClass(${ this . endpoint })` ; } return this . name ; } static makeDefaultsObject ( ) { let defaults = { } ; for ( let key in this . defaults ) { let thisDefault = this . defaults [ key ] ; if ( 'function' === typeof thisDefault ) { defaults [ key ] = thisDefault . call ( null ) ; } else { defaults [ key ] = thisDefault ; } } return defaults ; } param resourceId static getResourceHashKey ( resourceId ) { assert ( Boolean ( resourceId ) , "Can't generate resource hash key with an empty Resource ID. Please ensure Resource is saved first." ) ; return Buffer . from ( `${ this . uuid }:${ String ( resourceId ) }` ) . toString ( 'base64' ) ; } private static getRelatedClasses ( ) { if ( 'function' === typeof this . related ) { return this . related ( ) as RelatedDict ; } return this . related as RelatedDict ; } private static getValidatorObject ( ) { if ( 'function' === typeof this . validation ) { return this . validation ( ) as ValidatorDict ; } return this . validation as ValidatorDict ; } static extend < T , U > ( this , classProps ) { return Object . assign ( class extends this { } , classProps ) ; } param key param value set ( key , value ) { this . attributes [ key ] = value ; return this ; } param get < T = any > ( key ? ) { if ( typeof key !== 'undefined' ) { const pieces = key . split ( '.' ) ; const thisKey = String ( pieces . shift ( ) ) ; const thisValue = this . attributes [ thisKey ] ; const manager = this . rel ( thisKey ) ; if ( pieces . length > 0 ) { if ( ! manager ) { throw new exceptions . ImproperlyConfiguredError ( `No relation found on ${ this . toResourceName ( ) }[${ thisKey }]. Did you define it on ${ this . toResourceName ( ) }.related?` ) ; } if ( ! manager . hasValues ( ) ) { return undefined ; } if ( manager . many ) { return manager . resources . map ( ( thisResource ) => { return thisResource . get ( pieces . join ( '.' ) ) ; } ) as any ; } return manager . resource . get ( pieces . join ( '.' ) ) ; } else if ( Boolean ( thisValue ) && manager ) { return ( ! manager . many && manager . resolved ? manager . resource : manager ) as any ; } else { return thisValue ; } } else { const managers = Object . keys ( this . managers ) ; const obj = Object . assign ( { } , this . attributes ) ; while ( managers . length ) { const key = String ( managers . shift ( ) ) ; const manager = this . rel ( key ) ; if ( manager . many ) { obj [ key ] = manager . resources . map ( ( subResource ) => subResource . get ( ) ) ; } else if ( ! manager . many && manager . resource ) { obj [ key ] = manager . resource . get ( ) ; } } return obj as T ; } } param key resolveAttribute < T = any > ( key ) { return new Promise ( ( resolve , reject ) => { try { resolve ( this . get ( key ) ) ; } catch ( e ) { if ( e . name === 'AttributeError' ) { const pieces = key . split ( '.' ) ; const thisKey = String ( pieces . shift ( ) ) ; const manager = this . rel ( thisKey ) ; manager . resolve ( ) . then ( ( ) => { let relatedKey = pieces . join ( '.' ) ; let promises = manager . resources . map ( ( resource ) => { return resource . resolveAttribute ( relatedKey ) ; } ) ; Promise . all ( promises ) . then ( ( values ) => { if ( manager . many ) { resolve ( values as any ) ; } else if ( values . length === 1 ) { resolve ( values [ 0 ] as any ) ; } else { resolve ( values as any ) ; } } ) ; } ) ; } else { reject ( e ) ; } } } ) ; } param key getAsync < T = any > ( key ) { return this . resolveAttribute < T > ( key ) ; } param key param value toInternalValue ( key , value ) { let currentValue = this . attributes [ key ] ; let newValue = value ; let Ctor = this . getConstructor ( ) ; if ( ! _ . isEqual ( currentValue , newValue ) ) { if ( newValue && this . rel ( key ) instanceof RelatedManager ) { let manager = this . rel ( key ) . fromValue ( newValue ) ; newValue = manager . toJSON ( ) ; this . managers [ key ] = manager ; } this . changes [ key ] = newValue ; } if ( 'undefined' !== typeof Ctor . normalization [ key ] ) { let normalizer = < BaseNormalizer | Function & { normalize : ( ) => any ; } > Ctor . normalization [ key ] ; if ( 'function' === typeof normalizer . normalize ) { newValue = normalizer . normalize ( newValue ) ; } else if ( 'function' === typeof normalizer ) { newValue = normalizer ( newValue ) ; } if ( this . changes [ key ] ) { this . changes [ key ] = newValue ; } } return newValue ; } /**
     * Like calling instance.constructor but safer:
     * changing objects down the line won't creep up the prototype chain and end up on native global objects like Function or Object
     */ getConstructor < T extends typeof Resource > ( ) { if ( this . constructor === Function ) { return Resource as T ; } return this . constructor as T ; } param options resolveRelated ( { deep = false , managers = [ ] } = { } ) { const promises = [ ] ; for ( const resourceKey in this . managers ) { if ( Array . isArray ( managers ) && managers . length > 0 && ! ~ managers . indexOf ( resourceKey ) ) { continue ; } const manager = this . rel ( resourceKey ) ; const promise = manager . resolve ( ) . then ( ( objects ) => { if ( deep ) { let otherPromises = objects . map ( ( resource ) => resource . resolveRelated ( { deep , managers } ) ) ; return Promise . all ( otherPromises ) . then ( ( ) => { return void { } ; } ) ; } else { return void { } ; } } ) ; promises . push ( promise ) ; } return Promise . all ( promises ) . then ( ( ) => void { } ) ; } param options resolveRelatedDeep ( options ? ) { const opts = Object . assign ( { deep : true } , options || { } ) ; return this . resolveRelated ( opts ) ; } param key rel < T extends typeof Resource > ( key ) { return this . managers [ key ] as RelatedManager < T > ; } param relatedKey createManagerFor ( relatedKey ) { let Ctor = this . getConstructor ( ) ; let related = Ctor . getRelatedClasses ( ) ; let to = related [ relatedKey ] ; let nested = false ; try { if ( 'object' === typeof to ) { let relatedLiteral = to as RelatedLiteral ; to = relatedLiteral . to ; nested = ! ! relatedLiteral . nested ; } assert ( 'function' === typeof to , `Couldn't find RelatedResource class with key "${ relatedKey }". Does it exist?` ) ; let RelatedManagerCtor = to . RelatedManagerClass ; let relatedManager = new RelatedManagerCtor ( to , this . _attributes [ relatedKey ] ) ; if ( nested && relatedManager . canAutoResolve ( ) ) { relatedManager . resolveFromObjectValue ( ) ; } return relatedManager ; } catch ( e ) { if ( e instanceof assert . AssertionError ) { e . message = `${ e . message } -- Relation: ${ this . toResourceName ( ) }.related[${ relatedKey }]` ; } throw e ; } } /**
     * Saves the instance -- sends changes as a PATCH or sends whole object as a POST if it's new
     */ save < T extends this > ( options = { } ) { let promise ; const Ctor = this . getConstructor ( ) ; let errors = this . validate ( ) ; let fields = options . fields || Ctor . fields ; if ( errors . length > 0 && ! options . force ) { throw new exceptions . ValidationError ( errors ) ; } if ( this . isNew ( ) ) { let attrs = fields . length ? _ . pick ( this . attributes , fields ) : this . attributes ; promise = Ctor . client . post ( Ctor . getListRoutePath ( ) , attrs ) ; } else if ( options . partial === false ) { let attrs = fields . length ? _ . pick ( this . attributes , fields ) : this . attributes ; promise = Ctor . client . put ( Ctor . getDetailRoutePath ( this . id ) , attrs ) ; } else { let attrs = fields . length ? _ . pick ( this . changes , fields ) : this . changes ; promise = Ctor . client . patch ( Ctor . getDetailRoutePath ( this . id ) , attrs ) ; } return promise . then ( ( response ) => { this . changes = { } ; for ( const resKey in response . data ) { this . set ( resKey , response . data [ resKey ] ) ; } if ( this . id ) { this . cache ( options . replaceCache === false ? false : true ) ; } return { response , resources : [ this ] , } as ResourceResponse < T > ; } ) ; } returns validate ( ) { let errs = [ ] ; let validators = this . getConstructor ( ) . getValidatorObject ( ) ; let tryFn = ( func , key ) => { try { func . call ( null , this . attributes [ key ] , this , exceptions . ValidationError ) ; } catch ( e ) { errs . push ( e ) ; } } ; for ( let key in validators ) { if ( 'function' === typeof validators [ key ] ) { tryFn ( validators [ key ] as ValidatorFunc , key ) ; } else if ( Array . isArray ( validators [ key ] ) ) { let validatorArray = validators [ key ] as ValidatorFunc [ ] ; for ( let vKey in validatorArray ) { tryFn ( validatorArray [ vKey ] , key ) ; } } } return errs ; } update < T extends Resource > ( this ) { return this . getConstructor ( ) . detail ( this . id , { useCache : false } ) . then ( ( resource ) => { for ( let key in resource . attributes ) { this . attributes [ key ] = resource . attributes [ key ] ; } } ) as Promise < T > ; } delete ( options ? ) { return this . getConstructor ( ) . client . delete ( this . getConstructor ( ) . getDetailRoutePath ( this . id ) , options ) ; } cache < T extends Resource > ( this , replace = false ) { this . getConstructor ( ) . cacheResource ( this , ! ! replace ) ; return this ; } getCached < T extends Resource > ( this ) { return this . getConstructor ( ) . getCached ( this . id ) as CachedResource < T > ; } wrap ( relativePath , query ? ) { assert ( relativePath && relativePath [ 0 ] === '/' , `Relative path "${ relativePath }" must start with a "/"` ) ; assert ( this . id , "Can't look up a relative route on a resource that has not been created yet." ) ; let Ctor = this . getConstructor ( ) ; let thisPath = '/' + this . id + relativePath ; return Ctor . wrap ( thisPath , query ) ; } isNew ( ) { return ! this . id ; } get id ( ) : string { let uniqueKey = this . getConstructor ( ) . uniqueKey ; return this . attributes [ uniqueKey ] ; } set id ( value ) { throw new exceptions . AttributeError ( 'Cannot set ID manually. Set ID by using attributes[id] = value' ) ; } toString ( ) { return `${ this . toResourceName ( ) } ${ this . id || '(New)' }` ; } toResourceName ( ) { return this . getConstructor ( ) . toResourceName ( ) ; } toJSON ( ) { return this . get ( ) ; } } export type TypeOrFunctionReturningType < T > = ( ( ) => T ) | T ; export type RelatedDict = Record < string , typeof Resource | RelatedLiteral > ; export type RelatedDictOrFunction = TypeOrFunctionReturningType < RelatedDict > ; export interface RelatedLiteral { to : typeof Resource ; nested ? : boolean ; } export type ValidatorFunc = ( value ? : any , resource ? : Resource , validationExceptionClass ? : typeof exceptions . ValidationError ) => void ; export type ValidatorDict = Record < string , ValidatorFunc | ValidatorFunc [ ] > ; export type ValidatorDictOrFunction = TypeOrFunctionReturningType < ValidatorDict > ; export interface CachedResource < T extends Resource > { expires : number ; resource : T ; } export interface SaveOptions { partial ? : boolean ; replaceCache ? : boolean ; force ? : boolean ; fields ? : any ; } export interface ResolveRelatedOpts { managers ? : string [ ] ; deep ? : boolean ; } export type ListOpts = RequestConfig & { resolveRelated ? : boolean ; resolveRelatedDeep ? : boolean ; } ; export type ListResponse < T extends typeof Resource > = Promise < ResourceResponse < InstanceType < T > , any > > ; export type DetailOpts = RequestConfig & { resolveRelated ? : boolean ; resolveRelatedDeep ? : boolean ; } ;