Source

interfaces/ApiInterface.ts

import axios, {AxiosInstance, AxiosRequestConfig, AxiosResponse} from 'axios'
import {deepMerge} from 'grommet/utils'

type ErrorRecord = {
    date: string
    error: string
}

type ApiStatus = {
    inflight?: number,
    errors?: ErrorRecord[]
}

/**
 * This is the parent class for all API interfaces. An API interface
 * is the connection between your React web app and a remote JCU API.
 *
 * From this class, API interfaces inherit the ability to manage a
 * base url, have transient headers (such as user tokens) automatically
 * added to url requests, and can call convenience functions like
 * [getData]{@link ApiInterface#getData} and
 * [postData]{@link ApiInterface#postData}.
 *
 * Use this {@link ApiInterface} by:
 *
 * - Creating a new API interface class that extends this
 *      {@link ApiInterface} parent class (see example below)
 * - Setting static values {@link interfaceName} and
 *      {@link interfaceVersion} in your new class
 * - Defining your interface's configuration in your application's
 *      config file (see {@link appConfigSchema.js} for info about
 *      API interface configurations)
 * - Importing your new interface into {@link ApiManager} and calling
 *      {@link addInterface} to add it to the library of API interfaces.
 *
 * Once you've done these steps, your new interface will be available
 * from the {@link ApiManager}'s {@link getApiInterface}, retrievable
 * using the {@link interfaceName} and {@link interfaceVersion} you gave it.
 *
 * ### Example API interface using this parent class
 *
 * ```js
 * import { deepMerge } from 'grommet/utils'
 * import { ApiInterface } from '@jcu/spark'
 *
 * class ExampleInterface extends ApiInterface {
 *     static interfaceName = 'example-api'  // name of this API interface
 *     static interfaceVersion = '0.1.0'     // version of this interface implementation
 *
 *     // Optional defaults for configuration.
 *     // Items specified here can be overridden from the
 *     // config file.
 *     static defaultConfig = {
 *         dateFormat: "YYYY-MM-DDThh:mm"
 *     }
 *
 *     constructor(config) {
 *         const superConfig = deepMerge(ExampleInterface.defaultConfig, config)
 *         super(superConfig)
 *     }
 *
 *     // API-specific action (fetching Examples)
 *     // should (almost) always be asynchronous
 *     async fetchExampleData(options = {}) {
 *         let exampleData = this.getData('')
 *         .then( (response) => {
 *              let info = response.data
 *              // do data manipulation here, e.g. sorting, filtering...
 *              return info
 *         })
 *         .catch( (e) => {
 *              console.log('problem getting data: ', e)
 *              return e
 *         })
 *
 *         return exampleData
 *     }
 * }
 * export default ExampleInterface
 * ```
 *
 * ### Mocking API Responses
 *
 * It's common to mock API data like this:
 *
 * ```js
 *     // ... inside your getInfo(...) function:
 *     return delayedResult({
 *          data: {
 *              id: userId,
 *              name: 'Pierce Hawthorne',
 *              dob: '1944/11/27'
 *          }
 *     }, 5)
 * ```
 *
 * The above returns a promise (similar to regular API fetches) that resolves after 5 seconds (+- 2.5 seconds)
 *
 * ### Future Direction
 *
 * Potentially, non-RESTful interfaces like GraphQL or streaming connections might
 * require alternative architectures; one option would be that this becomes the base
 * class for RESTful APIs, there is another base class for graph APIs, and a common
 * super parent for those which handles the common concerns like header management.
 *
 * @category Core
 */

export class ApiInterface {

    /**
	 * This object provides extended functionality to the ApiInterface class methods
	 * @typedef {Object} ApiInterface#options
	 * @property {Object} headers A JSON key/value set of HTTP headers
	 */

	/**
	 * An <a href="https://github.com/axios/axios#response-schema">Axios response object</a>
	 * @typedef {Object} Response
	 */

	/**
	 * An <a href="https://github.com/axios/axios#request-config">Axios request object</a>
	 * @typedef {Object} Request
	 */

	static defaultConfig: object = {options: {headers: {'Content-Type': 'application/json'}}}

	/**
	 * The string identifier for this API interface.  You will use this, along with
	 * {@link ApiInterface#interfaceVersion interfaceVersion},
	 * to retrieve an interface from the API library in
	 * your React app. Use dash-separated, all-lower-case words, e.g. 'eop-schedule',
	 * 'web-app-config'
	 * @abstract
     * @member {string}
	 */
	static interfaceName: string = 'NOT_SET'

	/**
	 * Version number (in string format) for this edition of this API interface. You
	 * will use this, along with {@link ApiInterface#interfaceName interfaceName},
	 * to retrieve an interface from the API library in your React app.
     * Use a three-value semver style version e.g. '1.2.3'
     * @abstract
     */
    static interfaceVersion: string = 'NOT_SET'

    /**
     * The current status of the ApiInterface
     * @typedef {Object} ApiInterface#status
     * @property {number} inflight The number of currently in-flight requests
     * @property {array} errors An array of the errors "raised" by the API interface
     */
    status: ApiStatus = {
        inflight: 0,
        errors: []
    }

    /**
     * A Map of transient headers that get attached to axios connections when the interface is retrieved.
     *
     * Most commonly used for authentication headers, to attach and remove auth tokens during login / logout
     */
    transientHeaders: Map<string, string>
    transientHeaderKeys: string[]			// This is to work around typescript transpiling broken iterators

    //TODO: Proper typing for config object
    config: any = {}
    conn?: AxiosInstance
    connectionValid: boolean = false

    // Override to specify scopes (use key for a easy ID to refer to in your code)
    static scopes = {}


    /**
     * The ApiInterface constructor. Takes a configuration object.
     * @param {Object} config API interface configuration details. Schema in {@link appConfigSchema.js}
     * @param {string} config.url Base URL for the HTTP(S) endpoint
     * @param {ApiInterface#options} config.options Additional configuration parameters
     *
     */
    constructor(config: object) {
        this.config = deepMerge(ApiInterface.defaultConfig, config)
        this.transientHeaders = new Map<string, string>()
        this.transientHeaderKeys = []
        this.connect()
    }

    /**
     * Adds an error to the interfaces error list
     * @param {String} errorDescription A description of the error
     */
    addError(errorDescription: string) {

        const nowStr = new Date().toISOString()
        if (this && this.status && this.status.errors) {
            this.status.errors.push({date: nowStr, error: errorDescription})
        }
    }

    /**
     * Listener function for adding and removing transient headers. Generally
     * you won't need to use this method yourself -- it's used internally by
     * the OIDC context to attach and remove transient headers like OIDC user
     * tokens.
     * @method
     * @param {*} header The header to set or remove
     * @param {string} value The value of the header
     */
    transientHeadersListener(header: string, value: string) {
        if (value !== undefined) {
            this.transientHeaders.set(header, value)
            this.transientHeaderKeys.push(header)
        } else if (this.transientHeaders.has(header)) {
            this.transientHeaders.delete(header)
            this.transientHeaderKeys = this.transientHeaderKeys.filter(key => key !== header)
        }
        this.connectionValid = false
    }

    /**
     * "Connect" the interface to the configured API endpoint. This does not
     * open an actual network connection, it only creates an Axios connection
     * object that will be used to handle any requests.
     */
    connect() {
        const headers = Object.assign(this.config.options.headers)
        for (let key of this.transientHeaderKeys) {
            headers[key] = this.transientHeaders.get(key)
        }
        this.conn = axios.create({baseURL: this.config.url, headers: headers})
        this.connectionValid = true
    }

    /**
     * Make a HTTP request to the configured API endpoint. Uses Axios library.
     * @param {Request} requestConfig An {@link https://github.com/axios/axios#request-config Axios config object}
     * for the HTTP request
     * @returns {Promise} (A Promise that resolves to) the API response
     */
    async query(requestConfig: AxiosRequestConfig) {
        // Reconnect interface (sets headers)
        this.connect()
        if (this.conn) {

            const queryPromise = this.conn.request(requestConfig)

            // TODO: Caching?

            if (this.status && this.status.inflight) {
                this.status.inflight += 1
            }
            // Axios' default error handling definition
            // TODO: Add richer error handling
            queryPromise.catch((err) => {
                if (err.response) {
                    // The request was made and the server responded with a status code
                    // that falls out of the range of 2xx
                    this.addError('response code ' + err.response.status + ' from ' + requestConfig.url)
                } else if (err.request) {
                    // The request was made but no response was received
                    // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
                    // http.ClientRequest in node.js
                    this.addError('no response from ' + requestConfig.url)
                } else {
                    // Something happened in setting up the request that triggered an Error
                    this.addError(err.message)
                }
            })

            // when the query's done, that's one less query in flight
            // @ts-ignore
            queryPromise.finally(() => this.status.inflight -= 1)

            return queryPromise
        }
    }

    /**
     * A convenience method that performs a GET request to the API endpoint
     * and directly returns the response that carried that data.
     *
     * This uses {@link ApiInterface#query query} in the background.
     *
     * @example
     * ```
     *     const {data, status} = await getData(url)
     *     if (status >= 200 && status < 300) { console.log('all good') }
     * ```
     *
     * @param {string} path Trailing path for the request
     * @param {*} [params] Extra query params for the request
     * @param {ApiInterface#options} [options] // TODO: Future implementation
     * @returns {Promise<AxiosResponse>} the response to the GET call
     */
    async getData(path: string, params: any = {}, options: any = {}) {
        // Make GET requests
        const reqConfig: AxiosRequestConfig = {
            params,
            url: path,
            headers: deepMerge(this.transientHeaders, options.headers),
            method: 'GET'
        }
        return this.query(reqConfig)
    }

    /**
     * A convenience method that performs a POST request to the API endpoint
     * and directly returns the server's response to the POST request.
     *
     * @example
     * ```
     *     const {status} = await getData(url, postData)
     *     if (status >= 200 && status < 300) { console.log('posted') }
     * ```
     *
     * This uses {@link ApiInterface#query query} in the background.
     * @param {string} path Trailing path for the request
     * @param {*} [data] JSON data to be sent by the request
     * @param {ApiInterface#options} [options] //TODO: Future implementation
     * @returns {Promise<AxiosResponse>} the response to the POST call
     */
    async postData(path: string, data: any = {}, options: any = {}) {
        const reqConfig: AxiosRequestConfig = {
            data,
            url: path,
            headers: deepMerge(this.transientHeaders, options.headers),
            method: 'POST'
        }
        return this.query(reqConfig)
    }

    /**
     * A convenience method that performs a PUT request to the API endpoint
     * and directly returns the server's response to the PUT request.
     *
     * This uses {@link ApiInterface#query query} in the background.
     *
     * @param {string} path Trailing path for the request
     * @param {*} [data] JSON data to be sent by the request
     * @param {ApiInterface#options} [options] // TODO: Future implementation
     * @returns {Promise<AxiosResponse>} the response to the PUT call
     */
    async putData(path: string, data: any = {}, options: any = {}) {
        const reqConfig: AxiosRequestConfig = {
            data,
            url: path,
            headers: deepMerge(this.transientHeaders, options.headers),
            method: 'PUT'
        }
        return this.query(reqConfig)
    }

    /**
     * A convenience method that performs a PATCH request to the API endpoint
     * and directly returns the server's response.
     *
     * This uses {@link ApiInterface#query query} in the background.
     *
     * @param {string} path Trailing path for the request
     * @param {*} [data] JSON data to be sent by the request
     * @param {ApiInterface#options} [options] // TODO: Future implementation
     * @returns {Promise<AxiosResponse>} the response to the PATCH call
     */
    async patchData(path: string, data: any = {}, options: any = {}) {
        const reqConfig: AxiosRequestConfig = {
            data,
            url: path,
            headers: deepMerge(this.transientHeaders, options.headers),
            method: 'PATCH'
        }
        return this.query(reqConfig)
    }

    /**
     * A convenience method that performs a DELETE request to the API endpoint
     * and directly returns the response that carried that data.
     *
     * Note that unlike some other methods, there is no `data` argument;
     * if you need to send data with your DELETE call, supply the data
     * structure in `options.data`.
     *
     * This uses {@link ApiInterface#query query} in the background.
     *
     * @param {string} path Trailing path for the request
     * @param {ApiInterface#options} [options] // TODO: Future implementation
     * @param {*} [options.data] JSON data to be sent by the request
     * @returns {Promise<AxiosResponse>} the response to the DELETE call
     */
    async delete(path: string, options: any = {}) {
        const reqConfig: AxiosRequestConfig = {
            data: options.data || null,
            url: path,
            headers: deepMerge(this.transientHeaders, options.headers),
            method: 'DELETE'
        }
        const resp = this.query(reqConfig)
        return resp
    }

    /**
     * A utility function returning a Promise that will be
     * fulfilled by `data` after a duration of roughly `delay`
     * seconds. `delay` will be 3 seconds if not supplied.
     *
     * By default the delay will vary randomly between
     * `0.5 * delay` and `1.5 * delay` (±50%).
     *
     * If you don't want the delay length to randomly vary,
     * supply `true` as the third argument, `fixedDelay`.
     * Use this to mock out returning results asynchonously
     * from your API calls, for example:
     *
     * ```
     * async getUserInfo(userId) {
     *     // mock data for now
     *     return delayedResult({
     *          data: {
     *              id: userId,
     *              name: 'Pierce Hawthorne',
     *              dob: '1944/11/27'
     *          }
     *     })
     * }
     * ```
     *
     * **NOTE**: For your benefit, it's best to try and match the response data to the expected response from the API.
     * For most JCU APIs, this usually means including a data key as a wrapper for your data (as the APIs tend to return
     * their answers in a data key, see below).
     *
     * ```
     * {
     *     data: {
     *         ...
     *     }
     * }
     * ```
     *
     * @param {*} data The data you want returned after the delay
     * @param {number} [delay] Time (in seconds) to delay returning the response
     * @param {boolean} [fixedDelay] Flag to set the delay to fixed instead of +- 50%
     */
    delayedResult(data, delay = 3, fixedDelay = false) {
        // TODO: Add extra features to support more extensive testing e.g. full mock responses (status codes etc...)

        // make sure the user hasn't set a crazy delay
        if (delay > 60) {
            console.warn('You have asked for a delay of over a minute. Are you sure?')
        }
        // how long to delay
        let millis = 1000 * delay * (fixedDelay ? 1.0 : (0.5 + Math.random()))
        // make a promise
        console.log('delaying ' + millis)
        const res = new Promise((resolve) => {
                window.setTimeout(() => {
                    resolve(data)
                }, millis)
            }
        )
        return res
    }

    getStatus() {
        return this.status
    }
}