diaspora-orm/diaspora

View on GitHub
src/adapters/webApi/adapter.ts

Summary

Maintainability
A
2 hrs
Test Coverage
import { map, forIn, assign, omit, isArray, isUndefined, isNil, get } from 'lodash';

import { Adapter as _AAdapter } from '../base';
import AAdapter = _AAdapter.Base.AAdapter;
import { Adapter as _WebApiEntity } from './entity';
import WebApiEntity = _WebApiEntity.WebApi.WebApiEntity;
import { Adapter as _DefaultQueryTransformerFactory } from './defaultQueryTransformer';

import { logger } from '../../logger';
import { _QueryLanguage } from '../../types/queryLanguage';
import { IEntityProperties, IEntityAttributes } from '../../types/entity';
import * as WebApiTypes from './types';

export namespace Adapter.WebApi {
    /**
     * Adapter for RESTful HTTP APIs.
     *
     * @see https://www.npmjs.com/package/%40diaspora/plugin-server Diaspora Server plugin: Package built on Diaspora & Express.js to easily configure HTTP APIs compatible with this adapter.
     */
    export abstract class AWebApiAdapter extends AAdapter<WebApiEntity> {
        protected static readonly httpErrorFactories = {
            400: ( response: WebApiTypes.IErrorMessage, statusCode: number ) =>
            new Error(
                `Bad Request: Posted data through HTTP is invalid; message ${AWebApiAdapter.getMessage( response )}`
            ),
            404: ( response: WebApiTypes.IErrorMessage, statusCode: number ) =>
            new Error(
                `Not Found: Reached 404, message is ${AWebApiAdapter.getMessage( response )}`
            ),
            _: ( response: WebApiTypes.IErrorMessage, statusCode: number ) =>
            new Error(
                `Unhandled HTTP error with status code ${statusCode} & message ${AWebApiAdapter.getMessage( response )}`
            ),
        };
        
        private readonly baseEndPoint: string;
        
        /**
         * Hash mapping singular API names to plural API names
         *
         * @author Gerkin
         */
        private readonly pluralApis: { [key: string]: string };
        
        /**
         * Create a new instance of web api adapter.
         *
         * @param config         - Configuration of this adapter.
         * @param eventProviders - Event providers that will catch requests and pre/post process around them.
         * @author Gerkin
         */
        public constructor(
            dataSourceName: string,
            config: AWebApiAdapter.IWebApiAdapterConfig,
            eventProviders: AWebApiAdapter.IEventProvider[] = [
                AWebApiAdapter.DefaultQueryTransformerFactory(),
            ]
        ) {
            super( WebApiEntity, dataSourceName );
            const transformedConfig = this.normalizeConfig( config );
            if ( typeof transformedConfig.baseEndPoint === 'undefined' ) {
                this.baseEndPoint = this.generateBaseEndPoint(
                    transformedConfig.scheme,
                    transformedConfig.host,
                    transformedConfig.port,
                    transformedConfig.path
                );
            } else {
                this.baseEndPoint = transformedConfig.baseEndPoint;
            }
            
            this.pluralApis = config.pluralApis || {};
            
            // Bind lifecycle events
            map( eventProviders, eventProvider =>
                forIn( eventProvider, ( listener, event ) => this.on( event, listener ) )
            );
            
            if ( this.has( 'initialize' ) ) {
                this.emit( 'initialize' )
                .then( () => this.emit( 'ready' ) )
                .catch( err => this.emit( 'error', err ) );
            } else {
                this.emit( 'ready' ).catch( err => this.emit( 'error', err ) );
            }
        }
        
        /**
         * Triggers the `beforeQuery` event that serves to generate the API call description
         * 
         * @param apiDesc - Raw description sent by the adapter
         */
        private beforeQuery(
            apiDesc: WebApiTypes.IQueryDescriptorRaw
        ): Promise<WebApiTypes.IQueryDescriptor> {
            const filteredApiDescOptions = AWebApiAdapter.transformQueryOptions( apiDesc.options );
            return this.emit( 'beforeQuery', assign(
                omit( apiDesc, ['options'] ),
                { options: filteredApiDescOptions }
            ) );
        }
        
        /**
         * Gets the field 'message' in an XHR
         *
         * @param xhr - XHR to get field from
         */
        protected static getMessage( response: { message?: string } ) {
            return `"${response.message || 'NULL'}"`;
        }

        /**
         * Converts the user-provided universal config to an adapter-specific one.
         * 
         * @author Gerkin
         * @param options - The user-provided config to transform
         */
        protected abstract normalizeConfig( options:WebApiTypes.IWebApiAdapterConfig ): WebApiTypes.IWebApiAdapterInternalConfig;
        
        /**
         * Filters the query object for non-http query relevant informations
         *
         * @param queryFind - Selection query object
         * @param options   - Options object
         */
        private static transformQueryOptions(
            options: _QueryLanguage.IQueryOptions
        ): WebApiTypes.QueryOptions {
            if ( 0 === options.skip ) {
                delete options.skip;
            }
            if ( !isFinite( options.limit ) ) {
                delete options.limit;
            }
            
            return omit( options, ['remapInput', 'remapOutput'] );
        }
        
        /**
         * Manipulate a raw query response to check if it contains several elements, then define local required properties
         * 
         * @author Gerkin
         * @param entity - The raw entity sent by the API
         */
        private postProcessManyQuery( entities: WebApiTypes.TEntitiesJsonResponse ) {
            if ( !isArray( entities ) && !isUndefined( entities ) ){
                throw new TypeError( `The API should have returned "undefined" or an array, but received ${JSON.stringify( entities )}` );
            }
            if ( !isNil( entities ) ) {
                return map( entities, entity => this.postProcessOneQuery( entity ) as IEntityProperties );
            } else {
                return [];
            }
        }

        /**
         * Manipulate a raw query response to check if it contains a single element, then define local required properties
         * 
         * @author Gerkin
         * @param entity - The raw entity sent by the API
         */
        private postProcessOneQuery( entity: WebApiTypes.TEntitiesJsonResponse ) {
            if ( isArray( entity ) ){
                throw new TypeError( `The API should have returned "undefined" or a single plain object, but received ${JSON.stringify( entity )}` );
            }
            if ( !isNil( entity ) ) {
                entity.idHash = { [this.name]: entity.id };
            }
            return entity;
        }
        
        // -----
        // ### Insert
        
        /**
         * Insert a single entity through an HTTP API.
         *
         * @summary This reimplements {@link Adapters.DiasporaAdapter#insertOne}, modified for use of web api.
         * @author gerkin
         * @param   table  - Name of the table to insert data in.
         * @param   entity - Hash representing the entity to insert.
         * @returns Promise resolved once insertion is done. Called with (*{@link WebApiEntity}* `entity`).
         */
        public async insertOne(
            table: string,
            entity: IEntityAttributes
        ): Promise<IEntityProperties | undefined> {
            const newEntity = await this.apiQuery( WebApiTypes.EHttpVerb.POST, table, entity );
            return this.postProcessOneQuery( newEntity );
        }
        
        /**
         * Insert several entities through an HTTP API.
         *
         * @summary This reimplements {@link Adapters.DiasporaAdapter#insertMany}, modified for use of web api.
         * @author gerkin
         * @param   table    - Name of the table to insert data in.
         * @param   entities - Hash representing entities to insert.
         * @returns Promise resolved once insertion is done. Called with (*{@link WebApiEntity[]}* `entities`).
         */
        public async insertMany(
            table: string,
            entities: IEntityAttributes[]
        ): Promise<IEntityProperties[]> {
            const newEntities = await this.apiQuery( WebApiTypes.EHttpVerb.POST, this.getPluralEndpoint( table ), entities );
            return this.postProcessManyQuery( newEntities );
        }
        
        // -----
        // ### Find
        
        /**
         * Find a single entity from the web api store
         *
         * @summary This reimplements {@link Adapters.DiasporaAdapter#findOne}, modified for use of web api.
         * @author gerkin
         * @param   table     - Name of the table to retrieve data from.
         * @param   queryFind - Hash representing the entity to find.
         * @param   options   - Hash of options.
         * @returns Promise resolved once item is found. Called with (*{@link InMemoryEntity}* `entity`).
         */
        public async findOne(
            table: string,
            queryFind: _QueryLanguage.ISelectQuery,
            options: _QueryLanguage.IQueryOptions
        ): Promise<IEntityProperties | undefined> {
            const { apiDesc } = await this.beforeQuery( {
                queryType: 'find',
                queryNum: 'one',
                modelName: table,
                select: queryFind,
                options: options,
                apiDesc: undefined as any,
            } );
            const entity = await this.apiQuery( apiDesc.method, apiDesc.endPoint, apiDesc.body, apiDesc.queryString );
            return this.postProcessOneQuery( entity );
        }
        
        /**
         * Find several entities from the web api store
         *
         * @summary This reimplements {@link Adapters.DiasporaAdapter#findMany}, modified for use of web api.
         * @author gerkin
         * @param   table     - Name of the table to retrieve data from.
         * @param   queryFind - Hash representing entities to find.
         * @param   options   - Hash of options.
         * @returns Promise resolved once items are found. Called with (*{@link InMemoryEntity}[]* `entities`).
         */
        public async findMany(
            table: string,
            queryFind: _QueryLanguage.ISelectQuery,
            options: _QueryLanguage.IQueryOptions
        ): Promise<IEntityProperties[]> {
            const { apiDesc } = await this.beforeQuery( {
                queryType: 'find',
                queryNum: 'many',
                modelName: table,
                select: queryFind,
                options: options,
                apiDesc: undefined as any,
            } );
            const entities = await this.apiQuery( apiDesc.method, apiDesc.endPoint, apiDesc.body, apiDesc.queryString );
            return this.postProcessManyQuery( entities );
        }
        
        // -----
        // ### Update
        
        /**
         * Update a single entity from the web api store
         *
         * @summary This reimplements {@link Adapters.DiasporaAdapter#updateOne}, modified for use of web api.
         * @author gerkin
         * @param   table     - Name of the table to update data in.
         * @param   queryFind - Hash representing the entity to find.
         * @param   update    - Object properties to set.
         * @param   options   - Hash of options.
         * @returns  Promise resolved once update is done. Called with (*{@link InMemoryEntity}* `entity`).
         */
        public async updateOne(
            table: string,
            queryFind: _QueryLanguage.ISelectQuery,
            update: IEntityAttributes,
            options: _QueryLanguage.IQueryOptions
        ): Promise<IEntityProperties | undefined> {
            const { apiDesc } = await this.beforeQuery( {
                queryType: 'update',
                queryNum: 'one',
                modelName: table,
                select: queryFind,
                update,
                options: options,
                apiDesc: undefined as any,
            } );
            const entity = await this.apiQuery( apiDesc.method, apiDesc.endPoint, apiDesc.body, apiDesc.queryString );
            return this.postProcessOneQuery( entity );
        }
        
        /**
         * Update several entities from the web api store
         *
         * @summary This reimplements {@link Adapters.DiasporaAdapter#updateMany}, modified for use of web api.
         * @author gerkin
         * @param   table     - Name of the table to update data in.
         * @param   queryFind - Hash representing entities to find.
         * @param   update    - Object properties to set.
         * @param   options   - Hash of options.
         * @returns Promise resolved once update is done. Called with (*{@link InMemoryEntity}[]* `entities`).
         */
        public async updateMany(
            table: string,
            queryFind: _QueryLanguage.ISelectQuery,
            update: IEntityAttributes,
            options: _QueryLanguage.IQueryOptions
        ): Promise<IEntityProperties[]> {
            const { apiDesc } = await this.beforeQuery( {
                queryType: 'update',
                queryNum: 'many',
                modelName: table,
                select: queryFind,
                update,
                options: options,
                apiDesc: undefined as any,
            } );
            const entities = await this.apiQuery( apiDesc.method, apiDesc.endPoint, apiDesc.body, apiDesc.queryString );
            return this.postProcessManyQuery( entities );
        }
        
        // -----
        // ### Delete
        
        /**
         * Destroy a single entity from the web api store
         *
         * @summary This reimplements {@link Adapters.DiasporaAdapter#deleteOne}, modified for use of web api.
         * @author gerkin
         * @param   table     - Name of the table to delete data from.
         * @param   queryFind - Hash representing the entity to find.
         * @param   options   - Hash of options.
         * @returns Promise resolved once item is found. Called with (*undefined*).
         */
        public async deleteOne(
            table: string,
            queryFind: _QueryLanguage.ISelectQuery,
            options: _QueryLanguage.IQueryOptions
        ): Promise<void> {
            const { apiDesc } = await this.beforeQuery( {
                queryType: 'delete',
                queryNum: 'one',
                modelName: table,
                select: queryFind,
                options: options,
                apiDesc: undefined as any,
            } );
            await this.apiQuery( apiDesc.method, apiDesc.endPoint, apiDesc.body, apiDesc.queryString );
        }
        
        /**
         * Destroy several entities from the web api store
         *
         * @summary This reimplements {@link Adapters.DiasporaAdapter#deleteMany}, modified for use of web api.
         * @author gerkin
         * @param   table     - Name of the table to delete data from.
         * @param   queryFind - Hash representing entities to find.
         * @param   options   - Hash of options.
         * @returns Promise resolved once items are deleted. Called with (*undefined*).
         */
        public async deleteMany(
            table: string,
            queryFind: _QueryLanguage.ISelectQuery,
            options: _QueryLanguage.IQueryOptions
        ): Promise<void> {
            const { apiDesc } = await this.beforeQuery( {
                queryType: 'delete',
                queryNum: 'many',
                modelName: table,
                select: queryFind,
                options: options,
                apiDesc: undefined as any,
            } );
            await this.apiQuery( apiDesc.method, apiDesc.endPoint, apiDesc.body, apiDesc.queryString );
        }
        
        /**
         * Creates a request, send it and get the result
         *
         * @param method      - HTTP verb that describes the request type
         * @param endPoint    - Url to send on
         * @param data        - Object to send
         * @param queryObject - Object to put in query string
         */
        protected abstract async httpRequest(
            method: WebApiTypes.EHttpVerb,
            endPoint: string,
            data?: object,
            queryObject?: object
        ): Promise<WebApiTypes.TEntitiesJsonResponse>;
        
        /**
         * Generates the URL to the base of the API
         *
         * @param scheme - Represents the protocol (`http` or `https` most of the time)
         * @param host   - Hostname to target (domain name, IP, ...)
         * @param port   - Port of the target to point to
         * @param path   - Absolute URI on the API
         * @returns The URI string to the base of the API
         */
        protected generateBaseEndPoint(
            scheme: string | false,
            host: string | false,
            port: number | false,
            path: string
        ) {
            const portString = port ? `:${port}` : '';
            const schemeString = scheme ? `${scheme}:` : '';
            return `${schemeString}//${host}${portString}${path}`;
        }
        
        /**
         * Send an http query to the targeted `endPoint` using `method` as verb.
         *
         * @async
         * @param   verb        - Valid HTTP verb. This adapter uses `GET`, `POST`, `PATCH` & `DELETE`.
         * @param   endPoint    - Name of the endpoint to interact with. It will be prepended with {@link Adapters.WebApiDiasporaAdapter#baseEndPoint}.
         * @param   data        - Optionnal data to send within the body of the request.
         * @param   queryObject - Optionnal query object to send along with the request.
         * @returns Promise resolved with the resulting data.
         */
        private apiQuery(
            verb: WebApiTypes.EHttpVerb,
            endPoint: string,
            data?: object,
            queryObject?: object
        ): Promise<WebApiTypes.TEntitiesJsonResponse> {
            return this.sendRequest( verb, `${this.baseEndPoint}/${endPoint.toLowerCase()}`, data, queryObject );
        }
        
        /**
         * Send the query, and log inputs & outputs
         *
         * @see WebApiAdapter.httpRequest
         * @author Gerkin
         * @param method      - HTTP verb that describes the request type
         * @param endPoint    - Url to send on
         * @param data        - Object to send
         * @param queryObject - Object to put in query string
         */
        private async sendRequest(
            verb: WebApiTypes.EHttpVerb,
            endPoint: string,
            body?: object,
            queryString?: object
        ) {
            logger.verbose( `Sending ${verb} HTTP request to "${endPoint}" with data:`, {
                body,
                queryString,
            } );
            const response = await this.httpRequest( verb, endPoint, body, queryString );
            logger.silly( 'HTTP Request response:', response );
            return response;
        }
        
        /**
         * Returns the endpoint that corresponds to the plural variant of the provided endpoint.
         *
         * @see WebApiAdapter.pluralApis
         * @author Gerkin
         * @param endpoint - Name of the endpoint
         */
        private getPluralEndpoint( endpoint: string ): string {
            return get( this.pluralApis, endpoint, endpoint + 's' );
        }
        
        /**
         * Generates the error corresponding to the status code & message.
         *
         * @author Gerkin
         * @param response   - Object containing the raw response of the server
         * @param statusCode - Status code of the response
         * @returns the constructed error to throw
         */
        protected static handleError(
            response: WebApiTypes.IErrorMessage | undefined,
            statusCode: number
        ) {
            // Retrieve the function that will generate the error
            const errorBuilder = get(
                AWebApiAdapter.httpErrorFactories,
                statusCode,
                AWebApiAdapter.httpErrorFactories._
            );
            return errorBuilder( response || {}, statusCode );
        }
    }

    export namespace AWebApiAdapter {
        export import IWebApiAdapterConfig = WebApiTypes.IWebApiAdapterConfig;
        export import IEventProvider = WebApiTypes.IEventProvider;
        export import DefaultQueryTransformerFactory = _DefaultQueryTransformerFactory.WebApi.DefaultQueryTransformerFactory;
    }
}