UNPKG

bookshelf-entity

Version:

Bookshelf plugin for controlling/formatting model output using json-entity

205 lines (176 loc) 8.34 kB
/*! * Bookshelf-Entity * * Copyright 2017-2018 Josh Swan * Released under the MIT license * https://github.com/joshswan/bookshelf-entity/blob/master/LICENSE */ const Entity = require('json-entity'); const extend = require('lodash/extend'); const has = require('lodash/has'); const isObject = require('lodash/isObject'); /** * Recursively auto-detect unloaded relations based on exposed properties on the provided entity. * Note: Depending on how your relationships are defined on the model, this may or may not work. * @param {Entity} entity Entity specified for representing the model instance * @param {Model} model The model instance * @param {String} [prefix=''] Relation nesting prefix for recursion * @return {Array} Array of relations that should be loaded */ function detectRelationsToLoad(entity, model, prefix = '') { if (!entity) return []; return entity.properties.reduce((relations, property) => { const { key, using } = property; if (using) { if (!model || (!has(model.relations, key) && model[key])) { relations.push(`${prefix}${key}`); } relations.push(...detectRelationsToLoad(using, model && model.relations[key], `${prefix}${key}.`)); } return relations; }, []).filter(Boolean); } /** * Recursively checks models for relations that are exposed on the provided entity without a * "using" option specified to control the exposed properties of the relation. This safety check * can be disabled by setting `model.prototype.entitySafeMode` to `false` * @param {Entity} entity Entity specified for representing the model instance * @param {Model} model The model instance * @throws {Error} */ function performSafetyCheck(entity, model) { Object.keys(model.relations || {}).forEach((relation) => { // Check for exposed properties matching relation entity.properties.forEach((property) => { // Throw an error if a "using" option isn't specified as this could lead to unintended // data leaks since the entire relation would be exposed! if (relation === property.key) { if (!property.using) { throw new Error(`Entity has an exposed relation "${relation}" that does not have a "using" option specified!`); } // Perform safety check on relations recursively performSafetyCheck(property.using, model.relations[relation]); } }); }); } module.exports = (bookshelf) => { const CollectionBase = bookshelf.Collection; const ModelBase = bookshelf.Model; /** * Make bookshelf.Entity available for extending. Create new Entities by extending this Entity or * use json-entity directly to create Entities. * @type {Entity} */ bookshelf.Entity = new Entity(); bookshelf.Model = ModelBase.extend({ /** * Specify a defaultEntity on a model to use as a fallback if no entity is specified when * calling toJSON * NOTE: This entity is NOT used for relations! You must specify a `using` option for relation * properties on your entity or an error will be thrown. (I.e. if you have an address relation * on your user model, the defaultEntity of the address model will not be used. Instead your * user entity should specify address: { using: AddressEntity }). * @type {Entity} */ defaultEntity: null, /** * Whether to perform relation checks and throw an error if a relation is exposed without a * "using" option to specify an Entity. This prevents unintentional data leaks since toJSON * returns the full relation representation and without an entity, the entire relation object * would be exposed! DISABLE AT YOUR OWN RISK!!! * @type {Boolean} */ entitySafeMode: true, /** * Serialize model using entity specified in options object or this.defaultEntity. If no Entity * is specified, nothing will be exposed, thus providing more control over output than toJSON. * Note: This method returns a Promise and will automatically attempt to detect and load any * unloaded and exposed relations. Safe mode is also disabled by default so an error will be * thrown if the model is missing any attributes/relations that are supposed to be exposed. * @param {Object} [options={}] Options to be passed to Entities and their functions * @return {Promise} */ present(options = {}) { // Ensure options is an object const opts = isObject(options) ? options : {}; const entity = opts.entity || opts.with || opts.using || this.defaultEntity; if (!entity) return Promise.resolve(undefined); return Promise.resolve().then(() => { const relations = detectRelationsToLoad(entity, this); return relations ? this.load(relations) : this; }).then(() => this.represent(entity, extend({ safe: false }, opts))); }, /** * Alias for present method * @param {Object} [options={}] Options to be passed to Entities and their functions * @return {Promise} */ render(options = {}) { return this.present(options); }, /** * Use specified entity to serialize model data. Only whitelisted/exposed properties specified * in the Entity will be exposed in the output. The options obect will be passed through to any * if/value functions and nested Entities. * @param {Entity} entity Entity to use for serialization * @param {Object} options Options to pass to Entities and their functions * @return {Object} */ represent(entity, options = {}) { // Output nothing if no entity specified if (!entity) return undefined; // Perform safety check on relations if entitySafeMode enabled if (this.entitySafeMode && !options.shallow) performSafetyCheck(entity, this); return entity.represent(ModelBase.prototype.toJSON.call(this, options), options); }, }); bookshelf.Collection = CollectionBase.extend({ /** * Serialize collection using entity specified in options object or the model's defaultEntity. * If no Entity is specified, any empty array will be returned, thus providing more control * over output than toJSON. Note: This method returns a Promise and will automatically attempt * to detect and load any unloaded and exposed relations. Safe mode is also disabled by default * so an error will be thrown if any of the models are missing any attributes/relations that * are supposed to be exposed. * @param {Object} [options={}] Options to be passed to Entities and their functions * @return {Promise} */ present(options = {}) { // Ensure options is an object const opts = isObject(options) ? options : {}; const entity = opts.entity || opts.with || opts.using || this.model.prototype.defaultEntity; if (!entity || !this.size()) return Promise.resolve([]); return Promise.resolve().then(() => { const relations = detectRelationsToLoad(entity, this.first()); return relations ? this.load(relations) : this; }).then(() => this.represent(entity, extend({ safe: false }, opts))); }, /** * Alias for present method * @param {Object} [options={}] Options to be passed to Entities and their functions * @return {Promise} */ render(options = {}) { return this.present(options); }, /** * Use specified entity to serialize collection data. Only whitelisted/exposed properties * specified in the Entity will be exposed in the outputted objects. The options obect will be * passed through to any if/value functions and nested Entities. * @param {Entity} entity Entity to use for serialization * @param {Object} options Options to pass to Entities and their functions * @return {Object} */ represent(entity, options = {}) { // Output nothing if no entity specified if (!entity) return []; // Perform safety check on relations if entitySafeMode enabled if (this.model.prototype.entitySafeMode && !options.shallow) { // Loop through all models this.models.forEach(model => performSafetyCheck(entity, model)); } return entity.represent(CollectionBase.prototype.toJSON.call(this, options), options); }, }); };