ember-app-scheduler
Version:
Ember addon to schedule work at different phases of app life cycle.
814 lines (737 loc) • 22.5 kB
JavaScript
import Ember from 'ember';
import {
AjaxError,
UnauthorizedError,
InvalidError,
ForbiddenError,
BadRequestError,
NotFoundError,
TimeoutError,
AbortError,
ConflictError,
ServerError,
isAjaxError,
isUnauthorizedError,
isForbiddenError,
isInvalidError,
isBadRequestError,
isNotFoundError,
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 {
A,
Error: EmberError,
Logger,
Mixin,
Test,
get,
isEmpty,
merge,
run,
runInDebug,
testing,
warn
} = 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 stripSlashes(path) {
// make sure path starts with `/`
if (startsWithSlash(path)) {
path = removeLeadingSlash(path);
}
// remove end `/`
if (endsWithSlash(path)) {
path = path.slice(0, -1);
}
return path;
}
let pendingRequestCount = 0;
if (testing) {
Test.registerWaiter(function() {
return pendingRequestCount === 0;
});
}
/**
* AjaxRequest Mixin
*
* @public
* @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
* @default
*/
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 {Object} headers
* @public
* @default
*/
headers: {},
/**
* Make an AJAX request, ignoring the raw XHR object and dealing only with
* the response
*
* @method request
* @public
* @param {string} url The url to make a request to
* @param {Object} options The options for the request
* @return {Promise} The result of the request
*/
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
*
* @method raw
* @public
* @param {string} url The url to make a request to
* @param {Object} options The options for the request
* @return {Promise} The result of the request
*/
raw(url, options) {
const hash = this.options(url, options);
return this._makeRequest(hash);
},
/**
* Shared method to actually make an AJAX request
*
* @method _makeRequest
* @private
* @param {Object} hash The options for the request
* @param {string} hash.url The URL to make the request to
* @return {Promise} The result of the 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);
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)) {
run.join(null, reject, { payload, textStatus, jqXHR, response });
} else {
run.join(null, resolve, { payload, textStatus, jqXHR, response });
}
})
.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 (errorThrown instanceof Error) {
response = errorThrown;
} else if (textStatus === 'timeout') {
response = new TimeoutError();
} else if (textStatus === 'abort') {
response = new AbortError();
} else {
response = this.handleResponse(
jqXHR.status,
parseResponseHeaders(jqXHR.getAllResponseHeaders()),
payload,
requestData
);
}
run.join(null, reject, { payload, textStatus, jqXHR, errorThrown, response });
})
.always(() => {
pendingRequestCount = pendingRequestCount - 1;
});
}, `ember-ajax: ${hash.type} ${hash.url}`);
promise.xhr = jqXHR;
return promise;
},
/**
* calls `request()` but forces `options.type` to `POST`
*
* @method post
* @public
* @param {string} url The url to make a request to
* @param {Object} options The options for the request
* @return {Promise} The result of the request
*/
post(url, options) {
return this.request(url, this._addTypeToOptionsFor(options, 'POST'));
},
/**
* calls `request()` but forces `options.type` to `PUT`
*
* @method put
* @public
* @param {string} url The url to make a request to
* @param {Object} options The options for the request
* @return {Promise} The result of the request
*/
put(url, options) {
return this.request(url, this._addTypeToOptionsFor(options, 'PUT'));
},
/**
* calls `request()` but forces `options.type` to `PATCH`
*
* @method patch
* @public
* @param {string} url The url to make a request to
* @param {Object} options The options for the request
* @return {Promise} The result of the request
*/
patch(url, options) {
return this.request(url, this._addTypeToOptionsFor(options, 'PATCH'));
},
/**
* calls `request()` but forces `options.type` to `DELETE`
*
* @method del
* @public
* @param {string} url The url to make a request to
* @param {Object} options The options for the request
* @return {Promise} The result of the request
*/
del(url, options) {
return this.request(url, this._addTypeToOptionsFor(options, 'DELETE'));
},
/**
* calls `request()` but forces `options.type` to `DELETE`
*
* Alias for `del()`
*
* @method delete
* @public
* @param {string} url The url to make a request to
* @param {Object} options The options for the request
* @return {Promise} The result of the request
*/
delete() {
return this.del(...arguments);
},
/**
* 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
*
* @method get
* @public
*/
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
*
* @method _addTypeToOptionsFor
* @private
* @param {Object} options The original request options
* @param {string} method The method to enforce
* @return {Object} The new options, with the method set
*/
_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
*
* @method _getFullHeadersHash
* @private
* @param {Object} headers
* @return {Object}
*/
_getFullHeadersHash(headers) {
const classHeaders = get(this, 'headers');
const _headers = merge({}, classHeaders);
return merge(_headers, headers);
},
/**
* @method options
* @private
* @param {string} url
* @param {Object} options
* @return {Object}
*/
options(url, options = {}) {
options = merge({}, 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.
*
* @private
* @param {string} url the url, or url segment, to request
* @param {Object} [options={}] the options for the request being made
* @param {string} [options.host] the host to use for this request
* @returns {string} the URL to make a request to
*/
_buildURL(url, options = {}) {
if (isFullURL(url)) {
return url;
}
const urlParts = [];
let host = options.host || get(this, 'host');
if (host) {
host = stripSlashes(host);
}
urlParts.push(host);
let namespace = options.namespace || get(this, 'namespace');
if (namespace) {
namespace = stripSlashes(namespace);
urlParts.push(namespace);
}
// If the URL has already been constructed (presumably, by Ember Data), then we should just leave it alone
const hasNamespaceRegex = new RegExp(`^(\/)?${namespace}`);
if (hasNamespaceRegex.test(url)) {
return url;
}
// *Only* remove a leading slash -- we need to maintain a trailing slash for
// APIs that differentiate between it being and not being present
if (startsWithSlash(url)) {
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.
*
* @method handleResponse
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @param {Object} requestData the original request information
* @return {Object | AjaxError} response
*/
handleResponse(status, headers, payload, requestData) {
let error;
if (this.isSuccess(status, headers, payload)) {
return payload;
}
// Allow overriding of error payload
payload = this.normalizeErrorResponse(status, headers, payload);
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.isAbortError(status, headers, payload)) {
error = new AbortError(payload);
} else if (this.isConflictError(status, headers, payload)) {
error = new ConflictError(payload);
} else if (this.isServerError(status, headers, payload)) {
error = new ServerError(payload);
} else {
const detailedMessage = this.generateDetailedMessage(
status,
headers,
payload,
requestData
);
error = new AjaxError(payload, detailedMessage);
}
return error;
},
/**
* Match the host to a provided array of strings or regexes that can match to a host
*
* @method matchHosts
* @private
* @param {string} host the host you are sending too
* @param {RegExp | string} matcher a string or regex that you can match the host to.
* @returns {Boolean} if the host passed the matcher
*/
_matchHosts(host, matcher) {
if (matcher.constructor === RegExp) {
return matcher.test(host);
} else if (typeof matcher === 'string') {
return matcher === host;
} else {
Logger.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.
*
* @method _shouldSendHeaders
* @private
* @property {Object} hash request options hash
* @returns {Boolean} whether or not headers should be sent
*/
_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!)
*
* @method generateDetailedMessage
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @param {Object} requestData the original request information
* @return {Object} request information
*/
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.
*
* @method isUnauthorizedError
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @return {Boolean}
*/
isUnauthorizedError(status) {
return isUnauthorizedError(status);
},
/**
* Default `handleResponse` implementation uses this hook to decide if the
* response is a forbidden error.
*
* @method isForbiddenError
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @return {Boolean}
*/
isForbiddenError(status) {
return isForbiddenError(status);
},
/**
* Default `handleResponse` implementation uses this hook to decide if the
* response is a an invalid error.
*
* @method isInvalidError
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @return {Boolean}
*/
isInvalidError(status) {
return isInvalidError(status);
},
/**
* Default `handleResponse` implementation uses this hook to decide if the
* response is a bad request error.
*
* @method isBadRequestError
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @return {Boolean}
*/
isBadRequestError(status) {
return isBadRequestError(status);
},
/**
* Default `handleResponse` implementation uses this hook to decide if the
* response is a "not found" error.
*
* @method isNotFoundError
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @return {Boolean}
*/
isNotFoundError(status) {
return isNotFoundError(status);
},
/**
* Default `handleResponse` implementation uses this hook to decide if the
* response is an "abort" error.
*
* @method isAbortError
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @return {Boolean}
*/
isAbortError(status) {
return isAbortError(status);
},
/**
* Default `handleResponse` implementation uses this hook to decide if the
* response is a "conflict" error.
*
* @method isConflictError
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @return {Boolean}
*/
isConflictError(status) {
return isConflictError(status);
},
/**
* Default `handleResponse` implementation uses this hook to decide if the
* response is a server error.
*
* @method isServerError
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @return {Boolean}
*/
isServerError(status) {
return isServerError(status);
},
/**
* Default `handleResponse` implementation uses this hook to decide if the
* response is a success.
*
* @method isSuccess
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @return {Boolean}
*/
isSuccess(status) {
return isSuccess(status);
},
/**
* @method parseErrorResponse
* @private
* @param {string} responseText
* @return {Object}
*/
parseErrorResponse(responseText) {
try {
return JSON.parse(responseText);
} catch(e) {
return responseText;
}
},
/**
* Can be overwritten to allow re-formatting of error messages
*
* @method normalizeErrorResponse
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @return {*} error response
*/
normalizeErrorResponse(status, headers, payload) {
return payload;
}
});