* [COMPLIANCE] Add Copyright and License Headers * chore: 🤖 update Copyright headers to satisfy our lint rules --------- Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com> Co-authored-by: Cameron Perera <cameron.perera@hashicorp.com>
305 lines
9.3 KiB
JavaScript
305 lines
9.3 KiB
JavaScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* SPDX-License-Identifier: MPL-2.0
|
|
*/
|
|
|
|
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 { getOwner } from '@ember/application';
|
|
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
|
|
|
|
#host;
|
|
#namespace;
|
|
|
|
/**
|
|
* Sets host to the `api.host` string in the application's config,
|
|
* if set.
|
|
* @type {string}
|
|
*/
|
|
get host() {
|
|
const config = getOwner(this).resolveRegistration('config:environment');
|
|
return this.#host || get(config, 'api.host');
|
|
}
|
|
set host(value) {
|
|
// Setting this value is useful for testing purposes.
|
|
this.#host = value;
|
|
}
|
|
|
|
/**
|
|
* Sets namespace to the `api.namespace` string in the application's config,
|
|
* if set.
|
|
* @type {string}
|
|
*/
|
|
get namespace() {
|
|
const config = getOwner(this).resolveRegistration('config:environment');
|
|
return this.#namespace || get(config, 'api.namespace');
|
|
}
|
|
set namespace(value) {
|
|
// Setting this value is useful for testing purposes.
|
|
this.#namespace = value;
|
|
}
|
|
|
|
// =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,
|
|
// Going forward, nested attributes will be hoisted. Thus "attributes."
|
|
// must be stripped from the error name key. Until all models are
|
|
// transitioned away from model fragments, however, both this and the
|
|
// prior error key mappings are necessary.
|
|
source: {
|
|
pointer: `/data/attributes/${error.name.replace('attributes.', '')}`,
|
|
},
|
|
}));
|
|
|
|
// 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;
|
|
}
|
|
}
|