Super easy Node.js + MongoDB CRUD backend for JSON:API consuming apps like Ember.
jsonapi-server-mini
allows you to quickly write an ExpressJS JSON:API server. Adhering to Convention Over Configuration, your endpoints should have zero boilerplate code, and only contain your business logic.
This module attempts to be a simplistic lightweight yet complete JSON:API endpoint. There should be out-of-the-box support for all basic JSON:API features you need to write a decent app, like sort
, filter
, page
and include
. Routes are limited to one type, one model, one schema. If you want something crazy, like a custom type that uses six models and a hard-coded joke about Belgians, you should look for a more advanced solution, such as the ones listed below:
- Fortune.js with fortune-json-api
- jsonapi-server (no longer maintained)
- express-autoroute with express-autoroute-json
This project was forked from express-autoroute-json
because its wide scope made it hard to land pull requests. jsonapi-server-mini
is KISS, so we can implement the full JSON:API base spec without taking complexities into account.
npm install --save jsonapi-server-mini
The module contains everything to get up and running. Even test routes /tests
and /users
. Don't worry, they will go away once you've defined your own models. But for now, check this out!
Note: For this super easy quick start, an authless mongo database is expected to run at port
27017
. You can run one for testing purposes usingdocker
:docker run -d -p 27017:27017 --rm --name jsonapi-server-mini-mongo mongo
mkdir jsonapi-server-mini && cd $_
npm init -y
npm install --save jsonapi-server-mini
echo "require('jsonapi-server-mini')()" > index.js
node .
Bam! We're running a server. Let's create a user
curl -X POST http://localhost:8888/api/users \
-H 'Content-Type: application/vnd.api+json' \
-d @- <<'EOF'
{
"data": {
"type": "users",
"attributes": {
"name": "Some User",
"email": "[email protected]"
}
}
}
EOF
OMG! Is this real? Can we fetch the user?
wget http://localhost:8888/api/users | jq
Response:
{
"data": [
{
"type": "users",
"id": "5c1d81d48a162b5776ab7fd0",
"attributes": {
"name": "Some User",
"email": "[email protected]"
}
}
]
}
The above /api/users
example route is active because you haven't defined your own resources. The fallback from node_modules/jsonapi-server-mini/routes/api/user.js
is loaded:
module.exports = ({mongoose}) => ({
schema : new mongoose.Schema({
name : String,
email : String
}),
// CRUD operations. Remove to disable.
find : {},
create : {},
update : {},
delete : {}
})
You should create your own routes in your app's routes
directory. You'll need to specify the full path to your own routes
directory. Now let's create our first fully working app:
index.js
:
const path = require('path')
const jsMini = require('jsonapi-server-mini')
jsMini({
routes: path.join(__dirname, 'routes')
})
Any sub-directory will be appended to the route in case you want /api/v1
, /api/v2
etc. The filename (in singular form) decides the resource type and schema name (in plural form). Go ahead and copy the file, adding something crazy to the schema!
When starting jsonapi-server-mini, we can provide an options
Object like so: jsMini(options)
. Every option is, well, optional. But it makes sense to at least define your own routes, and specify the path to them using routes
.
app
Router Express routerauthn
function Basic first-line authenticationlimit
Number Global limit forfind
queries. Default:50
limitMax
Number Maximum query string limit override. Default:100
logger
Module Default:winston
console loggermeta
Object Static metadata to append to every JSON:API response. E.g. server version information.mongoose
Module in case you want to re-use an existing instance.mongoUri
String MongoDB connection stringroutes
String Path to custom routes
If you want to use your own Express Router so you can add different (non-JSON:API) endpoints to your app, you can do so, but you'll need to enable some basic parsing for jsonapi-server-mini
to work on requests:
- Handle CORS
- Decode
urlencoded
requests - Parse body for
application/vnd.api+json
mimetype
Here is an example:
index.js
:
const path = require('path')
const express = require('express')
const bodyParser= require('body-parser')
const morgan = require('morgan')
const cors = require('cors')
const jsMini = require('jsonapi-server-mini')
const app = new express.Router()
const routes = path.join(__dirname, 'routes')
app.use(cors())
.use(bodyParser.urlencoded({ extended: true }))
.use(bodyParser.json({ type: 'application/vnd.api+json' }))
express()
.use(morgan('combined'))
.use('/jsonapi', app) // jsonapi-server-mini endpoint
.get('/', (req, res) => res.json({ hello: 'world' })) // custom endpoint
.listen(4488)
jsMini({ app, routes })
authn
function(req, res, next) -> Callback
If you are not running an authless MongoDB server on localhost, you need to provide a MongoDB connection string. E.g.:
jsMini({
mongoUri: 'mongodb://user:password@hostname/database'
})
schema
Schema A Mongoose Schemadescription
String Describe your routeindexes
Object or Array Define one or more indexes
You can define a single compound index that cannot be defined in the schema itself.
module.exports = ({mongoose}) => ({
schema : new mongoose.Schema({
name : String,
group : { type: String, required: true },
friends : [ { type: String, required: true } ]
}),
indexes : {
group : 1,
friends : 1
},
// CRUD operations. Remove to disable.
find : {}
})
Or multiple compound indexes.
indexes : [
{
group : 1,
friends : 1
},
{
group : 1,
name : 1
}
]
You can specify index options only when writing your indexes in an array, like the second example above. Then wrap your index in a second array, and specify the options as the second item.
indexes : [
[
{
createdAt : 1
},
{
expireAfterSeconds : 3600
}
],
// [ another index ]
]
Note: Although this is useful for development, it's recommended to define indexes manually, so that your application restarts faster.
For defining your schema, take a look at the Mongoose Schema documentation. Try not to make it too complex; it needs to map to JSON:API. Basically, everything is an attribute
. To define a relationship
, first create a route for the type of the relationship, e.g. test.js
. Next, specify a property with its type
set to ObjectID
and its ref
to the (capitalized) (file)name of the new resource you just created:
module.exports = ({mongoose}) => ({
schema : new mongoose.Schema({
name : String,
email : String,
// One-to-many relationship
myTests : [{
type : mongoose.Schema.Types.ObjectId,
ref : 'Test'
}],
// One-to-one relationship
lastUsed : {
type : mongoose.Schema.Types.ObjectId,
ref : 'Test'
}
}),
// CRUD operations. Remove to disable.
find : {}
})
The comment says it all. You can remove them to disable them, or add route-specific options.
Once you've written some custom authentication logic, you can create an Express Middleware function authn
to either continue or throw an error. The same authentication applies to all CRUD methods.
module.exports = ({mongoose}) => ({
schema,
authn(req, res, next) {
// Allow access for user
if (req.user) return next()
// Deny access for everyone else
next(new Error("You are not logged in"))
},
// CRUD operations. Remove to disable.
find : {}
})
Let's make it a bit more complex. What if you want to be publicly readable, but only writable for a user, and only deletable by an admin? Simply move the method-specific auth
function to the method object:
function authn(req, res, next) {
// Allow access for user
if (res.locals.user) return next()
// Deny access for everyone else
next(new Error("You are not logged in"))
}
module.exports = ({mongoose}) => ({
schema,
// CRUD operations. Remove to disable.
find : {},
create : {
authn
},
update : {
authn
},
delete : {
authn(req, res, next) {
// Allow access for admin
if (res.locals.user && res.locals.isAdmin) return next()
// Deny access for everyone else
next(new Error("You do not have administrator privileges"))
}
}
})
You may have a global authn
function in your jsMini
defenition, but are looking to pass an otherwise rejected authentication. To do this, you can use an upstream authn
function to set a variable that you will check downstream.
someRoute.js
:
module.exports = ({mongoose}) => ({
description : 'This route will override global authn',
schema,
authn(req, res, next) {
res.locals.authenticated = true
return next()
},
find,
index.js
:
const jsMini = require('jsonapi-server-mini')
function authn() {
if (res.locals.authenticated === true) return next() // Granted elsewhere
}
jsMini({ authn })
Even when your user is authenticated, they are probably not supposed to access data from another user. Authorization allows for more granular control. Add the authz
function to the specific CRUD, route, or global configuration you would like to apply rules to.
You can either add full express middleware:
function authz(req, res, next) {
const { query } = req.jsMini
const isAdmin = res.locals.role == 'admin'
const adminOnly = {$ne: true}
if (isAdmin)
req.jsMini.query = {...query, adminOnly}
next()
}
A shortcut function with one argument, returning a mandatory query selector:
function authz(req) {
const { userId } = req.res.locals
return {
userId
}
}
Or a shortcut function simply returning a boolean indicating access granted or denied:
function authz(req) {
return ['subscriber', 'editor'].includes(res.locals.role)
}
function authn(req, res, next) {
// Allow access for user
if (res.locals.user) return next()
// Deny access for everyone else
next(new Error("You are not logged in"))
}
function authz(req) {
const { userId } = req.res.locals
// Assuming user created resources have the users' userId attribute
return {
userId
}
}
module.exports = ({mongoose}) => ({
schema,
// Everyone can find
find : {},
// All users can create
create : {
authn
},
// Only owners can update or delete
update : {
authn,
authz
},
delete : {
authn,
authz
}
})
The current implementation attempts to follow the basics of the JSON:API 1.0 spec.
Allow sorting using a preconfigured sort
Object on the route configuration, or a sort
String in your query.
?sort=-date
Filter (or 'query') results with a filter
String in your query.
?filter[type]=order&filter[quantity]=<10
There are a couple of filter operators for making advanced queries:
<=
like MongoDB's$lte
>=
like MongoDB's$gte
<
like MongoDB's$lt
>
like MongoDB's$gt
:
partial match (case insensitive)~
exact match (case insensitive):~
ends with (case insensitive)~:
starts with (case insensitive)!
is like MongoDB's$ne
Choose what fields to select from documents. You can either predefine this in your route, or instruct the jsonapi server with your querystring.
Add a field
object to your route, with keys being the fieldnames you want to include (1
) or exclude (0
). You can only include or exclude all fields. Not a mix of both.
module.exports = ({mongoose}) => ({
// ...
fields : {
bio : 0,
friends : 0
},
Submit a comma separated value with the name of fields you want to include. or a list of names you want to exclude prepended by a minus symbol.
GET api/users?fields=-bio,-friends
At the moment, includes work one level deep.
Perhaps you're trying to do something slightly more complicated, but you are not ready for Fortune.js yet. You might benefit from using a custom middleware function right before your query is executed. You can specify this function on multiple levels. The CRUD level, the route level, and the global level (when initializing jsMini
).
module.exports = ({mongoose}) => ({
// ...
middleware(req, res, next) {
// Executed before _every_ crud operation on this route
return next()
},
find : {
middleware(req, res, next) {
// Executed before every _find_ operation on this route
return next()
Often you'll just want to pre-fill the query (i.e. req.jsMini.query
) with a non-async object-returning function, similar to authz
. You can suffice with a single req
parameter:
middleware(req) {
return {
customFilter: 'presetValue'
}
},
You can turn middleware into an object with multiple hooks. pre(req)
is the same as middleware(req)
. With this notation, you can add advanced middleware:
middleware {
pre(req, res, next) { /* ... */ },
beforeSerializer(data) { /* ... */ }, // findOne or findMany
afterSerializer(json) { /* ... */ },
},
Note that beforeSerializer
and afterSerializer
will be applied to both GET
and PATCH
requests. We can't have carefully hidden attributes show up by sending a PATCH
request.
We like to keep the scope of this module very small and easy to maintain in order to allow for quickly building small yet decent apps. Any pull request that adds complexity not part of the JSON:API spec will be rejected.
Stop anything running on port 27017
, and start an authless mongo database using docker
:
npm run start-docker
Install dependencies:
npm install
Run mocha:
npm run test-mocha
Copyright (c) 2018 - 2019, Redsandro Media [email protected]
Copyright (c) 2014 - 2018, Stone Circle [email protected]
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.