Source: util/server.js

/**
 * Provides functions for making requests to the server.
 * These functions will handle any generic error handling, such as server down, or session timed out, for you.
 *
 * NOTE: This module is highly dependent on the version of jQuery that is being used. We may upgrade jQuery at any time,
 * and this will affect the behavior of this module.
 *
 * **Web Resource:** com.atlassian.bitbucket.server.bitbucket-web-api:server
 *
 * @module bitbucket/util/server
 * @namespace bitbucket/util/server
 */
import * as AJS from '@atlassian/aui';
import $ from 'jquery';
import _ from 'lodash';

import pageState from 'bitbucket/internal/model/page-state';
import { deprecateFn, deprecateProp } from 'bitbucket/internal/util/deprecation';
import errorUtil from 'bitbucket/internal/util/error';
import fn from 'bitbucket/internal/util/function';
import ErrorDialog from 'bitbucket/internal/widget/error-dialog/error-dialog';
import * as nav from 'bitbucket/util/navbuilder';

/**
 * Constant that allows to easily disable all error handlers
 */
const DISABLE_ALL_ERROR_HANDLERS = {
    statusCode: {
        '*': false,
    },
};

$.ajaxSetup({
    timeout: 60000,
});

/**
 * Return a promise resolved with an empty REST page
 *
 * @returns {Promise}
 */
const createEmptyPage = () =>
    $.Deferred().resolve({ start: 0, size: 0, values: [], isLastPage: true }).promise();

const method = {
    DELETE: 'DELETE',
    GET: 'GET',
    PATCH: 'PATCH',
    POST: 'POST',
    PUT: 'PUT',
};

var errorDialogIsOpen = false;

function afterCountdown($countdownTimeHolder, intervalMs, endDate, afterCountdownFunc) {
    var now = new Date();

    if (now < endDate) {
        var onSecondsChanged = function () {
            var secondsLeft = Math.ceil((+endDate - +new Date()) / intervalMs);

            if (secondsLeft <= 0) {
                clearInterval(intervalId);
                afterCountdownFunc();
            } else {
                $countdownTimeHolder.text(secondsLeft);
            }
        };
        var intervalId = setInterval(onSecondsChanged, intervalMs);

        onSecondsChanged();
    } else {
        afterCountdownFunc();
    }
}

function hideUntilCountdown($el, $replacementEl, $countdownTimeHolder, intervalMs, endDate) {
    var now = new Date();

    if (now < endDate) {
        $el.addClass('hidden');
        $el.before($replacementEl);

        afterCountdown($countdownTimeHolder, intervalMs, endDate, function () {
            $replacementEl.remove();
            $el.removeClass('hidden');
        });
    }
}

/**
 * Adds on global error handling to an ajax request.
 *
 * If the ajax request returns with global errors, they will be displayed to the user, and the xhr promise will be rejected.
 *
 * If the error is something that can be fixed with a retry, the error will be displayed, but the xhr promise will NOT be resolved or rejected.
 * Instead, progress callbacks will be called with 'stalled' as the argument. If the user attempts a retry, progress
 * callbacks will be called with 'unstalled' and the result of a retry request will be used to resolve or reject the original xhr promise.
 *
 * @private
 *
 * @param jqXhr - The ajax request to handle global errors for.
 * @param ajaxOptions - The options object used in the call to $.ajax.  These options are reused if the request needs to be retried.
 */
function ajaxPipe(jqXhr, ajaxOptions, statusHandlers, isRest) {
    var pipedXhr;
    var latestXhr;
    var latestAbort;

    function updateLatest(jqXhr) {
        latestXhr = jqXhr;
        latestAbort = latestXhr.abort;
        latestXhr.abort = abort;
    }

    function abort() {
        latestAbort.apply(latestXhr, arguments);
    }

    function handleError(error, data, textStatus, jqXhr, errorThrown, ajaxOptions, isRest) {
        if (error.shouldLogin) {
            // Ideally at this point we want to run as little code as we can to redirect to the log in page ASAP
            // with as little interference as possible.
            window.onbeforeunload = null;
            window.location.href = nav.login().next(window.location.href).build();

            return $.Deferred(); // don't resolve|reject
        }

        if (data) {
            delete data.errors;
        }

        var errorDialog;

        if (!errorDialogIsOpen) {
            errorDialog = new ErrorDialog();
        }

        var deferredToReturn =
            error.shouldRetry && !errorDialogIsOpen
                ? $.Deferred()
                : $.Deferred().rejectWith(this, [
                      jqXhr,
                      textStatus,
                      errorThrown,
                      data,
                      errorDialog,
                  ]);

        if (!errorDialogIsOpen) {
            var extraPanelContent = '';
            var needsRetryCountdown = false;

            var errorHtml = bitbucket.internal.widget.exception.exception.errorContent(error);

            errorDialog.addHideListener(function () {
                errorDialogIsOpen = false;
            });

            var dialogOptions = {
                id: 'ajax-error',
                titleText: error.title,
                titleClass: error.titleClass || 'error-header',
                showCloseButton: _.isUndefined(error.canClose) ? true : error.canClose,
                closeOnOutsideClick: false,
            };

            if (error.fallbackUrl) {
                dialogOptions.okButtonText = AJS.escapeHtml(error.fallbackTitle);

                errorDialog.addOkListener(function (e) {
                    window.location.href = error.fallbackUrl;

                    e.preventDefault();
                });
            } else if (error.shouldReload) {
                dialogOptions.okButtonText = AJS.escapeHtml(
                    AJS.I18n.getText('bitbucket.web.ajax.reload')
                );

                errorDialog.addOkListener(function (e) {
                    window.location.reload();

                    e.preventDefault();
                });
            } else if (error.shouldRetry) {
                deferredToReturn.notify('stalled');

                if (error.retryAfterDate) {
                    if (+error.retryAfterDate - +new Date() > 60 * 60 * 1000) {
                        extraPanelContent = AJS.I18n.getText('bitbucket.web.retry.later');
                    } else {
                        needsRetryCountdown = true;
                    }
                }

                dialogOptions.okButtonText = AJS.escapeHtml(
                    AJS.I18n.getText('bitbucket.web.ajax.try.again')
                );

                var retryXhr;
                errorDialog.addOkListener(function (e) {
                    deferredToReturn.notify('unstalled');

                    retryXhr = ajax(ajaxOptions, isRest);

                    errorDialog.remove();

                    updateLatest(retryXhr);

                    // pipe results from the retryXhr straight to the deferredToReturn
                    retryXhr.done(function () {
                        return deferredToReturn.resolveWith(this, arguments);
                    });
                    retryXhr.fail(function () {
                        return deferredToReturn.rejectWith(this, arguments);
                    });

                    e.preventDefault();
                });

                errorDialog.addHideListener(function () {
                    if (deferredToReturn.state() === 'pending' && !retryXhr) {
                        deferredToReturn.rejectWith(this, [jqXhr, textStatus, errorThrown, data]);
                    }
                });
            } else {
                // if the Ok button doesn't do anything but close the dialog, hide the second Close button.
                dialogOptions.showCloseButton = false;
            }

            dialogOptions.panelContent = '<p>' + errorHtml + extraPanelContent + '</p>';

            setTimeout(() => {
                // Allow open dialogs to close before showing the new error dialog
                errorDialog.reinit(dialogOptions).show();
            }, 0);
            errorDialogIsOpen = true;

            if (needsRetryCountdown) {
                var intervalMs;
                var retryInHtml;

                if (+error.retryAfterDate - +new Date() > 60 * 1000) {
                    retryInHtml = AJS.I18n.getText(
                        'bitbucket.web.retry.in.x.minutes',
                        '<time><span></span>',
                        '</time>'
                    );
                    intervalMs = 60 * 1000;
                } else {
                    retryInHtml = AJS.I18n.getText(
                        'bitbucket.web.retry.in.x.seconds',
                        '<time><span></span>',
                        '</time>'
                    );
                    intervalMs = 1000;
                }

                var $retryMessage = $('<span>' + retryInHtml + '</span>');
                var $intervalHolder = $retryMessage.children('time').children();
                hideUntilCountdown(
                    errorDialog.getOkButton(),
                    $retryMessage,
                    $intervalHolder,
                    intervalMs,
                    error.retryAfterDate
                );
            }
        }

        return deferredToReturn;
    }

    function xhrPipe(data, textStatus, jqXhr, errorThrown, customHandler, fallbackFunc) {
        var error = isRest
            ? errorUtil.getDominantRESTError(data, jqXhr)
            : errorUtil.getDominantAJAXError(jqXhr);
        var handleErrors = true;

        if (customHandler) {
            var ret = customHandler(error);

            // custom handler can return a deferred which will be piped through. We won't handle errors
            if (ret && typeof ret.promise === 'function') {
                return ret.promise(jqXhr);
            }

            // custom handler can return a replacement error object which will replace the one we generate
            if (ret && _.isObject(ret)) {
                error = ret;
            }

            // if the custom handler returns false, we won't handle errors,
            // and will simply fallback to normal behavior
            handleErrors = ret !== false;
        }

        if (handleErrors && error) {
            return handleError(error, data, textStatus, jqXhr, errorThrown, ajaxOptions, isRest);
        }

        return fallbackFunc();
    }

    function getStatusHandler(status) {
        var customHandler = statusHandlers[status];

        if (customHandler == null) {
            customHandler = statusHandlers['*'];
        }
        if (typeof customHandler === 'function') {
            return customHandler;
        }

        // Allow status handlers to be non-functions (ie false), which should always be returned
        return fn.constant(customHandler);
    }

    function done(data, textStatus, jqXhr) {
        var self = this;

        var customHandler = getStatusHandler(jqXhr.status);
        var callCustomHandler = customHandler
            ? _.bind(customHandler, self, data, textStatus, jqXhr)
            : null;

        return xhrPipe(data, textStatus, jqXhr, null, callCustomHandler, function () {
            return $.Deferred().resolveWith(self, [data, textStatus, jqXhr]);
        });
    }

    function fail(jqXhr, textStatus, errorThrown) {
        var self = this;
        var data = jqXhr.responseText;

        try {
            data = JSON.parse(data);
        } catch (e) {
            /* ignore */
        }

        var customHandler = getStatusHandler(jqXhr.status);
        var callCustomHandler = customHandler
            ? _.bind(customHandler, self, jqXhr, textStatus, errorThrown, data)
            : null;

        return xhrPipe(data, textStatus, jqXhr, errorThrown, callCustomHandler, function () {
            return $.Deferred().rejectWith(self, [jqXhr, textStatus, errorThrown, data]);
        });
    }

    updateLatest(jqXhr);

    pipedXhr = jqXhr.then(done, fail);

    // return the original xhr, but with the piped done|fail|notify methods.
    return pipedXhr.promise(jqXhr);
}

/**
 * @private
 * @param {Object} options
 * @param {boolean} internalIsRest
 */
function ajaxInternal(options, internalIsRest) {
    var statusHandlers;

    if (options.statusCode) {
        statusHandlers = options.statusCode;
        delete options.statusCode;
    }
    statusHandlers = statusHandlers || {};

    var xhr = ajaxPipe($.ajax(options), options, statusHandlers, internalIsRest);

    xhr.statusCode = function (map) {
        if (map) {
            if (xhr.state() === 'pending') {
                $.extend(statusHandlers, map);
            } else {
                for (var prop in map) {
                    if (map.hasOwnProperty(prop)) {
                        AJS.log(
                            'xhr.statusCode() should not be called after the request has completed. ' +
                                'Your handler will have no affect on the resolution of the request.'
                        );
                        break;
                    }
                }

                var tmp = map[xhr.status];
                xhr.then(tmp, tmp);
            }
        }
    };

    return xhr;
}

/**
 * This function closely resembles the jQuery.ajax() function with a few notable exceptions.
 *
 * First, it only accepts the options signature - all options including `url` must be included on the options object.
 *
 * Second, it adds default error handling for all HTTP error codes and for cases where logged in state changes.
 *
 * Third, it overrides the `statusCode` option to allow you to specify your own error handling per-HTTP code.
 *
 * @memberOf bitbucket/util/server
 *
 * @example
 *
 * require('bitbucket/util/server').ajax({
 *     url : '/plugins/servlet/my-plugin'
 *     statusCode : {
 *         400 : false, // do not do any default handling for HTTP 400
 *         404 : function(xhr, textStatus, errorThrown, dominantError) {
 *             // return false; // do not handle this by default
 *             // return myDeferred.promise(); // resolve the request with my custom promise
 *             return { shouldReload : true }; // open a dialog requesting the user to reload the page.
 *         }
 *     }
 * });
 *
 * @param {Object} options - A map of option values. All options accepted by jQuery.ajax are accepted here.
 * @returns {jqXHR} - A jQuery XHR object.
 */
function ajax(options) {
    return ajaxInternal(options);
}

/**
 * This function retrieves the X-AUSERNAME and X-AUSERID headers for the current user. If the current user is not set,
 * then an empty object is returned.
 *
 * @memberOf bitbucket/util/server
 *
 * @returns {headers} - A JSON object.
 */
function getHeaders() {
    return pageState.getCurrentUser()
        ? {
              'X-AUSERNAME': encodeURIComponent(pageState.getCurrentUser().getName()),
              'X-AUSERID': pageState.getCurrentUser().getId(),
          }
        : {};
}

/**
 * This function builds on {@link bitbucket/util/server.ajax} to add some defaults. It will:
 *
 * - Default the content type and Accept header to JSON (but this can be overridden).
 * - Add X-AUSERNAME and X-AUSERID headers for the current user, so the server knows which user we are attempting to make the request as.
 * - Stringify any JSON objects passed through the `data` option.
 * - Adds a fourth argument to the success handler that contains the JSON object parsed from the response body.
 * - Adds a fourth argument to the error handler that contains a description of how the error would have been handled by default.
 *
 * @memberOf bitbucket/util/server
 *
 * @example
 * require('bitbucket/util/server').rest({
 *     type : 'DELETE',
 *     url : '/rest/my-plugin/latest/things/1'
 *     statusCode : {
 *         404 : function() {
 *             return $.Deferred().resolve('Already deleted.');
 *         }
 *     }
 * });
 *
 * @param {Object} options - See {@link bitbucket/util/server.ajax} for a description of the accepted options.
 * @returns {jqXHR} - A jQuery XHR object.
 */
function rest(options) {
    options = $.extend(
        true,
        {
            dataType: 'json',
            contentType: 'application/json',
            headers: getHeaders(),
            jsonp: false,
            type: 'GET',
        },
        options
    );

    if (
        options.type.toUpperCase() !== 'GET' &&
        ($.isPlainObject(options.data) || $.isArray(options.data))
    ) {
        options.data = JSON.stringify(options.data);
    }

    return ajaxInternal(options, true);
}

/**
 * This function builds on {@link rest} by adding polling. You can use it to make a request repeatedly, until
 * a "finished" response is returned. This is useful, for example, when waiting for the server to complete a background task
 * like deleting a repository or waiting for maintenance to complete.
 *
 * @memberOf bitbucket/util/server
 *
 * @example
 *
 * require('bitbucket/util/server').poll({
 *     url : '/plugin/servlet/expensive-task-checker',
 *     tick : function(data, textStatus, xhr) {
 *         if (data.expensiveTaskComplete) {
 *             return true; // success
 *         }
 *         if (data.expensiveTaskAborted) {
 *             return false; // failure
 *         }
 *         // return undefined; // keep polling. return undefined is implied.
 *     }
 * });
 *
 * @param {Object} options - See {@link bitbucket/util/server.rest} for the options accepted.
 * @param {number|boolean} [options.pollTimeout=60000] - The number of milliseconds to poll before ending the poll
 *                                                       as a failure. May pass `false` to indicate no timeout.
 * @param {number} [options.interval=500] - The number of milliseconds between each AJAX response and subsequent request.
 * @param {number} [options.delay=0] - The number of milliseconds before the first AJAX call.
 * @param {Function} options.tick - A function to call with each AJAX response's callback parameters.
 *                                  It should return `truthy` to end polling successfully, `undefined`
 *                                  to continue polling, or `falsy` to end polling as a failure.
 * @returns {Promise} A jQuery Promise with added pause() and resume() methods.
 */
function poll(options) {
    options = $.extend(
        {
            pollTimeout: 60000,
            interval: 500,
            delay: 0,
            tick: $.noop,
        },
        options
    );
    var paused = false;
    var polling = false;
    var defer = $.Deferred();
    var startTime = Date.now();

    var doPoll = function () {
        // Short circuit if the poller is paused or if it is already polling
        if (paused || polling) {
            return;
        }
        polling = true;
        rest(options)
            .done(function (data, textStatus, xhr) {
                var isDone = options.tick(data, textStatus, xhr);

                if (isDone) {
                    defer.resolveWith(this, [data, textStatus, xhr]);
                } else if (
                    (options.pollTimeout !== false &&
                        Date.now() - startTime > options.pollTimeout) ||
                    typeof isDone !== 'undefined'
                ) {
                    defer.rejectWith(this, [xhr, textStatus, null, data]);
                } else {
                    setTimeout(doPoll, options.interval);
                }
            })
            .fail(function (xhr, textStatus, errorThrown, data) {
                defer.rejectWith(this, [xhr, textStatus, errorThrown, data]);
            })
            .always(function () {
                polling = false;
            });
    };
    setTimeout(doPoll, options.delay);
    var promise = defer.promise();

    promise.resume = function () {
        if (paused) {
            paused = false;
            doPoll();
        }
    };
    promise.pause = function () {
        paused = true;
    };

    return promise;
}

export { ajax, createEmptyPage, method, poll, rest, DISABLE_ALL_ERROR_HANDLERS };

const deprecateExposedFn = (deprecatedFn) => {
    const currentUsage = `The usage of 'navbuilder.${deprecatedFn.name}' from 'bitbucket/util/server' module`;
    const alternativeUsage = `'import {${deprecatedFn.name}} from "bitbucket/util/server"'`;

    return deprecateFn(deprecatedFn, currentUsage, alternativeUsage, '7.0', '8.0');
};

const deprecateExposeProp = (obj, prop) => {
    const currentUsage = `The usage of 'navbuilder.${prop}' from 'bitbucket/util/server' module`;
    const alternativeUsage = `'import {${prop}} from "bitbucket/util/server"'`;

    return deprecateProp(obj, prop, currentUsage, alternativeUsage, '7.0', '8.0');
};

const deprecatedDefaultExport = {
    /** @deprecated since 7.0 */ ajax: deprecateExposedFn(ajax),
    /** @deprecated since 7.0 */ createEmptyPage: deprecateExposedFn(createEmptyPage),
    /** @deprecated since 7.0 */ method,
    /** @deprecated since 7.0 */ poll: deprecateExposedFn(poll),
    /** @deprecated since 7.0 */ rest: deprecateExposedFn(rest),
};

deprecateExposeProp(deprecatedDefaultExport, method);

/** @deprecated since 7.0 */
export default deprecatedDefaultExport;

export { getHeaders as _getHeaders };