UNPKG

rest-hapi

Version:
1,617 lines (1,421 loc) 62.8 kB
'use strict' const Joi = require('joi') const _ = require('lodash') const assert = require('assert') const joiMongooseHelper = require('./joi-mongoose-helper') Joi.objectId = joiMongooseHelper.joiObjectId const validationHelper = require('./validation-helper') const authHelper = require('./auth-helper') const chalk = require('chalk') const config = require('../config') const restHapiPolicies = require('./policy-generator') // TODO: remove "options"? // TODO: change model "alias" to "routeAlias" (or remove the option) module.exports = function(logger, mongoose, server) { const HandlerHelper = require('./handler-helper-factory')() let headersValidation if (config.authStrategy) { headersValidation = Joi.object({ authorization: Joi.string().required() }).options({ allowUnknown: true }) } else { headersValidation = Joi.object().options({ allowUnknown: true }) } return { defaultHeadersValidation: headersValidation, /** * Generates the restful API endpoints for a single model. * @param server: A Hapi server. * @param model: A mongoose model. * @param options: options object. */ generateRoutes: function(server, model, options) { // TODO: generate multiple DELETE routes at /RESOURCE and at // TODO: /RESOURCE/{ownerId}/ASSOCIATION that take a list of Id's as a payload try { validationHelper.validateModel(model, logger) const collectionName = model.collectionDisplayName || model.modelName const Log = logger.bind(chalk.blue(collectionName)) options = options || {} if (model.routeOptions.allowRead !== false) { if (model.routeOptions.allowList !== false) { this.generateListEndpoint(server, model, options, Log) } this.generateFindEndpoint(server, model, options, Log) } if (model.routeOptions.allowCreate !== false) { this.generateCreateEndpoint(server, model, options, Log) } if (model.routeOptions.allowUpdate !== false) { this.generateUpdateEndpoint(server, model, options, Log) } if (model.routeOptions.allowDelete !== false) { this.generateDeleteOneEndpoint(server, model, options, Log) this.generateDeleteManyEndpoint(server, model, options, Log) } if (model.routeOptions.associations) { for (const associationName in model.routeOptions.associations) { const association = model.routeOptions.associations[associationName] if ( association.type === 'MANY_MANY' || association.type === 'ONE_MANY' || association.type === '_MANY' ) { if (association.allowAdd !== false) { this.generateAssociationAddOneEndpoint( server, model, association, options, Log ) this.generateAssociationAddManyEndpoint( server, model, association, options, Log ) } if (association.allowRemove !== false) { this.generateAssociationRemoveOneEndpoint( server, model, association, options, Log ) this.generateAssociationRemoveManyEndpoint( server, model, association, options, Log ) } if (association.allowRead !== false) { this.generateAssociationGetAllEndpoint( server, model, association, options, Log ) } } } } if (model.routeOptions && model.routeOptions.extraEndpoints) { for (const extraEndpointIndex in model.routeOptions.extraEndpoints) { const extraEndpointFunction = model.routeOptions.extraEndpoints[extraEndpointIndex] extraEndpointFunction(server, model, options, Log) } } } catch (error) { logger.error('Error:', error) throw error } }, /** * Creates an endpoint for GET /RESOURCE. * @param server: A Hapi server. * @param model: A mongoose model. * @param options: Options object. * @param logger: A logging object. */ generateListEndpoint: function(server, model, options, logger) { // This line must come first validationHelper.validateModel(model, logger) const Log = logger.bind(chalk.yellow('List')) const collectionName = model.collectionDisplayName || model.modelName options = options || {} if (config.logRoutes) { Log.note('Generating List endpoint for ' + collectionName) } let resourceAliasForRoute if (model.routeOptions) { resourceAliasForRoute = model.routeOptions.alias || model.modelName } else { resourceAliasForRoute = model.modelName } const handler = HandlerHelper.generateListHandler(model, options, Log) const queryModel = joiMongooseHelper.generateJoiListQueryModel(model, Log) let readModel = joiMongooseHelper.generateJoiReadModel(model, Log) if (!config.enableResponseValidation) { const label = readModel._flags.label readModel = Joi.alternatives() .try(readModel, Joi.any()) .label(label) } let auth = false let listHeadersValidation = Object.assign(headersValidation, {}) if (config.authStrategy && model.routeOptions.readAuth !== false) { auth = { strategy: config.authStrategy } const scope = authHelper.generateScopeForEndpoint(model, 'read', Log) if (!_.isEmpty(scope)) { auth.scope = scope if (config.logScopes) { Log.debug('Scope for GET/' + resourceAliasForRoute + ':', scope) } } } else { listHeadersValidation = null } let policies = [] if (model.routeOptions.policies && config.enablePolicies) { policies = model.routeOptions.policies policies = (policies.rootPolicies || []).concat( policies.readPolicies || [] ) } if (config.enableDocumentScopes && auth) { policies.push(restHapiPolicies.enforceDocumentScopePre(model, Log)) policies.push(restHapiPolicies.enforceDocumentScopePost(model, Log)) } server.route({ method: 'GET', path: '/' + resourceAliasForRoute, config: { handler: handler, auth: auth, description: 'Get a list of ' + collectionName + 's', tags: ['api', collectionName], cors: config.cors, validate: { query: queryModel, headers: listHeadersValidation }, plugins: { model: model, 'hapi-swagger': { responseMessages: [ { code: 204, message: 'The resource(s) was/were found successfully.' }, { code: 400, message: 'The request was malformed.' }, { code: 401, message: 'The authentication header was missing/malformed, or the token has expired.' }, { code: 500, message: 'There was an unknown error.' }, { code: 503, message: 'There was a problem with the database.' } ] }, policies: policies }, response: { failAction: config.enableResponseFail ? 'error' : 'log', schema: Joi.alternatives() .try( Joi.object({ docs: Joi.array() .items(readModel) .label(collectionName + 'ArrayModel'), pages: Joi.any(), items: Joi.any() }), Joi.number() ) .label(collectionName + 'ListModel') } } }) }, /** * Creates an endpoint for GET /RESOURCE/{_id} * @param server: A Hapi server. * @param model: A mongoose model. * @param options: Options object. * @param logger: A logging object. */ generateFindEndpoint: function(server, model, options, logger) { // This line must come first validationHelper.validateModel(model, logger) const Log = logger.bind(chalk.yellow('Find')) const collectionName = model.collectionDisplayName || model.modelName if (config.logRoutes) { Log.note('Generating Find endpoint for ' + collectionName) } let resourceAliasForRoute if (model.routeOptions) { resourceAliasForRoute = model.routeOptions.alias || model.modelName } else { resourceAliasForRoute = model.modelName } const handler = HandlerHelper.generateFindHandler(model, options, Log) const queryModel = joiMongooseHelper.generateJoiFindQueryModel(model, Log) let readModel = model.readModel || joiMongooseHelper.generateJoiReadModel(model, Log) if (!config.enableResponseValidation) { const label = readModel._flags.label readModel = Joi.alternatives() .try(readModel, Joi.any()) .label(label) } let auth = false let findHeadersValidation = Object.assign(headersValidation, {}) if (config.authStrategy && model.routeOptions.readAuth !== false) { auth = { strategy: config.authStrategy } const scope = authHelper.generateScopeForEndpoint(model, 'read', Log) if (!_.isEmpty(scope)) { auth.scope = scope if (config.logScopes) { Log.debug( 'Scope for GET/' + resourceAliasForRoute + '/{_id}' + ':', scope ) } } } else { findHeadersValidation = null } let policies = [] if (model.routeOptions.policies && config.enablePolicies) { policies = model.routeOptions.policies policies = (policies.rootPolicies || []).concat( policies.readPolicies || [] ) } if (config.enableDocumentScopes && auth) { policies.push(restHapiPolicies.enforceDocumentScopePre(model, Log)) policies.push(restHapiPolicies.enforceDocumentScopePost(model, Log)) } server.route({ method: 'GET', path: '/' + resourceAliasForRoute + '/{_id}', config: { handler: handler, auth: auth, description: 'Get a specific ' + collectionName, tags: ['api', collectionName], cors: config.cors, validate: { query: queryModel, params: { _id: Joi.objectId().required() }, headers: findHeadersValidation }, plugins: { model: model, 'hapi-swagger': { responseMessages: [ { code: 204, message: 'The resource(s) was/were found successfully.' }, { code: 400, message: 'The request was malformed.' }, { code: 401, message: 'The authentication header was missing/malformed, or the token has expired.' }, { code: 404, message: 'There was no resource found with that ID.' }, { code: 500, message: 'There was an unknown error.' }, { code: 503, message: 'There was a problem with the database.' } ] }, policies: policies }, response: { failAction: config.enableResponseFail ? 'error' : 'log', schema: readModel } } }) }, /** * Creates an endpoint for POST /RESOURCE * @param server: A Hapi server. * @param model: A mongoose model. * @param options: Options object. * @param logger: A logging object. */ generateCreateEndpoint: function(server, model, options, logger) { // This line must come first validationHelper.validateModel(model, logger) const Log = logger.bind(chalk.yellow('Create')) const collectionName = model.collectionDisplayName || model.modelName if (config.logRoutes) { Log.note('Generating Create endpoint for ' + collectionName) } options = options || {} let resourceAliasForRoute if (model.routeOptions) { resourceAliasForRoute = model.routeOptions.alias || model.modelName } else { resourceAliasForRoute = model.modelName } const handler = HandlerHelper.generateCreateHandler(model, options, Log) let createModel = joiMongooseHelper.generateJoiCreateModel(model, Log) if (!config.enablePayloadValidation) { const label = createModel._flags.label createModel = Joi.alternatives() .try(createModel, Joi.any()) .label(label) } // EXPL: support bulk creates createModel = Joi.alternatives().try( Joi.array().items(createModel), createModel ) let readModel = joiMongooseHelper.generateJoiReadModel(model, Log) const label = readModel._flags.label readModel = Joi.alternatives() .try(Joi.array().items(readModel), readModel) .label(label) if (!config.enableResponseValidation) { readModel = Joi.alternatives() .try(readModel, Joi.any()) .label(label) } let auth = false let createHeadersValidation = Object.assign(headersValidation, {}) if (config.authStrategy && model.routeOptions.createAuth !== false) { auth = { strategy: config.authStrategy } const scope = authHelper.generateScopeForEndpoint(model, 'create', Log) if (!_.isEmpty(scope)) { auth.scope = scope if (config.logScopes) { Log.debug('Scope for POST/' + resourceAliasForRoute + ':', scope) } } } else { createHeadersValidation = null } let policies = [] if (model.routeOptions.policies && config.enablePolicies) { policies = model.routeOptions.policies policies = (policies.rootPolicies || []).concat( policies.createPolicies || [] ) } if (config.enableDocumentScopes && auth) { const authorizeDocumentCreator = model.routeOptions.authorizeDocumentCreator === undefined ? config.authorizeDocumentCreator : model.routeOptions.authorizeDocumentCreator const authorizeDocumentCreatorToRead = model.routeOptions.authorizeDocumentCreatorToRead === undefined ? config.authorizeDocumentCreatorToRead : model.routeOptions.authorizeDocumentCreatorToRead const authorizeDocumentCreatorToUpdate = model.routeOptions.authorizeDocumentCreatorToUpdate === undefined ? config.authorizeDocumentCreatorToUpdate : model.routeOptions.authorizeDocumentCreatorToUpdate const authorizeDocumentCreatorToDelete = model.routeOptions.authorizeDocumentCreatorToDelete === undefined ? config.authorizeDocumentCreatorToDelete : model.routeOptions.authorizeDocumentCreatorToDelete const authorizeDocumentCreatorToAssociate = model.routeOptions.authorizeDocumentCreatorToAssociate === undefined ? config.authorizeDocumentCreatorToAssociate : model.routeOptions.authorizeDocumentCreatorToAssociate if (authorizeDocumentCreator) { policies.push(restHapiPolicies.authorizeDocumentCreator(model, Log)) } if (authorizeDocumentCreatorToRead) { policies.push( restHapiPolicies.authorizeDocumentCreatorToRead(model, Log) ) } if (authorizeDocumentCreatorToUpdate) { policies.push( restHapiPolicies.authorizeDocumentCreatorToUpdate(model, Log) ) } if (authorizeDocumentCreatorToDelete) { policies.push( restHapiPolicies.authorizeDocumentCreatorToDelete(model, Log) ) } if (authorizeDocumentCreatorToAssociate) { policies.push( restHapiPolicies.authorizeDocumentCreatorToAssociate(model, Log) ) } if (model.routeOptions.documentScope) { policies.push(restHapiPolicies.addDocumentScope(model, Log)) } } if (config.enableCreatedBy) { policies.push(restHapiPolicies.addCreatedBy(model, Log)) } if (config.enableDuplicateFields) { policies.push( restHapiPolicies.populateDuplicateFields(model, mongoose, Log) ) } if (config.enableAuditLog) { policies.push(restHapiPolicies.logCreate(mongoose, model, Log)) } server.route({ method: 'POST', path: '/' + resourceAliasForRoute, config: { handler: handler, auth: auth, cors: config.cors, description: 'Create one or more new ' + collectionName + 's', tags: ['api', collectionName], validate: { payload: createModel, headers: createHeadersValidation }, plugins: { model: model, 'hapi-swagger': { responseMessages: [ { code: 201, message: 'The resource was created successfully.' }, { code: 400, message: 'The request was malformed.' }, { code: 401, message: 'The authentication header was missing/malformed, or the token has expired.' }, { code: 500, message: 'There was an unknown error.' }, { code: 503, message: 'There was a problem with the database.' } ] }, policies: policies }, response: { failAction: config.enableResponseFail ? 'error' : 'log', schema: readModel } } }) }, /** * Creates an endpoint for DELETE /RESOURCE/{_id} * @param server: A Hapi server. * @param model: A mongoose model. * @param options: Options object. * @param logger: A logging object. */ generateDeleteOneEndpoint: function(server, model, options, logger) { // This line must come first validationHelper.validateModel(model, logger) const Log = logger.bind(chalk.yellow('DeleteOne')) const collectionName = model.collectionDisplayName || model.modelName if (config.logRoutes) { Log.note('Generating Delete One endpoint for ' + collectionName) } options = options || {} let resourceAliasForRoute if (model.routeOptions) { resourceAliasForRoute = model.routeOptions.alias || model.modelName } else { resourceAliasForRoute = model.modelName } const handler = HandlerHelper.generateDeleteHandler(model, options, Log) let payloadModel = null if (config.enableSoftDelete) { payloadModel = Joi.object({ hardDelete: Joi.bool() }).allow(null) if (!config.enablePayloadValidation) { payloadModel = Joi.alternatives().try(payloadModel, Joi.any()) } } let auth = false let deleteOneHeadersValidation = Object.assign(headersValidation, {}) if (config.authStrategy && model.routeOptions.deleteAuth !== false) { auth = { strategy: config.authStrategy } const scope = authHelper.generateScopeForEndpoint(model, 'delete', Log) if (!_.isEmpty(scope)) { auth.scope = scope if (config.logScopes) { Log.debug( 'Scope for DELETE/' + resourceAliasForRoute + '/{_id}' + ':', scope ) } } } else { deleteOneHeadersValidation = null } let policies = [] if (model.routeOptions.policies && config.enablePolicies) { policies = model.routeOptions.policies policies = (policies.rootPolicies || []).concat( policies.deletePolicies || [] ) } if (config.enableDocumentScopes && auth) { policies.push(restHapiPolicies.enforceDocumentScopePre(model, Log)) policies.push(restHapiPolicies.enforceDocumentScopePost(model, Log)) } if (config.enableDeletedBy && config.enableSoftDelete) { policies.push(restHapiPolicies.addDeletedBy(model, Log)) } if (config.enableAuditLog) { policies.push(restHapiPolicies.logDelete(mongoose, model, Log)) } server.route({ method: 'DELETE', path: '/' + resourceAliasForRoute + '/{_id}', config: { handler: handler, auth: auth, cors: config.cors, description: 'Delete a ' + collectionName, tags: ['api', collectionName], validate: { params: { _id: Joi.objectId().required() }, payload: payloadModel, headers: deleteOneHeadersValidation }, plugins: { model: model, 'hapi-swagger': { responseMessages: [ { code: 204, message: 'The resource was deleted successfully.' }, { code: 400, message: 'The request was malformed.' }, { code: 401, message: 'The authentication header was missing/malformed, or the token has expired.' }, { code: 404, message: 'There was no resource found with that ID.' }, { code: 500, message: 'There was an unknown error.' }, { code: 503, message: 'There was a problem with the database.' } ] }, policies: policies }, response: { // TODO: add a response schema if needed // failAction: config.enableResponseFail ? 'error' : 'log', // schema: model.readModel ? model.readModel : Joi.object().unknown().optional() } } }) }, /** * Creates an endpoint for DELETE /RESOURCE/ * @param server: A Hapi server. * @param model: A mongoose model. * @param options: Options object. * @param logger: A logging object. */ // TODO: handle partial deletes (return list of ids that failed/were not found) generateDeleteManyEndpoint: function(server, model, options, logger) { // This line must come first validationHelper.validateModel(model, logger) const Log = logger.bind(chalk.yellow('DeleteMany')) const collectionName = model.collectionDisplayName || model.modelName if (config.logRoutes) { Log.note('Generating Delete Many endpoint for ' + collectionName) } options = options || {} let resourceAliasForRoute if (model.routeOptions) { resourceAliasForRoute = model.routeOptions.alias || model.modelName } else { resourceAliasForRoute = model.modelName } const handler = HandlerHelper.generateDeleteHandler(model, options, Log) let payloadModel = null if (config.enableSoftDelete) { payloadModel = Joi.alternatives().try( Joi.array().items( Joi.object({ _id: Joi.objectId(), hardDelete: Joi.bool().default(false) }) ), Joi.array().items(Joi.objectId()) ) } else { payloadModel = Joi.array().items(Joi.objectId()) } if (!config.enablePayloadValidation) { payloadModel = Joi.alternatives().try(payloadModel, Joi.any()) } let auth = false let deleteManyHeadersValidation = Object.assign(headersValidation, {}) if (config.authStrategy && model.routeOptions.deleteAuth !== false) { auth = { strategy: config.authStrategy } const scope = authHelper.generateScopeForEndpoint(model, 'delete', Log) if (!_.isEmpty(scope)) { auth.scope = scope if (config.logScopes) { Log.debug('Scope for DELETE/' + resourceAliasForRoute + ':', scope) } } } else { deleteManyHeadersValidation = null } let policies = [] if (model.routeOptions.policies && config.enablePolicies) { policies = model.routeOptions.policies policies = (policies.rootPolicies || []).concat( policies.deletePolicies || [] ) } if (config.enableDocumentScopes && auth) { policies.push(restHapiPolicies.enforceDocumentScopePre(model, Log)) policies.push(restHapiPolicies.enforceDocumentScopePost(model, Log)) } if (config.enableDeletedBy && config.enableSoftDelete) { policies.push(restHapiPolicies.addDeletedBy(model, Log)) } if (config.enableAuditLog) { policies.push(restHapiPolicies.logDelete(mongoose, model, Log)) } server.route({ method: 'DELETE', path: '/' + resourceAliasForRoute, config: { handler: handler, auth: auth, cors: config.cors, description: 'Delete multiple ' + collectionName + 's', tags: ['api', collectionName], validate: { payload: payloadModel, headers: deleteManyHeadersValidation }, plugins: { model: model, 'hapi-swagger': { responseMessages: [ { code: 204, message: 'The resource was deleted successfully.' }, { code: 400, message: 'The request was malformed.' }, { code: 401, message: 'The authentication header was missing/malformed, or the token has expired.' }, { code: 404, message: 'There was no resource found with that ID.' }, { code: 500, message: 'There was an unknown error.' }, { code: 503, message: 'There was a problem with the database.' } ] }, policies: policies }, response: { // TODO: add a response schema if needed // failAction: config.enableResponseFail ? 'error' : 'log', // schema: model.readModel ? model.readModel : Joi.object().unknown().optional() } } }) }, /** * Creates an endpoint for PUT /RESOURCE/{_id} * @param server: A Hapi server. * @param model: A mongoose model. * @param options: Options object. * @param logger: A logging object. */ generateUpdateEndpoint: function(server, model, options, logger) { // This line must come first validationHelper.validateModel(model, logger) const Log = logger.bind(chalk.yellow('Update')) const collectionName = model.collectionDisplayName || model.modelName if (config.logRoutes) { Log.note('Generating Update endpoint for ' + collectionName) } options = options || {} let resourceAliasForRoute if (model.routeOptions) { resourceAliasForRoute = model.routeOptions.alias || model.modelName } else { resourceAliasForRoute = model.modelName } const handler = HandlerHelper.generateUpdateHandler(model, options, Log) let updateModel = joiMongooseHelper.generateJoiUpdateModel(model, Log) if (!config.enablePayloadValidation) { const label = updateModel._flags.label updateModel = Joi.alternatives() .try(updateModel, Joi.any()) .label(label) } let readModel = joiMongooseHelper.generateJoiReadModel(model, Log) if (!config.enableResponseValidation) { const label = readModel._flags.label readModel = Joi.alternatives() .try(readModel, Joi.any()) .label(label) } let auth = false let updateHeadersValidation = Object.assign(headersValidation, {}) if (config.authStrategy && model.routeOptions.updateAuth !== false) { auth = { strategy: config.authStrategy } const scope = authHelper.generateScopeForEndpoint(model, 'update', Log) if (!_.isEmpty(scope)) { auth.scope = scope if (config.logScopes) { Log.debug( 'Scope for PUT/' + resourceAliasForRoute + '/{_id}' + ':', scope ) } } } else { updateHeadersValidation = null } let policies = [] if (model.routeOptions.policies && config.enablePolicies) { policies = model.routeOptions.policies policies = (policies.rootPolicies || []).concat( policies.updatePolicies || [] ) } if (config.enableDocumentScopes && auth) { policies.push(restHapiPolicies.enforceDocumentScopePre(model, Log)) policies.push(restHapiPolicies.enforceDocumentScopePost(model, Log)) } if (config.enableUpdatedBy) { policies.push(restHapiPolicies.addUpdatedBy(model, Log)) } if (config.enableDuplicateFields) { policies.push( restHapiPolicies.populateDuplicateFields(model, mongoose, Log) ) if (config.trackDuplicatedFields) { policies.push( restHapiPolicies.trackDuplicatedFields(model, mongoose, Log) ) } } if (config.enableAuditLog) { policies.push(restHapiPolicies.logUpdate(mongoose, model, Log)) } server.route({ method: 'PUT', path: '/' + resourceAliasForRoute + '/{_id}', config: { handler: handler, auth: auth, cors: config.cors, description: 'Update a ' + collectionName, tags: ['api', collectionName], validate: { params: { _id: Joi.objectId().required() }, payload: updateModel, headers: updateHeadersValidation }, plugins: { model: model, 'hapi-swagger': { responseMessages: [ { code: 204, message: 'The resource was updated successfully.' }, { code: 400, message: 'The request was malformed.' }, { code: 401, message: 'The authentication header was missing/malformed, or the token has expired.' }, { code: 404, message: 'There was no resource found with that ID.' }, { code: 500, message: 'There was an unknown error.' }, { code: 503, message: 'There was a problem with the database.' } ] }, policies: policies }, response: { failAction: config.enableResponseFail ? 'error' : 'log', schema: readModel } } }) }, /** * Creates an endpoint for PUT /OWNER_RESOURCE/{ownerId}/CHILD_RESOURCE/{childId} * @param server: A Hapi server. * @param ownerModel: A mongoose model. * @param association: An object containing the association data/child mongoose model. * @param options: Options object. * @param logger: A logging object. */ generateAssociationAddOneEndpoint: function( server, ownerModel, association, options, logger ) { // This line must come first validationHelper.validateModel(ownerModel, logger) const Log = logger.bind(chalk.yellow('AddOne')) assert( ownerModel.routeOptions.associations, 'model associations must exist' ) assert(association, 'association input must exist') const associationName = association.include.as || association.include.model.modelName const ownerModelName = ownerModel.collectionDisplayName || ownerModel.modelName const childModel = association.include.model const childModelName = childModel.collectionDisplayName || childModel.modelName if (config.logRoutes) { Log.note( 'Generating addOne association endpoint for ' + ownerModelName + ' -> ' + associationName ) } options = options || {} const ownerAlias = ownerModel.routeOptions.alias || ownerModel.modelName const childAlias = association.alias || association.include.model.modelName const handler = HandlerHelper.generateAssociationAddOneHandler( ownerModel, association, options, Log ) let payloadValidation = null // EXPL: A payload is only relevant if a through model is defined if (association.include.through) { payloadValidation = joiMongooseHelper.generateJoiCreateModel( association.include.through, Log ) if (!config.enablePayloadValidation) { const label = payloadValidation._flags.label payloadValidation = Joi.alternatives() .try(payloadValidation, Joi.any()) .label(label) } } let auth = false let addOneHeadersValidation = Object.assign(headersValidation, {}) if (ownerModel.routeOptions.associateAuth === false) { Log.warn( '"associateAuth" property is deprecated, please use "addAuth" instead.' ) } if ( config.authStrategy && ownerModel.routeOptions.associateAuth !== false && association.addAuth !== false ) { auth = { strategy: config.authStrategy } let scope = authHelper.generateScopeForEndpoint( ownerModel, 'associate', Log ) const addScope = 'add' + ownerModelName[0].toUpperCase() + ownerModelName.slice(1) + associationName[0].toUpperCase() + associationName.slice(1) + 'Scope' scope = scope.concat( authHelper.generateScopeForEndpoint(ownerModel, addScope, Log) ) if (!_.isEmpty(scope)) { auth.scope = scope if (config.logScopes) { Log.debug( 'Scope for PUT/' + ownerAlias + '/{ownerId}/' + childAlias + '/{childId}' + ':', scope ) } } } else { addOneHeadersValidation = null } let policies = [] if (ownerModel.routeOptions.policies) { policies = ownerModel.routeOptions.policies policies = (policies.rootPolicies || []).concat( policies.associatePolicies || [] ) } if (config.enableDocumentScopes && auth) { policies.push(restHapiPolicies.enforceDocumentScopePre(ownerModel, Log)) policies.push( restHapiPolicies.enforceDocumentScopePost(ownerModel, Log) ) } if (config.enableAuditLog) { policies.push( restHapiPolicies.logAdd( mongoose, ownerModel, childModel, association.type, Log ) ) } server.route({ method: 'PUT', path: '/' + ownerAlias + '/{ownerId}/' + childAlias + '/{childId}', config: { handler: handler, auth: auth, cors: config.cors, description: 'Add a single ' + childModelName + ' to a ' + ownerModelName + "'s list of " + associationName, tags: ['api', associationName, ownerModelName], validate: { params: { ownerId: Joi.objectId().required(), childId: Joi.objectId().required() }, payload: payloadValidation, headers: addOneHeadersValidation }, plugins: { ownerModel: ownerModel, association: association, 'hapi-swagger': { responseMessages: [ { code: 204, message: 'The association was added successfully.' }, { code: 400, message: 'The request was malformed.' }, { code: 401, message: 'The authentication header was missing/malformed, or the token has expired.' }, { code: 404, message: 'There was no resource found with that ID.' }, { code: 500, message: 'There was an unknown error.' }, { code: 503, message: 'There was a problem with the database.' } ] }, policies: policies }, response: { // failAction: config.enableResponseFail ? 'error' : 'log', } // TODO: verify what response schema is needed here } }) }, /** * Creates an endpoint for DELETE /OWNER_RESOURCE/{ownerId}/CHILD_RESOURCE/{childId} * @param server: A Hapi server. * @param ownerModel: A mongoose model. * @param association: An object containing the association data/child mongoose model. * @param options: Options object. * @param logger: A logging object. */ generateAssociationRemoveOneEndpoint: function( server, ownerModel, association, options, logger ) { // This line must come first validationHelper.validateModel(ownerModel, logger) const Log = logger.bind(chalk.yellow('RemoveOne')) assert( ownerModel.routeOptions.associations, 'model associations must exist' ) assert(association, 'association input must exist') const associationName = association.include.as || association.include.model.modelName const ownerModelName = ownerModel.collectionDisplayName || ownerModel.modelName const childModel = association.include.model const childModelName = childModel.collectionDisplayName || childModel.modelName if (config.logRoutes) { Log.note( 'Generating removeOne association endpoint for ' + ownerModelName + ' -> ' + associationName ) } options = options || {} const ownerAlias = ownerModel.routeOptions.alias || ownerModel.modelName const childAlias = association.alias || association.include.model.modelName const handler = HandlerHelper.generateAssociationRemoveOneHandler( ownerModel, association, options, Log ) let auth = false let removeOneHeadersValidation = Object.assign(headersValidation, {}) if (ownerModel.routeOptions.associateAuth === false) { Log.warn( '"associateAuth" property is deprecated, please use "removeAuth" instead.' ) } if ( config.authStrategy && ownerModel.routeOptions.associateAuth !== false && association.removeAuth !== false ) { auth = { strategy: config.authStrategy } let scope = authHelper.generateScopeForEndpoint( ownerModel, 'associate', Log ) const removeScope = 'remove' + ownerModelName[0].toUpperCase() + ownerModelName.slice(1) + associationName[0].toUpperCase() + associationName.slice(1) + 'Scope' scope = scope.concat( authHelper.generateScopeForEndpoint(ownerModel, removeScope, Log) ) if (!_.isEmpty(scope)) { auth.scope = scope if (config.logScopes) { Log.debug( 'Scope for DELETE/' + ownerAlias + '/{ownerId}/' + childAlias + '/{childId}' + ':', scope ) } } } else { removeOneHeadersValidation = null } let policies = [] if (ownerModel.routeOptions.policies) { policies = ownerModel.routeOptions.policies policies = (policies.rootPolicies || []).concat( policies.associatePolicies || [] ) } if (config.enableDocumentScopes && auth) { policies.push(restHapiPolicies.enforceDocumentScopePre(ownerModel, Log)) policies.push( restHapiPolicies.enforceDocumentScopePost(ownerModel, Log) ) } if (config.enableAuditLog) { policies.push( restHapiPolicies.logRemove( mongoose, ownerModel, childModel, association.type, Log ) ) } server.route({ method: 'DELETE', path: '/' + ownerAlias + '/{ownerId}/' + childAlias + '/{childId}', config: { handler: handler, auth: auth, cors: config.cors, description: 'Remove a single ' + childModelName + ' from a ' + ownerModelName + "'s list of " + associationName, tags: ['api', associationName, ownerModelName], validate: { params: { ownerId: Joi.objectId().required(), childId: Joi.objectId().required() }, headers: removeOneHeadersValidation }, plugins: { ownerModel: ownerModel, association: association, 'hapi-swagger': { responseMessages: [ { code: 204, message: 'The association was deleted successfully.' }, { code: 400, message: 'The request was malformed.' }, { code: 401, message: 'The authentication header was missing/malformed, or the token has expired.' }, { code: 404, message: 'There was no resource found with that ID.' }, { code: 500, message: 'There was an unknown error.' }, { code: 503, message: 'There was a problem with the database.' } ] }, policies: policies }, response: { // failAction: config.enableResponseFail ? 'error' : 'log', } } }) }, /** * Creates an endpoint for POST /OWNER_RESOURCE/{ownerId}/CHILD_RESOURCE * @param server: A Hapi server. * @param ownerModel: A mongoose model. * @param association: An object containing the association data/child mongoose model. * @param options: Options object. * @param logger: A logging object. */ generateAssociationAddManyEndpoint: function( server, ownerModel, association, options, logger ) { // This line must come first validationHelper.validateModel(ownerModel, logger) const Log = logger.bind(chalk.yellow('AddMany')) assert( ownerModel.routeOptions.associations, 'model associations must exist' ) assert(association, 'association input must exist') const associationName = association.include.as || association.include.model.modelName const ownerModelName = ownerModel.collectionDisplayName || ownerModel.modelName const childModel = association.include.model const childModelName = childModel.collectionDisplayName || childModel.modelName if (config.logRoutes) { Log.note( 'Generating addMany association endpoint for ' + ownerModelName + ' -> ' + associationName ) } options = options || {} const ownerAlias = ownerModel.routeOptions.alias || ownerModel.modelName const childAlias = association.alias || association.include.model.modelName const handler = HandlerHelper.generateAssociationAddManyHandler( ownerModel, association, options, Log ) let payloadValidation let label = '' if (association.include && association.include.through) { payloadValidation = joiMongooseHelper.generateJoiCreateModel( association.include.through, Log ) label = payloadValidation._flags.label + '_many' payloadValidation = payloadValidation.keys({ childId: Joi.objectId().description( 'the ' + childModelName + "'s _id" ) }) payloadValidation = Joi.array().items(payloadValidation) payloadValidation = Joi.alternatives() .try(payloadValidation, Joi.array().items(Joi.objectId())) .label(label || 'blank') .required() } else { payloadValidation = Joi.array() .items(Joi.objectId()) .required() } if (!config.enablePayloadValidation) { label = payloadValidation._flags.label payloadValidation = Joi.alternatives() .try(payloadValidation, Joi.any()) .label(label || 'blank') } let auth = false let addManyHeadersValidation = Object.assign(headersValidation, {}) if (ownerModel.routeOptions.associateAuth === false) { Log.warn( '"associateAuth" property is deprecated, please use "addAuth" instead.' ) } if ( config.authStrategy && ownerModel.routeOptions.associateAuth !== false && association.addAuth !== false ) { auth = { strategy: config.authStrategy } let scope = authHelper.generateScopeForEndpoint( ownerModel, 'associate', Log ) const addScope = 'add' + ownerModelName[0].toUpperCase() + ownerModelName.slice(1) + associationName[0].toUpperCase() + associationName.slice(1) + 'Scope' scope = scope.concat( authHelper.generateScopeForEndpoint(ownerModel, addScope, Log) ) if (!_.isEmpty(scope)) { auth.scope = scope if (config.logScopes) { Log.debug( 'Scope for POST/' + ownerAlias + '/{ownerId}/' + childAlias + ':', scope ) } } } else { addManyHeadersValidation = null } let policies = [] if (ownerModel.routeOptions.policies) { policies = ownerModel.routeOptions.policies policies = (policies.rootPolicies || []).concat( policies.associatePolicies || [] ) } if (config.enableDocumentScopes && auth) { policies.push(restHapiPolicies.enforceDocumentScopePre(ownerModel, Log)) policies.push( restHapiPolicies.enforceDocumentScopePost(ownerModel, Log) ) } if (config.enableAuditLog) { policies.push( restHapiPolicies.logAdd( mongoose, ownerModel, childModel, association.type, Log ) ) } server.route({ method: 'POST', path: '/' + ownerAlias + '/{ownerId}/' + childAlias, config: { handler: handler, auth: auth, cors: config.cors, description: 'Add multiple ' + childModelName + 's to a ' + ownerModelName + "'s list of " + associationName, tags: ['api', associationName, ownerModelName], validate: { params: { ownerId: Joi.objectId().required() }, payload: payloadValidation, headers: addManyHeadersValidation }, plugins: { ownerModel: ownerModel, association: association, 'hapi-swagger': { responseMessages: [ { code: 204, message: 'The association was set successfully.' }, { code: 400, message: 'The request was malformed.' },