Source: lib/DirectAPIRequest.js

'use strict';

const axios = require('axios').default;

/** 
 * @classdesc 
 * The DirectAPIRequest class is a wrapper around Axios to more consistently issue
 * and process Dynatrace API requests. It attempts to responds to and recover from
 * 429 and 503 errors gracefully (up till specified limits). It also automatically
 * aggregates paged responses (from both v1 and v2 APIs).  Finally, it unifies the
 * various types of errors that may happen while initializating a request, issuing
 * it, and processing its response. This greatly simplifies writing code that makes
 * Dynatrace API requests.
 */
class DirectAPIRequest {
	#limits = {
        maxRetries: 3,
	    retryAfter: 100,  // ms
	    timeout:    5000  // ms
    }
	
    #resultProps = {
        "/api/v1/userSessionQueryLanguage/tree"       : "values",
        "/api/v1/userSessionQueryLanguage/table"      : "values",
        "/api/v1/entity/infrastructure/processes"     : null,
        "/api/v1/entity/infrastructure/process-groups": null,
        "/api/v1/entity/infrastructure/hosts"         : null,
        "/api/v1/entity/infrastructure/services"      : null,
        "/api/v1/entity/infrastructure/applications"  : null,
        "/api/v1/oneagents"                           : "hosts",
        "/api/config/v1/managementZones"              : "values",
        "/api/config/v1/autoTags"                     : "values",
        "/api/v2/entityTypes"                         : "types",
        "/api/v2/entities"                            : "entities",
        "/api/v2/problems"                            : "problems",
        "/api/v2/metrics/query"                       : "result",
        "/api/v2/metrics"                             : "metrics",
        "/api/v2/auditlogs"                           : "auditLogs",
        "/api/v2/settings/schemas"                    : "items",
        "/api/v2/settings/objects"                    : "items",
        "/api/v1/synthetic/monitors"                  : "monitors",
        "/api/v2/activeGates"                         : "activeGates",
        "/api/v2/tags"                                : "tags"
    };

    /*  When using axios, the rejectUnauthorized works like this:

            const agent = new https.Agent({ rejectUnauthorized: false });
            axios.get('https://something.com/foo', { httpsAgent: agent });

        OR:
            https.globalAgent.options.rejectUnauthorized = false;

        As a last resort, this can be placed in the top of the main JS file:
            process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
     */ 

    /**
     * Creates an instance with which any number of API requests can be made.
     * @constructor
     * @param {object} limits - Default values for `maxRetries`, `retryAfter` and `timeout`.
     */              
    constructor(limits = {}) {
        this.#limits = { ...this.#limits, ...limits };
    }

    /**
     * Issues a request to a Dynatrace API. 
     * @async
     * @param {RequestOptions} options - The request options, Axios-style. 
     * @param {RequestCallback} [onDone] - Callback that handles the result (alternative to using a Promise). 
     * @returns {EventEmitter|Promise} 
     * If `onDone` is provided, this method returns an EventEmitter. 
     * Else, this method returns a Promise.
     * 
     * @description
     * Required properties in the {@link RequestOptions} object (unless an alias is used - see below):
     * - `url`: URL relative to the `baseURL`. Ex.: `'/api/v2/entities'`.
     * - `baseURL`: URL of the Dynatrace tenant. Ex.: `'https://abc12345.live.dynatrace.com'`.
     *
     * For convenience aliases have been provided for the following request methods:
     * - `get(url, options[, onDone])`
     * - `delete(url, options[, onDone])`
     * - `post(url, data, options[, onDone])`
     * - `put(url, data, options[, onDone])`
     */
    async fetch(options, onDone = () => {}) {
        const now        = (new Date()).getTime();
		const issueTime  = now;	
        const timeout    = options.timeout    || this.#limits.timeout;
        const maxRetries = options.maxRetries || this.#limits.maxRetries;
        const retryAfter = this.#limits.retryAfter;

        // There are certain errors that are potentially recoverable.
        options.validateStatus = status => 
            (status  >= 200 && status  <  400) || 
             status === 429 || status === 500  || status === 503;

        // If we need to append result sets due to paging, we have to account 
        // for situations where the sets are under a property rather than as 
        // top-level flat array.		
        let prop = this.#resultProps[options.url];
        let list = null;
        let data = null;
        let response = null;
        let waitTime = null;
        let waitAndRetry = null;
        let nextPageKey  = null;
		let attempts     = maxRetries;
		
		const getAPIResetDelay = (headers) => {
			if (!headers) return retryAfter;
			const delay = Number(headers["Retry-After"]);
			return isNaN(delay) ? retryAfter : delay;
		};
			
        try {
            do {
                if (waitAndRetry)    // Wait for the specified amount of time.
                    await new Promise(resolve => setTimeout(resolve, waitAndRetry));   

                // In case we need to retry or get multiple pages it's best to  
                // give Axios a clean 'options' object for each request.
                response = await axios({ ...options }); 

                // We collect the wait time, but we only use it if we receive a
                // recoverable error or if the response is paged.
                waitTime = getAPIResetDelay(response.headers);

                if (response.status >= 400) {
                    let timeLeft = (issueTime + timeout) > (now + waitTime);
            
                    if (response.status === 429 || response.status === 503) {   
                        // Too Many Requests or Service Unavailable. In both cases
                        // the 'Retry-After' header may be present. We will retry 
                        // after the retry time or a default delay has elapsed.
                        if (!timeLeft)
                            throw new Error(response.statusText + " - timeout of " + timeout + "ms exceeded");

                        waitAndRetry = waitTime;
                    }               
                    else {
                        // Internal Server Error. Use default delay and retry as
                        // many times as we're allowed for this request.
                        if (!timeLeft || attempts-- < 0)
                            throw new Error(response.statusText + " - timeout of " + timeout + "ms or retry max of " + maxRetries + " exceeded");
                        
                        waitAndRetry = waitTime;
                    }
                }
                else {
                    // Good, useable JSON response received.
                    waitAndRetry = null;

                    // Depending on the API, a paged set of results may be an
                    // array, or may be an array at a property.
                    list = prop ? response.data[prop] : response.data;
                    data = data !== null 
                         ? data.concat(list)
                         : list;  
                        
                    nextPageKey = options.responseType === 'stream' 
                                ? null   // If we stream, we don't handle paging.
                                : response.headers["next-page-key"] || // v1
                                  response.data.nextPageKey;           // v2      

                    if (nextPageKey) {
                        // There's slight difference between v1 and v2 APIs here.
                        if (options.url.includes('/v1'))
                            options.params.nextPageKey = encodeURIComponent(nextPageKey);
                        else 
                            options.params = { nextPageKey: encodeURIComponent(nextPageKey) };

                        // Wait a sec and then get the next set (page) of data.
                        waitAndRetry = waitTime;
                    }
                }
            } while (waitAndRetry);

            // If we had take the data from a property so that we could
            // keep appending paged data, then put that property back again.
            if (prop) {
                list = data;
                data = {};
                data[prop] = list;
            }

			onDone(null, data);
            return data; 
        }
        catch (error) {  
            // Errors handled here are unrecoverable.
            const raisedError= {
                status:  null,
                message: null,
                url:     options.url,
                baseURL: options.baseURL,
                method:  options.method,
                params:  options.params,
                data:    options.data
            }

            if (error.response) {
                // The error was returned by the server.
                // We can still have Dynatrace explanations in the response.
                if (error.response.data && error.response.data.error) {
                    raisedError.status  = error.response.status;                                      
                    raisedError.message = error.response.data.error.constraintViolations
                                      ||  error.response.data.error.message
                                      ||  error.response.statusText;
                }
                else {
                    raisedError.status  = error.response.status;   
                    raisedError.message = error.response.statusText;
                }
            }
            else if (error.request) {
                // The request was made but no response was received.
                raisedError.status  = error.code;    
                raisedError.message = error.message || error.code || "Failed to issue request"
            }
            else {
                // The request was not made because an error occurred.
                raisedError.status  = error.status || 0;
                raisedError.message = error.message;
            }       
            
            onDone(raisedError);
            throw raisedError;            
        }
    }

    async get(url, options, onDone = () => {}) {
        options.url = url;
        options.method = 'get';
        return this.fetch(options, onDone);
    }
    async delete(url, options, onDone = () => {}) {
        options.url = url;
        options.method = 'delete';
        return this.fetch(options, onDone);
    }
    async post(url, data, options, onDone = () => {}) {
        options.url = url;
        options.data = data;
        options.method = 'post';
        return this.fetch(options, onDone);
    }
    async put(url, data, options, onDone = () => {}) {
        options.url = url;
        options.data = data;
        options.method = 'put';
        return this.fetch(options, onDone);
    }
}

module.exports = DirectAPIRequest;