A RESTful API generator for the hapi framework utilizing the mongoose ODM.
rest-hapi is a hapi plugin intended to abstract the work involved in setting up API routes/validation/handlers/etc. for the purpose of rapid app development. At the same time it provides a powerful combination of relational structure with NoSQL flexibility. You define your models and the rest is done for you. Have your own API server up and running in minutes!
NOTE: If updating from a version previous to v0.27.0, make sure to set config.embedAssociations
to true
. Please refer to the changelog for details.
- Automatic generation of CRUD endpoints with middleware support
- Automatic generation of association endpoints
- joi validation
- Built in route-level and document-level authorization
- Swagger docs for all generated endpoints via hapi-swagger
- Query parameter support for searching, sorting, filtering, pagination, and embedding of associated models
- Support for policies via mrhorse
- Support for "soft" delete
- Built in metadata
- Mongoose wrapper methods
View the swagger docs for the live demos:
appy: http://ec2-52-25-112-131.us-west-2.compute.amazonaws.com:8125
rest-hapi-demo: http://ec2-52-25-112-131.us-west-2.compute.amazonaws.com:8124
appy: A ready-to-go user system built on rest-hapi.
rest-hapi-demo: A simple demo project implementing rest-hapi in a hapi server.
- Requirements
- Installation
- First time setup/Demo
- Using the plugin
- Configuration
- Swagger documentation
- Creating endpoints
- Associations
- Route customization
- Querying
- Validation
- Middleware
- Authorization
- Policies
- Mongoose wrapper methods
- Soft delete
- Metadata
- Model generation
- Testing
- License
- Questions
- Future work
- Contributing
You need Node.js installed and you'll need MongoDB installed and running.
$ npm install rest-hapi
WARNING: This will clear all data in the following MongoDB collections (in the db defined in restHapi.config
, default mongodb://localhost/rest_hapi
) if they exist: users
, roles
.
If you would like to seed your database with some demo models/data, run:
$ ./node_modules/.bin/rest-hapi-cli seed
NOTE: The password for all seed users is 1234
.
You can use these models as templates for your models or delete them later if you wish.
As rest-hapi is a hapi plugin, you'll need to set up a hapi server to generate API endpoints. You'll also need to set up a mongoose instance and include it in the plugin's options when you register. Below is an example nodejs script api.js
with the minimum requirements to set up an API with rest-hapi:
'use strict';
let Hapi = require('hapi');
let mongoose = require('mongoose');
let restHapi = require('rest-hapi');
function api(){
let server = new Hapi.Server();
server.connection(restHapi.config.server.connection);
server.register({
register: restHapi,
options: {
mongoose: mongoose
}
},
function() {
server.start();
});
return server;
}
module.exports = api();
You can then run $ node api.js
and point your browser to http://localhost:8124/ to view the swagger docs (NOTE: API endpoints will only be generated if you have provided models. See First time setup/Demo or Creating endpoints.)
Configuration of rest-hapi is handled through the restHapi.config
object. Below is a description of the current configuration options/properties.
/**
* config.js - Configuration settings for rest-hapi
*/
var config = {};
/**
* Your app title goes here.
* @type {string}
*/
config.appTitle = "rest-hapi API";
/**
* Your app version goes here.
* @type {string}
*/
config.version = '1.0.0';
/**
* Flag signifying whether the absolute path to the models directory is provided
* default: false
* @type {boolean}
*/
config.absoluteModelPath = false;
/**
* Path to the models directory
* default: 'models'
* @type {string}
*/
config.modelPath = 'models';
/**
* Flag signifying whether the absolute path to the api directory is provided
* @type {boolean}
*/
config.absoluteApiPath = false;
/**
* Path to the directory for additional endpoints
* default: 'api'
* @type {string}
*/
config.apiPath = 'api';
/**
* Cors settings for generated endpoints. Can be set to false to disable.
* @type {{additionalHeaders: string[], additionalExposedHeaders: string[]}}
*/
config.cors = {
additionalHeaders: [],
additionalExposedHeaders: []
};
/**
* Mongo settings
* - config.mongo.URI = 'mongodb://localhost/rest_hapi'; (local db, default)
*/
config.mongo.URI = 'mongodb://localhost/rest_hapi';
/**
* Authentication strategy to be used for all generated endpoints.
* Set to false for no authentication.
* default: false
* @type {boolean/string}
*/
config.authStrategy = false;
/**
* If set to false, MANY_MANY associations (including linking model data) will be saved in their own collection in th db. This is useful if a single document
* will be associated with many other documents, which could cause the document size to become very large. For example,
* a business might be associated with thousands of users.
*
* Embedding the associations will be more efficient for population/association queries but less efficient for memory/document size.
*
* This setting can be individually overwritten by setting the "embedAssociation" association property.
* default: false
* @type {boolean}
*/
config.embedAssociations = false;
/**
* MetaData options:
* - createdAt: (default: true) date specifying when the document was created.
* - updatedAt: (default: true) date specifying when the document was last updated.
* - deletedAt: (default: true) date specifying when the document was soft deleted.
* - createdBy: (default: false) _id of user that created the document.
* - updatedBy: (default: false) _id of user that last updated the document.
* - updatedBy: (default: false) _id of user that soft deleted the document.
*/
config.enableCreatedAt = true;
config.enableUpdatedAt = true;
config.enableDeletedAt = true;
config.enableCreatedBy = false;
config.enableUpdatedBy = false;
config.enableDeletedBy = false;
/**
* Enables policies via mrhorse (https://github.com/mark-bradshaw/mrhorse).
* default: false
* @type {boolean}
*/
config.enablePolicies = false;
/**
* Flag signifying whether the absolute path to the policies directory is provided.
* default: false
* @type {boolean}
*/
config.absolutePolicyPath = false;
/**
* Path to the directory for mrhorse policies (https://github.com/mark-bradshaw/mrhorse).
* default: 'policies'
* @type {string}
*/
config.policyPath = 'policies';
/**
* Enables document level authorization.
* default: true
* @type {boolean}
*/
config.enableDocumentScopes = true;
/**
* If true, modifies the root scope of any document to allow access to the document's creator.
* The scope value added is in the form: "user-{_id}" where "{_id}" is the _id of the user.
* NOTE:
* - This assumes that your authentication credentials (request.auth.credentials) will contain either
* a "user" object with a "_id" property, or the user's _id stored in a property defined by "config.userIdKey".
* - This also assumes that the user creating the document will have "user-{_id}" within their scope.
* - Requires "config.enableDocumentScopes" to be "true".
* - This setting can be individually overwritten by setting the "authorizeDocumentCreator" routeOptions property.
* default: false
* @type {boolean}
*/
config.authorizeDocumentCreator = false;
/**
* Same as "authorizeDocumentCreator", but modifies the "readScope" rather than the root scope.
* default: false
* @type {boolean}
*/
config.authorizeDocumentCreatorToRead = false;
/**
* Same as "authorizeDocumentCreator", but modifies the "updateScope" rather than the root scope.
* default: false
* @type {boolean}
*/
config.authorizeDocumentCreatorToUpdate = false;
/**
* Same as "authorizeDocumentCreator", but modifies the "deleteScope" rather than the root scope.
* default: false
* @type {boolean}
*/
config.authorizeDocumentCreatorToDelete = false;
/**
* Same as "authorizeDocumentCreator", but modifies the "associateScope" rather than the root scope.
* default: false
* @type {boolean}
*/
config.authorizeDocumentCreatorToAssociate = false;
/**
* This is the path/key to the user _id stored in your request.auth.credentials object.
* default: "user._id"
* @type {string}
*/
config.userIdKey = "user._id";
/**
* Determines what action takes place when one or more document scope checks fail for requests dealing with multiple
* documents (Ex: deleteMany or list). Options are:
* - true: if one or more documents fail, the request responds with a 403.
* - false: documents that don't pass are simply removed from the request (Ex: not deleted or not retrieved)
* default: false
* @type {boolean}
*/
config.enableDocumentScopeFail = false;
/**
* Flag specifying whether to text index all string fields for all models to enable text search.
* WARNING: enabling this adds overhead to add inserts and updates, as well as added storage requirements.
* default: false.
* @type {boolean}
*/
config.enableTextSearch = false;
/**
* Soft delete options
* - enableSoftDelete: adds "isDeleted" property to each model. Delete endpoints set "isDeleted" to true
* unless the payload contains { hardDelete: true }, in which case the document is actually deleted (default false)
* - filterDeletedEmbeds: if enabled, associations with "isDeleted" set to true will not populate (default false)
* NOTE: this option is known to be buggy
* @type {boolean}
*/
config.enableSoftDelete = false;
config.filterDeletedEmbeds = false;
/**
* Validation options:
* default: true
* @type {boolean}
*/
config.enableQueryValidation = true;
config.enablePayloadValidation = true;
config.enableResponseValidation = true;
/**
* Determines the hapi failAction of each response. Options are:
* - true: responses that fail validation will return a 500 error.
* - false: responses that fail validation will just log the offense and send the response as-is.
* default: false
* @type {boolean}
*/
config.enableResponseFail = false;
/**
* If set to true, (and authStrategy is not false) then endpoints will be generated with pre-defined
* scopes based on the model definition.
* default: false
* @deprecated since v0.29.0, use "config.generateRouteScopes" instead
* @type {boolean}
*/
config.generateScopes = false;
/**
* If set to true, (and authStrategy is not false) then endpoints will be generated with pre-defined
* scopes based on the model definition.
* default: false
* @type {boolean}
*/
config.generateRouteScopes = false;
/**
* If set to true, the scope for each endpoint will be logged when then endpoint is generated.
* default: false
* @type {boolean}
*/
config.logScopes = false;
/**
* If set to true, each route will be logged as it is generated.
* default: false
* @type {boolean}
*/
config.logRoutes = false;
/**
* Log level options:
* - INTERNAL use it for logging calls and other internal stuff
* - DEBUG recommended to use it for debugging applications
* - NOTE development verbose information (default)
* - INFO minor information
* - LOG significant messages
* - WARNING really important stuff
* - ERROR application business logic error condition
* - FATAL system error condition
*/
config.loglevel = "DEBUG";
/**
* Determines the initial expansion state of the swagger docs
* - options: 'none', 'list', 'full' (default: 'none')
* default: 'none'
* @type {string}
*/
config.docExpansion = 'none';
module.exports = config;
Swagger documentation is automatically generated for all endpoints and can be viewed by pointing a browser at the server URL. By default this will be http://localhost:8124/. The swagger docs provide quick access to testing your endpoints along with model schema descriptions and query options.
Creating endpoints with rest-hapi can be accomplished three different ways: generating endpoints based off of model definitions, defining standalone endpoints, and adding endpoints to a model.
Restful endpoints are automatically generated based off of any mongoose models that you add to your models
directory
with the file structure of {model name}.model.js
. These models must adhere to the following format:
'use strict';
module.exports = function (mongoose) {
var Schema = new mongoose.Schema({
/*fill in schema fields*/
});
Schema.statics = {
collectionName: /*your model name*/,
routeOptions: {}
};
return Schema;
};
As a concrete example, here is a user
model:
/models/user.model.js
:
'use strict';
module.exports = function (mongoose) {
var modelName = "user";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
email: {
type: Types.String,
required: true,
unique: true
},
password: {
type: Types.String,
required: true,
exclude: true,
allowOnUpdate: false
}
});
Schema.statics = {
collectionName: modelName
routeOptions: {}
};
return Schema;
};
This will generate the following CRUD endpoints:
DELETE /user Delete multiple users
POST /user Create one or more new users
GET /user Get a list of users
DELETE /user/{_id} Delete a user
GET /user/{_id} Get a specific user
PUT /user/{_id} Update a user
Association endpoints can also be generated based on model definitions, see the Associations section.
NOTE: If your models
directory is not in your projects root directory, you will need to specify the path (relative to your projects root directory) by assigning the path to the config.modelPath
property and you will need to set the config.absoluteModelPath
property to true
.
Standalone endpoints can be generated by adding files to your api
directory. The content of these files must adhere to the following format:
'use strict';
module.exports = function (server, mongoose, logger) {
/*register hapi endpoint here*/
};
As a concrete example, here is a hello-world
endpoint that will show in the generated swagger docs:
/api/hello.js
:
'use strict';
module.exports = function (server, mongoose, logger) {
server.route({
method: 'GET',
path: '/hello-world',
config: {
handler: function(request, reply) { reply("Hello World") },
tags: ['api'],
plugins: {
'hapi-swagger': {}
}
}
});
};
NOTE: If your api
directory is not in your projects root directory, you will need to specify the path (relative to your projects root directory) by assigning the path to the config.apiPath
property and you will need to set the config.absoluteApiPath
property to true
.
If endpoints beyond the generated CRUD endpoints are needed for a model, they can easily be added as an item in the routeOptions.extraEndpoints
array. The endpoint logic should be contained within a function using the footprint: function (server, model, options, Log)
. For example, if we wanted to add a Password Update
endpoint to the user
model, it could look like this:
'use strict';
var Joi = require('joi');
var bcrypt = require('bcrypt');
var restHapi = require('rest-hapi');
module.exports = function (mongoose) {
var modelName = "user";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
email: {
type: Types.String,
required: true,
unique: true
},
password: {
type: Types.String,
required: true,
exclude: true,
allowOnUpdate: false
}
});
Schema.statics = {
collectionName:modelName,
routeOptions: {
extraEndpoints: [
//Password Update Endpoint
function (server, model, options, Log) {
Log = Log.bind("Password Update");
var Boom = require('boom');
var collectionName = model.collectionDisplayName || model.modelName;
Log.note("Generating Password Update endpoint for " + collectionName);
var handler = function (request, reply) {
var hashedPassword = model.generatePasswordHash(request.payload.password);
return restHapi.update(model, request.params._id, {password: hashedPassword}, Log).then(function (result) {
if (result) {
return reply("Password updated.").code(200);
}
else {
return reply(Boom.notFound("No resource was found with that id."));
}
})
.catch(function (error) {
Log.error("error: ", error);
return reply(Boom.badImplementation("An error occurred updating the resource.", error));
});
}
server.route({
method: 'PUT',
path: '/user/{_id}/password',
config: {
handler: handler,
auth: null,
description: 'Update a user\'s password.',
tags: ['api', 'User', 'Password'],
validate: {
params: {
_id: Joi.objectId().required()
},
payload: {
password: Joi.string().required()
.description('The user\'s new password')
}
},
plugins: {
'hapi-swagger': {
responseMessages: [
{code: 200, message: 'Success'},
{code: 400, message: 'Bad Request'},
{code: 404, message: 'Not Found'},
{code: 500, message: 'Internal Server Error'}
]
}
}
}
});
}
]
},
generatePasswordHash: function(password) {
var salt = bcrypt.genSaltSync(10);
var hash = bcrypt.hashSync(password, salt);
return hash;
}
};
return Schema;
};
The rest-hapi framework supports model associations that mimic associations in a relational database. This includes one-one, one-many, many-one, and many-many relationships. Associations are created by adding the relevant schema fields and populating the associations
object within routeOptions
. Associations exists as references to a document's _id
field, and can be populated to return the associated object. See Querying for more details on how to populate associations.
Update: One sided -many relationships are available as of v0.19.0
Below is an example of a one-one relationship between a user
model and a
dog
model. Notice the dog
and owner
fields in the schemas. A schema
field is required for associations of type ONE_ONE
or MANY_ONE
. This
field must match the association name, include a type of ObjectId
, and
include a ref
property with the associated model name.
Each association must be added to an associations
object within the
routeOptions
object. The type
and model
fields are
required for all associations.
/models/user.model.js
:
'use strict';
module.exports = function (mongoose) {
var modelName = "user";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
email: {
type: Types.String,
required: true,
unique: true
},
password: {
type: Types.String,
required: true,
exclude: true,
allowOnUpdate: false
},
dog: {
type: Types.ObjectId,
ref: "dog"
}
});
Schema.statics = {
collectionName:modelName,
routeOptions: {
associations: {
dog: {
type: "ONE_ONE",
model: "dog"
}
}
}
};
return Schema;
};
/models/dog.model.js
:
'use strict';
module.exports = function (mongoose) {
var modelName = "dog";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
name: {
type: Types.String,
required: true
},
breed: {
type: Types.String
},
owner: {
type: Types.ObjectId,
ref: "user"
}
});
Schema.statics = {
collectionName:modelName,
routeOptions: {
associations: {
owner: {
type: "ONE_ONE",
model: "user"
}
}
}
};
return Schema;
};
Below is an example of a one-many/many-one relationship between the user
and role
models. Notice the title
field in the schema. A schema
field is required for associations of type ONE_ONE
or MANY_ONE
. This
field must match the association name, include a type of ObjectId
, and
include a ref
property with the associated model name.
/models/user.model.js
:
'use strict';
module.exports = function (mongoose) {
var modelName = "user";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
email: {
type: Types.String,
required: true,
unique: true
},
password: {
type: Types.String,
required: true,
exclude: true,
allowOnUpdate: false
},
title: {
type: Types.ObjectId,
ref: "role"
}
});
Schema.statics = {
collectionName:modelName,
routeOptions: {
associations: {
title: {
type: "MANY_ONE",
model: "role"
}
}
}
};
return Schema;
};
/models/role.model.js
:
'use strict';
module.exports = function (mongoose) {
var modelName = "role";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
name: {
type: Types.String,
required: true,
enum: ["Account", "Admin", "SuperAdmin"]
},
description: {
type: Types.String
}
});
Schema.statics = {
collectionName:modelName,
routeOptions: {
associations: {
users: {
type: "ONE_MANY",
foreignField: "title",
model: "user"
}
}
}
};
return Schema;
};
In this example, a user can belong to one role and a role can be
assigned to many users. The type
and model
fields are
required for all associations, and the foreignField
field is
required for ONE_MANY
type associations.
Along with the normal CRUD endpoints, the following association
endpoints will be generated for the role
model:
GET /role/{ownerId}/user Get all of the users for a role
POST /role/{ownerId}/user Add multiple users to a role
DELETE /role/{ownerId}/user Remove multiple users from a role's list of users
PUT /role/{ownerId}/user/{childId} Add a single user object to a role's list of users
DELETE /role/{ownerId}/user/{childId} Remove a single user object from a role's list of users
Below is an example of a many-many relationship between the user
and
group
models. In this relationship a single user
instance can belong
to multiple group
instances and vice versa.
/models/user.model.js
:
'use strict';
module.exports = function (mongoose) {
var modelName = "user";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
email: {
type: Types.String,
required: true,
unique: true
},
password: {
type: Types.String,
required: true,
exclude: true,
allowOnUpdate: false
}
});
Schema.statics = {
collectionName:modelName,
routeOptions: {
associations: {
groups: {
type: "MANY_MANY",
model: "group"
}
}
}
};
return Schema;
};
/models/group.model.js
:
'use strict';
module.exports = function (mongoose) {
var modelName = "group";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
name: {
type: Types.String,
required: true,
},
description: {
type: Types.String
}
});
Schema.statics = {
collectionName:modelName,
routeOptions: {
associations: {
users: {
type: "MANY_MANY",
model: "user"
}
}
}
};
return Schema;
};
Along with the normal CRUD endpoints, the following association
endpoints will be generated for the user
model:
GET /user/{ownerId}/group Get all of the groups for a user
POST /user/{ownerId}/group Add multiple groups for a user
DELETE /user/{ownerId}/group Remove multiple groups from a user's list of groups
PUT /user/{ownerId}/group/{childId} Add a single group object to a user's list of groups
DELETE /user/{ownerId}/group/{childId} Remove a single group object from a user's list of groups
and for the group
model:
GET /group/{ownerId}/user Get all of the users for a group
POST /group/{ownerId}/user Add multiple users for a group
DELETE /group/{ownerId}/user Remove multiple users from a group's list of users
PUT /group/{ownerId}/user/{childId} Add a single user object to a group's list of users
DELETE /group/{ownerId}/user/{childId} Remove a single user object from a group's list of users
Many-many relationships can include extra fields that contain data specific
to each association instance. This is accomplished through linking models which
behave similar to junction tables in a relational database. Linking model files are
stored in the /models/linking-models
directory and follow the same
{model name}.model.js
format as normal models. Below is an example of a many-many
relationship between the user
model and itself through the friends
association.
The extra field friendsSince
could contain a date representing how long the two
associated users have known each other. This example also displays how models can contain a
reference to themselves.
NOTE: The linking model filename does not have to match the model name, however the linkingModel
association property must match the linking model modleName
property.
/models/user.model.js
:
'use strict';
module.exports = function (mongoose) {
var modelName = "user";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
email: {
type: Types.String,
required: true,
unique: true
},
password: {
type: Types.String,
required: true,
exclude: true,
allowOnUpdate: false
}
});
Schema.statics = {
collectionName:modelName,
routeOptions: {
associations: {
friends: {
type: "MANY_MANY",
model: "user",
alias: "friend",
linkingModel: "user_user"
}
}
}
};
return Schema;
};
/models/linking-models/user_user.model.js
:
'use strict';
var mongoose = require("mongoose");
module.exports = function () {
var Types = mongoose.Schema.Types;
var Model = {
Schema: {
friendsSince: {
type: Types.Date
}
},
modelName: "user_user"
};
return Model;
};
By nature every new instance of a MANY_MANY association adds new data to the database. At minimum this data must contain the \_id
s of the associated documents, but this can be extended to include extra fields through a linking model. rest-hapi provides two options as to how this data is stored in the db (controlled by the config.embedAssociations
property):
config.embedAssociations
: true- The data is embeded as an array property within the related documents.
- Pros:
- The data is easy to access and quick to read from the db (theoretically, not proven).
- Fewer collections in the db.
- The association data is more human readable.
- Cons:
- Linking model data is duplicated for each related document.
- Exists as an array that grows without bound, which is a MonboDB anti-pattern
config.embedAssociations
: false (default)- The data is stored in an auto-generated linking collection.
- Pros:
- Data is offloaded to the linking collections, leaving the associated documents smaller and less cluttered.
- Prevents unbounded arrays and takes full advantage of mongoose virtual references
- Linking model data isn't duplicated.
- Cons:
- Reading data is slower (theoretically, not proven).
- Less human readable.
The config.embedAssociations
can be overwritten for individual associations through the embedAssociation
property. See the example below:
'use strict';
module.exports = function (mongoose) {
var modelName = "group";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
name: {
type: Types.String,
required: true,
unique: true
},
description: {
type: Types.String
}
}, { collection: modelName });
Schema.statics = {
collectionName: modelName,
routeOptions: {
associations: {
users: {
type: "MANY_MANY",
alias: "user",
model: "user",
embedAssociation: true //<-----overrides the config.embedAssociations property
}
}
}
};
return Schema;
};
'use strict';
module.exports = function (mongoose) {
var modelName = "user";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
name: {
type: Types.String,
required: true
}
}, { collection: modelName });
Schema.statics = {
collectionName: modelName,
routeOptions: {
associations: {
groups: {
type: "MANY_MANY",
alias: "group",
model: "group",
embedAssociation: true //<-----overrides the config.embedAssociations property
}
}
}
};
return Schema;
};
NOTE: If the embedAssociation
property is set, then it must be set to the same value for both association definitions as seen above.
As of v0.28.0 the rest-hapi cli includes an update-associations
command that can migrate your db data to match your desired MANY_MANY structure. This command follows the following format:
$ ./node_modules/.bin/rest-hapi-cli update-associations mongoURI [embedAssociations] [modelPath]
where:
mongoURI
: The URI to you mongodb databaseembedAssociations
: (optional, defaults tofalse
) This must match your currentconfig.embedAssociations
value.modelPath
: (optional, defaults tomodels
) This must match yourconfig.modelPath
value if you haveconfig.absoluteModelPath
set totrue
.
This is useful if you have a db populated with documents and you decide to change the embedAssociaion
property of one or more associations.
For instance, consider a MANY_MANY relationship between user
(groups) and group
(users) with config.embedAssociations
set to true
. Each user
document will contain the array groups
and each group
document will contain the array users
. Lets say you implement this structure in a project, but several months into the project some of your group
documents have collected thousands of users
, resulting in very large document sizes. You decide it would be better to migrate the data out of the parent documents and into a linking collection, user_group
. You can do this by setting the embedAssociation
property for users
and groups
to false
, and running the following command:
$ ./node_modules/.bin/rest-hapi-cli update-associations mongodb://localhost:27017/mydb true
A one-sided -many relationship can exists between two models. This allows the parent model to have direct control over the reference Ids. Below is an example of a -many relationship between the post
and hashtag
models.
/models/post.model.js
:
'use strict';
module.exports = function (mongoose) {
var modelName = "post";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
caption: {
type: Types.String
}
user: {
type: Types.ObjectId,
ref: "user",
required: true
}
});
Schema.statics = {
collectionName:modelName,
routeOptions: {
associations: {
hashtags: {
type: "_MANY",
model: "hashtag"
},
user: {
type: "MANY_ONE",
model: "user"
}
}
}
};
return Schema;
};
In this example, a post
contains many hashtags, but the hashtag
model will have no association with the post
model.
Similar to one-many or many-many relationships the following association
endpoints will be generated for the post
model:
GET /post/{ownerId}/hashtag Get all of the hashtags for a post
POST /post/{ownerId}/hashtag Add multiple hashtags to a post
DELETE /post/{ownerId}/hashtag Remove multiple hashtags from a post's list of hashtags
PUT /post/{ownerId}/hashtag/{childId} Add a single hashtag object to a post's list of hashtags
DELETE /post/{ownerId}/hashtag/{childId} Remove a single hashtag object from a post's list of hashtags
However, unlike a one-many or many-many relationship, the -many relationship will exist as a mutable model property which is simply an array of objectIds. This means the associations can be directly modified through the parent model create
and update
endpoints. For example, the following json could be used as a payload for either the POST /post
or PUT /post/{_id}
endpoints:
{
"caption": "Having a great day!",
"user":"59960dce22a535c8edfa1317",
"hashtags": [
"59960dce22a535c8edfa132d",
"59960dce22a535c8edfa132e"
]
}
By default route paths are constructed using model names, however aliases can be provided to customize the route paths.
routeOptions.alias
can be set to alter the base path name, and an alias
property for an association can be set
to alter the association path name. For example:
'use strict';
module.exports = function (mongoose) {
var modelName = "user";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
email: {
type: Types.String,
required: true,
unique: true
},
password: {
type: Types.String,
required: true,
exclude: true,
allowOnUpdate: false
}
});
Schema.statics = {
collectionName: modelName
routeOptions: {
alias: "person"
associations: {
groups: {
type: "MANY_MANY",
model: "group",
alias: "team"
}
}
}
};
return Schema;
};
will result in the following endpoints:
DELETE /person
POST /person
GET /person
DELETE /person/{_id}
GET /person/{_id}
PUT /person/{_id}
GET /person/{ownerId}/team
DELETE /person/{ownerId}/team
POST /person/{ownerId}/team
DELETE /person/{ownerId}/team/{childId}
PUT /person/{ownerId}/team/{childId}
You can prevent CRUD endpoints from generating by setting the correct property to false
within the routeOptions
object. Below is a list of properties and their effect:
Property | Effect when false |
---|---|
allowRead | omits GET /path and GET /path/{_id} endpoints |
allowCreate | omits POST /path endpoint |
allowUpdate | omits PUT /path/{_id} endpoint |
allowDelete | omits DELETE /path and DELETE /path/{_id} endpoints |
Similarly, you can prevent association endpoints from generating through the following properties within each association object:
Property | Effect when false |
---|---|
allowAdd | omits POST /owner/{ownerId}/child and PUT /owner/{ownerId}/child/{childId} endpoints |
allowRemove | omits DELETE /owner/{ownerId}/child and ELETE /owner/{ownerId}/child/{childId} endpoints |
allowRead | omits GET /owner/{ownerId}/child endpoint |
For example, a routeOption object that omits endpoints for creating objects and removing a specific association could look like this:
routeOptions: {
allowCreate: false,
associations: {
users: {
type: "MANY_ONE",
alias: "user",
model: "user",
allowRemove: false
}
}
}
Query parameters can be added to GET requests to filter responses. These parameters are structured and function similar to mongoose queries. Below is a list of currently supported parameters:
-
$skip
- The number of records to skip in the database. This is typically used in pagination.
-
$page
- The number of records to skip based on the $limit parameter. This is typically used in pagination.
-
$limit
- The maximum number of records to return. This is typically used in pagination.
-
$select
- A list of basic fields to be included in each resource.
-
$sort
- A set of fields to sort by. Including field name indicates it should be sorted ascending, while prepending '-' indicates descending. The default sort direction is 'ascending' (lowest value to highest value). Listing multiple fields prioritizes the sort starting with the first field listed.
-
$text
- A full text search parameter. Takes advantage of indexes for efficient searching. Also implements stemming with searches. Prefixing search terms with a "-" will exclude results that match that term.
-
$term
- A regex search parameter. Slower than $text search but supports partial matches and doesn't require indexing. This can be refined using the $searchFields parameter.
-
$searchFields
- A set of fields to apply the $term search parameter to. If this parameter is not included, the $term search parameter is applied to all searchable fields.
-
$embed
- A set of associations to populate.
-
$flatten
- Set to true to flatten embedded arrays, i.e. remove linking-model data.
-
$count
- If set to true, only a count of the query results will be returned.
-
$where
- An optional field for raw mongoose queries.
-
(field "where" queries)
- Ex:
/[email protected]
- Ex:
Query parameters can either be passed in as a single string, or an array of strings.
For any GET query that returns multiple documents, pagination data is returned alongside the documents. The response object has the form:
- docs - an array of documents.
- pages - an object where:
- current - a number indicating the current page.
- prev - a number indicating the previous page.
- hasPrev - a boolean indicating if there is a previous page.
- next - a number indicating the next page.
- hasNext - a boolean indicating if there is a next page.
- total - a number indicating the total number of pages.
- items - an object where:
- limit - a number indicating the how many results should be returned.
- begin - a number indicating what item number the results begin with.
- end - a number indicating what item number the results end with.
- total - a number indicating the total number of matching results.
NOTE: Pagination format borrowed from mongo-models pagedFind.
Associations can be populated through the $embed
parameter. To populate nested associations,
simply chain a parameter with .
. For example, consider the MANY_MANY group-user association
from the example above. If we populate the users of a group with /group?$embed=users
we might get a
response like so:
{
"_id": "58155f1a071468d3bda0fc6e",
"name": "A-team",
"users": [
{
"user": {
"_id": "580fc1a0e2d3308609470bc6",
"email": "[email protected]",
"title": "580fc1e2e2d3308609470bc8"
},
"_id": "58155f6a071468d3bda0fc6f"
},
{
"user": {
"_id": "5813ad3d0d4e5c822d2f05bd",
"email": "[email protected]",
"title": "580fc1eee2d3308609470bc9"
},
"_id": "58155f6a071468d3bda0fc71"
}
]
}
However we can further populate each user's title
field with a nested $embed
parameter: /group?$embed=users.title
which could result in the following response:
{
"_id": "58155f1a071468d3bda0fc6e",
"name": "A-team",
"users": [
{
"user": {
"_id": "580fc1a0e2d3308609470bc6",
"email": "[email protected]",
"title": {
"_id": "580fc1e2e2d3308609470bc8",
"name": "Admin"
}
},
"_id": "58155f6a071468d3bda0fc6f"
},
{
"user": {
"_id": "5813ad3d0d4e5c822d2f05bd",
"email": "[email protected]",
"title": {
"_id": "580fc1eee2d3308609470bc9",
"name": "SuperAdmin"
}
},
"_id": "58155f6a071468d3bda0fc71"
}
]
}
Validation in the rest-hapi framework is implemented with joi.
This includes validation of headers, query parameters, payloads, and responses. joi validation models
are based primarily off of each model's field properties. Below is a list of mongoose schema types
and their joi equivalent within rest-hapi:
Schema Type | joi Validation |
---|---|
ObjectId | Joi.objectId() (via joi-objectid) |
Boolean | Joi.bool() |
Number | Joi.number() |
Date | Joi.date() |
String | Joi.string() |
types | Joi.any() |
Fields of type String
can include further validation restrictions based on additional field properties as shown below:
Field Property | joi Validation |
---|---|
enum: [items] | Joi.any().only([items]) |
stringType: 'email' | Joi.string().email() |
stringType: 'uri' | Joi.string().uri() |
stringType: 'token' | Joi.string().token() |
stringType: 'base64' | Joi.string().base64() |
stringType: 'lowercase' | Joi.string().lowercase() |
stringType: 'uppercase' | Joi.string().uppercase() |
stringType: 'hostname' | Joi.string().hostname() |
stringType: 'hex' | Joi.string().hex() |
stringType: 'trim' | Joi.string().trim() |
stringType: 'creditCard' | Joi.string().creditCard() |
In addition, if a description: "Description text."
field property is included, then .description("Description text.")
will be called on the joi validation object.
rest-hapi generates joi validation models for create, read, and update events as well as association events with linking models. By default these validation models include all the fields of the mongoose models and list them as optional. However additional field properties can be included to customize the validation models. Below is a list of currently supported field properties and their effect on the validation models.
Field Property | Validation Model |
---|---|
required: true | field required on create |
requireOnRead: true | field required on read/response |
requireOnUpdate: true | field required on update |
allowOnRead: false | field excluded from read model |
allowOnUpdate: false | field excluded from update model |
allowOnCreate: false | field excluded from create model |
queryable: false | field cannot be included as a query parameter |
exclude: true | field cannot be included in a response or as part of a query |
allowNull: true | field accepts null as a valid value |
rest-hapi exposes the helper methods it uses to generate Joi models through the joiHelper
property. Combined with the exposed mongoose wrapper methods, this allows you to easily create custom endpoints. You can see a description of these methods below:
/**
* Generates a Joi object that validates a query result for a specific model
* @param model: A mongoose model object.
* @param Log: A logging object.
* @returns {*}: A Joi object
*/
generateJoiReadModel = function (model, Log) {...};
/**
* Generates a Joi object that validates a query request payload for updating a document
* @param model: A mongoose model object.
* @param Log: A logging object.
* @returns {*}: A Joi object
*/
generateJoiUpdateModel = function (model, Log) {...};
/**
* Generates a Joi object that validates a request payload for creating a document
* @param model: A mongoose model object.
* @param Log: A logging object.
* @returns {*}: A Joi object
*/
generateJoiCreateModel = function (model, Log) {...};
/**
* Generates a Joi object that validates a request query for the list function
* @param model: A mongoose model object.
* @param Log: A logging object.
* @returns {*}: A Joi object
*/
generateJoiListQueryModel = function (model, Log) {...};
/**
* Generates a Joi object that validates a request query for the find function
* @param model: A mongoose model object.
* @param Log: A logging object.
* @returns {*}: A Joi object
*/
generateJoiFindQueryModel = function (model, Log) {...};
/**
* Generates a Joi object for a model field
* @param model: A mongoose model object
* @param field: A model field
* @param fieldName: The name of the field
* @param modelType: The type of CRUD model being generated
* @param Log: A logging object
* @returns {*}: A Joi object
*/
generateJoiFieldModel = function (model, field, fieldName, modelType, Log) {...};
/**
* Returns a Joi object based on the mongoose field type.
* @param field: A field from a mongoose model.
* @param Log: A logging object.
* @returns {*}: A Joi object.
*/
generateJoiModelFromFieldType = function (field, Log) {...};
/**
* Provides easy access to the Joi ObjectId type.
* @returns {*|{type}}
*/
joiObjectId = function () {...};
/**
* Checks to see if a field is a valid model property
* @param fieldName: The name of the field
* @param field: The field being checked
* @param model: A mongoose model object
* @returns {boolean}
*/
isValidField = function (fieldName, field, model) {...};
Models can support middleware functions for CRUD operations. These exist under the routeOptions
object. The following middleware functions are available:
- list:
- pre(query, request, Log)
- returns:
query
- returns:
- post(request, result, Log)
- returns:
result
- returns:
- pre(query, request, Log)
- find:
- pre(_id, query, request, Log)
- returns:
query
- returns:
- post(request, result, Log)
- returns:
result
- returns:
- pre(_id, query, request, Log)
- create:
- pre(payload, request, Log)
- NOTE: For payloads with multiple documents, the pre function will be called for each document individually (passed in through the
payload
parameter) i.e.request.payload
= array of documents,payload
= single document - returns:
payload
- NOTE: For payloads with multiple documents, the pre function will be called for each document individually (passed in through the
- post(document, request, result, Log)
- returns:
result
- returns:
- pre(payload, request, Log)
- update:
- pre(_id, request, Log)
- returns:
request.payload
- returns:
- post(request, result, Log)
- returns:
result
- returns:
- pre(_id, request, Log)
- delete:
- pre(_id, hardDelete, request, Log)
- returns:
null
- returns:
- post(hardDelete, deleted, request, Log)
- returns:
null
- returns:
- pre(_id, hardDelete, request, Log)
For example, a create: pre
function can be defined to encrypt a users password
using a static method generatePasswordHash
.
'use strict';
var bcrypt = require('bcrypt');
module.exports = function (mongoose) {
var modelName = "user";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
email: {
type: Types.String,
unique: true
},
password: {
type: Types.String,
required: true,
exclude: true,
allowOnUpdate: false
}
});
Schema.statics = {
collectionName:modelName,
routeOptions: {
create: {
pre: function (payload, request, Log) {
var hashedPassword = mongoose.model('user').generatePasswordHash(payload.password);
payload.password = hashedPassword;
return payload;
}
}
},
generatePasswordHash: function(password) {
var salt = bcrypt.genSaltSync(10);
var hash = bcrypt.hashSync(password, salt);
return hash;
}
};
return Schema;
};
Custom errors can be returned in middleware functions simply by throwing the error message as a string. This will result in a 400 error response with your custom message. Ex:
create: {
pre: function (payload, request, Log) {
throw "TEST ERROR"
}
}
will result in a response body of:
{
"statusCode": 400,
"error": "Bad Request",
"message": "TEST ERROR"
}
Support is being added for association middlware. Currently the following association middleware exist:
- getAll:
- post(request, result, Log)
- returns: result
- post(request, result, Log)
- add:
- pre(request, payload, Log)
- returns: payload
- pre(request, payload, Log)
Association middleware is defined similar to CRUD middleware, with the only difference being the association name must be specified. See below for an example:
routeOptions: {
associations: {
groups: {
type: "MANY_MANY",
model: "group"
}
}
},
getAll: {
groups: { //<---this must match the association name
post: function(request, result, Log) {
/** modify and return result **/
}
}
}
rest-hapi takes advantage of the scope
property within the auth
route config object of a hapi endpoint. When a request is made, an endpoint's scope (if it is populated) is compared to the user's scope (stored in request.auth.credentials.scope
) to determine if the requesting user is authorized to access the endpoint. Below is an quote from the hapi docs describing scopes in more detail:
scope - the application scope required to access the route. Value can be a scope string or an array of scope strings. The authenticated credentials object scope property must contain at least one of the scopes defined to access the route. If a scope string begins with a + character, that scope is required. If a scope string begins with a ! character, that scope is forbidden. For example, the scope ['!a', '+b', 'c', 'd'] means the incoming request credentials' scope must not include 'a', must include 'b', and must include one of 'c' or 'd'. You may also access properties on the request object (query and params) to populate a dynamic scope by using {} characters around the property name, such as 'user-{params.id}'. Defaults to false (no scope requirements).
In rest-hapi, each generated endpoint has its scope
property set based on model properties within the routeOptions.routeScope
object. There are three types of scopes that can be set: a root scope property, action scope properties, and association scope properties. A description of these can be seen below.
NOTE: As of v0.29.0 routeOptions.scope
and routeOptions.scope.scope
have been deprecated and replaced with routeOptions.routeScope
and routeOptions.routeScope.rootScope
The first type of scope is a rootScope
property that, when set, is applied to all generated endpoints for that model.
The second is an action specific scope property that only applies to endpoints corresponding with the action. A list of these action scope properties can be seen below:
createScope
: value is added to the scope of any endpoint that creates model documentsreadScope
: value is added to the scope of any endpoint that retrieves documents and can be queried againstupdateScope
: value is added to the scope of any endpoint that directly updates documentsdeleteScope
: value is added to the scope of any endpoint that deletes documentsassociateScope
: value is added to the scope of any endpoint that modifies an association
The third type of scope is property that relates to a specific association action, with an action prefix of add
, remove
, or get
. These scope properties are specific to the associations defined in the model and take the form of:
-{action}{modelName}{associationName}Scope
In the example below, users with the Admin
scope in their authentication credentials can access all of the generated endpoints for the user model, users with the User
scope are granted read access for the user model, and users with the Project Lead
scope are capable of adding group associations to a user document.
'use strict';
module.exports = function (mongoose) {
var modelName = "user";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
email: {
type: Types.String,
required: true,
unique: true
},
password: {
type: Types.String,
required: true,
exclude: true,
allowOnUpdate: false
}
});
Schema.statics = {
collectionName: modelName
routeOptions: {
routeScope: {
rootScope: "Admin",
readScope: "User",
addUserGroupsScope: "Project Lead"
},
associations: {
groups: {
type: "MANY_MANY",
model: "group",
alias: "team"
}
}
}
};
return Schema;
};r
NOTE: Use of route scope properties requires that an authentication strategy be defined and implemented. If the config.authStrategy
property is set to false
, then no route scopes will be applied, even if they are defined in the model. For an example of route scopes in action, check out appy:
If the config.generateScopes
property is set to true, then generated endpoints will come pre-defined with scope values. These values will exist in addition to any route scope values defined in the routeOptions.routeScope
object. For instance, the tables below show two possibilities for the user model scope: the first is with no model route scope defined, and the second is with a model route scope defined as in the example above.
Endpoint | Scope |
---|---|
DELETE /user | [ 'root', 'delete', 'deleteUser' ] |
POST /user | [ 'root', 'create', 'createUser' ] |
GET /user | [ 'root', 'read', 'readUser' ] |
DELETE /user/{_id} | [ 'root', 'delete', 'deleteUser' ] |
GET /user/{_id} | [ 'root', 'read', 'readUser' ] |
PUT /user/{_id} | [ 'root', 'update', 'updateUser' ] |
GET /user/{ownerId}/group | [ 'root', 'read', 'readUser', 'getUserGroups' ] |
POST /user/{ownerId}/group | [ 'root', 'associate', 'associateUser', 'addUserGroups' ] |
DELETE /user/{ownerId}/group | [ 'root', 'associate', 'associateUser', 'removeUserGroups' ] |
PUT /user/{ownerId}/group/{childId} | [ 'root', 'associate', 'associateUser', 'addUserGroups' ] |
DELETE /user/{ownerId}/group/{childId} | [ 'root', 'associate', 'associateUser', 'removeUserGroups' ] |
Endpoint | Scope |
---|---|
DELETE /user | [ 'root', 'Admin', 'delete', 'deleteUser' ] |
POST /user | [ 'root', 'Admin', 'create', 'createUser' ] |
GET /user | [ 'root', 'Admin', 'read', 'readUser', 'User' ] |
DELETE /user/{_id} | [ 'root', 'Admin', 'delete', 'deleteUser' ] |
GET /user/{_id} | [ 'root', 'Admin', 'read', 'readUser', 'User' ] |
PUT /user/{_id} | [ 'root', 'Admin', 'update', 'updateUser' ] |
GET /user/{ownerId}/group | [ 'root', 'Admin', 'read', 'readUser', 'User', 'getUserGroups' ] |
POST /user/{ownerId}/group | [ 'root', 'Admin', 'associate', 'associateUser', 'addUserGroups', 'Project Lead' ] |
DELETE /user/{ownerId}/group | [ 'root', 'Admin', 'associate', 'associateUser', 'removeUserGroups' ] |
PUT /user/{ownerId}/group/{childId} | [ 'root', 'Admin', 'associate', 'associateUser', 'addUserGroups', 'Project Lead' ] |
DELETE /user/{ownerId}/group/{childId} | [ 'root', 'Admin', 'associate', 'associateUser', 'removeUserGroups' ] |
Authentication (and as such Authorization) can be disabled for certain routes by adding a property under a model's routeOptions
property with the value set to false
. Below is a list of options and their effects:
Property | Effect |
---|---|
createAuth: false | auth is disabled for any endpoint that creates model documents |
readAuth: false | auth is disabled for any endpoint that retrieves documents and can be queried against |
updateAuth: false | auth is disabled for any endpoint that directly updates documents |
deleteAuth: false | auth is disabled for any endpoint that deletes documents |
associateAuth: false | auth is disabled for any endpoint that modifies an association |
In addition to route-level authorization, rest-hapi supports document-specific authorization. For consistency, document authorization is implemented through the use of scopes similar to the hapi scope system. To enable document scopes, config.enableDocumentScopes
must be set to true
. Once set, the scope
field shown below will be added to the schema of every model:
{
scope: {
rootScope: {
type: [Types.String]
},
readScope: {
type: [Types.String]
},
updateScope: {
type: [Types.String]
},
deleteScope: {
type: [Types.String]
},
associateScope: {
type: [Types.String]
},
type: Types.Object,
allowOnUpdate: false,
allowOnCreate: false
}
};
If a document's scope
property is populated with values, it will be compared to a requesting user's scope to determine whether the user is authorized to perform a certain action on the document. For example, if the document's scope
property looked like the following:
scope: {
rootScope: ['Admin']
readScope: ['User']
}
Then users with the Admin
scope value would have full access to the document while users with the User
scope value would only have read access. Users without either scope value would have no access to the document.
rest-hapi provides several options for populating a document's scope. One option is through the routeOptions.documentScope
property. Any values added to this property will be copied over to a document's scope
property upon its creation.
Another option is to set config.authorizeDocumentCreator
to true
. Setting this option will add the _id of the user who created the document to the document's rootScope
property (in the form of user-{_id}
, where {_id}
is the _id of the user). Assuming user-{_id}
is in the user's scope, this will grant the user full access to any document the user creates. Consider the example document below created by a user with an _id of 59d93c673401e16f0f66a5d4
:
name: "Test doc",
scope: {
rootScope: ['user-59d93c673401e16f0f66a5d4']
}
This document scope will allow the user with user-59d93c673401e16f0f66a5d4
in their scope full access while all other users will be denied.
For more details and alternatives to this option see the config docs below:
/**
* If true, modifies the root scope of any document to allow access to the document's creator.
* The scope value added is in the form: "user-{_id}" where "{_id}" is the _id of the user.
* NOTE:
* - This assumes that your authentication credentials (request.auth.credentials) will contain either
* a "user" object with a "_id" property, or the user's _id stored in a property defined by "config.userIdKey".
* - This also assumes that the user creating the document will have "user-{_id}" within their scope.
* - Requires "config.enableDocumentScopes" to be "true".
* - This setting can be individually overwritten by setting the "authorizeDocumentCreator" routeOptions property.
* default: false
* @type {boolean}
*/
config.authorizeDocumentCreator = false;
/**
* Same as "authorizeDocumentCreator", but modifies the "readScope" rather than the root scope.
* default: false
* @type {boolean}
*/
config.authorizeDocumentCreatorToRead = false;
/**
* Same as "authorizeDocumentCreator", but modifies the "updateScope" rather than the root scope.
* default: false
* @type {boolean}
*/
config.authorizeDocumentCreatorToUpdate = false;
/**
* Same as "authorizeDocumentCreator", but modifies the "deleteScope" rather than the root scope.
* default: false
* @type {boolean}
*/
config.authorizeDocumentCreatorToDelete = false;
/**
* Same as "authorizeDocumentCreator", but modifies the "associateScope" rather than the root scope.
* default: false
* @type {boolean}
*/
config.authorizeDocumentCreatorToAssociate = false;
/**
* This is the path/key to the user _id stored in your request.auth.credentials object.
* default: "user._id"
* @type {string}
*/
config.userIdKey = "user._id";
rest-hapi comes with built-in support for policies via the mrhorse plugin. Policies provide a powerful method of applying the same business logic to multiple routes declaratively. They can be inserted at any point in the hapi request lifecycle, allowing you to layer your business logic in a clean, organized, and centralized manner. We highly recommend you learn more about the details and benefits of policies in the mrhorse readme.
Internally, rest-hapi uses policies to implement features such as document authorization and certain metadata.
You can enable your own custom policies in rest-hapi by setting config.enablePolicies
to true
and adding your policy files to your policies
directory. You can then apply policies to your generated routes through the routeOptions.policies
property, which has the following structure:
routeOptions: {
policies: {
rootPolicies: [/* policies applied to all routes for this model */],
createPolicies: [/* policies applied to any endpoint that creates model documents */],
readPolicies: [/* policies applied to any endpoint that retrieves documents and can be queried against */],
updatePolicies: [/* policies applied to any endpoint that directly updates documents */],
deletePolicies: [/* policies applied to any endpoint that deletes documents */],
associatePolicies: [/* policies applied to any endpoint that modifies an association */],
}
}
NOTE: If your policies
directory is not in your projects root directory, you will need to specify the path (relative to your projects root directory) by assigning the path to the config.policyPath
property and you will need to set the config.absolutePolicyPath
property to true
.
NOTE: You can access the current model within a policy function through request.route.settings.plugins.model
(see the example below).
Since policies and middleware functions seem to provide similar funcitonality, it's important to understand their differences in order to determine which is best suited for your use case. Listed below are a few of the major differences:
Policies | Middleware |
---|---|
Policies are most useful when applied to multiple routes for multiple models, which is why they are located in a centralized place | Middleware functions are meant to be both model and endpoint specific |
Policies are only active when an endpoint is called | Middleware functions are active when either an endpoint is called or when a wrapper method is used |
Policies can run before (onPreHandler ) or after (onPostHander ) the handler function |
Since middleware functions are run as part of the handler, a pre middleware function will run after any onPreHandler policy, and a post middlware function will run before any onPostHandler policy |
To provide an example of the power of policies within rest-hapi, consider the following scenario:
A developer wants to implement document authorization, but wants to maintain control over the implementation and have the option of providing functionality outside of what is available with rest-hapi's built in document authorization. They want to only allow the user that creates a document to be able to modify the document. They decide to implement this via the policy below (docAuth.js
).
'use strict';
const Boom = require('boom');
let docAuth = function(request, reply, next) {
let Log = request.logger;
try {
let model = request.route.settings.plugins.model;
let userId = request.auth.credentials.user._id;
return model.findById(request.params._id)
.then(function(document) {
if (document && document.createdBy.toString() === userId.toString()) {
return next(null, true);
}
else {
return next(Boom.notFound("No resource was found with that id."), false);
}
})
}
catch (err) {
Log.error("ERROR", err);
return next(Boom.badImplementation(err), false);
}
};
docAuth.applyPoint = 'onPreHandler';
module.exports = docAuth;
NOTE: This assumes that config.enableCreatedBy
is set to true
.
They can then apply this policy to their model routes like so:
/models/blog.model.js
:
'use strict';
module.exports = function (mongoose) {
var modelName = "blog";
var Types = mongoose.Schema.Types;
var Schema = new mongoose.Schema({
title: {
type: Types.String,
required: true
},
description: {
type: Types.String
}
});
Schema.statics = {
collectionName:modelName,
routeOptions: {
policies: {
updatePolicies: ['docAuth'],
deletePolicies: ['docAuth']
}
}
};
return Schema;
};
rest-hapi provides mongoose wrapper methods for the user to take advantage of in their server code. These methods provide several advantages including:
- middleware functionality
- metadata support
- soft delete support
- association/relational management
- rest-hapi query support
The available methods are:
- list
- find
- create
- update
- deleteOne
- deleteMany
- addOne
- removeOne
- addMany
- removeMany
- getAll
When used with the model generating function, these methods provide a quick and easy way to start adding rich, relational data to your db. Check out the appy seed file for an excellent example of these methods in action, or refer to the Additional endpoints section example.
A more detailed description of each method can be found below:
/**
* Finds a list of model documents
* @param model: A mongoose model.
* @param query: rest-hapi query parameters to be converted to a mongoose query.
* @param Log: A logging object.
* @returns {object} A promise for the resulting model documents.
*/
function list(model, query, Log) {...},
/**
* Finds a model document
* @param model: A mongoose model.
* @param _id: The document id.
* @param query: rest-hapi query parameters to be converted to a mongoose query.
* @param Log: A logging object.
* @returns {object} A promise for the resulting model document.
*/
function find(model, _id, query, Log) {...},
/**
* Creates a model document
* @param model: A mongoose model.
* @param payload: Data used to create the model document.
* @param Log: A logging object.
* @returns {object} A promise for the resulting model document.
*/
function create(model, payload, Log) {...},
/**
* Updates a model document
* @param model: A mongoose model.
* @param _id: The document id.
* @param payload: Data used to update the model document.
* @param Log: A logging object.
* @returns {object} A promise for the resulting model document.
*/
function update(model, _id, payload, Log) {...},
/**
* Deletes a model document
* @param model: A mongoose model.
* @param _id: The document id.
* @param hardDelete: Flag used to determine a soft or hard delete.
* @param Log: A logging object.
* @returns {object} A promise returning true if the delete succeeds.
*/
function deleteOne(model, _id, hardDelete, Log) {...},
/**
* Deletes multiple documents
* @param model: A mongoose model.
* @param payload: Either an array of ids or an array of objects containing an id and a "hardDelete" flag.
* @param Log: A logging object.
* @returns {object} A promise returning true if the delete succeeds.
*/
function deleteMany(model, payload, Log) {...},
/**
* Adds an association to a document
* @param ownerModel: The model that is being added to.
* @param ownerId: The id of the owner document.
* @param childModel: The model that is being added.
* @param childId: The id of the child document.
* @param associationName: The name of the association from the ownerModel's perspective.
* @param payload: An object containing an extra linking-model fields.
* @param Log: A logging object
* @returns {object} A promise returning true if the add succeeds.
*/
function addOne(ownerModel, ownerId, childModel, childId, associationName, payload, Log) {...},
/**
* Removes an association to a document
* @param ownerModel: The model that is being removed from.
* @param ownerId: The id of the owner document.
* @param childModel: The model that is being removed.
* @param childId: The id of the child document.
* @param associationName: The name of the association from the ownerModel's perspective.
* @param Log: A logging object
* @returns {object} A promise returning true if the remove succeeds.
*/
function removeOne(ownerModel, ownerId, childModel, childId, associationName, Log) {...},
/**
* Adds multiple associations to a document
* @param ownerModel: The model that is being added to.
* @param ownerId: The id of the owner document.
* @param childModel: The model that is being added.
* @param associationName: The name of the association from the ownerModel's perspective.
* @param payload: Either a list of id's or a list of id's along with extra linking-model fields.
* @param Log: A logging object
* @returns {object} A promise returning true if the add succeeds.
*/
function addMany(ownerModel, ownerId, childModel, associationName, payload, Log) {...},
/**
* Removes multiple associations from a document
* @param ownerModel: The model that is being removed from.
* @param ownerId: The id of the owner document.
* @param childModel: The model that is being removed.
* @param associationName: The name of the association from the ownerModel's perspective.
* @param payload: A list of ids
* @param Log: A logging object
* @returns {object} A promise returning true if the remove succeeds.
*/
function removeMany(ownerModel, ownerId, childModel, associationName, payload, Log) {...},
/**
* Get all of the associations for a document
* @param ownerModel: The model that is being added to.
* @param ownerId: The id of the owner document.
* @param childModel: The model that is being added.
* @param associationName: The name of the association from the ownerModel's perspective.
* @param query: rest-hapi query parameters to be converted to a mongoose query.
* @param Log: A logging object
* @returns {object} A promise returning true if the add succeeds.
*/
function getAll(ownerModel, ownerId, childModel, associationName, query, Log) {...}
rest-hapi supports soft delete functionality for documents. When the enableSoftDelete
config property is set to true
, documents will gain an isDeleted
property when they are created that will be set to false
. Whenever that document is deleted (via a rest-hapi endpoint or method), the document will remain in the collection, its isDeleted
property will be set to true
, and the deletedAt
and deletedBy
properties (if enabled) will be populated.
"Hard" deletion is still possible when soft delete is enabled. In order to hard delete a document (i.e. remove a document from it's collection) via the api, a payload must be sent with the hardDelete
property set to true
.
The rest-hapi delete methods include a hardDelete
flag as a parameter. The following is an example of a hard delete using a rest-hapi method:
restHapi.deleteOne(model, _id, true, Log);
rest-hapi supports the following optional timestamp metadata:
- createdAt (default enabled, activated via
config.enableCreatedAt
) - updatedAt (default enabled, activated via
config.enableUpdatedAt
) - deletedAt (default enabled, activated via
config.enableDeletedAt
) (see Soft delete)
When enabled, these properties will automatically be populated during CRUD operations. For example, say I create a user with a payload of:
{
"email": "[email protected]",
"password": "1234"
}
If I then query for this document I might get:
{
"_id": "588077dfe8b75a830dc53e8b",
"email": "[email protected]",
"createdAt": "2017-01-19T08:25:03.577Z"
}
If I later update that user's email then an additional query might return:
{
"_id": "588077dfe8b75a830dc53e8b",
"email": "[email protected]",
"createdAt": "2017-01-19T08:25:03.577Z",
"updatedAt": "2017-01-19T08:30:46.676Z"
}
The deletedAt
property marks when a document was soft deleted.
NOTE: Timestamp metadata properties are only set/updated if the document is created/modified using rest-hapi endpoints/methods. Ex:
mongoose.model('user').findByIdAndUpdate(_id, payload)
will not modify updatedAt
whereas
restHapi.update(mongoose.model('user'), _id, payload)
will. (see Mongoose wrapper methods)
In addition to timestamps, the following user tag metadata can be added to a document:
- createdBy (default disabled, activated via
config.enableCreatedBy
) - updatedBy (default disabled, activated via
config.enableUpdatedBy
) - deletedBy (default disabled, activated via
config.enableDeletedBy
) (see Soft delete)
If enabled, these properties will record the _id
of the user performing the corresponding action.
This assumes that your authentication credentials (request.auth.credentials) will contain either a user
object with a \_id
property, or the user's _id stored in a property defined by config.userIdKey
.
NOTE: Unlike timestamp metadata, user tag properties are only set/updated if the document is created/modified using rest-hapi endpoints, (not rest-hapi methods).
In some situations models may be required before or without endpoint generation. For example some hapi plugins may require models to exist before the routes are registered. In these cases rest-hapi provides a generateModels
function that can be called independently. See below for example usage:
'use strict';
restHapi.generateModels(mongoose)
.then(function() {
server.register(require('hapi-auth-jwt2'), (err) => {
require('./utilities/auth').applyJwtStrategy(server); //requires models to exist
server.register({
register: restHapi,
options: {
mongoose: mongoose
}
}, function(err) {
server.start(function (err) {
server.log('info', 'Server initialized: ' + server.info);
restHapi.logUtil.logActionComplete(restHapi.logger, "Server Initialized", server.info);
});
});
});
})
.catch(function(error) {
console.log("There was an error generating the models: ", error)
});
NOTE: See the appy seed file (or gulp/seed.js) for another example usage of generateModels
.
If you have downloaded the source you can run the tests with:
$ gulp test
MIT
If you have any questions/issues/feature requests, please feel free to open an issue. We'd love to hear from you!
This project is still in its infancy, and there are many features we would still like to add. Below is a list of some possible future updates:
- sorting through populate fields (Ex: sort users through role.name)
- support marking fields as
duplicate
i.e. any associated models referencing that model will duplicate those fields along with the reference Id. This could allow for a shallow embed that will return a list of reference ids with their "duplicate" values, and a full embed that will return the fully embedded references - support automatic logging/auditing of all operations
- (LONG TERM) support mysql as well as mongodb
Please reference the contributing doc: https://github.com/JKHeadley/rest-hapi/blob/master/CONTRIBUTING.md
#Join the team Do you want to collaborate? Join the project at https://projectgroupie.com/projects/206