jsonapi-server
Advanced tools
Comparing version 0.7.0 to 0.8.0
2015-06-29 - Initial release | ||
2015-07-03 - Refactoring handlers to improve readability | ||
2915-07-03 - Separating out route handlers into separate files | ||
2015-07-05 - Allow user to implement their own error logging | ||
2015-07-06 - Handle 404s and allow logging for uncaught exceptions | ||
2015-07-08 - Added additional info to relation metadata | ||
2015-07-11 - Code Complexity tool via npm-run-complexity | ||
2015-07-12 - v0.7.0 | ||
2015-07-12 - Split postProcessing into smaller modules | ||
2015-07-12 - Split out Joi modifications into separate file | ||
2015-07-13 - Split out documentation into more manageable chunks | ||
2015-07-13 - Updating dependencies to latest stable releases | ||
2015-07-13 - v0.8.0 |
"use strict"; | ||
var jsonApi = module.exports = { }; | ||
jsonApi._resources = { }; | ||
jsonApi._apiConfig = { }; | ||
var _ = require("underscore"); | ||
var Joi = require("joi"); | ||
var ourJoi = require("./ourJoi.js"); | ||
var router = require("./router.js"); | ||
@@ -10,7 +12,5 @@ var responseHelper = require("./responseHelper.js"); | ||
var mockHandlers = require("./mockHandlers.js"); | ||
var postProcess = require("./postProcess.js"); | ||
jsonApi.Joi = ourJoi.Joi; | ||
jsonApi.mockHandlers = mockHandlers.handlers; | ||
jsonApi._resources = { }; | ||
jsonApi._apiConfig = { }; | ||
@@ -22,4 +22,2 @@ jsonApi.setConfig = function(apiConfig) { | ||
responseHelper.setMetadata(jsonApi._apiConfig.meta); | ||
router.using(jsonApi); | ||
postProcess.using(jsonApi); | ||
}; | ||
@@ -50,18 +48,18 @@ | ||
resourceConfig.searchParams = _.extend({ | ||
type: Joi.any().required().valid(resourceConfig.resource) | ||
type: ourJoi.Joi.any().required().valid(resourceConfig.resource) | ||
.description("Always \"" + resourceConfig.resource + "\"") | ||
.example(resourceConfig.resource), | ||
sort: Joi.any() | ||
sort: ourJoi.Joi.any() | ||
.description("An attribute to sort by") | ||
.example("title"), | ||
filter: Joi.any() | ||
filter: ourJoi.Joi.any() | ||
.description("An attribute+value to filter by") | ||
.example("title"), | ||
fields: Joi.any() | ||
fields: ourJoi.Joi.any() | ||
.description("An attribute+value to filter by") | ||
.example("title"), | ||
include: Joi.any() | ||
include: ourJoi.Joi.any() | ||
.description("An attribute to include") | ||
.example("title"), | ||
relationships: Joi.any() | ||
relationships: ourJoi.Joi.any() | ||
.description("An attribute to include") | ||
@@ -72,6 +70,6 @@ .example("title") | ||
resourceConfig.attributes = _.extend({ | ||
id: Joi.string().required() | ||
id: ourJoi.Joi.string().required() | ||
.description("Unique resource identifier") | ||
.example("1234"), | ||
type: Joi.string().required().valid(resourceConfig.resource) | ||
type: ourJoi.Joi.string().required().valid(resourceConfig.resource) | ||
.description("Always \"" + resourceConfig.resource + "\"") | ||
@@ -102,39 +100,1 @@ .example(resourceConfig.resource) | ||
}; | ||
jsonApi._joiBase = function(resourceName) { | ||
return Joi.object().keys({ | ||
id: Joi.string().required(), | ||
type: Joi.any().required().valid(resourceName) | ||
}); | ||
}; | ||
Joi.one = function(resource) { | ||
var obj = jsonApi._joiBase(resource); | ||
obj._settings = { | ||
__one: resource | ||
}; | ||
return obj; | ||
}; | ||
Joi.many = function(resource) { | ||
var obj = Joi.array().items(jsonApi._joiBase(resource)); | ||
obj._settings = { | ||
__many: resource | ||
}; | ||
return obj; | ||
}; | ||
Joi.belongsToOne = function(config) { | ||
var obj = jsonApi._joiBase(config.resource); | ||
obj._settings = { | ||
__one: config.resource, | ||
__as: config.as | ||
}; | ||
return obj; | ||
}; | ||
Joi.belongsToMany = function(config) { | ||
var obj = Joi.array().items(jsonApi._joiBase(config.resource)); | ||
obj._settings = { | ||
__many: config.resource, | ||
__as: config.as | ||
}; | ||
return obj; | ||
}; | ||
jsonApi.Joi = Joi; |
@@ -9,8 +9,7 @@ "use strict"; | ||
var async = require("async"); | ||
var debugExternalRequests = false; | ||
postProcess._applySort = require("./postProcessing/sort.js").action; | ||
postProcess._applyFilter = require("./postProcessing/filter.js").action; | ||
postProcess._applyIncludes = require("./postProcessing/include.js").action; | ||
postProcess._applyFields = require("./postProcessing/fields.js").action; | ||
postProcess.using = function(jsonApi) { | ||
postProcess._jsonApi = jsonApi; | ||
}; | ||
postProcess.handle = function(request, response, callback) { | ||
@@ -35,259 +34,2 @@ async.waterfall([ | ||
postProcess._applySort = function(request, response, callback) { | ||
var sort = request.params.sort; | ||
var ascending = 1; | ||
if (!sort) return callback(); | ||
sort = ("" + sort); | ||
if (sort[0] === "-") { | ||
ascending = -1; | ||
sort = sort.substring(1, sort.length); | ||
} | ||
if (!request.resourceConfig.attributes[sort]) { | ||
return callback({ | ||
status: "403", | ||
code: "EFORBIDDEN", | ||
title: "Invalid sort", | ||
detail: request.resourceConfig.resource + " do not have property " + sort | ||
}); | ||
} | ||
response.data = response.data.sort(function(a, b) { | ||
if (typeof a.attributes[sort] === "string") { | ||
return a.attributes[sort].localeCompare(b.attributes[sort]) * ascending; | ||
} else if (typeof a.attributes[sort] === "number") { | ||
return (a.attributes[sort] - b.attributes[sort]) * ascending; | ||
} else { | ||
return 0; | ||
} | ||
}); | ||
return callback(); | ||
}; | ||
postProcess._applyFilter = function(request, response, callback) { | ||
var allFilters = request.params.filter; | ||
if (!allFilters) return callback(); | ||
var filters = { }; | ||
for (var i in allFilters) { | ||
if (!request.resourceConfig.attributes[i]) { | ||
return callback({ | ||
status: "403", | ||
code: "EFORBIDDEN", | ||
title: "Invalid filter", | ||
detail: request.resourceConfig.resource + " do not have property " + i | ||
}); | ||
} | ||
if (allFilters[i] instanceof Array) { | ||
allFilters[i] = allFilters[i].join(","); | ||
} | ||
if (typeof allFilters[i] === "string") { | ||
filters[i] = allFilters[i]; | ||
} | ||
} | ||
if (response.data instanceof Array) { | ||
for (var j = 0; j < response.data.length; j++) { | ||
if (!postProcess._filterKeepObject(response.data[j], filters)) { | ||
response.data.splice(j, 1); | ||
j--; | ||
} | ||
} | ||
} else if (response.data instanceof Object) { | ||
if (!postProcess._filterKeepObject(response.data, filters)) { | ||
response.data = null; | ||
} | ||
} | ||
return callback(); | ||
}; | ||
postProcess._filterMatches = function(textToMatch, propertyText) { | ||
if (textToMatch[0] === ">") { | ||
textToMatch = textToMatch.substring(1); | ||
if (typeof propertyText === "number") textToMatch = parseInt(textToMatch, 10); | ||
if (textToMatch < propertyText) return true; | ||
} else if (textToMatch[0] === "<") { | ||
textToMatch = textToMatch.substring(1); | ||
if (typeof propertyText === "number") textToMatch = parseInt(textToMatch, 10); | ||
if (textToMatch > propertyText) return true; | ||
} else if (textToMatch[0] === "~") { | ||
if ((textToMatch.substring(1) + "").toLowerCase() === (propertyText + "").toLowerCase()) return true; | ||
} else if (textToMatch[0] === ":") { | ||
if ((propertyText + "").toLowerCase().indexOf((textToMatch.substring(1) + "").toLowerCase()) !== -1) return true; | ||
} else if (textToMatch === propertyText) return true; | ||
}; | ||
postProcess._filterKeepObject = function(someObject, filters) { | ||
for (var k in filters) { | ||
var whitelist = filters[k].split(","); | ||
var propertyText = (someObject.attributes[k] || ""); | ||
var matchOR = false; | ||
for (var j = 0; j < whitelist.length; j++) { | ||
var textToMatch = whitelist[j]; | ||
if (postProcess._filterMatches(textToMatch, propertyText)) matchOR = true; | ||
} | ||
if (!matchOR) return false; | ||
} | ||
return true; | ||
}; | ||
postProcess._applyIncludes = function(request, response, callback) { | ||
var includes = request.params.include; | ||
var filters = request.params.filter || { }; | ||
if (!includes) return callback(); | ||
includes = ("" + includes).split(","); | ||
postProcess._arrayToTree(request, includes, filters, function(attErr, includeTree) { | ||
if (attErr) return callback(attErr); | ||
var dataItems = response.data; | ||
if (!(dataItems instanceof Array)) dataItems = [ dataItems ]; | ||
includeTree._dataItems = dataItems; | ||
postProcess._fillIncludeTree(includeTree, request, function(fiErr) { | ||
if (fiErr) return callback(fiErr); | ||
includeTree._dataItems = [ ]; | ||
response.included = postProcess._getDataItemsFromTree(includeTree); | ||
response.included = _.uniq(response.included, false, function(someItem) { | ||
return someItem.type + "~~" + someItem.id; | ||
}); | ||
return callback(); | ||
}); | ||
}); | ||
}; | ||
postProcess._arrayToTree = function(request, includes, filters, callback) { | ||
var tree = { | ||
_dataItems: null, | ||
_resourceConfig: request.resourceConfig | ||
}; | ||
var iterate = function(text, node, filter) { | ||
if (text.length === 0) return null; | ||
var parts = text.split("."); | ||
var first = parts.shift(); | ||
var rest = parts.join("."); | ||
var resourceAttribute = node._resourceConfig.attributes[first]; | ||
if (!resourceAttribute) { | ||
return callback({ | ||
status: "403", | ||
code: "EFORBIDDEN", | ||
title: "Invalid inclusion", | ||
detail: node._resourceConfig.resource + " do not have property " + first | ||
}); | ||
} | ||
resourceAttribute = resourceAttribute._settings.__one || resourceAttribute._settings.__many; | ||
if (!resourceAttribute) { | ||
return callback({ | ||
status: "403", | ||
code: "EFORBIDDEN", | ||
title: "Invalid inclusion", | ||
detail: node._resourceConfig.resource + "." + first + " is not a relation and cannot be included" | ||
}); | ||
} | ||
filter = filter[first] || { }; | ||
if (!node[first]) { | ||
node[first] = { | ||
_dataItems: [ ], | ||
_resourceConfig: postProcess._jsonApi._resources[resourceAttribute], | ||
_filter: [ ] | ||
}; | ||
for (var i in filter) { | ||
if (!(typeof filter[i] === "string" || (filter[i] instanceof Array))) continue; | ||
node[first]._filter.push("filter[" + i + "]=" + filter[i]); | ||
} | ||
} | ||
iterate(rest, node[first], filter); | ||
}; | ||
includes.forEach(function(include) { | ||
iterate(include, tree, filters); | ||
}); | ||
return callback(null, tree); | ||
}; | ||
postProcess._getDataItemsFromTree = function(tree) { | ||
var items = tree._dataItems; | ||
for (var i in tree) { | ||
if (i[0] !== "_") { | ||
items = items.concat(postProcess._getDataItemsFromTree(tree[i])); | ||
} | ||
} | ||
return items; | ||
}; | ||
postProcess._fillIncludeTree = function(includeTree, request, callback) { | ||
/**** | ||
includeTree = { | ||
_dataItems: [ ], | ||
_filter: { }, | ||
_resourceConfig: { }, | ||
person: { includeTree }, | ||
booking: { includeTree } | ||
}; | ||
****/ | ||
var includes = Object.keys(includeTree); | ||
var resourcesToFetch = includeTree._dataItems.map(function(dataItem) { | ||
if (!dataItem) return [ ]; | ||
return Object.keys(dataItem.relationships || { }).filter(function(keyName) { | ||
return (keyName[0] !== "_") && (includes.indexOf(keyName) !== -1); | ||
}).map(function(keyName) { | ||
var url = "http://" + request.route.host + dataItem.relationships[keyName].links.related; | ||
if (url.indexOf("?") === -1) url += "?"; | ||
if (includeTree[keyName]._filter) { | ||
url += "&" + includeTree[keyName]._filter.join("&"); | ||
} | ||
return keyName + "~~" + url; | ||
}); | ||
}); | ||
resourcesToFetch = [].concat.apply([], resourcesToFetch); | ||
resourcesToFetch = _.unique(resourcesToFetch); | ||
// console.log(resourcesToFetch); | ||
async.map(resourcesToFetch, function(related, done) { | ||
var parts = related.split("~~"); | ||
var type = parts[0]; | ||
var link = parts[1]; | ||
if (debugExternalRequests) console.log(request.params.requestId, "Inc?", link); | ||
externalRequest.get(link, function(err, res, json) { | ||
if (debugExternalRequests) console.log(request.params.requestId, "Inc!", link); | ||
if (err || !json) { | ||
return done(null); | ||
} | ||
try { | ||
json = JSON.parse(json); | ||
} catch(e) { | ||
json = null; | ||
} | ||
if (res.statusCode >= 400) { | ||
return done(json.errors); | ||
} | ||
var data = json.data; | ||
if (!data) return done(); | ||
if (!(data instanceof Array)) data = [ data ]; | ||
includeTree[type]._dataItems = includeTree[type]._dataItems.concat(data); | ||
return done(); | ||
}); | ||
}, function(err) { | ||
if (err) return callback(err); | ||
async.map(includes, function(include, done) { | ||
if (include[0] === "_") return done(); | ||
postProcess._fillIncludeTree(includeTree[include], request, done); | ||
}, callback); | ||
}); | ||
}; | ||
postProcess._fetchRelatedResources = function(request, mainResource, callback) { | ||
@@ -303,5 +45,3 @@ | ||
async.map(resourcesToFetch, function(related, done) { | ||
if (debugExternalRequests) console.log(request.params.requestId, "Rel?", related); | ||
externalRequest.get(related, function(err, externalRes, json) { | ||
if (debugExternalRequests) console.log(request.params.requestId, "Rel!", related); | ||
if (err || !json) return done(null, [ ]); | ||
@@ -330,45 +70,2 @@ | ||
postProcess._applyFields = function(request, response, callback) { | ||
var fields = request.params.fields; | ||
if (!fields || !(fields instanceof Object)) return callback(); | ||
var allDataItems = response.included.concat(response.data); | ||
for (var resource in fields) { | ||
if (!postProcess._jsonApi._resources[resource]) { | ||
return callback({ | ||
status: "403", | ||
code: "EFORBIDDEN", | ||
title: "Invalid field resource", | ||
detail: resource + " is not a valid resource " | ||
}); | ||
} | ||
var field = ("" + fields[resource]).split(","); | ||
for (var i = 0; i < field.length; i++) { | ||
var j = field[i]; | ||
if (!postProcess._jsonApi._resources[resource].attributes[j]) { | ||
return callback({ | ||
status: "403", | ||
code: "EFORBIDDEN", | ||
title: "Invalid field selection", | ||
detail: resource + " do not have property " + j | ||
}); | ||
} | ||
} | ||
} | ||
allDataItems.forEach(function(dataItem) { | ||
Object.keys(dataItem.attributes).forEach(function(attribute) { | ||
if (field.indexOf(attribute) === -1) { | ||
delete dataItem.attributes[attribute]; | ||
} | ||
}); | ||
}); | ||
return callback(); | ||
}; | ||
postProcess.fetchForeignKeys = function(request, items, schema, callback) { | ||
@@ -375,0 +72,0 @@ if (!(items instanceof Array)) { |
@@ -9,2 +9,3 @@ "use strict"; | ||
var bodyParser = require("body-parser"); | ||
var jsonApi = require("./jsonApi.js"); | ||
@@ -35,10 +36,6 @@ app.use(bodyParser.json()); | ||
router.using = function(jsonApi) { | ||
router._jsonApi = jsonApi; | ||
}; | ||
router.bindRoute = function(config, callback) { | ||
app[config.verb](router._jsonApi._apiConfig.base + config.path, function(req, res) { | ||
app[config.verb](jsonApi._apiConfig.base + config.path, function(req, res) { | ||
var request = router._getParams(req); | ||
var resourceConfig = router._jsonApi._resources[request.params.type]; | ||
var resourceConfig = jsonApi._resources[request.params.type]; | ||
router._setResponseHeaders(request, res); | ||
@@ -73,3 +70,3 @@ request.resourceConfig = resourceConfig; | ||
host: req.headers.host, | ||
base: router._jsonApi._apiConfig.base, | ||
base: jsonApi._apiConfig.base, | ||
path: urlParts.shift() || "", | ||
@@ -76,0 +73,0 @@ query: urlParts.shift() || "", |
"use strict"; | ||
var relatedRoute = module.exports = { }; | ||
var jsonApi = require("../jsonApi.js"); | ||
var async = require("async"); | ||
@@ -49,3 +50,3 @@ var helper = require("./helper.js"); | ||
} | ||
request.resourceConfig = router._jsonApi._resources[relation._settings.__one || relation._settings.__many]; | ||
request.resourceConfig = jsonApi._resources[relation._settings.__one || relation._settings.__many]; | ||
response = responseHelper._generateResponse(request, resourceConfig, relatedResources); | ||
@@ -52,0 +53,0 @@ response.included = [ ]; |
{ | ||
"name": "jsonapi-server", | ||
"version": "0.7.0", | ||
"version": "0.8.0", | ||
"description": "A fully featured NodeJS sever implementation of json:api. You provide the resources, we provide the api.", | ||
@@ -21,7 +21,7 @@ "keywords": [ | ||
"dependencies": { | ||
"async": "0.9.2", | ||
"body-parser": "1.12.4", | ||
"express": "4.12.4", | ||
"joi": "6.4.1", | ||
"request": "2.55.0", | ||
"async": "1.3.0", | ||
"body-parser": "1.13.2", | ||
"express": "4.13.1", | ||
"joi": "6.5.0", | ||
"request": "2.58.0", | ||
"underscore": "1.8.3", | ||
@@ -32,3 +32,3 @@ "node-uuid": "1.4.3" | ||
"mocha": "2.2.5", | ||
"eslint": "0.24.0", | ||
"eslint": "0.24.1", | ||
"blanket": "1.1.7", | ||
@@ -35,0 +35,0 @@ "mocha-lcov-reporter": "0.0.2", |
472
README.md
@@ -5,2 +5,3 @@ [![Coverage Status](https://coveralls.io/repos/holidayextras/jsonapi-server/badge.svg?branch=master)](https://coveralls.io/r/holidayextras/jsonapi-server?branch=master) | ||
[![Code Climate](https://codeclimate.com/github/holidayextras/jsonapi-server/badges/gpa.svg)](https://codeclimate.com/github/holidayextras/jsonapi-server) | ||
[![Dependencies Status](https://david-dm.org/holidayextras/jsonapi-server.svg)](https://david-dm.org/holidayextras/jsonapi-server) | ||
@@ -11,2 +12,10 @@ # jsonapi-server | ||
### Full documentation | ||
- [Configuring jsonapi-server](documentation/configuring.md) | ||
- [Defining Resources](documentation/resources.md) | ||
- [Foreign Key Relations](documentation/foreign-relations.md) | ||
- [Creating Handlers](documentation/handlers.md) | ||
- [Post Processing Examples](documentation/post-processing.md) | ||
### The tl;dr | ||
@@ -16,3 +25,3 @@ | ||
```javascript | ||
var jsonApi = require("../."); | ||
var jsonApi = require("jsonapi-server"); | ||
@@ -22,5 +31,2 @@ jsonApi.setConfig({ | ||
port: 16006, | ||
meta: { | ||
"allMetaBlocks": "contain this data" | ||
} | ||
}); | ||
@@ -36,13 +42,3 @@ | ||
width: jsonApi.Joi.number().min(1).max(10000).precision(0) | ||
}, | ||
examples: [ | ||
{ | ||
id: "aab14844-97e7-401c-98c8-0bd5ec922d93", | ||
type: "photos", | ||
title: "Matrix Code", | ||
url: "http://www.example.com/foobar", | ||
height: 1080, | ||
width: 1920 | ||
} | ||
] | ||
} | ||
}); | ||
@@ -52,5 +48,5 @@ | ||
``` | ||
Your new API will be alive at http://localhost:16006/rest/ and your photos resources will be at http://localhost:16006/rest/photos | ||
Your new API will be alive at `http://localhost:16006/rest/` and your `photos` resources will be at `http://localhost:16006/rest/photos`. | ||
### I want to see it!! | ||
### Show me an full example! | ||
@@ -63,444 +59,6 @@ Fire up an example `json:api` server using the resources mentioned in the official spec via: | ||
``` | ||
and browse to | ||
then browse to | ||
``` | ||
http://localhost:16006/rest/photos | ||
``` | ||
### Defining a json:api resource | ||
```javascript | ||
jsonApi.define({ | ||
resource: "resourceNameGoesHere", | ||
handlers: { /* see "Handlers" section */ }, | ||
searchParams: { /* see "SearchParams" section */ }, | ||
attributes: { /* see "attributes" section */ } | ||
}); | ||
``` | ||
### Handlers | ||
Handlers represent the mechanism that backs a resource. Each handler is expected to provide some of the following functions: | ||
* initialise - when jsonapi-server loads, this is invoked once for every resource using this handler. Its an opportunity to allocate memory, connect to databases, etc. | ||
* search - for searching for resources that match some vague parameters. | ||
* find - for finding a specific resource by id. | ||
* create - for creating a new instance of a resource. | ||
* delete - for deleting an existing resource. | ||
* update - for updating an existing resource. | ||
Failure to provide the above handler functions will result in `EFORBIDDEN` HTTP errors if the corresponding routes are called. | ||
#### The `rawResource` Format | ||
All data stored behind handlers should be stored in a developer-friendly format with both attributes and relations mingled together: | ||
```javascript | ||
{ | ||
id: "aab14844-97e7-401c-98c8-0bd5ec922d93", | ||
type: "photos", | ||
title: "Matrix Code", | ||
url: "http://www.example.com/foobar", | ||
photographer: { type: "people", id: "ad3aa89e-9c5b-4ac9-a652-6670f9f27587" } | ||
} | ||
``` | ||
In the above example the `photographer` attribute is defined as relation to a resource of type `people`. jsonapi-server will deal with shuffling around and separating those attibutes and relations when it needs to. Keep It Simple. | ||
#### The `request` Format | ||
All requests are presented to handlers in the following format: | ||
```javascript | ||
{ | ||
params: { | ||
// All request parameters get combined into this object. Query params, body params, etc. | ||
foo: "bar" | ||
}, | ||
headers: { | ||
// All HTTP request headers | ||
host: "localhost:16006", | ||
connection: "keep-alive" | ||
}, | ||
route: { | ||
// Routing information | ||
host: "localhost:16006", | ||
path: "/v1/swagger.json", | ||
query: "foo=bar&baz=1", | ||
combined: "https://localhost:16006/v1/swagger.json" | ||
} | ||
} | ||
``` | ||
#### The `error` Format | ||
All errors should be provided in the following format: | ||
```javascript | ||
{ | ||
// The desired HTTP code | ||
status: "404", | ||
// A very short identifier for this error | ||
code: "ENOTFOUND", | ||
// A short human readable description | ||
title: "Requested resource does not exist", | ||
// Some detail to assist debugging | ||
detail: "There is no "+request.params.type+" with id "+request.params.id | ||
} | ||
``` | ||
#### jsonApi.mockHandlers | ||
jsonapi-server ships with an example barebones implementation of an in-memory handler. It can be found at `jsonApi.mockHandlers`. You can use it as a reference for writing new handlers. | ||
`mockHandlers` works by allowing each defined resource to contain an `examples` property, which must be an array of JSON objects representing raw resources. Those examples are loaded into memory when the server loads and are served up as if they were real resources. You can search through them, modify them, create new ones, delete them, straight away. | ||
Its a beautiful way of prototyping an experimental new API! Simply define the attributes of a resource, attach the `mockHandlers` and define some `examples`: | ||
```javascript | ||
jsonApi.define({ | ||
resource: "photos", | ||
handlers: jsonApi.mockHandlers, | ||
attributes: { | ||
title: jsonApi.Joi.string() | ||
url: jsonApi.Joi.string().uri().required() | ||
photographer: jsonApi.Joi.one("people") | ||
.description("The person who took the photo"), | ||
articles: jsonApi.Joi.belongsToMany({ | ||
resource: "articles", | ||
as: "photos" | ||
}) | ||
}, | ||
examples: [ | ||
{ | ||
id: "aab14844-97e7-401c-98c8-0bd5ec922d93", | ||
type: "photos", | ||
title: "Matrix Code", | ||
url: "http://www.example.com/foobar", | ||
photographer: { type: "people", id: "ad3aa89e-9c5b-4ac9-a652-6670f9f27587" } | ||
} | ||
] | ||
}); | ||
``` | ||
#### initialise | ||
`initialise` is invoked with the `resourceConfig` of each resource using this handler. | ||
```javascript | ||
function(resourceConfig) { }; | ||
``` | ||
`resourceConfig` is the complete configuration object passed in to `jsonApi.define()`. | ||
#### search | ||
`search` is invoked with a `request` object (see above). | ||
```javascript | ||
function(request, callback) { }; | ||
``` | ||
the `callback` should be invoked with with an `error` or `null, [ rawResource ]`. | ||
`search` needs to watch for any `request.params.relationships` parameters, they represent foreign key lookups. An example of this: | ||
``` | ||
request.params.relationships = { | ||
user: "ad3aa89e-9c5b-4ac9-a652-6670f9f27587" | ||
} | ||
``` | ||
translates to "Find me all of the resources whose user attribute is a link to a resource with id == ad3aa89e-9c5b-4ac9-a652-6670f9f27587". | ||
#### find | ||
`find` is invoked with a `request` object (see above). | ||
``` | ||
function(request, callback) { }; | ||
``` | ||
the `callback` should be invoked with with an `error` or `null, rawResource`. | ||
#### create | ||
`create` is invoked with a `request` object (see above) AND a `newResource` object which is an instance of `rawResource` representing a validated instance of type `request.params.type`. The `newResource` will already have an `id` and is ready to be stored as per the resource definition. | ||
``` | ||
function(request, newResource, callback) { }; | ||
``` | ||
the `callback` should be invoked with with an `error` or `newResource`. | ||
#### delete | ||
`delete` is invoked with a `request` object (see above). It should delete the resource of type `request.params.type` and id `request.params.id`. | ||
``` | ||
function(request, callback) { }; | ||
``` | ||
the `callback` should be invoked with with an `error` or `null`. | ||
#### update | ||
`update` is invoked with a `request` object (see above) and a `partialResource` which represents a partial instance of `rawResource` - the properties of `rawResource` need to be merged over the original resource and saved. | ||
``` | ||
function(request, partialResource, callback) { }; | ||
``` | ||
the `callback` should be invoked with with an `error` or `null, newUpdatedResource`. | ||
### SearchParams | ||
A resource's `searchParams` should be declared using the version of `Joi` bundled with `jsonapi-server`: | ||
```javascript | ||
url: jsonApi.Joi.string().uri() | ||
height: jsonApi.Joi.number().min(1).max(10000).precision(0) | ||
``` | ||
In addition to the fields declared in the `searchParams` object, jsonapi-server will also accept `sort`, `include`, `fields`, `filter` and `relationships` parameters. | ||
### Attributes | ||
A resource's `attributes` should be declared using the version of `Joi` bundled with `jsonapi-server`: | ||
```javascript | ||
url: jsonApi.Joi.string().uri() | ||
height: jsonApi.Joi.number().min(1).max(10000).precision(0) | ||
``` | ||
In addition to the functionality provided by `Joi`, there are 4x additional types: | ||
```javascript | ||
photos: jsonApi.Joi.one("photos").description("This attribute is a relation to a photos resource"); | ||
article: jsonApi.Joi.belongsToOne({ | ||
resource: "articles", | ||
as: "comments" | ||
}).description("This attribute declares that the articles resource contains comments that links back to this resource"); | ||
photos: jsonApi.Joi.many("photos").description("This attribute is a relation to many photos resources"); | ||
article: jsonApi.Joi.belongsToMany({ | ||
resource: "articles", | ||
as: "comments" | ||
}).description("This attribute declares that the articles resource contains comments that links back to many of this resource"); | ||
``` | ||
Attributes can be marked as `required` using the regular `Joi` functionality. Required fields are enforced in both directions - user created/updated resources must comply with the required attributes, as must all resources provided by the server. | ||
```javascript | ||
url: jsonApi.Joi.string().uri().required() | ||
``` | ||
Attributes can be declared `readonly` by attaching metadata. Any attempt to write to this attribute when creating a new resource via POST, or when amending a resource via PUT/PATCH/DELETE will result in a validation error. | ||
```javascript | ||
url: jsonApi.Joi.string().uri().meta("readonly").description("This attribute cannot be created nor modified by a user"); | ||
``` | ||
If you look through the example json:api resources in the `/example/resources` folder things should become clearer. | ||
### Post Processing | ||
The following examples can be demo'd via the example json:api implentation launched via `npm start`. | ||
#### Inclusions | ||
To include `author` and `tags` relations of `articles`: | ||
http://localhost:16006/rest/articles?include=author,tags | ||
To include `author`, `author`.`photos` and `tags` relations of `articles`: | ||
http://localhost:16006/rest/articles?include=author.photos,tags | ||
#### Filtering | ||
To only show `articles` where the `title` attribute is exactly `mySpecificTitle`: | ||
http://localhost:16006/rest/articles?filter[title]=mySpecificTitle | ||
To only show `articles` where the `title` attribute is before `M` alphabetically: | ||
http://localhost:16006/rest/articles?filter[title]=<M | ||
To only show `articles` where the `title` attribute is after `M` alphabetically: | ||
http://localhost:16006/rest/articles?filter[title]=>M | ||
To only show `articles` where the `title` attribute is a case-insensitive match against `linux-rocks`: | ||
http://localhost:16006/rest/articles?filter[title]=~linux-rocks | ||
To only show `articles` where the `title` attribute contains `for`: | ||
http://localhost:16006/rest/articles?filter[title]=:for | ||
To only show included `authors``photos` where the `photos` `width` is greater than 500: | ||
http://localhost:16006/rest/articles?include=author.photos&filter[author][photos][width]=>500 | ||
#### Fields | ||
To only bring back `articles` `title` and `content` fields: | ||
http://localhost:16006/rest/articles?fields[articles]=title,content | ||
To only bring back `articles` `title` and `content` fields, and `photos` `url` fields: | ||
http://localhost:16006/rest/articles?include=photos&fields[articles]=title,content&fields[photos]=url | ||
#### Sorting | ||
To sort `articles` `DESC` by `title`: | ||
http://localhost:16006/rest/articles?sort=-title | ||
To sort `articles` `ASC` by `title`: | ||
http://localhost:16006/rest/articles?sort=+title | ||
### Automatically Generating Documentation | ||
Watch this space. | ||
### The Relationship Challenge | ||
We want to be able to back our resources in any datastore we like, in any system we like. This means we can't rely on any database layer relationships to join resources. | ||
#### Problem: supporting back-links | ||
In other words, each resource would maintain its own linkage. | ||
Consider these resources: | ||
``` | ||
Bookings have people: | ||
"HPABCDE": { | ||
owner: "Dave" | ||
} | ||
People have Bookings: | ||
"Dave": { | ||
bookings: [ "HPABCDE" ] | ||
} | ||
"Fred": { | ||
bookings: [ ] | ||
} | ||
``` | ||
If I were to update the owner of HPABCDE to "Fred": | ||
PATCH -> /bookings/HPABCDE/relationships/owner | ||
then the dataset would look like this: | ||
``` | ||
Bookings have people: | ||
"HPABCDE": { | ||
owner: "Fred" | ||
} | ||
People have Bookings: | ||
"Dave": { | ||
bookings: [ "HPABCDE" ] | ||
} | ||
"Fred": { | ||
bookings: [ ] | ||
} | ||
``` | ||
Notice the reverse linkage between bookings and people is now broken. We could get around this by automatically forcing other update requests: | ||
``` | ||
PATCH -> /bookings/HPABCDE/relationships/owner | ||
---- becomes | ||
PATCH -> /bookings/HPABCDE/relationships/owner | ||
PATCH -> /people/Dave/relationships/bookings | ||
PATCH -> /people/Fred/relationships/bookings | ||
``` | ||
All 3x requests combined will produce the desired end linkage: | ||
``` | ||
Bookings have people: | ||
"HPABCDE": { | ||
owner: "Dave" | ||
} | ||
People have Bookings: | ||
"Dave": { | ||
bookings: [ ] | ||
} | ||
"Fred": { | ||
bookings: [ "HPABCDE" ] | ||
} | ||
``` | ||
The number of requests vary on the relationship (1-1, 1-many, many-many, many-1). | ||
Consider now if one of the 3 updates fails - we need to rollback the other 2. We now have to do all of this: | ||
``` | ||
PATCH -> /bookings/HPABCDE/relationships/owner | ||
---- becomes | ||
GET -> /bookings/HPABCDE/relationships/owner | ||
GET -> /people/Dave/relationships/bookings | ||
GET -> /people/Fred/relationships/bookings | ||
PATCH -> /bookings/HPABCDE/relationships/owner | ||
PATCH -> /people/Dave/relationships/bookings | ||
PATCH -> /people/Fred/relationships/bookings | ||
[if one fails...] | ||
PATCH -> /bookings/HPABCDE/relationships/owner | ||
PATCH -> /people/Dave/relationships/bookings | ||
``` | ||
There's also now a racehazard whereby two people try to update the same resource and mess up the relations. This is solvable by passing in a checksum to the PATCH requests, allowing us to identify if a resource has been remotely modified. The checksum would need to be returned as a part of the JSON:API formatted response, alongside "id" and "type". | ||
In a nutshell - all of the above is a terrible idea. | ||
#### Solution: we only support forward-links | ||
This: | ||
``` | ||
Bookings have people: | ||
"HPABCDE": { | ||
owner: "Dave" | ||
} | ||
People have Bookings: | ||
"Dave": { | ||
bookings: [ ] | ||
} | ||
"Fred": { | ||
bookings: [ "HPABCDE" ] | ||
} | ||
``` | ||
becomes: | ||
``` | ||
Bookings have people: | ||
"HPABCDE": { | ||
owner: "Dave" | ||
} | ||
People don't know about their Bookings: | ||
"Dave": { | ||
} | ||
"Fred": { | ||
} | ||
``` | ||
HOWEVER, we still want to maintain the reverse linkage, so we'll still offer up a linkage like this: | ||
``` | ||
/rest/people/26aa8a92-2845-4e40-999f-1fa006ec8c63/bookings | ||
``` | ||
although under the hood, we'll re-map it to something like this: | ||
``` | ||
/rest/bookings/relationships/?customer=26aa8a92-2845-4e40-999f-1fa006ec8c63 | ||
``` | ||
and query for `bookings` where `customer=?`. | ||
The main gotcha here is this in this situation: | ||
``` | ||
Bookings maintain links to People | ||
People maintain links to Trips | ||
Trips maintain links to Bookings | ||
``` | ||
Looking up a person creates a reverse lookup against Bookings, creating a reverse lookup against Trips, causing a reverse lookup against People, creating a reverse lookup against.... | ||
Our solution here is to add `meta` blocks to relationships to inform the consumer what kind of linage they are looking at, and to not provide foreign keys directly: | ||
``` | ||
relationships: { | ||
author: { | ||
meta: { | ||
relation: "primary", | ||
readOnly: false | ||
}, | ||
links: { | ||
self: "/rest/comments/6b017640-827c-4d50-8dcc-79d766abb408/relationships/author", | ||
related: "/rest/comments/6b017640-827c-4d50-8dcc-79d766abb408/author" | ||
}, | ||
data: { | ||
type: "people", | ||
id: "ad3aa89e-9c5b-4ac9-a652-6670f9f27587" | ||
} | ||
}, | ||
article: { | ||
meta: { | ||
relation: "foreign", | ||
belongsTo: "articles", | ||
readOnly: true | ||
}, | ||
links: { | ||
// get information about the linkage - list of ids and types | ||
self: "/rest/articles/relationships/?comments=6b017640-827c-4d50-8dcc-79d766abb408", | ||
// get full details of all linked resources (perform a search against the foreign key) | ||
related: "/rest/articles/?relationships[comments]=6b017640-827c-4d50-8dcc-79d766abb408" | ||
} | ||
} | ||
} | ||
``` | ||
the example implementation can be found [here](example) |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
136381
54
3127
58
+ Addedarray-flatten@1.1.0(transitive)
+ Addedasync@1.3.02.6.4(transitive)
+ Addedbody-parser@1.13.2(transitive)
+ Addedbytes@2.4.0(transitive)
+ Addedcaseless@0.10.0(transitive)
+ Addedcombined-stream@1.0.8(transitive)
+ Addedcookie@0.1.3(transitive)
+ Addeddelayed-stream@1.0.0(transitive)
+ Addeddepd@1.1.2(transitive)
+ Addeddestroy@1.0.4(transitive)
+ Addedee-first@1.1.1(transitive)
+ Addedescape-html@1.0.21.0.3(transitive)
+ Addedetag@1.7.0(transitive)
+ Addedexpress@4.13.1(transitive)
+ Addedextend@2.0.2(transitive)
+ Addedfinalhandler@0.4.0(transitive)
+ Addedform-data@1.0.1(transitive)
+ Addedfresh@0.3.0(transitive)
+ Addedhttp-errors@1.3.1(transitive)
+ Addedhttp-signature@0.11.0(transitive)
+ Addediconv-lite@0.4.110.4.13(transitive)
+ Addedjoi@6.5.0(transitive)
+ Addedlodash@4.17.21(transitive)
+ Addedoauth-sign@0.8.2(transitive)
+ Addedon-finished@2.3.0(transitive)
+ Addedpath-to-regexp@0.1.6(transitive)
+ Addedqs@3.1.04.0.0(transitive)
+ Addedraw-body@2.1.7(transitive)
+ Addedrequest@2.58.0(transitive)
+ Addedsend@0.13.00.13.2(transitive)
+ Addedserve-static@1.10.3(transitive)
+ Addedstatuses@1.2.11.5.0(transitive)
+ Addedunpipe@1.0.0(transitive)
- Removedasync@0.9.2(transitive)
- Removedbody-parser@1.12.4(transitive)
- Removedbytes@1.0.0(transitive)
- Removedcaseless@0.9.0(transitive)
- Removedcombined-stream@0.0.7(transitive)
- Removedcookie@0.1.2(transitive)
- Removedcrc@3.2.1(transitive)
- Removeddelayed-stream@0.0.5(transitive)
- Removedee-first@1.1.0(transitive)
- Removedescape-html@1.0.1(transitive)
- Removedetag@1.6.0(transitive)
- Removedexpress@4.12.4(transitive)
- Removedfinalhandler@0.3.6(transitive)
- Removedform-data@0.2.0(transitive)
- Removedfresh@0.2.4(transitive)
- Removedhttp-signature@0.10.1(transitive)
- Removediconv-lite@0.4.8(transitive)
- Removedjoi@6.4.1(transitive)
- Removedoauth-sign@0.6.0(transitive)
- Removedon-finished@2.2.1(transitive)
- Removedpath-to-regexp@0.1.3(transitive)
- Removedqs@2.4.2(transitive)
- Removedraw-body@2.0.2(transitive)
- Removedrequest@2.55.0(transitive)
- Removedsend@0.12.3(transitive)
- Removedserve-static@1.9.3(transitive)
Updatedasync@1.3.0
Updatedbody-parser@1.13.2
Updatedexpress@4.13.1
Updatedjoi@6.5.0
Updatedrequest@2.58.0