From 27cce1934128d7ba83876eeef489492fd6de6602 Mon Sep 17 00:00:00 2001 From: Pradeep Raput Date: Tue, 30 Jun 2020 19:21:19 +0530 Subject: [PATCH 1/6] Added recover endpoints --- utilities/rest-helper-factory.js | 284 +++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) diff --git a/utilities/rest-helper-factory.js b/utilities/rest-helper-factory.js index 6a67f38b..14bb2990 100644 --- a/utilities/rest-helper-factory.js +++ b/utilities/rest-helper-factory.js @@ -60,6 +60,11 @@ module.exports = function(logger, mongoose, server) { if (model.routeOptions.allowUpdate !== false) { this.generateUpdateEndpoint(server, model, options, Log) } + + if (model.routeOptions.allowUpdate !== false && config.enableSoftDelete) { + this.generateRecoverOneEndpoint(server, model, options, Log) + this.generateRecoverManyEndpoint(server, model, options, Log) + } if (model.routeOptions.allowDelete !== false) { this.generateDeleteOneEndpoint(server, model, options, Log) @@ -585,6 +590,285 @@ module.exports = function(logger, mongoose, server) { } }) }, + + /** + * Creates an endpoint for RECOVER /RESOURCE/{_id}/recover + * @param server: A Hapi server. + * @param model: A mongoose model. + * @param options: Options object. + * @param logger: A logging object. + */ + generateRecoverOneEndpoint: function(server, model, options, logger) { + // This line must come first + validationHelper.validateModel(model, logger) + const Log = logger.bind(chalk.yellow('RecoverOne')) + + const collectionName = model.collectionDisplayName || model.modelName + if (config.logRoutes) { + Log.note('Generating Recover One endpoint for ' + collectionName) + } + + options = options || {} + + let resourceAliasForRoute + + if (model.routeOptions) { + resourceAliasForRoute = model.routeOptions.alias || model.modelName + } else { + resourceAliasForRoute = model.modelName + } + + const handler = HandlerHelper.generateRecoverHandler(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 recoverOneHeadersValidation = Object.assign(headersValidation, {}) + + if (config.authStrategy && model.routeOptions.recoverAuth !== false) { + auth = { + strategy: config.authStrategy + } + + const scope = authHelper.generateScopeForEndpoint(model, 'recover', Log) + + if (!_.isEmpty(scope)) { + auth.scope = scope + if (config.logScopes) { + Log.debug( + 'Scope for PUT/' + resourceAliasForRoute + '/{_id}/recover' + ':', + scope + ) + } + } + } else { + recoverOneHeadersValidation = null + } + + let policies = [] + + if (model.routeOptions.policies && config.enablePolicies) { + policies = model.routeOptions.policies + policies = (policies.rootPolicies || []).concat( + policies.recoverPolicies || [] + ) + } + + if (config.enableDocumentScopes && auth) { + policies.push(restHapiPolicies.enforceDocumentScopePre(model, Log)) + policies.push(restHapiPolicies.enforceDocumentScopePost(model, Log)) + } + + if (config.enableAuditLog) { + policies.push(restHapiPolicies.logRecover(mongoose, model, Log)) + } + + server.route({ + method: 'PUT', + path: '/' + resourceAliasForRoute + '/{_id}/recover', + config: { + handler: handler, + auth: auth, + cors: config.cors, + description: 'Recover a ' + collectionName, + tags: ['api', collectionName], + validate: { + params: { + _id: Joi.objectId().required() + }, + payload: payloadModel, + headers: recoverOneHeadersValidation + }, + plugins: { + model: model, + 'hapi-swagger': { + responseMessages: [ + { + code: 204, + message: 'The resource was recovered 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 recovers (return list of ids that failed/were not found) + generateRecoverManyEndpoint: function(server, model, options, logger) { + // This line must come first + validationHelper.validateModel(model, logger) + const Log = logger.bind(chalk.yellow('RecoverMany')) + + const collectionName = model.collectionDisplayName || model.modelName + if (config.logRoutes) { + Log.note('Generating Recover Many endpoint for ' + collectionName) + } + + options = options || {} + + let resourceAliasForRoute + + if (model.routeOptions) { + resourceAliasForRoute = model.routeOptions.alias || model.modelName + } else { + resourceAliasForRoute = model.modelName + } + + const handler = HandlerHelper.generateRecoverHandler(model, options, Log) + + let payloadModel = null + /* if (config.enableSoftDelete) { + payloadModel = Joi.alternatives().try( + Joi.array().items( + Joi.object({ + _id: Joi.objectId() + }) + ), + Joi.array().items(Joi.objectId()) + ) + } else { + payloadModel = Joi.array().items(Joi.objectId()) + } */ + + payloadModel = Joi.alternatives().try( + Joi.array().items( + Joi.object({ + _id: Joi.objectId() + }) + ), + Joi.array().items(Joi.objectId()) + ) + + if (!config.enablePayloadValidation) { + payloadModel = Joi.alternatives().try(payloadModel, Joi.any()) + } + + let auth = false + let recoverManyHeadersValidation = Object.assign(headersValidation, {}) + + if (config.authStrategy && model.routeOptions.recoverAuth !== false) { + auth = { + strategy: config.authStrategy + } + + const scope = authHelper.generateScopeForEndpoint(model, 'recover', Log) + + if (!_.isEmpty(scope)) { + auth.scope = scope + if (config.logScopes) { + Log.debug('Scope for PUT/' + resourceAliasForRoute + ':', scope) + } + } + } else { + recoverManyHeadersValidation = null + } + + let policies = [] + + if (model.routeOptions.policies && config.enablePolicies) { + policies = model.routeOptions.policies + policies = (policies.rootPolicies || []).concat( + policies.recoverPolicies || [] + ) + } + + 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.logRecover(mongoose, model, Log)) + } + + server.route({ + method: 'PUT', + path: '/' + resourceAliasForRoute + '/recover', + config: { + handler: handler, + auth: auth, + cors: config.cors, + description: 'Recover multiple ' + collectionName + 's', + tags: ['api', collectionName], + validate: { + payload: payloadModel, + headers: recoverManyHeadersValidation + }, + plugins: { + model: model, + 'hapi-swagger': { + responseMessages: [ + { + code: 204, + message: 'The resource was recovered 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/{_id} From 817af2ac8ff7fa9887f98192b214b81fffa7b4f3 Mon Sep 17 00:00:00 2001 From: Pradeep Raput Date: Tue, 30 Jun 2020 19:24:39 +0530 Subject: [PATCH 2/6] Added recover handlers (#133) --- utilities/handler-helper.js | 256 ++++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/utilities/handler-helper.js b/utilities/handler-helper.js index da6ed635..c44bbac3 100644 --- a/utilities/handler-helper.js +++ b/utilities/handler-helper.js @@ -27,6 +27,14 @@ module.exports = { update: _update, updateHandler: _updateHandler, + + recoverOne: _recoverOne, + + recoverOneHandler: _recoverOneHandler, + + recoverMany: _recoverMany, + + recoverManyHandler: _recoverManyHandler, deleteOne: _deleteOne, @@ -745,6 +753,254 @@ async function _updateHandler(model, _id, request, Log) { } } +/** + * Recovers a model document. + * @param {...any} args + * **Positional:** + * - function recoverOne(model, _id, Log) + * + * **Named:** + * - function deleteOne({ + * model, + * _id, + * Log = RestHapi.getLogger('deleteOne'), + * restCall = false, + * credentials + * }) + * + * **Params:** + * - model {object | string}: A mongoose model. + * - _id: The document id. + * - Log: A logging object. + * - restCall: If 'true', then will call DELETE /model/{_id} + * - credentials: Credentials for accessing the endpoint. + * + * @returns {object} A promise for the resulting model document. + */ +function _recoverOne(...args) { + if (args.length > 1) { + return _recoverOneV1(...args) + } else { + return _recoverOneV2(...args) + } +} + +function _recoverOneV1(model, _id, Log) { + model = getModel(model) + const request = { params: { _id: _id } } + return _recoverOneHandler(model, _id, request, Log) +} + +async function _recoverOneV2({ + model, + _id, + Log, + restCall = false, + credentials +}) { + model = getModel(model) + const RestHapi = require('../rest-hapi') + Log = Log || RestHapi.getLogger('recoverOne') + + if (restCall) { + assertServer() + credentials = defaultCreds(credentials) + + const request = { + method: 'Post', + url: `/${model.routeOptions.alias || model.modelName}/${_id}/recover`, + params: { _id }, + credentials, + headers: { authorization: 'Bearer' } + } + + const injectOptions = RestHapi.testHelper.mockInjection(request) + const { result } = await RestHapi.server.inject(injectOptions) + return result + } else { + return _recoverOneV1(model, _id, Log) + } +} + +/** + * Recovers a model document + * @param model {object | string}: A mongoose model. + * @param _id: The document id. + * @param request: The Hapi request object. + * @param Log: A logging object. + * @returns {object} A promise returning true if the recover succeeds. + * @private + */ +async function _recoverOneHandler(model, _id, request, Log) { + try { + try { + if ( + model.routeOptions && + model.routeOptions.recover && + model.routeOptions.recover.pre + ) { + await model.routeOptions.recover.pre(_id, request, Log) + } + } catch (err) { + handleError( + err, + 'There was a preprocessing error recovering the resource.', + Boom.badRequest, + Log + ) + } + let recovered + + try { + const payload = { $set: {isDeleted: false}, $unset: {deletedAt: ""} } + recovered = await model.findByIdAndUpdate(_id, payload, { + new: true, + runValidators: config.enableMongooseRunValidators + }) + } catch (err) { + handleError( + err, + 'There was an error recovering the resource.', + Boom.badImplementation, + Log + ) + } + // TODO: clean up associations/set rules for ON DELETE CASCADE/etc. + if (recovered) { + // TODO: add eventLogs + + try { + if ( + model.routeOptions && + model.routeOptions.recover && + model.routeOptions.recover.post + ) { + await model.routeOptions.recover.post( + recovered, + request, + Log + ) + } + } catch (err) { + handleError( + err, + 'There was a postprocessing error recovering the resource.', + Boom.badRequest, + Log + ) + } + return true + } else { + throw Boom.notFound('No resource was found with that id.') + } + } catch (err) { + handleError(err, null, null, Log) + } +} + +/** + * Recovers multiple documents. + * @param {...any} args + * **Positional:** + * - function recoverMany(model, payload, Log) + * + * **Named:** + * - function recoverMany({ + * model, + * payload, + * Log = RestHapi.getLogger('delete'), + * restCall = false, + * credentials + * }) + * + * **Params:** + * - model {object | string}: A mongoose model. + * - payload: Either an array of ids or an array of objects containing an id. + * - Log: A logging object. + * - restCall: If 'true', then will call POST /model + * - credentials: Credentials for accessing the endpoint. + * + * @returns {object} A promise for the resulting model document. + */ +function _recoverMany(...args) { + if (args.length > 1) { + return _recoverManyV1(...args) + } else { + return _recoverManyV2(...args) + } +} + +function _recoverManyV1(model, payload, Log) { + model = getModel(model) + const request = { payload: payload } + return _recoverManyHandler(model, request, Log) +} + +async function _recoverManyV2({ + model, + payload, + Log, + restCall = false, + credentials +}) { + model = getModel(model) + const RestHapi = require('../rest-hapi') + Log = Log || RestHapi.getLogger('recoverOne') + + if (restCall) { + assertServer() + credentials = defaultCreds(credentials) + + const request = { + method: 'Post', + url: `/${model.routeOptions.alias || model.modelName}/recover`, + payload, + credentials, + headers: { authorization: 'Bearer' } + } + + const injectOptions = RestHapi.testHelper.mockInjection(request) + const { result } = await RestHapi.server.inject(injectOptions) + return result + } else { + return _recoverManyV1(model, payload, Log) + } +} + +/** + * Recovers multiple documents. + * @param model {object | string}: A mongoose model. + * @param request: The Hapi request object, or a container for the wrapper payload. + * @param Log: A logging object. + * @returns {object} A promise returning true if the recover succeeds. + * @private + */ +// TODO: prevent Promise.all from catching first error and returning early. Catch individual errors and return a list +// TODO(cont) of ids that failed +async function _recoverManyHandler(model, request, Log) { + try { + // EXPL: make a copy of the payload so that request.payload remains unchanged + const payload = request.payload.map(item => { + return _.isObject(item) ? _.assignIn({}, item) : item + }) + const promises = [] + for (const arg of payload) { + if (JoiMongooseHelper.isObjectId(arg)) { + promises.push(_recoverOneHandler(model, arg, request, Log)) + } else { + promises.push( + _recoverOneHandler(model, arg._id, request, Log) + ) + } + } + + await Promise.all(promises) + return true + } catch (err) { + handleError(err, null, null, Log) + } +} + /** * Deletes a model document. * @param {...any} args From 68ee57dddbd5e85ff7c3b46233ff1631bfe47d5c Mon Sep 17 00:00:00 2001 From: Pradeep Raput Date: Tue, 30 Jun 2020 19:28:00 +0530 Subject: [PATCH 3/6] Added recover handler (#133) --- utilities/handler-helper-factory.js | 49 ++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/utilities/handler-helper-factory.js b/utilities/handler-helper-factory.js index dd39ece3..e775c4cb 100644 --- a/utilities/handler-helper-factory.js +++ b/utilities/handler-helper-factory.js @@ -72,7 +72,16 @@ module.exports = function() { * @returns {Function} A handler function */ generateUpdateHandler: generateUpdateHandler, - + + /** + * Handles incoming RECOVER requests to /RESOURCE/{_id}/recover + * @param model: A mongoose model. + * @param options: Options object. + * @param logger: A logging object. + * @returns {Function} A handler function + */ + generateRecoverHandler: generateRecoverHandler, + /** * Handles incoming PUT requests to /OWNER_RESOURCE/{ownerId}/CHILD_RESOURCE/{childId} * @param ownerModel: A mongoose model. @@ -248,6 +257,44 @@ function generateUpdateHandler(model, options, logger) { } } +/** + * Handles incoming Recover requests to /RESOURCE/{_id}/recover or /RESOURCE/recover + * @param model: A mongoose model. + * @param options: Options object. + * @param logger: A logging object. + * @returns {Function} A handler function + */ +function generateRecoverHandler(model, options, logger) { + const Log = logger.bind() + options = options || {} + + return async function(request, h) { + try { + Log.log( + 'params(%s), query(%s), payload(%s)', + JSON.stringify(request.params), + JSON.stringify(request.query), + JSON.stringify(request.payload) + ) + + if (request.params._id) { + await handlerHelper.recoverOneHandler( + model, + request.params._id, + request, + Log + ) + } else { + await handlerHelper.recoverManyHandler(model, request, Log) + } + + return h.response().code(204) + } catch (err) { + handleError(err, Log) + } + } +} + /** * Handles incoming DELETE requests to /RESOURCE/{_id} or /RESOURCE * @param model: A mongoose model. From 2785a8b9c0f64a2080d5f3f0e4331a618fd899b3 Mon Sep 17 00:00:00 2001 From: Pradeep Raput Date: Tue, 30 Jun 2020 19:33:19 +0530 Subject: [PATCH 4/6] Added recover log (#133) --- policies/audit-log.js | 59 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/policies/audit-log.js b/policies/audit-log.js index cb448e51..0e301ede 100644 --- a/policies/audit-log.js +++ b/policies/audit-log.js @@ -173,6 +173,65 @@ module.exports = { logDelete: internals.logDelete } +/** + * Policy to log recover actions. + * @param model + * @param logger + * @returns {logRecoverForModel} + */ +internals.logRecover = function(mongoose, model, logger) { + const logRecoverForModel = async function logRecoverForModel(request, h) { + const Log = logger.bind('logRecover') + try { + const AuditLog = mongoose.model('auditLog') + + const ipAddress = internals.getIP(request) + const userId = _.get(request.auth.credentials, config.userIdKey) + let documents = request.params._id || request.payload + if (_.isArray(documents) && documents[0]._id) { + documents = documents.map(function(doc) { + return doc._id + }) + } else if (!_.isArray(documents)) { + documents = [documents] + } + + await AuditLog.create({ + method: 'POST', + action: 'Recover', + endpoint: request.path, + user: userId || null, + collectionName: model.collectionName, + childCollectionName: null, + associationType: null, + documents: documents || null, + payload: _.isEmpty(request.payload) ? null : request.payload, + params: _.isEmpty(request.params) ? null : request.params, + result: request.response.source || null, + isError: _.isError(request.response), + statusCode: + request.response.statusCode || request.response.output.statusCode, + responseMessage: request.response.output + ? request.response.output.payload.message + : null, + ipAddress + }) + return h.continue + } catch (err) { + Log.error(err) + return h.continue + } + } + + logRecoverForModel.applyPoint = 'onPreResponse' + return logRecoverForModel +} +internals.logRecover.applyPoint = 'onPreResponse' + +module.exports = { + logRecover: internals.logRecover +} + /** * Policy to log add actions. * @param model From da67db4b4b0e03c4695a899f96b48bfa1523276c Mon Sep 17 00:00:00 2001 From: Pradeep Raput Date: Sat, 11 Jul 2020 19:16:53 +0530 Subject: [PATCH 5/6] Updated as per comments --- utilities/rest-helper-factory.js | 39 ++++++++++---------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/utilities/rest-helper-factory.js b/utilities/rest-helper-factory.js index 14bb2990..1f127394 100644 --- a/utilities/rest-helper-factory.js +++ b/utilities/rest-helper-factory.js @@ -621,14 +621,7 @@ module.exports = function(logger, mongoose, server) { const handler = HandlerHelper.generateRecoverHandler(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 recoverOneHeadersValidation = Object.assign(headersValidation, {}) @@ -637,7 +630,7 @@ module.exports = function(logger, mongoose, server) { strategy: config.authStrategy } - const scope = authHelper.generateScopeForEndpoint(model, 'recover', Log) + const scope = authHelper.generateScopeForEndpoint(model, 'update', Log) if (!_.isEmpty(scope)) { auth.scope = scope @@ -665,6 +658,10 @@ module.exports = function(logger, mongoose, server) { policies.push(restHapiPolicies.enforceDocumentScopePre(model, Log)) policies.push(restHapiPolicies.enforceDocumentScopePost(model, Log)) } + + if (config.enableUpdatedBy) { + policies.push(restHapiPolicies.addUpdatedBy(model, Log)) + } if (config.enableAuditLog) { policies.push(restHapiPolicies.logRecover(mongoose, model, Log)) @@ -723,7 +720,7 @@ module.exports = function(logger, mongoose, server) { }, /** - * Creates an endpoint for DELETE /RESOURCE/ + * Creates an endpoint for PUT /RESOURCE/recover * @param server: A Hapi server. * @param model: A mongoose model. * @param options: Options object. @@ -753,19 +750,7 @@ module.exports = function(logger, mongoose, server) { const handler = HandlerHelper.generateRecoverHandler(model, options, Log) let payloadModel = null - /* if (config.enableSoftDelete) { - payloadModel = Joi.alternatives().try( - Joi.array().items( - Joi.object({ - _id: Joi.objectId() - }) - ), - Joi.array().items(Joi.objectId()) - ) - } else { - payloadModel = Joi.array().items(Joi.objectId()) - } */ - + payloadModel = Joi.alternatives().try( Joi.array().items( Joi.object({ @@ -787,7 +772,7 @@ module.exports = function(logger, mongoose, server) { strategy: config.authStrategy } - const scope = authHelper.generateScopeForEndpoint(model, 'recover', Log) + const scope = authHelper.generateScopeForEndpoint(model, 'update', Log) if (!_.isEmpty(scope)) { auth.scope = scope @@ -813,9 +798,9 @@ module.exports = function(logger, mongoose, server) { policies.push(restHapiPolicies.enforceDocumentScopePost(model, Log)) } - /* if (config.enableDeletedBy && config.enableSoftDelete) { - policies.push(restHapiPolicies.addDeletedBy(model, Log)) - } */ + if (config.enableUpdatedBy) { + policies.push(restHapiPolicies.addUpdatedBy(model, Log)) + } if (config.enableAuditLog) { policies.push(restHapiPolicies.logRecover(mongoose, model, Log)) From b86b9cbc8b7d5a8dbc2f1662a23444a9c5e7e454 Mon Sep 17 00:00:00 2001 From: Pradeep Raput Date: Sat, 11 Jul 2020 19:17:07 +0530 Subject: [PATCH 6/6] Updated as per comments --- utilities/handler-helper.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/utilities/handler-helper.js b/utilities/handler-helper.js index c44bbac3..38ec6799 100644 --- a/utilities/handler-helper.js +++ b/utilities/handler-helper.js @@ -760,10 +760,10 @@ async function _updateHandler(model, _id, request, Log) { * - function recoverOne(model, _id, Log) * * **Named:** - * - function deleteOne({ + * - function recoverOne({ * model, * _id, - * Log = RestHapi.getLogger('deleteOne'), + * Log = RestHapi.getLogger('recoverOne'), * restCall = false, * credentials * }) @@ -772,7 +772,7 @@ async function _updateHandler(model, _id, request, Log) { * - model {object | string}: A mongoose model. * - _id: The document id. * - Log: A logging object. - * - restCall: If 'true', then will call DELETE /model/{_id} + * - restCall: If 'true', then will call PUT /model/{_id}/recover * - credentials: Credentials for accessing the endpoint. * * @returns {object} A promise for the resulting model document. @@ -852,7 +852,7 @@ async function _recoverOneHandler(model, _id, request, Log) { let recovered try { - const payload = { $set: {isDeleted: false}, $unset: {deletedAt: ""} } + const payload = { $set: {isDeleted: false}, $unset: {deletedAt: "", deletedBy: ""} } recovered = await model.findByIdAndUpdate(_id, payload, { new: true, runValidators: config.enableMongooseRunValidators @@ -917,7 +917,7 @@ async function _recoverOneHandler(model, _id, request, Log) { * - model {object | string}: A mongoose model. * - payload: Either an array of ids or an array of objects containing an id. * - Log: A logging object. - * - restCall: If 'true', then will call POST /model + * - restCall: If 'true', then will call PUT /model/recover * - credentials: Credentials for accessing the endpoint. * * @returns {object} A promise for the resulting model document.