model.js

'use strict'

const _ = require('lodash')
const parseBody = require('co-body')

const InvalidBodyError = require('../errors/invalid-body-error')
const ServerError = require('../errors/server-error')

/**
 * Parent class for all models.
 *
 * @example
 * const Model = require('five-bells-shared/lib/model').Model
 *
 * class Car extends Model {
 *   constructor () {
 *     super()
 *   }
 *
 *   // Define filters for external data formats
 *   static convertFromExternal (data) {
 *     // Convert year to number
 *     data.year = parseInt(data.year)
 *     return data
 *   }
 *   static convertToExternal (data) {
 *     // Year is presented as a string to the outside world
 *     data.year = String(model.year)
 *     return data
 *   }
 *
 *   // Add a validator for external data
 *   static validateExternal (data) {
 *     if (typeof data.year !== 'string' || data.year.length > 4) {
 *       throw new Error('Invalid year')
 *     }
 *     return true
 *   }
 *
 *   // Add virtual properties via getters and setters
 *   get description () {
 *     return this.make + ' ' + this.model + ' ' + this.year
 *   }
 * }
 */
class Model {
  clone () {
    const cloned = new this.constructor()
    cloned.setData(this.getData())
    return cloned
  }

  /**
   * Creates instance from data.
   *
   * @param {Object} data Raw data.
   * @return {Model} Instance populated with data.
   */
  static fromData (data) {
    const Self = this

    if (data instanceof Self) {
      return data
    }

    // TODO We may wish to validate the data against the schema

    const model = new Self()
    model.setData(data)
    return model
  }

  /**
   * Creates instance from raw data.
   *
   * @param {Object} data Raw data.
   * @return {Model} Instance populated with data.
   */
  static fromDataExternal (data) {
    const Self = this

    if (!data) {
      return data
    }

    if (data instanceof Self) {
      return data
    }

    const model = new Self()
    model.setDataExternal(data)

    return model
  }

  /**
   * Set data to the model instance, bypassing filters.
   *
   * @param {Object} data Input data
   * @function Model.setData
   */
  setData (data) {
    return _.merge(this, data)
  }

  /**
   * Apply a set of JSON data (in external format) to this instance.
   *
   * @param {Object} data Input data
   */
  setDataExternal (data) {
    this.setData(this._applyFilter(this.constructor.convertFromExternal, data))
  }

  /**
   * Get model data as plain old JS object.
   *
   * Bypasses output filters.
   *
   * @return {Object} Plain representation of model
   */
  getData () {
    return _.assign({}, _.cloneDeep(this))
  }

  /**
   * Get model data as plain old JS object.
   *
   * Applies output filters.
   *
   * @return {Object} Plain, normalized representation of model
   */
  getDataExternal () {
    return this._applyFilter(this.constructor.convertToExternal, this.getData())
  }

  _applyFilter (filter, data0) {
    let data = _.isObject(data0) ? _.assign({}, _.cloneDeep(data0)) : data0

    data = filter(data, this)

    if (!data) {
      throw new ServerError('Invalid filter, did not return data: ' + filter)
    }
    return data
  }

  /**
   * Filter any data incoming from the outside world.
   *
   * You can override this method to add data mutations anytime external
   * data is passed in. This can include format conversions, sanitization and
   * the like.
   *
   * The data passed in is cloned, so feel free to modify the data object.
   *
   * You must return the data object, or you will trigger an error.
   *
   * @param {Object} data External data
   * @return {Object} Internal data
   */
  static convertFromExternal (data) {
    return data
  }

  /**
   * Filter any data being sent to the outside world.
   *
   * You can override this method to add filtering behavior anytime data is
   * passed to the outside. This can include format conversions, converting
   * local IDs to URIs and the like.
   *
   * The data passed to this method is cloned, so feel free to modify the data
   * object.
   *
   * filterInput(filterOutput(data)) should be idempotent although it may
   * normalize the data.
   *
   * You must return the data object, or you will trigger an error.
   *
   * @param {Object} data Internal data
   * @return {Object} External data
   */
  static convertToExternal (data) {
    return data
  }

  /**
   * Validate incoming external data.
   *
   * Models may override this method to validate external data against a
   * schema.
   *
   * @param {object} data Data to be validated
   * @return {ValidationResult} Result of the validation
   */
  static validateExternal (data) {
    return {
      valid: true,
      errors: []
    }
  }

  /**
   * Generate a middleware that creates an instance from the request body.
   *
   * This method returns a middleware which will read the request body, parse
   * it, validate it (if the model has set a schema) and create an instace of
   * the model with that data.
   *
   * The resulting model will be added to the Koa context as `this.body`.
   *
   * @return {bodyParserMiddleware} Middleware for parsing model out of JSON body
   */
  static createBodyParser () {
    const Self = this

    return function * (next) {
      let json = yield parseBody(this)
      const validationResult = Self.validateExternal(json)
      if (validationResult.valid !== true) {
        const message = validationResult.schema
          ? 'Body did not match schema ' + validationResult.schema
          : 'Body did not pass validation'
        throw new InvalidBodyError(message, validationResult.errors)
      }

      const model = new Self()
      model.setDataExternal(json)
      this.body = model

      yield next
    }
  }
}

module.exports = Model