Source: http/xhr.js

/**
 * This file is part of Sesatheque.
 *   Copyright 2014-2015, Association Sésamath
 *
 * Sesatheque is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License version 3
 * as published by the Free Software Foundation.
 *
 * Sesatheque is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Sesatheque (LICENCE.txt).
 * @see http://www.gnu.org/licenses/agpl.txt
 *
 *
 * Ce fichier fait partie de l'application Sésathèque, créée par l'association Sésamath.
 *
 * Sésathèque est un logiciel libre ; vous pouvez le redistribuer ou le modifier suivant
 * les termes de la GNU Affero General Public License version 3 telle que publiée par la
 * Free Software Foundation.
 * Sésathèque est distribué dans l'espoir qu'il sera utile, mais SANS AUCUNE GARANTIE,
 * sans même la garantie tacite de QUALITÉ MARCHANDE ou d'ADÉQUATION à UN BUT PARTICULIER.
 * Consultez la GNU Affero General Public License pour plus de détails.
 * Vous devez avoir reçu une copie de la GNU General Public License en même temps que Sésathèque
 * (cf LICENCE.txt et http://vvlibri.org/fr/Analyse/gnu-affero-general-public-license-v3-analyse
 * pour une explication en français)
 */
'use strict'
var hasProp = require('../index').hasProp

/* eslint-env browser */

/**
 * Pour gérer les appels ajax
 * @module sesajstools/http/xhr
 */

/**
 * 10s de timeout par défaut mis à toutes les requêtes qui n'en précisent pas
 * @private
 * @type {Integer}
 */
var defaultTimeout = 10000
var minTimeout = 100
var maxTimeout = 60000
var MyXMLHttpRequest

/**
 * Retourne responseText si ça existe dans ce xmlHttpRequest (sinon chaîne vide)
 * @param {XMLHttpRequest} xmlHttpRequest
 * @return {string}
 */
function getResponseText (xmlHttpRequest) {
  try {
    return (xmlHttpRequest && hasProp(xmlHttpRequest, 'responseText') && xmlHttpRequest.responseText) || ''
  } catch (error) {
    // sur un xhr standard la propriété responseText n'est pas accessible si responseType vaut json, mais
    return ''
  }
}

/**
 * Fonction qui gère l'appel xhr
 * @private
 * @param {string}     verb
 * @param {string}     url
 * @param {object}     data
 * @param {xhrOptions} options
 * @param {function}   callback Sera appelée avec (error, réponse),
 *                              si options.responseType === 'json' la réponse sera un objet,
 *                              sinon l'objet response du XMLHttpRequest
 */
function xhrCall (verb, url, data, options, callback) {
  // pour s'assurer qu'on ne l'appelle qu'une fois, entre timeout, error et done
  function next (error, data) {
    if (!isNextCalled) {
      isNextCalled = true
      callback(error, data)
    }
  }

  if (typeof options === 'function') {
    callback = options
    options = {}
  }
  if (typeof callback !== 'function') throw new Error('xhr need a callback')
  if (['DELETE', 'GET', 'POST', 'PUT'].indexOf(verb) === -1) return callback(new Error('invalid verb'))

  var xhr
  var isNextCalled = false
  if (!options || typeof options !== 'object') options = {}

  if (MyXMLHttpRequest) {
    xhr = new MyXMLHttpRequest()
  } else if (typeof XMLHttpRequest !== 'undefined') {
    xhr = new XMLHttpRequest() // eslint-disable-line no-undef
  }

  if (!xhr) return next(new Error('Votre navigateur ne permet pas de faire des appels ajax'))

  if (options.urlParams) {
    for (var p in options.urlParams) {
      if (hasProp(options.urlParams, p)) {
        url += (url.indexOf('?') > 0) ? '&' : '?'
        url += p + '='
        url += encodeURIComponent(options.urlParams[p])
      }
    }
  }

  xhr.open(verb, url, !options.sync)

  if (options.withCredentials) xhr.withCredentials = true
  if (options.responseType) xhr.responseType = options.responseType
  // mettre un Content-Type sur du GET déclenche un preflight,
  // Ce Content-Type ne concerne que ce que l'on envoie donc on regarde
  // si y'a un objet à envoyer et que Content-Type n'est pas précisé on ajoute json
  if (!!data && typeof data === 'object' && (!options.headers || !options.headers['Content-Type'])) {
    xhr.setRequestHeader('Content-Type', 'application/json')
  }
  // sinon l'appelant devra préciser ce qu'il veut (on peut poster du xml par ex),
  if (options.headers) {
    for (var header in options.headers) {
      if (hasProp(options.headers, header) && typeof options.headers[header] === 'string') {
        xhr.setRequestHeader(header, options.headers[header])
      }
    }
  }
  // pas de timeout si sync
  if (options.sync) {
    console.warn('Appel xhr synchrone deprecated (chrome l’ignore), au unload préférer navigator.sendBeacon si c’est dispo (il faut faire de l’async dans tous les autres cas)')
  } else {
    xhr.timeout = options.timeout || defaultTimeout
    if (xhr.timeout < minTimeout) {
      console.error(new Error('timeout ' + xhr.timeout + "ms trop faible sur l'url " + url + ' réinitialisé à ' + (defaultTimeout / 1000) + 's'))
      xhr.timeout = defaultTimeout
    }
    if (xhr.timeout > maxTimeout) {
      console.error(new Error('timeout ' + xhr.timeout + "ms trop élevé sur l'url " + url + ' réinitialisé à ' + (defaultTimeout / 1000) + 's'))
      xhr.timeout = defaultTimeout
    }
  }

  xhr.onerror = options.onerror || function () {
    // Pb de connexion au serveur
    var errMsg = 'Le serveur a renvoyé une erreur'
    if (xhr.status) errMsg += ' ' + xhr.status
    var responseText = getResponseText(xhr)
    if (responseText) errMsg += ' : ' + responseText
    next(new Error(errMsg))
  }

  xhr.ontimeout = function () {
    var errMsg = "Le serveur n'a pas répondu"
    if (options.timeout) errMsg += ' après ' + Math.floor(options.timeout / 1000) + "s d'attente."
    var error = new Error(errMsg)
    error.isTimeout = true
    next(error)
  }

  xhr.onreadystatechange = function () {
    function parse (str) {
      try {
        return JSON.parse(str)
      } catch (err) {
        error = err
      }
    }
    var error, retour

    if (this.readyState === this.DONE) {
      // et IE11 renvoie une string dans this.response sans responseType
      var jsonRe = /^application\/json/i
      var isJson = xhr.responseType === 'json' || jsonRe.test(xhr.getResponseHeader('Content-Type'))
      // avec firefox, on a parfois des réponses 304 du serveur
      // (avec If-None-Match $etag à l'aller et 304 au retour)
      // qui donnent ici un status 0 avec une réponse, si y'a une réponse avec success, true ou false, ou message ok)
      if (
        this.status === 200 ||
        (this.response && hasProp(this.response, 'success')) ||
        (this.response && this.response.message === 'ok')
      ) {
        if (this.response) {
          // ie11 n'affecte pas responseType à 'json' et renvoie une string, d'où la regex sur le Content-Type
          // on se charge de transformer la réponse en objet
          if (typeof this.response === 'string' && isJson) {
            retour = parse(this.response)
          } else {
            retour = this.response
          }
        } else {
          // un navigateur gère ça tout seul, avec this.response en objet si y'avait un content-type json
          // mais le module xmlhttprequest ne renvoie que responseXml et responseText (avec responseType json)
          var responseText = getResponseText(this)
          if (isJson && responseText) {
            // pour xmlhttprequest seulement, car avec un navigateur on passe jamais là
            retour = parse(responseText)
          } else {
            retour = responseText
          }
        }
        // on précise si ≠ 200 (à priori 304, les autres cas passent en erreur dans le else qui suit)
        if (this.status !== 200) retour.status = this.status
      } else {
        // KO (les redirections sont normalement gérées par le navigateur)
        var message
        switch (this.status) {
          case 0:
            message = 'Pas de connexion'
            break
          case 400:
            message = 'Requête invalide'
            break
          case 401:
            message = 'Authentification requise'
            break
          case 403:
            message = 'Accès refusé'
            break
          case 404:
            message = 'Url inexistante'
            break
          case 500:
            message = 'Erreur serveur'
            break
          case 503:
            message = 'Serveur indisponible'
            break
          default:
            message = 'Erreur ' + this.status
        }
        message += ' sur ' + verb + ' ' + url
        // au cas où c'est du json qui renvoie une erreur
        var addOn
        if (this.response) {
          if (typeof this.response === 'string' && isJson) {
            retour = parse(this.response)
            addOn = retour.error || retour.message
            retour = undefined
          } else {
            addOn = this.response.error || this.response.message
          }
          if (addOn) message += ' qui précise « ' + addOn + ' »'
        }
        error = new Error(message)
        error.status = this.status
        error.content = this.response
      }

      next(error, retour)
    }
  }

  if (data) {
    try {
      data = JSON.stringify(data)
    } catch (error) {
      console.error(error)
      data = undefined
    }
  }
  xhr.send(data)
} // xhrCall

module.exports = {
  /**
   * Appel ajax en DELETE
   * Si 3 arguments, le 2e sera pris comme options
   * @param {string}           url
   * @param {string|object}    [data]     Données éventuelles à envoyer dans le body de la requête
   * @param {xhrOptions}       [options]
   * @param {responseCallback} callback
   */
  delete: function del (url, data, options, callback) {
    // dernier argument doit être callback, on vérifie pas les types,
    // c'est xhrCall qui se plaindra si c'est pas une fct
    if (arguments.length === 3) {
      callback = options
      options = data
      data = undefined
    } else if (arguments.length === 2) {
      callback = data
      data = undefined
      options = {}
    }
    xhrCall('DELETE', url, data, options, callback)
  },
  /**
   * Appel ajax en GET
   * @param {string}           url
   * @param {xhrOptions}       [options]
   * @param {responseCallback} callback
   */
  get: function get (url, options, callback) {
    xhrCall('GET', url, null, options, callback)
  },
  /**
   * Appel ajax en POST
   * @param {string}           url
   * @param {object}           data      Données à poster dans le body
   * @param {xhrOptions}       [options] json sera automatiquement ajouté si data est un object,
   *                                     sinon faut préciser Content-type
   * @param {responseCallback} callback
   */
  post: function post (url, data, options, callback) {
    xhrCall('POST', url, data, options, callback)
  },
  /**
   * Appel ajax en PUT
   * @param {string}           url
   * @param {object}           data     Données éventuelles à envoyer dans le body de la requête
   * @param {xhrOptions}       [options]
   * @param {responseCallback} callback
   */
  put: function put (url, data, options, callback) {
    xhrCall('PUT', url, data, options, callback)
  },
  /**
   * Affecte un XMLHttpRequest (pour utiliser ce module coté serveur)
   * par ex `xhr.setXMLHttpRequest(require('xmlhttprequest').XMLHttpRequest)
   * @param {XMLHttpRequest} XMLHttpRequest
   */
  setXMLHttpRequest: function (XMLHttpRequest) {
    MyXMLHttpRequest = XMLHttpRequest
  }
}

/**
 * Options éventuelles à passer avec la requête xhr
 * @typedef xhrOptions
 * @param {string}  responseType    Préciser json pour récupérer un objet plutôt qu'une string dans la réponse
 * @param {object}  headers         Liste de headers à ajouter à l'appel, sous la forme header:headerValue (par ex {'Content-Type':'text/xml'})
 * @param {boolean} withCredentials Passer true pour l'ajouter
 * @param {object}  urlParams       Liste de clé:valeur à ajouter à l'url (les valeurs seront passées à encodeURIComponent)
 * @param {boolean} sync            Passer true pour passer en appel ajax synchrone (donc pas de timeout possible,
 *                                    à éviter mais indispensable si c'est sur un evt unload, justement pour le mode bloquant)
 */

/**
 * @callback responseCallback
 * @param {Error}         error    Erreur éventuelle
 * @param {string|object} response L'objet response du XMLHttpRequest, un objet si on avait précisé options.responseType = 'json', une string sinon
 */