src/api/Api.js

Summary

Maintainability
D
1 day
Test Coverage
/**
 * @module api
 */
import 'isomorphic-fetch'
import { checkType } from '../lib/check'
import { customEncodeURIComponent } from '../lib/utils'
import System from '../system/System'

function getMergeStrategyParam(mergeType = 'REPLACE') {
    const system = System.getSystem()

    if (system.version && Number(system.version.minor) <= 22) {
        return `mergeStrategy=${mergeType}`
    }

    return `mergeMode=${mergeType}`
}

function getUrl(baseUrl, url) {
    // If we are dealing with an absolute url use that instead
    if (new RegExp('^(:?https?:)?//').test(url)) {
        return url
    }

    const urlParts = []

    if (baseUrl) {
        urlParts.push(baseUrl)
    }
    urlParts.push(url)

    return urlParts
        .join('/')
        .replace(new RegExp('(.(?:[^:]))//+', 'g'), '$1/')
        .replace(new RegExp('/$'), '')
}

/**
 * @description
 * Used for interaction with the dhis2 api.
 *
 * This class is used as the backbone for d2 and handles all the interaction with the server. There is a singleton
 * available to be reused across your applications. The singleton can be grabbed from the d2 instance. The api methods all handle URL-encoding for you, so you can just pass them unencoded strings
 *
 * ```js
 * import { getInstance } from 'd2/lib/d2';
 *
 * getInstance()
 *  .then(d2 => {
 *      const api = d2.Api.getApi() // Returns the Api singleton.
 *
 *      api.get('resources');
 *  });
 * ```
 *
 * Uses {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API|Fetch} to do network requests.
 *
 * @memberof module:api
 */
class Api {
    /**
     * @constructor
     *
     * @param {Fetch} [fetchImpl] The fetch implementation to use. Can be used to pass a different implementation
     * similar to the fetch Api. Will default to `window.fetch` in a browser context.
     */
    constructor(fetchImpl) {
        // Optionally provide fetch to the constructor so it can be mocked during testing
        if (typeof fetchImpl === 'function') {
            this.fetch = fetchImpl.bind(
                typeof window !== 'undefined' ? window : global
            )
        } else if (typeof fetch !== 'undefined') {
            this.fetch = fetch.bind(
                typeof window !== 'undefined' ? window : global
            )
        } else {
            throw new Error(
                'Failed to initialise D2 Api: No fetch implementation is available'
            )
        }

        this.baseUrl = '/api'
        this.defaultFetchOptions = {
            mode: 'cors', // requests to different origins fail
            credentials: 'include', // include cookies with same-origin requests
            cache: 'default', // See https://fetch.spec.whatwg.org/#concept-request-cache-mode
        }
        this.defaultHeaders = {
            'X-Requested-With': 'XMLHttpRequest',
        }

        this.unauthorizedCallback = null
    }

    /**
     * Used for setting default headers that should be send with every request.
     *
     * @example
     * const api = Api.getApi();
     *
     * api.setDefaultHeaders({
     *  'x-requested-with': 'XMLHttpRequest', // Make sure the Api does not redirect when authorization is expired.
     * });
     *
     * @param {Object.<string, string>} headers Default headers that should be set on every request.
     */
    setDefaultHeaders(headers) {
        this.defaultHeaders = headers
    }

    /**
     * When any request encounters a 401 - Unauthorized. This callback is called.
     * Useful for when you want an session expiration-handler API-wide.
     *
     * @param {*} cb - Function to call when any request recieves a 401. Called with the response from the server.
     */
    setUnauthorizedCallback(cb) {
        if (typeof cb !== 'function') {
            throw new Error('Callback must be a function.')
        }
        this.unauthorizedCallback = cb
    }

    /**
     * Performs a GET request.
     *
     * @param {string} url The url for the request, should be unencoded. Will return a rejected promise for malformed urls and urls that contain encoded query strings.
     * @param {*} data Any data that should be sent with the request. For a GET request these are encoded and turned into
     * query parameters. For POST and PUT requests it becomes the body.
     * @param {Object.<string, any>} options The request options are passed as options to the fetch request.
     * These options are passed as the {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters|init}
     * parameter to the fetch request.
     *
     * @returns {Promise.<*>} The response body.
     */
    get(url, data, options) {
        return this.request('GET', getUrl(this.baseUrl, url), data, options)
    }

    /* eslint-disable complexity */
    /**
     * Performs a POST request.
     *
     * @param {string} url The url for the request
     * @param {*} data Any data that should be send with the request this becomes the body for the POST request
     * @param {Object.<string, any>} options The request options are passed as options to the fetch request.
     * These options are passed as the {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters|init}
     * parameter to the fetch request.
     *
     * @returns {Promise.<*>} The response body.
     */
    post(url, data, options = {}) {
        const requestUrl = getUrl(this.baseUrl, url)
        let payload = data

        // Ensure that headers are defined and are treated without case sensitivity
        options.headers = new Headers(options.headers || {})

        if (data !== undefined) {
            if (data.constructor.name === 'FormData') {
                // Ensure that the browser will set the correct Content-Type header for FormData, including boundary
                options.headers.delete('Content-Type')
                payload = data
            } else if (
                options.headers.has('Content-Type') &&
                options.headers
                    .get('Content-Type')
                    .toLocaleLowerCase()
                    .startsWith('text/')
            ) {
                payload = String(data)
            } else {
                // Send JSON data by default
                options.headers.set('Content-Type', 'application/json')
                payload = JSON.stringify(data)
            }
        }

        return this.request('POST', requestUrl, payload, options)
    }

    /**
     * Performs a DELETE request.
     *
     * @param {string} url The url for the request
     * @param {Object.<string, any>} options The request options are passed as options to the fetch request.
     * These options are passed as the {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters|init}
     * parameter to the fetch request.
     *
     * @returns {Promise.<*>} The response body.
     */
    /* eslint-enable complexity */
    delete(url, options) {
        return this.request(
            'DELETE',
            getUrl(this.baseUrl, url),
            undefined,
            options
        )
    }

    /**
     * Perform a PUT request.
     *
     * @param {string} url The url for the request
     * @param {*} data Any data that should be send with the request. This becomes the body of the PUT request.
     * @param {boolean} [useMergeStrategy=false]
     * @param {Object.<string, any>} options The request options are passed as options to the fetch request.
     *
     * @returns {Promise.<*>} The response body.
     */
    update(url, data, useMergeStrategy = false, options = {}) {
        let payload = data

        // Ensure that headers are defined and are treated without case sensitivity
        const requestOptions = {
            ...options,
            headers: new Headers(options.headers || {}),
        }

        if (data !== undefined) {
            if (
                !requestOptions.headers.has('Content-Type') &&
                typeof payload === 'string'
            ) {
                requestOptions.headers.set('Content-Type', 'text/plain')
            } else {
                payload = JSON.stringify(data)
            }
        }

        const urlForUpdate =
            useMergeStrategy === true
                ? `${url}?${getMergeStrategyParam()}`
                : url

        return this.request(
            'PUT',
            getUrl(this.baseUrl, urlForUpdate),
            payload,
            requestOptions
        )
    }

    /**
     * Perform a PATCH request.
     *
     * @param {string} url The url for the request
     * @param {*} data Any data that should be send with the request. This becomes the body of the PATCH request.
     *
     * @returns {Promise.<*>} The response body.
     */
    patch(url, data) {
        return this.request(
            'PATCH',
            getUrl(this.baseUrl, url),
            JSON.stringify(data)
        )
    }

    /**
     * General purpose request function for making http requests.
     *
     * The more specific functions like `delete`, `post` and `get`, utilize this function to make the requests.
     *
     * @param {string} method The HTTP request method (e.g. POST/GET/PATCH)
     * @param {string} url The url for the request
     * @param {*} data Any data that should be send with the request. For a GET request these are turned into
     * query parameters. For POST and PUT requests it becomes the body.
     * @param {Object.<string, any>} options The request options are passed as options to the fetch request.
     * These options are passed as the {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters|init}
     * parameter to the fetch request.
     *
     * @returns {Promise.<*>} The response body.
     */
    /* eslint-disable complexity */
    request(method, url, data, options = {}) {
        checkType(method, 'string', 'Request type')
        checkType(url, 'string', 'Url')
        const api = this
        let requestUrl = url
        let query = ''

        if (requestUrl.indexOf('?') !== -1) {
            query = requestUrl.substr(requestUrl.indexOf('?') + 1)
            requestUrl = requestUrl.substr(0, requestUrl.indexOf('?'))
        }

        // Encode existing query parameters, since tomcat does not accept unencoded brackets. Throw
        // an error if they're already encoded to prevent double encoding.
        if (query) {
            let decodedURL

            try {
                decodedURL = decodeURIComponent(query)
            } catch (err) {
                return Promise.reject(
                    new Error('Query parameters in URL are invalid')
                )
            }

            const isEncoded = query !== decodedURL

            if (isEncoded) {
                return Promise.reject(
                    new Error(
                        'Cannot process URL-encoded URLs, pass an unencoded URL'
                    )
                )
            }

            query = customEncodeURIComponent(query)
        }

        // Transfer filter properties from the data object to the query string
        if (data && Array.isArray(data.filter)) {
            const encodedFilters = data.filter.map((filter) =>
                filter.split(':').map(encodeURIComponent).join(':')
            )

            query = `${query}${
                query.length ? '&' : ''
            }filter=${encodedFilters.join('&filter=')}`
            delete data.filter
        }

        // When using the GET method, transform the data object to query parameters
        if (data && method === 'GET') {
            Object.keys(data).forEach((key) => {
                query =
                    `${query}${query.length > 0 ? '&' : ''}` +
                    `${encodeURIComponent(key)}=${encodeURIComponent(
                        data[key]
                    )}`
            })
        }

        function getOptions(defaultHeaders, mergeOptions, requestData) {
            const resultOptions = Object.assign(
                {},
                api.defaultFetchOptions,
                mergeOptions
            )
            const headers = new Headers(mergeOptions.headers || {})

            Object.keys(defaultHeaders)
                .filter((header) => !headers.get(header))
                .forEach((header) =>
                    headers.set(header, defaultHeaders[header])
                )

            resultOptions.method = method

            // Only set content type when there is data to send
            // GET requests and requests without data do not need a Content-Type header
            // 0 and false are valid requestData values and therefore should have a content type
            if (
                resultOptions.method === 'GET' ||
                (!requestData && requestData !== 0 && requestData !== false)
            ) {
                headers.delete('Content-Type')
            } else if (requestData) {
                if (data.constructor.name === 'FormData') {
                    headers.delete('Content-Type')
                } else if (!headers.get('Content-Type')) {
                    headers.set('Content-Type', 'application/json')
                }
                resultOptions.body = requestData
            }

            resultOptions.headers = headers
            return resultOptions
        }

        if (query.length) {
            requestUrl = `${requestUrl}?${query}`
        }
        const requestOptions = getOptions(this.defaultHeaders, options, data)

        // If the provided value is valid JSON, return the parsed JSON object. If not, return the raw value as is.
        function parseResponseData(value) {
            try {
                return JSON.parse(value)
            } catch (e) {
                return value
            }
        }

        return new Promise((resolve, reject) => {
            // fetch returns a promise that will resolve with any response received from the server
            // It will be rejected ONLY if no response is received from the server, i.e. because there's no internet
            this.fetch(requestUrl, requestOptions)
                .then((response) => {
                    // If the request failed, response.ok will be false and response.status will be the status code
                    if (response.ok) {
                        response
                            .text()
                            .then((text) => resolve(parseResponseData(text)))
                    } else {
                        response.text().then((text) => {
                            const parsedResponseData = parseResponseData(text)
                            if (response.status === 401) {
                                const request = {
                                    method,
                                    url,
                                    data,
                                    options,
                                }
                                if (this.unauthorizedCallback) {
                                    this.unauthorizedCallback(
                                        request,
                                        parsedResponseData
                                    )
                                }
                            }
                            if (
                                !process.env ||
                                process.env.npm_lifecycle_event !== 'test'
                            ) {
                                // eslint-disable-next-line
                                console.warn(
                                    `API request failed with status ${response.status} ${response.statusText}\n`,
                                    `Request: ${requestOptions.method} ${requestUrl}`
                                )
                            }
                            reject(parsedResponseData)
                        })
                    }
                })
                .catch((err) => {
                    // It's not usually possible to get much info about the cause of the error programmatically, but
                    // the user can check the browser console for more info
                    if (
                        !process.env ||
                        process.env.npm_lifecycle_event !== 'test'
                    ) {
                        console.error('Server connection error:', err) // eslint-disable-line
                    }

                    reject(
                        `Server connection failed for API request: ${requestOptions.method} ${requestUrl}`
                    )
                })
        })
    }
    /* eslint-enable complexity */

    /**
     * Sets the baseUrl that should be used for the api.
     *
     * When working against the dhis2 demo instance at {@link https://play.dhis2.org/demo} the
     * baseUrl would be set as `https://play.dhis2.org/demo/api`.
     *
     * This method is used when calling the `d2.init` method with the `baseUrl` config property
     * to configure the Api singleton.
     *
     * @param {string} baseUrl The base url to be used for the API.
     *
     * @returns {this} Itself for chaining purposes
     */
    setBaseUrl(baseUrl) {
        checkType(baseUrl, 'string', 'Base url')

        this.baseUrl = baseUrl

        return this
    }
}

/**
 * Retrieve the Api singleton or create one.
 *
 * When called for the first time it creates and Api singleton object.
 * Any subsequent calls will return the previously created singleton.
 *
 * @returns {Api} The Api singleton.
 * @memberof module:api~Api
 */
function getApi() {
    if (getApi.api) {
        return getApi.api
    }
    return (getApi.api = new Api())
}

Api.getApi = getApi

export default Api