UNPKG

ember-ajax

Version:

Service for making AJAX requests in Ember applications.

591 lines (590 loc) 21 kB
import { A } from '@ember/array'; import EmberError from '@ember/error'; import Mixin from '@ember/object/mixin'; import { get } from '@ember/object'; import { isEmpty } from '@ember/utils'; import { join } from '@ember/runloop'; import { warn, runInDebug } from '@ember/debug'; import Ember from 'ember'; import { AjaxError, UnauthorizedError, InvalidError, ForbiddenError, BadRequestError, NotFoundError, GoneError, TimeoutError, AbortError, ConflictError, ServerError, isAjaxError, isUnauthorizedError, isForbiddenError, isInvalidError, isBadRequestError, isNotFoundError, isGoneError, isConflictError, isAbortError, isServerError, isSuccess } from '../errors'; import ajax from 'ember-ajax/utils/ajax'; import parseResponseHeaders from 'ember-ajax/-private/utils/parse-response-headers'; import getHeader from 'ember-ajax/-private/utils/get-header'; import { isFullURL, parseURL, haveSameHost } from 'ember-ajax/-private/utils/url-helpers'; import isString from 'ember-ajax/-private/utils/is-string'; import AJAXPromise from 'ember-ajax/-private/promise'; const { Test } = Ember; const JSONContentType = /^application\/(?:vnd\.api\+)?json/i; function isJSONContentType(header) { if (!isString(header)) { return false; } return !!header.match(JSONContentType); } function isJSONStringifyable(method, { contentType, data, headers }) { if (method === 'GET') { return false; } if (!isJSONContentType(contentType) && !isJSONContentType(getHeader(headers, 'Content-Type'))) { return false; } if (typeof data !== 'object') { return false; } return true; } function startsWithSlash(string) { return string.charAt(0) === '/'; } function endsWithSlash(string) { return string.charAt(string.length - 1) === '/'; } function removeLeadingSlash(string) { return string.substring(1); } function removeTrailingSlash(string) { return string.slice(0, -1); } function stripSlashes(path) { // make sure path starts with `/` if (startsWithSlash(path)) { path = removeLeadingSlash(path); } // remove end `/` if (endsWithSlash(path)) { path = removeTrailingSlash(path); } return path; } let pendingRequestCount = 0; if (Ember.testing) { Test.registerWaiter(function () { return pendingRequestCount === 0; }); } /** * AjaxRequest Mixin */ export default Mixin.create({ /** * The default value for the request `contentType` * * For now, defaults to the same value that jQuery would assign. In the * future, the default value will be for JSON requests. * @property {string} contentType * @public */ contentType: 'application/x-www-form-urlencoded; charset=UTF-8', /** * Headers to include on the request * * Some APIs require HTTP headers, e.g. to provide an API key. Arbitrary * headers can be set as key/value pairs on the `RESTAdapter`'s `headers` * object and Ember Data will send them along with each ajax request. * * ```javascript * // app/services/ajax.js * import AjaxService from 'ember-ajax/services/ajax'; * * export default AjaxService.extend({ * headers: { * 'API_KEY': 'secret key', * 'ANOTHER_HEADER': 'Some header value' * } * }); * ``` * * `headers` can also be used as a computed property to support dynamic * headers. * * ```javascript * // app/services/ajax.js * import Ember from 'ember'; * import AjaxService from 'ember-ajax/services/ajax'; * * const { * computed, * get, * inject: { service } * } = Ember; * * export default AjaxService.extend({ * session: service(), * headers: computed('session.authToken', function() { * return { * 'API_KEY': get(this, 'session.authToken'), * 'ANOTHER_HEADER': 'Some header value' * }; * }) * }); * ``` * * In some cases, your dynamic headers may require data from some object * outside of Ember's observer system (for example `document.cookie`). You * can use the `volatile` function to set the property into a non-cached mode * causing the headers to be recomputed with every request. * * ```javascript * // app/services/ajax.js * import Ember from 'ember'; * import AjaxService from 'ember-ajax/services/ajax'; * * const { * computed, * get, * inject: { service } * } = Ember; * * export default AjaxService.extend({ * session: service(), * headers: computed('session.authToken', function() { * return { * 'API_KEY': get(document.cookie.match(/apiKey\=([^;]*)/), '1'), * 'ANOTHER_HEADER': 'Some header value' * }; * }).volatile() * }); * ``` * * @property {Headers} headers * @public */ headers: undefined, /** * @property {string} host * @public */ host: undefined, /** * @property {string} namespace * @public */ namespace: undefined, /** * @property {Matcher[]} trustedHosts * @public */ trustedHosts: undefined, /** * Make an AJAX request, ignoring the raw XHR object and dealing only with * the response */ request(url, options) { const hash = this.options(url, options); const internalPromise = this._makeRequest(hash); const ajaxPromise = new AJAXPromise((resolve, reject) => { internalPromise .then(({ response }) => { resolve(response); }) .catch(({ response }) => { reject(response); }); }, `ember-ajax: ${hash.type} ${hash.url} response`); ajaxPromise.xhr = internalPromise.xhr; return ajaxPromise; }, /** * Make an AJAX request, returning the raw XHR object along with the response */ raw(url, options) { const hash = this.options(url, options); return this._makeRequest(hash); }, /** * Shared method to actually make an AJAX request */ _makeRequest(hash) { const method = hash.method || hash.type || 'GET'; const requestData = { method, type: method, url: hash.url }; if (isJSONStringifyable(method, hash)) { hash.data = JSON.stringify(hash.data); } pendingRequestCount = pendingRequestCount + 1; const jqXHR = ajax(hash.url, hash); const promise = new AJAXPromise((resolve, reject) => { jqXHR .done((payload, textStatus, jqXHR) => { const response = this.handleResponse(jqXHR.status, parseResponseHeaders(jqXHR.getAllResponseHeaders()), payload, requestData); if (isAjaxError(response)) { const rejectionParam = { payload, textStatus, jqXHR, response }; join(null, reject, rejectionParam); } else { const resolutionParam = { payload, textStatus, jqXHR, response }; join(null, resolve, resolutionParam); } }) .fail((jqXHR, textStatus, errorThrown) => { runInDebug(function () { const message = `The server returned an empty string for ${requestData.type} ${requestData.url}, which cannot be parsed into a valid JSON. Return either null or {}.`; const validJSONString = !(textStatus === 'parsererror' && jqXHR.responseText === ''); warn(message, validJSONString, { id: 'ds.adapter.returned-empty-string-as-JSON' }); }); const payload = this.parseErrorResponse(jqXHR.responseText) || errorThrown; let response; if (textStatus === 'timeout') { response = new TimeoutError(); } else if (textStatus === 'abort') { response = new AbortError(); } else { response = this.handleResponse(jqXHR.status, parseResponseHeaders(jqXHR.getAllResponseHeaders()), payload, requestData); } const rejectionParam = { payload, textStatus, jqXHR, errorThrown, response }; join(null, reject, rejectionParam); }) .always(() => { pendingRequestCount = pendingRequestCount - 1; }); }, `ember-ajax: ${hash.type} ${hash.url}`); promise.xhr = jqXHR; return promise; }, /** * calls `request()` but forces `options.type` to `POST` */ post(url, options) { return this.request(url, this._addTypeToOptionsFor(options, 'POST')); }, /** * calls `request()` but forces `options.type` to `PUT` */ put(url, options) { return this.request(url, this._addTypeToOptionsFor(options, 'PUT')); }, /** * calls `request()` but forces `options.type` to `PATCH` */ patch(url, options) { return this.request(url, this._addTypeToOptionsFor(options, 'PATCH')); }, /** * calls `request()` but forces `options.type` to `DELETE` */ del(url, options) { return this.request(url, this._addTypeToOptionsFor(options, 'DELETE')); }, /** * calls `request()` but forces `options.type` to `DELETE` * * Alias for `del()` */ delete(url, options) { return this.del(url, options); }, /** * Wrap the `.get` method so that we issue a warning if * * Since `.get` is both an AJAX pattern _and_ an Ember pattern, we want to try * to warn users when they try using `.get` to make a request */ get(url) { if (arguments.length > 1 || url.indexOf('/') !== -1) { throw new EmberError('It seems you tried to use `.get` to make a request! Use the `.request` method instead.'); } return this._super(...arguments); }, /** * Manipulates the options hash to include the HTTP method on the type key */ _addTypeToOptionsFor(options, method) { options = options || {}; options.type = method; return options; }, /** * Get the full "headers" hash, combining the service-defined headers with * the ones provided for the request */ _getFullHeadersHash(headers) { const classHeaders = get(this, 'headers'); return Object.assign({}, classHeaders, headers); }, /** * Created a normalized set of options from the per-request and * service-level settings */ options(url, options = {}) { options = Object.assign({}, options); options.url = this._buildURL(url, options); options.type = options.type || 'GET'; options.dataType = options.dataType || 'json'; options.contentType = isEmpty(options.contentType) ? get(this, 'contentType') : options.contentType; if (this._shouldSendHeaders(options)) { options.headers = this._getFullHeadersHash(options.headers); } else { options.headers = options.headers || {}; } return options; }, /** * Build a URL for a request * * If the provided `url` is deemed to be a complete URL, it will be returned * directly. If it is not complete, then the segment provided will be combined * with the `host` and `namespace` options of the request class to create the * full URL. */ _buildURL(url, options = {}) { if (isFullURL(url)) { return url; } const urlParts = []; let host = options.host || get(this, 'host'); if (host) { host = endsWithSlash(host) ? removeTrailingSlash(host) : host; urlParts.push(host); } let namespace = options.namespace || get(this, 'namespace'); if (namespace) { // If host is given then we need to strip leading slash too( as it will be added through join) if (host) { namespace = stripSlashes(namespace); } else if (endsWithSlash(namespace)) { namespace = removeTrailingSlash(namespace); } // If the URL has already been constructed (presumably, by Ember Data), then we should just leave it alone const hasNamespaceRegex = new RegExp(`^(/)?${stripSlashes(namespace)}/`); if (!hasNamespaceRegex.test(url)) { urlParts.push(namespace); } } // *Only* remove a leading slash when there is host or namespace -- we need to maintain a trailing slash for // APIs that differentiate between it being and not being present if (startsWithSlash(url) && urlParts.length !== 0) { url = removeLeadingSlash(url); } urlParts.push(url); return urlParts.join('/'); }, /** * Takes an ajax response, and returns the json payload or an error. * * By default this hook just returns the json payload passed to it. * You might want to override it in two cases: * * 1. Your API might return useful results in the response headers. * Response headers are passed in as the second argument. * * 2. Your API might return errors as successful responses with status code * 200 and an Errors text or object. */ handleResponse(status, headers, payload, requestData) { if (this.isSuccess(status, headers, payload)) { return payload; } // Allow overriding of error payload payload = this.normalizeErrorResponse(status, headers, payload); return this._createCorrectError(status, headers, payload, requestData); }, _createCorrectError(status, headers, payload, requestData) { let error; if (this.isUnauthorizedError(status, headers, payload)) { error = new UnauthorizedError(payload); } else if (this.isForbiddenError(status, headers, payload)) { error = new ForbiddenError(payload); } else if (this.isInvalidError(status, headers, payload)) { error = new InvalidError(payload); } else if (this.isBadRequestError(status, headers, payload)) { error = new BadRequestError(payload); } else if (this.isNotFoundError(status, headers, payload)) { error = new NotFoundError(payload); } else if (this.isGoneError(status, headers, payload)) { error = new GoneError(payload); } else if (this.isAbortError(status, headers, payload)) { error = new AbortError(); } else if (this.isConflictError(status, headers, payload)) { error = new ConflictError(payload); } else if (this.isServerError(status, headers, payload)) { error = new ServerError(payload, status); } else { const detailedMessage = this.generateDetailedMessage(status, headers, payload, requestData); error = new AjaxError(payload, detailedMessage, status); } return error; }, /** * Match the host to a provided array of strings or regexes that can match to a host */ _matchHosts(host, matcher) { if (!isString(host)) { return false; } if (matcher instanceof RegExp) { return matcher.test(host); } else if (typeof matcher === 'string') { return matcher === host; } else { console.warn('trustedHosts only handles strings or regexes. ', matcher, ' is neither.'); return false; } }, /** * Determine whether the headers should be added for this request * * This hook is used to help prevent sending headers to every host, regardless * of the destination, since this could be a security issue if authentication * tokens are accidentally leaked to third parties. * * To avoid that problem, subclasses should utilize the `headers` computed * property to prevent authentication from being sent to third parties, or * implement this hook for more fine-grain control over when headers are sent. * * By default, the headers are sent if the host of the request matches the * `host` property designated on the class. */ _shouldSendHeaders({ url, host }) { url = url || ''; host = host || get(this, 'host') || ''; const trustedHosts = get(this, 'trustedHosts') || A(); const { hostname } = parseURL(url); // Add headers on relative URLs if (!isFullURL(url)) { return true; } else if (trustedHosts.find(matcher => this._matchHosts(hostname, matcher))) { return true; } // Add headers on matching host return haveSameHost(url, host); }, /** * Generates a detailed ("friendly") error message, with plenty * of information for debugging (good luck!) */ generateDetailedMessage(status, headers, payload, requestData) { let shortenedPayload; const payloadContentType = getHeader(headers, 'Content-Type') || 'Empty Content-Type'; if (payloadContentType.toLowerCase() === 'text/html' && payload.length > 250) { shortenedPayload = '[Omitted Lengthy HTML]'; } else { shortenedPayload = JSON.stringify(payload); } const requestDescription = `${requestData.type} ${requestData.url}`; const payloadDescription = `Payload (${payloadContentType})`; return [ `Ember AJAX Request ${requestDescription} returned a ${status}`, payloadDescription, shortenedPayload ].join('\n'); }, /** * Default `handleResponse` implementation uses this hook to decide if the * response is a an authorized error. */ isUnauthorizedError(status, _headers, _payload) { return isUnauthorizedError(status); }, /** * Default `handleResponse` implementation uses this hook to decide if the * response is a forbidden error. */ isForbiddenError(status, _headers, _payload) { return isForbiddenError(status); }, /** * Default `handleResponse` implementation uses this hook to decide if the * response is a an invalid error. */ isInvalidError(status, _headers, _payload) { return isInvalidError(status); }, /** * Default `handleResponse` implementation uses this hook to decide if the * response is a bad request error. */ isBadRequestError(status, _headers, _payload) { return isBadRequestError(status); }, /** * Default `handleResponse` implementation uses this hook to decide if the * response is a "not found" error. */ isNotFoundError(status, _headers, _payload) { return isNotFoundError(status); }, /** * Default `handleResponse` implementation uses this hook to decide if the * response is a "gone" error. */ isGoneError(status, _headers, _payload) { return isGoneError(status); }, /** * Default `handleResponse` implementation uses this hook to decide if the * response is an "abort" error. */ isAbortError(status, _headers, _payload) { return isAbortError(status); }, /** * Default `handleResponse` implementation uses this hook to decide if the * response is a "conflict" error. */ isConflictError(status, _headers, _payload) { return isConflictError(status); }, /** * Default `handleResponse` implementation uses this hook to decide if the * response is a server error. */ isServerError(status, _headers, _payload) { return isServerError(status); }, /** * Default `handleResponse` implementation uses this hook to decide if the * response is a success. */ isSuccess(status, _headers, _payload) { return isSuccess(status); }, parseErrorResponse(responseText) { try { return JSON.parse(responseText); } catch (e) { return responseText; } }, normalizeErrorResponse(_status, _headers, payload) { return payload; } });