Files
threshold-ui/addons/api/addon/adapters/application.js
T

280 lines
8.8 KiB
JavaScript

import RESTAdapter from '@ember-data/adapter/rest';
import { serializeIntoHash } from '@ember-data/adapter/-private';
/* eslint-disable-next-line ember/no-mixins */
import AdapterBuildURLMixin from '../mixins/adapter-build-url';
import config from 'ember-get-config';
import { get } from '@ember/object';
import { InvalidError } from '@ember-data/adapter/error';
import { dasherize } from '@ember/string';
import { pluralize } from 'ember-inflector';
import { isArray } from '@ember/array';
/**
* Returns true if the payload is an empty object, false otherwise.
* Taken from Ember Data fetch-manager and "positivized" the name.
* @param {object} adapterPayload
* @return {boolean}
*/
function payloadIsBlank(adapterPayload) {
if (Array.isArray(adapterPayload)) {
return false;
} else {
return Object.keys(adapterPayload || {}).length === 0;
}
}
/**
* If the resonse is empty, returns a new payload containing an empty `items`
* array, as expected by the serializer. Otherwise, returns the response.
*
* Normally this would be performed 100% inside the serializer.
* However, the fetch manager, which we cannot easily override, glues the
* adapter and serializers together. Before it forwards a response from the
* adapter on to the serializer, it checks for an "empty" response. If the
* response is empty, fetch manager throws and error and never forwards it on.
* @param {object} response
* @return {object}
*/
function prenormalizeArrayResponse(response) {
return payloadIsBlank(response) ? { items: [] } : response;
}
export default class ApplicationAdapter extends RESTAdapter.extend(
AdapterBuildURLMixin
) {
// =attributes
/**
* Sets host to the `api.host` string in the application's config,
* if set.
* @type {string}
*/
host = get(config, 'api.host');
/**
* Sets namespace to the `api.namespace` string in the application's config,
* if set.
* @type {string}
*/
namespace = get(config, 'api.namespace');
// =methods
/**
* Appends a custom method after a colon, if a method is passed via the
* snapshot's `adapterOptions.method` field.
* @param {string} modelName
* @param {string} id
* @param {object} snapshot
* @return {string}
*/
urlSuffix(modelName, id, snapshot = {}) {
const method = get(snapshot, 'adapterOptions.method') || '';
return method ? `:${method}` : '';
}
/**
* Transforms the type to a dasherized string used in our API paths.
*
* @override
* @param {string} type
* @return {string}
*/
pathForType(type) {
return dasherize(pluralize(type));
}
/**
* Intercepts "empty" responses and adds an empty `items` array.
* @override
* @method findAll
* @return {Promise} promise
*/
findAll() {
return super.findAll(...arguments).then(prenormalizeArrayResponse);
}
/**
* Intercepts "empty" responses and adds an empty `items` array.
* @override
* @method query
* @return {Promise} promise
*/
query() {
return super.query(...arguments).then(prenormalizeArrayResponse);
}
/**
* Our API expects update calls with a custom method suffix
* (e.g. `:set-grants`) to be made with POST instead of PUT/PATCH.
* If this method is called with a custom method via `adapterOptions.method`,
* the API request will be made with POST. All other update requests are
* made as normal.
* @override
* @param {Store} store
* @param {Model} type
* @param {Snapshot} snapshot
* @return {Promise} promise
*/
updateRecord(store, type, snapshot) {
const method = get(snapshot, 'adapterOptions.method');
const data = serializeIntoHash(store, type, snapshot, {});
const id = snapshot.id;
const url = this.buildURL(type.modelName, id, snapshot, 'updateRecord');
return this.ajax(url, method ? 'POST' : 'PUT', { data });
}
/**
* Overrides default ajax method by rewritting PUT requests as PATCH.
* PATCH is the request method used by our API for updates.
*
* @override
* @method ajax
* @private
* @param {String} url
* @param {String} type The request type GET, POST, PUT, DELETE etc.
* @param {Object} options
* @return {Promise} promise
*/
ajax(url, type, options) {
const method = type === 'PUT' ? 'PATCH' : type;
return super.ajax(url, method, options);
}
/**
* Our API represents invalid requests via the 400 error. But Ember Data
* expects 422 by default. We change it here.
*
* @override
* @method isInvalid
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @return {Boolean}
*/
isInvalid(status /*, headers, payload*/) {
return status === 400;
}
/**
* Ember Data blatantly assumes errors always present under an `errors` key
* in the payload, even _before normalizing errors_. Bizarre. Instead,
* we pass the payload through an adapter-level transformation step if the
* request was invalid. Then we can rely on the serializer's `extractErrors`
* to handle the rest.
*
* @override
* @method handleResponse
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @param {Object} requestData - the original request information
* @return {Object | AdapterError} response
*/
handleResponse(status, headers, payload, requestData) {
if (this.isInvalid(status, headers, payload)) {
const detailedMessage = this.generatedDetailedMessage(...arguments);
const transformedPayload = this.transformValidationErrors(payload);
return new InvalidError(transformedPayload.errors, detailedMessage);
}
return super.handleResponse(status, headers, payload, requestData);
}
/**
* Add convenience booleans to each error within the errors list indicating
* the type of error that occurred. This is useful in downstream templates,
* e.g. `{{if error.isForbidden}}`.
*
* Error booleans are attached based on the error status code:
*
* - `401 isUnauthenticated`: the session isn't authenticated
* - `403 isForbidden`: the session is authenticated but does not have
* permission to perform the requested action
* - `404 isNotFound`: the requested resource could not be found
* - `500 isServer`: an internal server error occurred
* - `isUnknown`: an error occurred, but we don't know which or
* we don't distinguish it yet
*
* @override
* @method normalizeErrorResponse
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @return {Array} errors payload
*/
normalizeErrorResponse(/*status, headers, payload*/) {
const errors = super.normalizeErrorResponse(...arguments);
if (isArray(errors)) {
errors.forEach((error) => {
switch (Number(error.status)) {
case 401:
error.isUnauthenticated = true;
break;
case 403:
error.isForbidden = true;
break;
case 404:
error.isNotFound = true;
break;
case 500:
error.isServer = true;
break;
default:
error.isUnknown = true;
}
});
}
return errors;
}
/**
* Normalizes the errors found in a payload from our API into JSON API
* error format.
*
* @param {object} payload
* @return {object}
*/
transformValidationErrors(payload) {
const fieldErrors = get(payload, 'details.request_fields') || [];
// Normalize the primary error message into "base", which is an error
// that applies to the whole model instance
const baseError = {
isInvalid: true,
status: payload.source,
code: payload.code,
detail: payload.message,
source: { pointer: '/data' },
};
// Normalize field-specific errors, if any.
const errors = fieldErrors.map((error) => ({
detail: error.description,
// Ember Data cannot handle errors on nested attributes, so these are
// flattened (e.g. `attributes.foobar` becomes `attributes_foobar`).
// To capture these errors, add read-only fields on the model
// corresponding to the underscored field name.
source: { pointer: `/data/attributes/${error.name.replace('.', '_')}` },
}));
// Return a list of JSON API errors rooted under the `errors` key.
return {
errors: [baseError, ...errors],
};
}
/**
* Returns `payload.message`, which our API provides as a top-level
* description of the error that occurred.
*
* @override
* @method generatedDetailedMessage
* @private
* @param {Number} status
* @param {Object} headers
* @param {Object} payload
* @param {Object} requestData
* @return {String} detailed error message
*/
generatedDetailedMessage(status, headers, payload /*, requestData*/) {
return payload.message;
}
}