Files
threshold-ui/addons/api/addon/serializers/application.js
T
Lisbet AlvarezandCarlos Alcaide Corvo 9dd1617b57 chore: 🤖 upgrade ember-data to v5.3.11 (#2745)
* chore: 🤖 upgrade ember-data to v5.3.11

* Add missing copyright headers

* refactor: 💡 remove comment

* chore: 🤖 update yarn.lock due to merge conflict

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16544

* fix: 🐛 undo ember-source upgrade (should be upgraded w/ ember)

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16544

* chore: 🤖 move dep to devDeps & resolve test-helper version

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16544

* fix: 🐛 unique record id error bug fix using store.push

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16544

* refactor: 💡 cleanup

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16544

* Delete old deprecation initializer (#2748)

* refactor: 💡 import from'ember-data/store'

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16544

* refactor: 💡 clean up comments

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16544

* refactor: 💡 remove compatWith comment

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16544

---------

Co-authored-by: Carlos Alcaide Corvo <ccorvo@hashicorp.com>
2025-04-03 09:32:44 -07:00

400 lines
13 KiB
JavaScript

/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import RESTSerializer from '@ember-data/serializer/rest';
import { underscore } from '@ember/string';
import { get } from '@ember/object';
import { typeOf } from '@ember/utils';
/**
* Manages serialization/normalization of data to/from the API.
*/
export default class ApplicationSerializer extends RESTSerializer {
// =properties
/**
* @type {boolean}
*/
serializeScopeID = true;
// =methods
/**
* Generates an underscored key for the attribute.
* @param {string} attr
* @return {string}
*/
keyForAttribute(attr) {
return underscore(attr);
}
/**
* If an attribute is annotated as readOnly in the model, don't serialize it.
* Otherwise delegate to default attribute serializer.
*
* @override
* @method serializeAttribute
* @param {Snapshot} snapshot
* @param {Object} json
* @param {String} key
* @param {Object} attribute
*/
serializeAttribute(snapshot, json, key, attribute) {
const { type, options } = attribute;
const { type: recordType, compositeType } = snapshot.record;
let value = super.serializeAttribute(...arguments);
// Remove secret attributes that are null or empty
if (options.isSecret) {
if (type === 'string' && !json[key]) delete json[key];
}
// Convert empty strings to null.
if (type === 'string' && json[key] === '') json[key] = null;
// Do not serialize read-only attributes.
if (options.readOnly) delete json[key];
// Version is sent only if it has a non-nullish value
if (key === 'version') {
if (json[key] === null || json[key] === undefined) delete json[key];
}
// Do not serialize `disabled` fields.
// TODO: disabled is temporarily disabled
if (key === 'disabled') delete json[key];
// Before we transform our json
// Let's trim the values in the json obj
// Currently, json values will only ever be a string or an array
if (options.trimWhitespace && json[key]) {
if (typeOf(json[key]) === 'array') {
json[key] = json[key].map((str) => str.trim());
} else if (typeOf(json[key] === 'string')) {
json[key] = json[key].trim();
}
}
// Push nested attributes down into the attributes key
if (options.isNestedAttribute && json[key] !== undefined) {
if (!json.attributes) json.attributes = {};
json.attributes[key] = json[key];
delete json[key];
}
// Push nested secrets down into the secrets key
if (options.isNestedSecret) {
if (json[key]) {
if (!json.secrets) json.secrets = {};
json.secrets[key] = json[key];
}
delete json[key];
}
// If an attribute has a `for` option, it must match the
// record's `type`, otherwise the attribute is excluded
// from serialization.
if (options?.for) {
// Check the typeof the `for` option to determine if it is a plugin or not
const isPlugin = options.for?.type === 'plugin';
// Helper function to handle deleting attribute and secret keys
const deleteKey = (json) => {
if (options.isNestedAttribute) {
delete json?.attributes?.[key];
} else if (options.isNestedSecret && json?.secrets?.[key]) {
delete json?.secrets?.[key];
if (Object.keys(json.secrets).length === 0) {
delete json.secrets;
}
} else {
delete json[key];
}
};
// For plugins, the `for` option is an object with a `name` key
// For non-plugins, the `for` option can be string or an array
if (
(isPlugin && !options.for?.name.includes(compositeType)) ||
(!isPlugin &&
!options.for?.includes(recordType) &&
typeOf(options.for) === 'array') ||
(!isPlugin &&
options.for !== recordType &&
typeOf(options.for) === 'string')
) {
deleteKey(json);
}
}
return value;
}
/**
* In our API, request payloads are unrooted. But Ember Data roots request
* payloads for this adapter by default. Instead of assigned a root key
* on the outgoing hash, we copy the serialized attributes into it.
*
* @override
* @method serializeIntoHash
* @param {Object} hash
* @param {Model} typeClass
* @param {Snapshot} snapshot
* @param {Object} options
*/
serializeIntoHash(hash, typeClass, snapshot, options) {
const serialized = this.serialize(snapshot, options);
Object.assign(hash, serialized);
}
/**
* Optionally serializes the scope ID into `scope_id`.
* @override
* @param {Snapshot} snapshot
* @return {object}
*/
serialize(snapshot) {
const serialized = super.serialize(...arguments);
// Delete `scope` field
delete serialized.scope;
// And serialize `scope_id`
if (this.serializeScopeID) {
const scope_id = snapshot?.attr('scope')?.scope_id;
if (scope_id) serialized.scope_id = scope_id;
}
return serialized;
}
/**
* In our API, array payloads are always rooted under the same key `items`.
* Ember Data normally expects them to be rooted under their model name,
* e.g. `{hostCatalogs: […]}`. This method makes this transformation to
* accommodate Ember Data.
*
* @override
* @param {object} store
* @param {object} primaryModelClass
* @param {object} payload
* @param {?string} id
* @param {string} requestType
* @return {object}
*/
normalizeArrayResponse(store, primaryModelClass, payload, id, requestType) {
// Setup a new payload data structure.
const transformedPayload = {};
// Find the Ember-data-expected root key name.
const payloadKey = this.payloadKeyFromModelName(
primaryModelClass.modelName,
);
// Copy the data rooted under `items` in the existing payload into the new
// payload under the expected root key name.
transformedPayload[payloadKey] = structuredClone(payload?.items);
// Return the result of normalizing the transformed payload.
return super.normalizeArrayResponse(
store,
primaryModelClass,
transformedPayload,
id,
requestType,
);
}
/**
* In our API, singular resources are _unrooted_, whereas Ember Data expects
* them to be rooted under their model name, e.g. `{modelName: {…}}`.
* This method makes this transformation to accommodate Ember Data.
*
* @method normalizeSingleResponse
* @override
* @param {Store} store
* @param {Model} primaryModelClass
* @param {Object} payload
* @param {String|Number} id
* @param {String} requestType
* @return {Object} JSON-API Document
*/
normalizeSingleResponse(store, primaryModelClass, payload, id, requestType) {
// Copy payload (mostly to prevent mocking issues)
payload = structuredClone(payload);
// Check for and normalize missing arrays
payload = this.normalizeMissingArrays(store, primaryModelClass, payload);
// Check for and normalize missing objects
payload = this.normalizeMissingObjects(store, primaryModelClass, payload);
// Setup a new payload data structure.
const transformedPayload = {};
// Find the Ember-data-expected root key name.
const payloadKey = this.payloadKeyFromModelName(
primaryModelClass.modelName,
);
// Copy the unrooted payload under the expected root key name.
transformedPayload[payloadKey] = payload;
// Return the result of normalizing the transformed payload.
return super.normalizeSingleResponse(
store,
primaryModelClass,
transformedPayload,
id,
requestType,
);
}
/**
* Resets missing array fields to an empty array if they are annotated by
* `emptyArrayIfMissing: true` in the associated model attribute
* declaration. Our API excludes arrays when they are empty from
* singular responses.
*
* @param {Store} store
* @param {Model} primaryModelClass
* @param {object} payload
* @return {object}
*/
normalizeMissingArrays(store, primaryModelClass, payload) {
const attrDefs = store.schema.fields({ type: primaryModelClass.modelName });
if (attrDefs) {
attrDefs.keys().forEach((key) => {
if (!payload[key] && attrDefs.get(key)?.options?.emptyArrayIfMissing) {
payload[key] = [];
}
});
}
return payload;
}
/**
* Resets missing object fields to an empty object if they are annotated by
* `emptyObjectIfMissing: true` in the associated model attribute
* declaration.
*
* @param {Store} store
* @param {Model} primaryModelClass
* @param {object} payload
* @return {object}
*/
normalizeMissingObjects(store, primaryModelClass, payload) {
const attrDefs = store.schema.fields({ type: primaryModelClass.modelName });
if (attrDefs) {
attrDefs.keys().forEach((key) => {
if (!payload[key] && attrDefs.get(key)?.options?.emptyObjectIfMissing) {
payload[key] = {};
}
});
}
return payload;
}
/**
* Hydrates the incoming scope JSON with additional boolean fields and
* delegates nested attributes and secrets normalization.
* @override
* @param {Model} typeClass
* @param {Object} hash
* @return {Object}
*/
normalize(typeClass, hash) {
let normalizedHash = structuredClone(hash);
const scopeID = get(normalizedHash, 'scope.id');
const scopeType = get(normalizedHash, 'scope.type');
if (scopeID) normalizedHash.scope.scope_id = scopeID;
if (scopeType) {
normalizedHash.scope.type = scopeType;
normalizedHash.scope.isGlobal = scopeType === 'global';
normalizedHash.scope.isOrg = scopeType === 'org';
normalizedHash.scope.isProject = scopeType === 'project';
}
normalizedHash = this.normalizeNestedAttributes(typeClass, normalizedHash);
normalizedHash = this.normalizeNestedSecrets(typeClass, normalizedHash);
return super.normalize(typeClass, normalizedHash);
}
/**
* Certain typed resources in the API have type-specific fields nested under
* an `attributes` key in the payload
* (e.g. `attributes: { fieldName: '123' }`). Since Ember Data lacks
* first-class support for nested attributes, these fields must be hoisted up
* into the main body of the payload.
*
* In order to annotate a field as being a nested attribute, add the key name
* to the `nestedAttributes` field on the serializer:
* `@attr('string', { isNestedAttribute: true }) fieldName;`
*
* @param {Model} typeClass
* @param {Object} hash
* @return {Object}
*/
normalizeNestedAttributes(typeClass, hash) {
typeClass.attributes.forEach((attribute) => {
const {
name,
options: { isNestedAttribute },
} = attribute;
const value = hash.attributes?.[name];
const isUndefined = value === undefined;
if (isNestedAttribute && !isUndefined) hash[name] = value;
});
return hash;
}
/**
* If an attribute is declared with the `isNestedSecret` option, it will
* be explicitly unset in the incoming payload to `null`. This helps ensure
* secret values do not linger in the UI.
*
* For example:
* `@attr('string', { isNestedSecret: true }) superSecretShhh;`
*
* @param {Model} typeClass
* @param {Object} hash
* @return {Object}
*/
normalizeNestedSecrets(typeClass, hash) {
typeClass.attributes.forEach((attribute) => {
const {
name,
options: { isNestedSecret },
} = attribute;
const value = hash.secrets?.[name];
if (isNestedSecret) hash[name] = value || null;
});
return hash;
}
/**
* If the API returns an updated response with removed fields, we need to
* explicitly set any read only field that is on the model but wasn't
* returned in the response to `null`.
* This will let ember data know the field was removed, otherwise
* it won't touch the removed field and will use the old value.
*
* @override
* @param {object} store
* @param {object} primaryModelClass
* @param {object} payload
* @param {?string} id
* @param {string} requestType
* @return {object}
*/
normalizeUpdateRecordResponse(
store,
primaryModelClass,
payload,
id,
requestType,
) {
const newPayload = structuredClone(payload);
primaryModelClass.attributes.forEach((attribute) => {
const {
name,
options: { readOnly },
} = attribute;
if (readOnly && !Object.prototype.hasOwnProperty.call(newPayload, name)) {
newPayload[name] = null;
}
});
return super.normalizeUpdateRecordResponse(
store,
primaryModelClass,
newPayload,
id,
requestType,
);
}
}