Source: Vra/index.js

import axios from 'axios';

import {
    parseSingle as parseSingleDefault,
    parseMultiple as parseMultipleDefault,
    parseBinary as parseBinaryDefault,
} from './parsers/index';

/**
 * Vra class initilisation options.
 *
 * @typedef {object} VraOptions
 * @property {string} baseUrl - URL of the endpoint, without the models ID.
 * @property {Array<string>|string} only - Which actions to create for this model.
 * @property {Array<string>|string} except - Which actions not to create for this model.
 * @property {string} identifier - The identifier field, e.g. `id`.
 * @property {object} children - Children for this module.
 * @property {boolean} singleton - Whether this is a singleton endpoint or not - i.e. Only `read` and `update` calls.
 * @property {Function} toModel - Convert API data to a model.
 * @property {object} customCalls - Custom and extra API calls to add to this model.
 *
 * @property {Function} parseSingle - Function to parse a single item returned from the API.
 * @property {Function} parseMultiple - Function to parse a multiple items returned from the API.
 * @property {Function} parseBinary - Function to parse a binary item returned from the API.
 */

/**
 * Vuex Rest API class.
 *
 * Generates Vuex stores based on the supplied config. The stores have actions
 * for fetching and returning the API data,.
 */
class Vra {
    /**
     * Allows you to change the request function to something else (e.g. To add
     * authorization headers) or to use a different HTTP library entirely. The
     * function context (`this`) is set to the same of the Vuex action.
     *
     * @param {object} requestParams - Request params for `axios.request`.
     * @returns {Promise<object>} - The result of the request.
     */
    static requestAdapter(requestParams) {
        return axios.request(requestParams);
    }

    /**
     * Sugar for creating lots of modules.
     *
     * @param {object} modules - Object of module configs.
     * @returns {object} - Namespaced Vuex modules.
     */
    static createModules(modules) {
        const Klass = this;
        const generated = {};

        Object.keys(modules).forEach(name => {
            generated[name] = new Klass(modules[name]).store;
        });

        return generated;
    }

    /**
     * Instantiate the class.
     *
     * @param {VraOptions} options
     */
    constructor({
        baseUrl = '/',
        singleton = false,
        only = singleton
            ? ['read', 'update']
            : ['index', 'create', 'read', 'update', 'destroy'],
        except = [],
        identifier = 'id',
        children = {},
        toModel = data => data,
        customCalls = {},

        parseSingle = parseSingleDefault,
        parseMultiple = parseMultipleDefault,
        parseBinary = parseBinaryDefault,
    } = {}) {
        this.baseUrl = baseUrl;
        this.identifier = identifier;
        this.calls = [];
        this.children = {};
        this.toModel = toModel;

        this.parseSingle = parseSingle;
        this.parseMultiple = parseMultiple;
        this.parseBinary = parseBinary;

        Object.keys(children).forEach(c => this.child(c, children[c]));

        const onlyCalls = Array.isArray(only) ? only : [only];
        const includeCalls = onlyCalls.filter(n => !except.includes(n));

        if (includeCalls.includes('index')) {
            this.createCall('index');
        }

        if (includeCalls.includes('create')) {
            this.createCall('create', {
                method: 'post',
                parser: parseSingle,
                identified: false,
            });
        }

        if (includeCalls.includes('read')) {
            this.createCall('read', {
                parser: parseSingle,
                identified: !singleton,
            });
        }

        if (includeCalls.includes('update')) {
            this.createCall('update', {
                method: 'patch',
                parser: parseSingle,
                identified: !singleton,
            });
        }

        if (includeCalls.includes('destroy')) {
            this.createCall('destroy', {
                method: 'delete',
                parser: parseSingle,
                identified: !singleton,
            });
        }

        Object.keys(customCalls).forEach(name => {
            this.createCall(name, customCalls[name]);
        });
    }

    /**
     * Get the URL using the baseUrl and the supplied fields.
     *
     * @param {object} fields
     * @param {string} path
     * @returns {string} Endpoint.
     */
    getUrl(fields = {}, path = '') {
        let url = this.baseUrl;
        const reqFields = (url.match(/:([a-z,_]+)/gi) || []).map(s => s.slice(1));

        reqFields.forEach(field => {
            if (fields[field] === undefined) {
                throw new Error(`You must pass the '${ field }' field`);
            }

            url = url.replace(`:${ field }`, fields[field]);
        });

        if (![null, undefined].includes(fields[this.identifier])) {
            if (url.slice(-1)[0] !== '/') {
                url += '/';
            }

            url += fields[this.identifier];
        }

        return `${ url }${ path }`;
    }

    /**
     * Add a child module to this model.
     *
     * @param {string} name - Name of the child model.
     * @param {object|Vra} child - Vra constructor options or Vra instance.
     */
    child(name, child) {
        this.children[name]
            = child instanceof this.constructor
                ? child
                : new this.constructor(child);
    }

    /**
     * Get the default parser.
     *
     * @param {object} options
     * @param {boolean} options.identified - Whether this is a parser for an identified API call or not.
     * @param {boolean} options.binary - Whether this is a parser for a binary API call or not.
     *
     * @returns {Function}
     */
    getParser({ identified, binary }) {
        if (binary) {
            return this.parseBinary;
        }

        return identified ? this.parseSingle : this.parseMultiple;
    }

    /**
     * Create an action for an endpoint.
     *
     * @param {string} name - Name of the action.
     * @param {object} options
     * @param {string} options.method - HTTP method for the call.
     * @param {Function} options.parser - Function used to parse the data from the API response.
     * @param {boolean} options.identified - Whether this endpoint needs an identifier field or not, e.g. `id`.
     * @param {string} options.path - Path for this callback, appended to baseUrl.
     * @param {boolean} options.binary - Whether this is a binary model or not.
     * @param {string} options.responseType - Override responseType, by default this is `undefined` for normal models, and `arraybuffer` for binary models.
     */
    createCall(name, {
        method = 'get',
        identified = false,
        path = '',
        binary = false,
        responseType = binary ? 'arraybuffer' : undefined,
        parser = this.getParser({ identified, binary }),
        ...rest
    } = {}) {
        this.calls.push({
            name,
            method,
            parser,
            identified,
            path,
            responseType,
            binary,
            ...rest,
        });
    }

    /**
     * Get a calls options.
     *
     * @param {string} name - Name of the action.
     * @returns {object|null}
     */
    getCall(name) {
        return this.calls.find(c => c.name === name);
    }

    /**
     * Modify an action for an endpoint.
     *
     * @param {string} name - Name of the action.
     * @param {object} options
     */
    modifyCall(name, options) {
        const call = this.getCall(name);

        if (!call) {
            return;
        }

        Object.assign(call, options);
    }

    /**
     * Instantiate a model class using the helpers if they exist.
     *
     * @param {object} fieldsOrData
     * @param {object} options
     * @param {boolean} options.binary - Whether this is a binary model or not.
     *
     * @returns {object} Model.
     */
    createModel(fieldsOrData, { binary }) {
        if (binary) {
            return fieldsOrData;
        }

        return this.toModel(fieldsOrData);
    }

    /**
     * Get child modules for this store.
     *
     * @returns {object} Modules.
     */
    get modules() {
        const modules = {};

        Object.keys(this.children).forEach(name => {
            modules[name] = this.children[name].store;
        });

        return modules;
    }

    /**
     * Get the actions for this module.
     *
     * @returns {object} Actions.
     */
    get actions() {
        const actions = {};
        const self = this;

        this.calls.forEach(call => {
            actions[call.name] = async function(context, {
                fields = {},
                params = {},
                method = call.method,
                path = call.path,
                responseType = call.responseType,
            } = {}) {
                if (call.identified) {
                    if ([null, undefined].includes(fields[self.identifier])) {
                        throw new Error(`The '${ call.name }' action requires a 'fields.${ self.identifier }' option`);
                    }
                } else if (fields[self.identifier]) {
                    throw new Error(`The '${ call.name }' action can not be used with the 'fields.${ self.identifier }' option`);
                }

                let data = undefined;

                if (['post', 'put', 'patch'].includes(method.toLowerCase())) {
                    data = Object.assign({}, fields);
                }

                const response = await self.constructor.requestAdapter.call(this, {
                    url: self.getUrl(fields, path),
                    method,
                    data,
                    params,
                    responseType,
                }, context);

                const parsed = call.parser(response.data, fields);

                if (parsed.models) {
                    const value = parsed.models.map(m => self.createModel(m, call));

                    value.meta = parsed.meta;

                    return value;
                }

                return self.createModel(parsed.model, call);
            };
        });

        return actions;
    }

    /**
     * Get the entire store for this module, for Vuex.
     *
     * @returns {object} Store.
     */
    get store() {
        const { actions, modules } = this;

        return {
            namespaced: true,
            actions,
            modules,
        };
    }
}

export default Vra;