export const NO_RESOURCEFUL_ENDPOINT_ERROR = ` No resourceful endpoint tied to this resource. You should use resourcefulEndpoint.newResource(), or store this current resource with resourcefulEndpoint.store(resource). `; function isIterable(obj) { /* istanbul ignore next */ if (obj == null) { return false; } return typeof obj[Symbol.iterator] === 'function'; } /** * Validation error values * @readonly * @enum {number} */ const ValidationError = { /** No validation error */ NONE: 0, /** The field to validate does not exist in the descriptor */ NO_FIELD: 1, /** A required field was omitted */ REQUIRED_FIELD: 2, /** A value didn't match the restricted values for this field */ NOT_ALLOWED: 3, /** The value doesn't match the pattern allowed */ INVALID_VALUE: 4, /** The Map value doesn't match the pattern allowed for either its keys or values */ INVALID_MAP: 5 }; export { ValidationError }; /** * This class should not be used directly, but should instead be obtained * from {@link ResourcefulEndpoint} methods * * @example * const service = await client.service('metadata'); * const contents = service.resourcefulEndpoint('contents'); * * // Get an existing resource: * const content = await contents.readOne('some:ref'); * * // Create a new Resource in metadata/contents: * const newContent = contents.newResource({ * title: 'something', * synopsis: 'a really long synopsis' * }); * * // `service`, `content` and `newContent` are used throughtout the rest of the examples * * @private */ class Resource { /** * @param {Object} rawData - the raw json response from Sequoia * @param {ResourcefulEndpoint} resourcefulEndpoint - the {@link ResourcefulEndpoint} * that this Resource is a member of * * @returns {Resource} */ constructor(rawData, resourcefulEndpoint) { this.rawData = rawData; this.resourcefulEndpoint = resourcefulEndpoint; Object.assign(this, rawData); this.indirectlyLinkedResources = []; this.errors = []; } /** * Get a JSON representation of this Resources's keys/values * * If this Resource is tied to a {@link ResourcefulEndpoint} it will take the * keys and default values from the endpoint's descriptor. Otherwise it will * return which ever keys/values have been populated. * * @returns {Object} */ toJSON() { let json = null; if (this.getResourceFields()) { json = {}; Object.keys(this.getResourceFields()).forEach((key) => { json[key] = this.hasOwnProperty(key) ? this[key] : this.getResourceFields()[key].default; }); } else { json = this.rawData; } return json; } /** * Get a stringified version of this {@link Resource} that is suitable * for saving to sequoia. * * Simply wraps the JSON of the resource as an array in the <pluralName>[] property * * @example * * const resource = new Resource({ * ref: 'test:testcontent', * name: 'testcontent', * title: 'A test resource' * }, contents); * * resource.serialise(); * * // Returns: * '{"contents":[{"ref":"test:testcontent","name":"testcontent","title":"A test resource"}]}' * * @returns {string} */ serialise() { return JSON.stringify( { [this.resourcefulEndpoint.pluralName]: [this.toJSON()] }, null, ' ' ); } getResourceFields() { if (this.resourcefulEndpoint && this.resourcefulEndpoint.resourceful) { return this.resourcefulEndpoint.resourceful.fields; } return null; } /** * Flush (save) Resources to the backend. Performs the actual write of * data to sequoia for [save()]{@link Resource#save} * * @see {@link Resource#save} * @private * * @returns {Promise} - the `fetch` Promise */ flush() { if (this.is_new === true) { return this.resourcefulEndpoint.store(this); } return this.resourcefulEndpoint.update(this); } /** * Save any [links]{@link Resource#link} that have been applied or resolve * immediately if there are none * * @private * * @returns {Promise} */ saveIndirectLinks() { if (this.indirectlyLinkedResources.length === 0) { return Promise.resolve(); } return Promise.all(this.indirectlyLinkedResources.map(link => link.save())); } /** * Save this Resource to the sequoia backend. * * Any [links]{@link Resource#link} that have also been applied in will also be saved at this point. * * @see {@link Resource#link} * @see {@link Resource#saveIndirectLinks} * @see {@link Resource#flush} * * @returns {Promise} */ save() { if (this.resourcefulEndpoint) { const clearLinks = (resource) => { this.indirectlyLinkedResources = []; return resource; }; // TODO: this should know whether this is a `new_resource` and `flush()` it first. // Should it also know the order? If only indirect links/relationships have been // added, don't bother `flush()ing` if the Resource exists already? return this.saveIndirectLinks() .then(() => this.flush()) .then(clearLinks); } return Promise.reject(NO_RESOURCEFUL_ENDPOINT_ERROR); } /** * Destroy (DELETE) this Resource. * * @see {@link ResourcefulEndpoint#destroy} * * @returns {Promise} */ destroy() { if (this.resourcefulEndpoint) { return this.resourcefulEndpoint.destroy(this); } return Promise.reject(NO_RESOURCEFUL_ENDPOINT_ERROR); } /** * @typedef {Object} Validation * @property {ValidationError} code - Error code * @property {string} message - What was invalid * @property {Object} typeInfo - The full typeInfo from the descriptor * @property {boolean} valid - Whether the field is valid or not */ /** * Validate a specific field in the Resource. * * @param {string} field - the field to validate e.g. 'ratings' * * @returns {Validation} */ validateField(field) { let code = ValidationError.NONE; let message = ''; let typeInformation; let valid = false; if (!(field in this.getResourceFields())) { message = `${field} does not exist`; code = ValidationError.NO_FIELD; } else { const currentField = this.getResourceFields()[field]; const { typeInfo, allowedValueMappings } = currentField; typeInformation = typeInfo; if (this[field] === undefined || this[field] === null) { if (currentField.required) { message = `${field} is required`; code = ValidationError.REQUIRED_FIELD; } } else if (currentField.readOnly === true) { // Skip readonly fields code = ValidationError.NONE; } else if (allowedValueMappings) { const allowedValues = Object.values(allowedValueMappings); if (!allowedValues.includes(this[field])) { message = `${field} is not one of ${allowedValues.join(', ')}`; code = ValidationError.NOT_ALLOWED; } } else if (typeInfo) { if ('pattern' in typeInfo) { // Simple value if (!new RegExp(typeInfo.pattern).test(this[field])) { message = `${field} does not match ${typeInfo.pattern}`; code = ValidationError.INVALID_VALUE; } } else if ('keys' in typeInfo) { // Map const re = new RegExp(typeInfo.keys.pattern); Object.keys(this[field]).forEach((key) => { if (!re.test(key)) { message += `${field}.${key} does not match ${ typeInfo.keys.pattern }. `; code = ValidationError.INVALID_MAP; } else if ('values' in typeInfo) { const allowedValues = typeInfo.values[key].values; if (!allowedValues.includes(this[field][key])) { message += `${field}.${key} is not one of ${allowedValues.join( ', ' )}. `; code = ValidationError.INVALID_MAP; } } }); } } } if (code === 0) { valid = true; } return { code, field, message, typeInfo: typeInformation, valid }; } /** * Validate the resource. * * This method uses {@link Resource#validateField} on each field in the descriptor * and adds any invalid fields to the {@link Resource.errors} array for later querying. * * @example * contents.validate().catch((resource) => { * // Show resource.errors[] * }).then((resource) => { * if (!resource.errors.length) { * return resource.save().then(() => { * // do something on successfully saving * }); * } * * return Promise.reject(resource); * }).catch((resource) => { * // Or show resource.errors[] here (or errors from save()ing) * }); * * @see {@link Resource#validateField} * * @returns {Promise} */ validate() { this.errors = []; Object.keys(this.getResourceFields()).forEach((field) => { const validation = this.validateField(field); if (!validation.valid) { this.errors.push(validation); } }); if (this.errors.length) { return Promise.reject(new Error(this)); } return Promise.resolve(this); } /** * Get the relationship info from the descriptor. When passing in * a Resource, the relationship will be inferred from its * {@link ResourcefulEndpoint#pluralName}. Pass a string to be explicit. * Note, when using a String value, this uses the key name in the descriptor's * relationship info, not the resourceType. * * @param {string|Resource} resource - A `string` or {@link Resource} to get relationship information for * * @see {@link Resource#linkResource} * @see {@link ResourcefulEndpoint#relationshipFor} * * @private * * @returns {undefined} */ getRelationshipFor(resource) { if (typeof resource === 'string') { const { relationships } = this.resourcefulEndpoint; if (!(resource in relationships)) { throw new Error(`Relationship '${resource}' does not exist`); } return relationships[resource]; } return this.resourcefulEndpoint.relationshipFor( resource.resourcefulEndpoint.pluralName ); } /** * Add a resource ref to the appropriate relationship so it is linked * to this Resource. * * @param {string} ref - the Resource's `ref` to link * @param {Object} relationship - The relationship from the descriptor obtained from {@link Resource#getRelationshipFor} * @param {Resource?} resource - Specify this when linking an indirect relationship. * Defaults to the current {@link Resource} instance * * @see {@link Resource#getRelationshipFor} * @private * */ linkRefToResource(ref, relationship, resource = this) { const { fieldNamePath } = relationship; // TODO: this will eventually be on the descriptor, but working // round it for now with the sequoia convention relationship.array = fieldNamePath.endsWith('Refs'); if (relationship.array === true) { const existingRelationships = resource[fieldNamePath]; if (!Array.isArray(existingRelationships)) { resource[fieldNamePath] = [ref]; } else if (!existingRelationships.includes(ref)) { resource[fieldNamePath].push(ref); } } else { resource[fieldNamePath] = ref; } } /** * Link a {@link Resource} with this Resource * * If the link is `direct`, simply update *this* Reource with the relationship info. * If the link is `indirect`, add a potential link (not yet saved) to the * `indirectlLinkedResources` array to be flush when calling [save()]{@link Resource#save} later. * * @param {Resource} resource - the Resource to link * @param {string} as - Override the relationship type for this link. * * @see {@link Resource#linkRefToResource} * * @private */ linkResource(resource, as) { const relationship = this.getRelationshipFor(as || resource); if (relationship.type === 'direct') { this.linkRefToResource(resource.ref, relationship); } else if (relationship.type === 'indirect') { this.linkRefToResource( this.ref, resource.getRelationshipFor(as || this), resource ); this.indirectlyLinkedResources.push(resource); } } /** * Link one or many {@link Resource}s with this Resource * When linking an array of resources, if the relationship is not an array type * the last member of the array will become the only link. For example, * `content.link([...contents], 'parent')` will end up with * `resource.parentRef === contents[contents.length - 1].ref`. * Due to this, it is expected to only `link` certain resources at a time e.g. * `content.link(parentContent).link(assets).link(members, 'members').save()` * * @param {Resource[]} resource - the Resource (or array or Resources) to link * @param {string} as - Override the relationship type for this link. * * @example * const assets = service.resourcefulEndpoint('assets'); * const asset = await assets.readOne('some:asset-ref'); * * // If it's a known relationship, you don't have to bother providing the * // relationship type: * content.link(asset).save(); // Link and save * * // You can link a ResourceCollection (or any iterable): * const assets = await assets.browse(); // Get all assets already stored * content.link(assets).save(); // Link them all and save * * // You can provide the relationship type: * const relatedContent = await contents.readOne('some:other-ref'); * content.link(relatedContent).save(); // Will default to the 'parent' relationship * * content.link(relatedContent, 'members').save(); // Will now be a 'member' of the content (e.g. episodes of a series) * * @returns {Resource} - the current Resource instance */ link(resource, as) { if (!isIterable(resource)) { this.linkResource(resource, as); } else { resource.forEach(r => this.linkResource(r, as)); } return this; } /** * Query where the current resource has linked items of a particular * relationship kind. * * @param {string} relationship - The name of the relationship in the descriptor * e.g. 'assets', 'categories', 'parent' * * @example * content.hasLinked('assets'); * content.hasLinked('categories'); * * @returns {boolean} */ hasLinked(relationship) { return ( this.linked && this.linked[relationship] && this.linked[relationship].length > 0 ); } // Convenience methods: // TODO: these should be rolled into an optional mixin /* istanbul ignore next */ getLinkedAssetOfType(type) { if (this.hasLinked('assets')) { return this.linked.assets.filter(item => item.type === type); } return []; } /* istanbul ignore next */ get categories() { if (this.hasLinked('categories')) { return this.linked.categories; } return null; } /* istanbul ignore next */ get images() { return this.getLinkedAssetOfType('image'); } /* istanbul ignore next */ get videos() { return this.getLinkedAssetOfType('video'); } /* istanbul ignore next */ get trailers() { return this.videos.filter((item) => { const { tags } = item; if (tags) { return ( item.tags.includes('trailerondemand') || item.tags.includes('usage:trailer') ); } return true; }); } /* istanbul ignore next */ get mainVideos() { return this.videos.filter((item) => { const { tags } = item; if (tags) { return ( !item.tags.includes('trailerondemand') && !item.tags.includes('usage:trailer') ); } return true; }); } /* istanbul ignore next */ primaryBoxArt() { return this.images.find((item) => { const { tags } = item; if (tags) { return ( item.tags.includes('portrait') || item.tags.includes('usage:boxart') ); } return false; }); } /* istanbul ignore next */ primaryStill() { return this.images.find( item => item.tags && (item.tags.includes('usage:still') || item.tags.includes('landscape')) ); } /* istanbul ignore next */ trailer() { return ( this.trailers.find( item => item.tags && item.tags.includes('console:primary') ) || this.trailers[0] ); } /* istanbul ignore next */ mainVideo(format) { return this.mainVideos.find(item => item.fileFormat === format); } } export default Resource;