src/Model.js
import { checkForUnrecognizedProperties, cloneInstance, filterToProperties } from './utils'
import { cloneDeep, forEach, isPlainObject, kebabCase, mapValues, noop, reduce } from 'lodash'
import Ajv from 'ajv'
import AutonymError from './AutonymError'
import defaultsDeep from '@nodeutils/defaults-deep'
import { pluralize } from 'inflection'
const STORE_METHODS = ['create', 'find', 'findOne', 'findOneAndUpdate', 'findOneAndDelete']
const POLICY_LIFECYCLE_HOOKS = {
create: ['preSchema', 'postSchema', 'preStore', 'postStore'],
find: ['preStore', 'postStore'],
findOne: ['preStore', 'postStore'],
findOneAndUpdate: ['preSchema', 'postSchema', 'preStore', 'postStore'],
findOneAndDelete: ['preStore', 'postStore'],
}
/**
* Class that defines an entity type for a record accessible in your API.
*/
export default class Model {
static _normalizeConfig(config) {
if (!isPlainObject(config)) {
throw new TypeError('config parameter must be a plain object.')
}
const { name } = config
if (typeof name !== 'string' || name.length === 0) {
throw new TypeError('config.name parameter must be a non-empty string.')
}
if (config.init !== undefined && typeof config.init !== 'function') {
throw new TypeError('config.init parameter must be a function or undefined.')
}
if (config.schema !== null && !isPlainObject(config.schema)) {
throw new TypeError('config.schema parameter must be a JSON schema or explicitly null.')
}
if (config.schema && config.schema.type !== 'object') {
throw new TypeError('config.schema.type parameter must be object.')
}
if (config.optionalUpdateProperties !== undefined && !Array.isArray(config.optionalUpdateProperties)) {
throw new TypeError('config.optionalUpdateProperties must be an array or undefined.')
}
if (config.optionalUpdateProperties) {
config.optionalUpdateProperties.forEach((optionalUpdateProperty, i) => {
if (
typeof optionalUpdateProperty !== 'string' &&
(!Array.isArray(optionalUpdateProperty) || !optionalUpdateProperty.every(p => typeof p === 'string'))
) {
throw new TypeError(`config.optionalUpdateProperties[${i}] must be a string or array of strings`)
}
})
}
if (config.computedProperties !== undefined && !Array.isArray(config.computedProperties)) {
throw new TypeError('config.computedProperties must be an array or undefined.')
}
if (config.computedProperties) {
config.computedProperties.forEach((computedProperty, i) => {
if (
typeof computedProperty !== 'string' &&
(!Array.isArray(computedProperty) || !computedProperty.every(p => typeof p === 'string'))
) {
throw new TypeError(`config.computedProperties[${i}] must be a string or array of strings`)
}
})
}
if (config.ajvOptions !== undefined && !isPlainObject(config.ajvOptions)) {
throw new TypeError('config.ajvOptions parameter must be a plain object or undefined.')
}
if (config.getAjv !== undefined && typeof config.getAjv !== 'function') {
throw new TypeError('config.getAjv parameter must be a function or undefined.')
}
if (config.policies !== undefined && !isPlainObject(config.policies)) {
throw new TypeError('config.policies parameter must be a plain object or undefined.')
}
if (config.store === null || typeof config.store !== 'object') {
throw new TypeError('config.store parameter must be an object.')
}
;[
'create',
'find',
'findOne',
'findOneAndUpdate',
'findOneAndDelete',
'serialize',
'unserialize',
].forEach(method => {
if (config.store[method] !== undefined && typeof config.store[method] !== 'function') {
throw new TypeError(`config.store.${method} must be a function or undefined.`)
}
})
if (config.route !== undefined && (typeof config.route !== 'string' || config.route === 0)) {
throw new TypeError('config.route parameter must be a non-empty string or undefined.')
}
if (config.initialMeta !== undefined && !isPlainObject(config.initialMeta)) {
throw new TypeError('config.initialMeta parameter must be a plain object or undefined.')
}
checkForUnrecognizedProperties('config', config, [
'name',
'init',
'schema',
'optionalUpdateProperties',
'computedProperties',
'ajvOptions',
'getAjv',
'policies',
'store',
'route',
])
checkForUnrecognizedProperties('config.policies', config.policies, STORE_METHODS)
forEach(config.policies, (hooks, method) => {
if (typeof hooks === 'boolean') {
config.policies[method] = { preSchema: hooks, preStore: hooks }
} else if (isPlainObject(hooks)) {
checkForUnrecognizedProperties(`config.policies.${method}`, hooks, POLICY_LIFECYCLE_HOOKS[method])
} else {
throw new TypeError(`config.policies.${method} must be a plain object or a boolean.`)
}
})
const normalizedConfig = defaultsDeep(config, {
init: noop,
schema: null,
optionalUpdateProperties: [],
computedProperties: [],
ajvOptions: {
allErrors: true,
format: 'full',
removeAdditional: 'all',
useDefaults: true,
errorDataPath: 'property',
},
getAjv: noop,
policies: reduce(
POLICY_LIFECYCLE_HOOKS,
(policies, hooks, method) => {
policies[method] = hooks.reduce((methodHooks, hook) => {
methodHooks[hook] = true
return methodHooks
}, {})
return policies
},
{}
),
store: {
...STORE_METHODS.reduce((methods, method) => {
methods[method] = () => {
throw new AutonymError(AutonymError.METHOD_NOT_ALLOWED, `${method} is not implemented for model "${name}".`)
}
return methods
}, {}),
serialize: cloneDeep,
unserialize: cloneDeep,
},
route: pluralize(kebabCase(name)),
initialMeta: {},
})
const { init } = normalizedConfig
normalizedConfig.init = async () => init()
if (normalizedConfig.schema) {
const ajv = new Ajv(normalizedConfig.ajvOptions)
normalizedConfig.getAjv(ajv)
const validateAgainstSchema = ajv.compile(normalizedConfig.schema)
let validateUpdateAgainstSchema = validateAgainstSchema
if (normalizedConfig.optionalUpdateProperties.length > 0) {
const filteredSchema = cloneDeep(normalizedConfig.schema)
normalizedConfig.optionalUpdateProperties.forEach(property => {
const dataPath = Array.isArray(property) ? property : [property]
const propertyToRemove = dataPath[dataPath.length - 1]
let object = filteredSchema
dataPath.slice(0, -1).forEach(key => {
if (!object.properties || !object.properties[key]) {
throw new TypeError(`Cannot remove property ${dataPath.join('.')} as it does not exist on the schema.`)
}
object = object.properties[key]
})
object.required = object.required.filter(prop => prop !== propertyToRemove)
})
validateUpdateAgainstSchema = ajv.compile(filteredSchema)
}
normalizedConfig.validateAgainstSchema = async (data, isUpdate = false) => {
const validatedData = cloneDeep(data)
const validateFn = isUpdate ? validateUpdateAgainstSchema : validateAgainstSchema
if (!validateFn(validatedData)) {
throw new AutonymError(AutonymError.UNPROCESSABLE_ENTITY, `Schema validation for model "${name}" failed.`, {
errors: validateFn.errors,
})
}
return validatedData
}
} else {
normalizedConfig.validateAgainstSchema = async data => data
}
normalizedConfig.store = mapValues(normalizedConfig.store, method => async (...args) =>
method.apply(normalizedConfig.store, args)
)
const { serialize, unserialize } = normalizedConfig.store
normalizedConfig.store.serialize = async data => serialize(data)
normalizedConfig.store.unserialize = async data => unserialize(data)
return normalizedConfig
}
/**
* @param {object} config Configuration.
* @param {string} config.name A unique name for the model, like `'user'`.
* @param {function(): *|Promise.<*, Error>} [config.init] A function to call when the model is first used.
* @param {Schema|null} config.schema A JSON schema to validate data against before passing it to the store
* methods, or explicitly `null` to disable schema validation.
* @param {Array<string|string[]>} [config.optionalUpdateProperties] A list of properties that are normally required
* in the schema but may be optional in a findOneAndUpdate request. This is rarely needed as request data is merged
* with the existing record before schema validation occurs, but this can be helpful when properties are converted to
* computed properties when saved (e.g. user records that have a passwordHash property and whose password is deleted).
* @param {AjvOptions} [config.ajvOptions] Additional options to pass to the Ajv instance.
* @param {function(ajv: Ajv): *} A function called when the Ajv object is instantiated. It is passed the Ajv instance
* for hooking in custom keywords, etc.
* @param {Array<string|string[]>} [config.computedProperties] A list of properties that do not appear on the schema
* but should still be passed to the store methods. These properties cannot be part of the request body since they
* are stripped during schema validation, but they may be set by policies.
* @param {ModelPolicies} [config.policies] Configuration policies.
* @param {Store} config.store Configuration store.
* @param {string} [config.route] The route to use for requests of this type of record. Defaults to pluralizing
* the `name` property and then converting it to kebab-case.
* @param {Meta} [config.initialMeta] The initial value of the `meta` object that is passed to the policies and
* store methods.
* @example
* const Post = new Model({
* name: 'post',
* init: Db.connect(),
* schema: {
* type: 'object',
* properties: {
* title: { type: 'string' },
* body: { type: 'string' },
* },
* require: ['title', 'body'],
* },
* policies: {
* create: {
* preSchema: { and: [getCurrentUserPolicy, canCreatePostPolicy] },
* postSchema: trimPostBodyPolicy,
* },
* find: {
* postStore: addTotalCountHeaderToResponsePolicy,
* },
* findOneAndUpdate: {
* preSchema: { and: [getCurrentUserPolicy, userIsOwnerOfPostPolicy] },
* postSchema: trimPostBodyPolicy,
* },
* findOneAndDelete: {
* preStore: { and: [getCurrentUserPolicy, userIsOwnerOfPostPolicy] },
* },
* },
* store: {
* create: data => Db.insert('posts', data),
* find: () => Db.selectAll('posts'),
* findOne: id => Db.selectOne('posts', { id }),
* findOneAndUpdate: (id, data) => Db.updateWhere('posts', { id }, data),
* findOneAndDelete: id => Db.deleteWhere('posts', { id }),
* serialize: data => mapKeys(data, property => snakeCase(property)),
* unserialize: data => mapKeys(data, columnName => camelCase(columnName)),
* },
* })
*/
constructor(config) {
this._config = Model._normalizeConfig(config)
this._hooks = null
this._initialization = null
}
/**
* Gets the normalized config.
* @returns {object} The normalized config.
*/
getConfig() {
return this._config
}
/**
* Gets the model name.
* @returns {string} The model name.
*/
getName() {
return this.getConfig().name
}
/**
* Gets the model route.
* @returns {string} The model route.
*/
getRoute() {
return this.getConfig().route
}
/**
* Gets the initial meta.
* @returns {Meta} The initial meta.
*/
getInitialMeta() {
return this.getConfig().initialMeta
}
/**
* Gets the policies.
* @returns {ModelPolicies} The policies.
*/
getPolicies() {
return this.getConfig().policies
}
/**
* Initializes the model if it hasn't been already.
* @returns {Promise.<*, Error>} The result of the initialization.
*/
async init() {
if (!this._initialization) {
this._initialization = this.getConfig().init()
}
return this._initialization
}
/**
* Creates a new record.
* @param {Record} data The properties of the record to create.
* @param {Meta} [meta] Additional metadata to pass to the store.
* @param {array} [hookArgs] *Used internally.* Arguments to pass into the hooks.
* @returns {Promise.<Record, AutonymError>} The new record data.
* @example
* const data = await Post.create({
* title: 'Hello World',
* body: 'This is my first post.',
* })
*
* console.log(data) // { id: '1', title: 'Hello World', body: 'This is my first post.' }
*/
async create(data, meta = {}, hookArgs) {
if (!isPlainObject(data)) {
throw new TypeError('data parameter must be a plain object.')
}
if (!isPlainObject(meta)) {
throw new TypeError('meta parameter must be a plain object.')
}
return this._callWithHooks(
'create',
data,
async transformedData => {
const serializedData = await this.serialize(transformedData)
const result = await this.getConfig().store.create(serializedData, meta, transformedData)
return this.unserialize(result)
},
hookArgs
)
}
/**
* Finds records.
* @param {object} [query] The query to filter by.
* @param {Meta} [meta] Additional metadata to pass to the store.
* @param {array} [hookArgs] *Used internally.* Arguments to pass into the hooks.
* @returns {Promise.<Record[], AutonymError>} The data of the found records.
* @example
* const data = await Post.find()
*
* console.log(data) // [{ id: '1', title: 'Hello World', body: 'This is my first post.' }]
*/
async find(query, meta = {}, hookArgs) {
if (!isPlainObject(meta)) {
throw new TypeError('meta parameter must be a plain object.')
}
return this._callWithHooks(
'find',
null,
async () => {
const results = await this.getConfig().store.find(query, meta)
return Promise.all(results.map(async result => this.unserialize(result)))
},
hookArgs
)
}
/**
* Finds a record.
* @param {string} id The id of the record to find.
* @param {Meta} [meta] Additional metadata to pass to the store.
* @param {array} [hookArgs] *Used internally.* Arguments to pass into the hooks.
* @returns {Promise.<Record, AutonymError>} The found record data.
* @example
* const data = await Post.findOne('1')
*
* console.log(data) // { id: '1', title: 'Hello World', body: 'This is my first post.' }
*/
async findOne(id, meta = {}, hookArgs) {
if (typeof id !== 'string' || id.length === 0) {
throw new TypeError('id parameter must be a non-empty string.')
}
if (!isPlainObject(meta)) {
throw new TypeError('meta parameter must be a plain object.')
}
return this._callWithHooks(
'findOne',
null,
async () => {
const result = await this.getConfig().store.findOne(id, meta)
return this.unserialize(result)
},
hookArgs
)
}
/**
* Updates a record.
* @param {string} id The id of the record to update.
* @param {Record} data The properties to update.
* @param {Record} [completeData] The complete record with the properties to update merged in. If omitted, it
* will be fetched.
* @param {Meta} [meta] Additional metadata to pass to the store.
* @param {array} [hookArgs] *Used internally.* Arguments to pass into the hooks.
* @returns {Promise.<Record, AutonymError>} The updated record data.
* @example
* const data = await Post.findOneAndUpdate('1', { title: 'Test' })
*
* console.log(data) // { id: '1', title: 'Test', body: 'This is my first post.' }
*/
async findOneAndUpdate(id, data, completeData = null, meta = {}, hookArgs) {
if (typeof id !== 'string' || id.length === 0) {
throw new TypeError('id parameter must be a non-empty string.')
}
if (!isPlainObject(data)) {
throw new TypeError('data parameter must be a plain object.')
}
if (completeData && !isPlainObject(completeData)) {
throw new TypeError('completeData parameter must be a plain object or undefined.')
}
if (!isPlainObject(meta)) {
throw new TypeError('meta parameter must be a plain object.')
}
const fetchedCompleteData = defaultsDeep(data, completeData || (await this.getConfig().store.findOne(id)))
return this._callWithHooks(
'findOneAndUpdate',
fetchedCompleteData,
async transformedData => {
const transformedDataToUpdate = filterToProperties(transformedData, data, this.getConfig().computedProperties)
const [serializedData, serializedCompleteData] = await Promise.all([
this.serialize(transformedDataToUpdate),
this.serialize(transformedData),
])
const result = await this.getConfig().store.findOneAndUpdate(
id,
serializedData,
serializedCompleteData,
meta,
transformedDataToUpdate,
transformedData
)
return this.unserialize(result)
},
hookArgs
)
}
/**
* Deletes a record.
* @param {string} id The id of the record to delete.
* @param {Meta} [meta] Additional metadata to pass to the store.
* @param {array} [hookArgs] *Used internally.* Arguments to pass into the hooks.
* @returns {Promise.<object, AutonymError>} An object containing an `id` property set to the deleted record's id.
* @example
* const data = await Post.findOneAndDelete('1')
*
* console.log(data) // { id: '1' }
*/
async findOneAndDelete(id, meta = {}, hookArgs) {
if (typeof id !== 'string' || id.length === 0) {
throw new TypeError('id parameter must be a non-empty string.')
}
if (!isPlainObject(meta)) {
throw new TypeError('meta parameter must be a plain object.')
}
return this._callWithHooks(
'findOneAndDelete',
null,
async () => {
await this.getConfig().store.findOneAndDelete(id, meta)
const result = { id }
return this.unserialize(result)
},
hookArgs
)
}
/**
* Serializes the data for a store method.
* @param {Record} data The data to serialize.
* @returns {Promise.<SerializedRecord, AutonymError>} The serialized data.
* @example
* const data = await Post.serialize({ authorId: '42' })
*
* console.log(data) // { author_id: '42' }
*/
async serialize(data) {
try {
return this.getConfig().store.serialize(data)
} catch (err) {
throw AutonymError.fromError(err)
}
}
/**
* Unserializes the data from a store method.
* @param {SerializedRecord} data The data to unserialize.
* @returns {Promise.<Record, AutonymError>} The unserialized data.
* @example
* const data = await Post.unserialize({ author_id: '42' })
*
* console.log(data) // { authorId: '42' }
*/
async unserialize(data) {
try {
const unserializedData = await this.getConfig().store.unserialize(data)
if (unserializedData && unserializedData.id != null) {
unserializedData.id = String(unserializedData.id)
}
return unserializedData
} catch (err) {
throw AutonymError.fromError(err)
}
}
/**
* Validates the data against the schema.
* @param {Record} data The data to validate. This must be a complete record.
* @param {string} [method] One of 'create', 'find', 'findOne', 'findOneAndUpdate', or 'findOneAndDelete', which may
* determine different schema restrictions based on the configuration.
* @returns {Promise.<Record, AutonymError>} Resolves with the validated data, which has unrecognized properties
* filtered out and default values added.
* @example
* const validatedData = await Post.validateAgainstSchema({ title: 'Hello World', xyz: 123 })
*
* console.log(validatedData) // { title: 'Hello World' }
*/
async validateAgainstSchema(data, method) {
return this.getConfig().validateAgainstSchema(data, method === 'findOneAndUpdate')
}
/**
* *Used internally.* Creates a copy of the model instance with the given lifecycle hooks added to it.
* @param {object} hooks A set of lifecycle hooks.
* @returns {Model} A copy of the model instance with the given hooks installed.
*/
withHooks(hooks) {
return cloneInstance(this, { _hooks: hooks })
}
async _callWithHooks(method, data, fn, hookArgs = []) {
try {
await this.init()
let transformedData = data
if (data) {
transformedData = await this._callHook(method, 'preSchema', hookArgs, transformedData)
transformedData = await this.validateAgainstSchema(transformedData, method)
transformedData = await this._callHook(method, 'postSchema', hookArgs, transformedData)
}
transformedData = await this._callHook(method, 'preStore', hookArgs, transformedData)
transformedData = await fn(transformedData)
transformedData = await this._callHook(method, 'postStore', hookArgs, transformedData)
return transformedData
} catch (err) {
throw AutonymError.fromError(err)
}
}
async _callHook(method, hook, hookArgs, data) {
if (this._hooks) {
return this._hooks[method][hook](...hookArgs, data)
} else {
return data
}
}
}