serviser-restfulness
v0.9.0
Published
Instant REST API - serviser plugin
Downloads
13
Readme
serviser-restfulness
is yet another serviser
plugin that will help you with your REST API implementation by generating fully functional API endpoints that operate on your ralational database backed resources.
That being said, its up to the user to decide when it's more appropriate to implement a route from scratch due to its increasing complexity.
The following is a simplest example of what it takes to design basic REST API operations:
//define resources
const Resource = require('serviser-restfulness').Resource;
const movie = new Resource({
singular: 'movie',
plural: 'movies',
properties: {
name: {type: 'string', pattern: '^[a-zA-Z0-9 ]+$'},
description: {type: 'string', maxLength: 256},
released_at: {type: 'string', format: 'date'}
},
responseProperties: {
id: {type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER},
name: {$ref: 'movie.name'},
released_at: {$ref: 'movie.released_at'},
rating: {type: 'number', minimum: 0, maximum: 10}
}
});
const app = service.buildApp('public');
//define http routers
const movies = app.buildRestfulRouter({
url: '/api/{version}/@movies',
version: 1.0
});
//define http endpoints
movies.get('/:{key}');//get movies
movies.get('/');//get movies
movies.post('/');//create new movie
movies.put('/:{key}');//update a movie
movies.del('/:{key}');//delete a movie
movies.del('/');//delete movies
Thats it, you are done. You have created 6 fully functional API endpoints!
With assumption that you were to use serviser-doc
plugin, you'd get THIS generated API documentation of above defined endpoints.
On the other hand if you were to plug in serviser-sdk
you would get client API SDKs for free.
Of cource, there is more to be familiarized with when defining REST operations using serviser-restfulness
:
- Resource definition
- constructor options
- json-schema column references
- associations
- API
- Resource.registry
- Resource.prototype.getName()
- Resource.prototype.getPluralName()
- Resource.prototype.getTableName()
- Resource.prototype.getKeyName()
- Resource.prototype.prop()
- Resource.prototype.hasProp()
- Resource.prototype.query(knex)
- Resource.prototype.belongsTo()
- Resource.prototype.hasMany()
- Resource.prototype.belongsToMany()
- Route definition
- Global configuration options
Resource definition
Resources are compound data structures describing a data source and how it relates to RDS
(relational database storage).
Disclaimer: This library is NOT a ORM
. Resources do NOT implement active-record
pattern.
constructor options
singular
- required singular resource nameplural
- required plural resource namedb
- options related toRDS
table
- required, default equals toplural
resource namekey
- primary key optionsname
- required, default:id
type
- required, default:integer
enum: [integer
,string
]format
- optionalajv
string formatpattern
- optionalajv
string regex
timestamps
- optional, default false, setscreated_at
&updated_at
timestamps every time the resource is created/updatedsoftDelete
- optional, default false, DELETE methods setdeleted_at
timestamp instead of permanently removing the recordproperties
- required,ajv-json-schema
object properties descriptor, lists allowed input properties for REST operationsresponseProperties
- optional,ajv-json-schema
object properties descriptor, lists allowed query and response properties, if NOT defined, ALLproperties
will be whitelisteddynamicDefaults
- optional,ajv
dynamic defaults definition object for properties listed inproperties
option objectCREATED_AT
- optional, defaultcreated_at
allows to customize timestamp property nameUPDATED_AT
- optional, defaultupdated_at
allows to customize timestamp property nameDELETED_AT
- optional, defaultdeleted_at
allows to customize timestamp property name
There are two Resource
contructor options properties
and responseProperties
holding resource property definitions.
properties
option describescolumns
whose values might be set through an API request, usually as part of json-payload ofPOST
&PUT
endpointsresponseProperties
option describescolumns
that routes can respond with. This is also default whitelist of properties the user of the API can filter resultset by.
The developer can of course overwrite input & response definitions on per route basis (see customizing route)
json-schema column references
const user = new Resource({
singular: 'user',
plural: 'users',
properties: {
name: {type: 'string', maxLength: 32},
password: {type: 'string'}
},
responseProperties: {
id: {type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER},
//reference the property schema from inside of the owning resource
name: {$ref: 'user.name'}
}
});
const post = new Resource({
singular: 'post',
plural: 'posts',
properties: {
title: {type: 'string', maxLength: 128},
content: {type: 'string', maxLength: 2048},
//reference user id from another resource
user_id: {$ref: 'user.id'}
}
});
Resource property schemas defined as part of
properties
&responseProperties
constructor options will get registered with Application wideAjv
validator instance allowing the user to reference property schema from outside of a resource the property belongs to.
Properties which reference other properties in its schema will NOT be registered with theAjv
instance. For example you cant referencepost.user_id
property from the above code example.Note that in order for
json-schema
references to work, resource objects have to be instantiated before you create your HttpApplication otherwise you get an error in following format:
Error: can't resolve reference resource-name.column from id #
associations
When designing a route which operates on multiple resources, eg:
/api/v1.0/@users/:{key}/@posts/:{key}
the user must tell serviser-restfulness
how the resources are related to each other in order for the operation to function properly:
//defines relation of user to the post and relation of post to the user.
user.hasMany(post);
// or (both are redundant)
post.belongsTo(user);
See Resource.prototype.belongsTo
or/and Resource.prototype.hasMany
or/and Resource.prototype.belongsToMany respectivelly.
API
Resource.registry
is global ResourceRegistry
instance
Resource.registry.getByPluralName()
retrieves a resource by its plural unique name
Resource.registry.getBySingularName()
retrieves a resource by its singular unique name
Resource.prototype.getName(count)
returns singular resource name by default, if count
argument value is greater than 1 then it returns plural version of the name.
Resource.prototype.getPluralName()
returns plural version of the name.
Resource.prototype.getTableName()
returns sql
table name as defined by db.table
constructor option
Resource.prototype.getKeyName()
returns name of resource primary key
Resource.prototype.prop()
returns property schema as defined either in properties
or responseProperties
object. properties
object takes precendence.
Resource.prototype.hasProp()
returns boolean
value. True if property is defined either in properties
or responseProperties
object.
Resource.prototype.query()
/*
* @param {Knex} knex
* return {QueryBuilder}
*/
returns knex
query builder for resource table. Automaticaly handles table timestamps
Resource.prototype.belongsTo(resource, options)
/*
* @param {Resource} resource
* @param {Object} [options]
* @param {String} [options.foreignKey]
* @param {String} [options.localKey]
*/
Defines One to One association between sourceResource.belongsTo(targetResource)
.
localKey
option defaults to<targetResourceSingularName>_<targetResourcePrimaryKeyName>
foreignKey
option defaults to<targetResourcePrimaryKeyName>
Resource.prototype.hasMany(resource, options)
/*
* @param {Resource} resource
* @param {Object} [options]
* @param {String} [options.foreignKey]
* @param {String} [options.localKey]
*/
Defines One to Many association between sourceResource.hasMany(targetResource)
.
localKey
option defaults to<sourceResourcePrimaryKeyName>
foreignKey
option defaults to<sourceResourceSingularName>_<sourceResourcePrimaryKeyName>
Resource.prototype.belongsToMany(resource, options)
/*
* @param {Resource} resource
* @param {Object} options
* @param {String} [options.foreignKey]
* @param {String} [options.localKey]
* @param {Object} [options.through]
* @param {Resource} [options.through.resource]
* @param {String} [options.through.localKey]
* @param {String} [options.through.foreignKey]
*/
Defines Many to Many association between sourceResource.belongsToMany(targetResource)
.
localKey
option defaults to<sourceResourcePrimaryKeyName>
foreignKey
option defaults to<targetResourcePrimaryKeyName>
through.resource
option defaults to generated pivot resource object whose singular and plural name is set to alphabetically sorted and concatenated singular and plural names of source and target resources respectivelly.
eg: for source and target resourcesusers.belongsToMany(movies);
default pivot singular name would bemovie_user
and pluralmovies_users
through.localKey
option defaults to<sourceResourceSingularName>_<sourceResourcePrimaryKeyName>
through.foreignKey
option defaults to<targetResourceSingularName>_<targetResourcePrimaryKeyName>
Route definition
list of supported REST operations
single resource queries
GET @resource/:{key}
- fetch resource response properties by primary keyGET @resource/:property
- fetch resource response properties by any underlying table columnGET @resource
- retrieve paginated and optionally sorted collection of all or filtered resourcesPOST @resource/:{key}
- create a new resource whose primary key type is NOT auto-incremented integerPOST @resource
- create a new resource whose primary key IS auto-incremented integerPUT @resource/:{key}
- update a resource by its primary keyPUT @resource/:property
- update a resource by any of its propertiesDELETE @resource/:{key}
- remove a resource by its primary keyDELETE @resource/:property
- remove a resource by any of its propertiesDELETE @resource
- delete all resources or resources that match particular query filter
multi resource queries
depending or relationship
many to many
GET @resource1/:{key}/@resource2/:{key}
- fetch resource2 which belongs to resource1GET @resource1/:property/@resource2/:property
- fetch resource2 which belongs to resource1GET @resource1/:{key}/@resource2
- retrieve paginated and optionally sorted collection of all or filtered resource2 records that belong to resource1GET @resource1/:property/@resource2
- retrieve paginated and optionally sorted collection of all or filtered resource2 records that belong to resource1POST @resource1/:{key}/@resource2/:{key}
- create a new resource2 whose primary key type is NOT auto-incremented integer and associate it with resource1POST @resource1/:property/@resource2/:{key}
- create a new resource2 whose primary key type is NOT auto-incremented integer and associate it with resource1POST @resource1/:{key}/@resource2
- create a new resource2 whose primary key type IS auto-incremented integer and associate it with resource1POST @resource1/:property/@resource2
- create a new resource2 whose primary key type IS auto-incremented integer and associate it with resource1PUT @resource1/:{key}/@resource2/:{key}
- associate resource1 with resource2 by inserting a new record to pivot tablePUT @resource1/:property/@resource2/:property
- associate resource1 with resource2 by inserting a new record to pivot tableDELETE @resource1/:{key}/@resource2/:{key}
- deassociate resource1 and resource2 from each otherDELETE @resource1/:property/@resource2/:property
- deassociate resource1 and resource2 from each otherDELETE @resource1/:{key}/@resource2
- deassociate all resource1 records and resource2 records from each other where optional condition applies
one to many
GET @resource1/:{key}/@resource2/:{key}
- fetch resource2 which belongs to resource1GET @resource1/:property/@resource2/:property
- fetch resource2 which belongs to resource1GET @resource1/:{key}/@resource2
- retrieve paginated and optionally sorted collection of all or filtered resource2 records that belong to resource1GET @resource1/:property/@resource2
- retrieve paginated and optionally sorted collection of all or filtered resource2 records that belong to resource1POST @resource1/:{key}/@resource2/:{key}
- create a new resource2 whose primary key type is NOT auto-incremented integer and associate it with resource1POST @resource1/:property/@resource2/:{key}
- create a new resource2 whose primary key type is NOT auto-incremented integer and associate it with resource1POST @resource1/:{key}/@resource2
- create a new resource2 whose primary key type IS auto-incremented integer and associate it with resource1POST @resource1/:property/@resource2
- create a new resource2 whose primary key type IS auto-incremented integer and associate it with resource1PUT @resource1/:{key}/@resource2/:{key}
- update a resource2 by its primary key whose foreign key value of resource1 is equal to particular valueDELETE @resource1/:{key}/@resource2/:{key}
- remove resource2 that is associated to resource1DELETE @resource1/:property/@resource2/:property
- remove resource2 that is associated to resource1DELETE @resource1/:{key}/@resource2
- remove all resource2 records that are associated to resource1 where optional condition applies
one to one
GET @resource1/:{key}/@resource2/:{key}
- fetch resource2 which belongs to resource1GET @resource1/:property/@resource2/:property
- fetch resource2 which belongs to resource1
spetial _embed
query parameter
Accepted by all GET routes. Eager loads associated One to One resources (embedding collections is not supported).
Examples:
GET posts/1?_embed=user
- embeds whole user resource in the response - meaning embeds all properties defined as part ofresponseProperties
user resource optionGET posts/1?_embed=user.username,user.id
- embeds user username and id in the response
spetial _sort
& _limit
& _offset
query parameters
Accepted by GET routes that return a collection of resources.
Allows to sort records and limit number of fetched records (paginate results)
Example:
GET users/?_sort=username,-id
- order by username ASC, id DESC
filtering results or reducing scope of delete queries
Accepted by GET and DELETE routes that operate on a collection of resources.
Example resource:
const user = new Resource({
singular: 'user',
plural: 'users',
properties: { //results can NOT be reduced by these properties
password: {type: 'string'},
email: {type: 'string'}
},
responseProperties: {//but can be filtered/reduced by these
id: {type: 'integer'},
username: {type: 'string'}
}
});
Considering the above code example, endpoints accept the following filter parameters (by default, can be modified):
GET users/?id=1
DELETE users/?username=anonym
When filtering by query property of type string
, WHERE like %filter-value%
clause will be generated.
spetial _filter
query parameter
In addition to simple query filters described above, related routes accept compound _filter
parameter which must be a serialized json object in the following format:
{
column: schema,
//condition negation
column2: {not: schema}
}
//where schema is valid according to:
{
type: 'object',
properties: {
eq: {type: ['string', 'number', 'null', 'boolean']}, //equal to
gt: {type: 'number'}, //greater than
gte: {type: 'number'}, //greater than or equal
lt: {type: 'number'}, //lower than
lte: {type: 'number'}, //lower than or equal
like: {type: 'string'},
iLike: {type: 'string'}, //case insensitive like
between: {
type: 'array',
maxItems: 2,
minItems: 2,
items: {type: ['string', 'number']}
},
in: {
type: 'array',
maxItems: config.getOrFail('filter:maxItems'),
minItems: 1,
items: {type: ['string', 'number']}
}
}
}
_filter
accepts only those column defined as part of route response schema.
Explicitly define what columns can be used for reducing dataset scope through query parameters:
route.reducesDatasetBy(['id', 'user.username']);
reducesDatasetBy()
overwrites the default whitelisted column set.
Empty array disables query filters altogether
customizing route
const user = new Resource({
singular: 'user',
plural: 'users',
properties: {
name: {type: 'string'},
username: {type: 'string'},
password: {type: 'string'},
email: {type: 'string'}
},
responseProperties: {
id: {type: 'integer'},
name: {$ref: 'user.name'},
username: {$ref: 'user.username'},
}
});
const users = app.buildRestfulRouter({
url: '/api/{version}/@users',
version: 1.0
});
Router
's get
& post
& put
& del
methods all return an uninitialized serviser
HttpRoute object.
The user is given time to manualy initialize the route in the current event loop tick, that is
to define validation rules for headers
& body
& query
& params
objects and/or response schema or even to implement or tweak
the main route's logic.serviser-restfulness
schedules initialization procedures that will execute on the next event loop tick and will set default behavior and rules
where it's not been done by the user.
users.get('/'); //returns Route object instance
users.post('/'); //returns Route object instance
users.put('/'); //returns Route object instance
users.del('/'); //returns Route object instance
response properties
users.get('/:{key}'); //get single user
the get user route, if not modified by the developer, returns id
& name
& username
properties for every user.
Lets modify the route so that it returns just the user username:
users
.get('/:{key}')
.respondsWith({
type: 'object',
additionalProperties: false,//always make sure to filter out any unexpected properties
properties: {
username: {$ref: 'user.username'}
}
});
simple, right? You just define a custom json-schema
.
That being said, by defining your cuctom response schema, you are essentially overwriting defaults.
For example the above custom schema does not allow any associated resources to be embedded along.
input data validation
users.post('/'); //register new user
Again, the post users route, if not modified by the developer, accepts in its json payload all properties that are defined as part of user's resource properties
constructor option.
In this case, the route accepts name
& username
& password
& email
properties.
Lets modify the route so that it accepts additional password_confirmation
field:
users
.post('/')
.validate({
type: 'object',
additionalProperties: false,//always make sure to filter out any unexpected properties
properties: {
name: {$ref: 'user.name'},
username: {$ref: 'user.username'},
password: {
$ref: 'user.password',
bcrypt: {saltLength: 8}
},
password_confirmation: {
const: {$data: '1/password'}
},
email: {$ref: 'user.email'}
}
}, 'body');
In the custom payload validator schema above, we make sure password_confirmation
field matches the password
field.
There is one more thing we did and thats we applied our custom validation/sanitization keyword to the valid password field which will make sure the password gets transformed into a more secure hash.
For custom keyword definition see ajv
's official documentation.
You can then register the keyword to a ajv
validator instance on your serviser
HttpApplication object.
about authentication/restricting access
You can define additional middlewares before or after data validation procedures.
- push a middleware to the top of middleware call stack that is run before input data are validated
const route = users.get('/:{key}')
route.step('auth', function auth(req, res) {
//do stuff
});
route.validate(jsonSchema, 'query');
- push a middleware on top of middleware call stack, immediately after default input data validators
const route = users.get('/:{key}')
route.once('after-validation-setup', function() {
//at this point validation middlewares are already registered with the route
route.step('auth', function auth(req, res) {
//do stuff
});
})
request lifecycle events and implementing extra logic
These extra (asynchronous) events are available on Route objects:
after-validation-setup
- emitted once after all default input data validators are attached, when you attach additional middlewares inside event listener, you can be sure data are validated at that pointbofore-query
- emitted once before the main sql query executed, the event is provided withreq
object and knexquery
objectbofore-response
- emitted once before the response is sent, the user can override the response, the event is provided withreq
object & response data
example:
const route = users.get('/:{key}')
route.on('before-query', function(req, query) {
return query.where('banned', false);
});
Global configuration options
Some validation constants of spetial query properties are configurable through config object:
const Restfulness = require('serviser-restfulness');
Restfulness.config.set('limit:maximum'); //query _limit maximum value, default 500
Restfulness.config.set('limit:minimum'); //query _limit minimum value, default 0
Restfulness.config.set('limit:default'); // query _limit default value when not provided by API call, default 0 meaning no limit at all
Restfulness.config.set('offset:maximum'); //query _offset maximum value, default Number.MAX_SAFE_INTEGER
Restfulness.config.set('offset:minimum'); //query _offset minimum value, default 0
Restfulness.config.set('offset:default'); //query _offset default value, default 0
Restfulness.config.set('embed:maxLength'); //default 256
Restfulness.config.set('sort:maxLength'); //default 128
Restfulness.config.set('filter:maxLength'); //default 32
Restfulness.config.set('filter:minLength'); //default 0
Restfulness.config.set('filter:minimum'); //default Number.MAX_SAFE_INTEGER
Restfulness.config.set('filter:maximum'); //default Number.MIN_SAFE_INTEGER
Restfulness.config.set('filter:maxItems'); //default 10
Tests
unit tests
npm test
integration tests
npm run test:docker
development
npm run test:shell
You will be dropped in docker container shell where you can run:
> npm run migrate
> npm test
> npm run test:integration
Your project files on host systems will be mounted into the container's fs so you can edit and immediately test changes to the project files.