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

399 lines
13 KiB
JavaScript
Raw Normal View History

/**
2026-01-28 17:09:06 -08:00
* Copyright IBM Corp. 2021, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
2020-05-19 16:33:31 -04:00
import RESTSerializer from '@ember-data/serializer/rest';
import { underscore } from '@ember/string';
import { get } from '@ember/object';
import { typeOf } from '@ember/utils';
2020-05-19 16:33:31 -04:00
/**
* Manages serialization/normalization of data to/from the API.
*/
export default class ApplicationSerializer extends RESTSerializer {
// =properties
/**
* @type {boolean}
*/
serializeScopeID = true;
2020-05-19 16:33:31 -04:00
// =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
2020-05-19 16:33:31 -04:00
* @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];
2020-08-21 08:55:07 -04:00
// 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();
}
}
2022-01-18 13:44:27 -05:00
// Push nested attributes down into the attributes key
2022-01-19 12:16:57 -05:00
if (options.isNestedAttribute && json[key] !== undefined) {
2022-01-18 13:44:27 -05:00
if (!json.attributes) json.attributes = {};
json.attributes[key] = json[key];
delete json[key];
}
2022-01-21 15:22:59 -05:00
// Push nested secrets down into the secrets key
if (options.isNestedSecret) {
if (json[key]) {
if (!json.secrets) json.secrets = {};
json.secrets[key] = json[key];
}
2022-01-21 15:22:59 -05:00
delete json[key];
}
2023-07-14 09:46:47 -07:00
// If an attribute has a `for` option, it must match the
// record's `type`, otherwise the attribute is excluded
2023-07-14 09:46:47 -07:00
// 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);
2023-07-14 09:46:47 -07:00
}
}
return value;
2020-05-19 16:33:31 -04:00
}
/**
* 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);
}
2020-09-03 14:35:05 -04:00
/**
* Optionally serializes the scope ID into `scope_id`.
* @override
* @param {Snapshot} snapshot
* @return {object}
*/
serialize(snapshot) {
const serialized = super.serialize(...arguments);
2020-09-10 18:32:17 -04:00
// Delete `scope` field
delete serialized.scope;
// And serialize `scope_id`
2021-03-04 15:47:38 -05:00
if (this.serializeScopeID) {
2022-07-18 11:30:53 -07:00
const scope_id = snapshot?.attr('scope')?.scope_id;
if (scope_id) serialized.scope_id = scope_id;
}
2020-09-03 14:35:05 -04:00
return serialized;
}
2020-05-19 16:33:31 -04:00
/**
* 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
2020-05-19 16:33:31 -04:00
* 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,
2020-05-19 16:33:31 -04:00
);
// 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);
2020-05-19 16:33:31 -04:00
// Return the result of normalizing the transformed payload.
return super.normalizeArrayResponse(
store,
primaryModelClass,
transformedPayload,
id,
requestType,
2020-05-19 16:33:31 -04:00
);
}
/**
* In our API, singular resources are _unrooted_, whereas Ember Data expects
2020-07-15 17:17:11 -04:00
* 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);
2024-07-12 13:31:42 -05:00
// 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
2024-07-12 13:31:42 -05:00
* `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) {
2025-04-03 09:32:44 -07:00
const attrDefs = store.schema.fields({ type: primaryModelClass.modelName });
if (attrDefs) {
2025-04-03 09:32:44 -07:00
attrDefs.keys().forEach((key) => {
if (!payload[key] && attrDefs.get(key)?.options?.emptyArrayIfMissing) {
payload[key] = [];
}
});
}
return payload;
}
2024-07-12 13:31:42 -05:00
/**
* 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) {
2025-04-03 09:32:44 -07:00
const attrDefs = store.schema.fields({ type: primaryModelClass.modelName });
2024-07-12 13:31:42 -05:00
if (attrDefs) {
2025-04-03 09:32:44 -07:00
attrDefs.keys().forEach((key) => {
if (!payload[key] && attrDefs.get(key)?.options?.emptyObjectIfMissing) {
2024-07-12 13:31:42 -05:00
payload[key] = {};
}
});
}
return payload;
}
/**
2022-07-18 11:30:53 -07:00
* 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');
2022-07-18 11:30:53 -07:00
const scopeType = get(normalizedHash, 'scope.type');
if (scopeID) normalizedHash.scope.scope_id = scopeID;
2022-07-18 11:30:53 -07:00
if (scopeType) {
normalizedHash.scope.type = scopeType;
normalizedHash.scope.isGlobal = scopeType === 'global';
normalizedHash.scope.isOrg = scopeType === 'org';
normalizedHash.scope.isProject = scopeType === 'project';
}
2022-01-18 13:44:27 -05:00
normalizedHash = this.normalizeNestedAttributes(typeClass, normalizedHash);
2022-01-21 15:22:59 -05:00
normalizedHash = this.normalizeNestedSecrets(typeClass, normalizedHash);
return super.normalize(typeClass, normalizedHash);
}
2022-01-18 13:44:27 -05:00
/**
* 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) => {
2022-02-14 11:14:52 -05:00
const {
name,
options: { isNestedAttribute },
} = attribute;
const value = hash.attributes?.[name];
const isUndefined = value === undefined;
if (isNestedAttribute && !isUndefined) hash[name] = value;
2022-01-18 13:44:27 -05:00
});
2022-01-21 15:26:27 -05:00
return hash;
2022-01-18 13:44:27 -05:00
}
2022-01-21 15:22:59 -05:00
/**
* 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;
2022-02-14 11:14:52 -05:00
const value = hash.secrets?.[name];
if (isNestedSecret) hash[name] = value || null;
2022-01-21 15:22:59 -05:00
});
2022-01-21 15:26:27 -05:00
return hash;
2022-01-21 15:22:59 -05:00
}
/**
* 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,
);
}
2020-05-19 16:33:31 -04:00
}