Joint Kit
A Node server library & development kit for rapidly implementing data layers and RESTful endpoints.
Designed to be flexible. Mix it with existing code -or- use it to
generate an entire server-side method library and isomorphic HTTP API from scratch.
DB model configuration, robust CRUD and relational data logic, resource-level & user-level authorization, field validation,
data transformation, paginated & non-paginated datasets, rich error handling, payload serialization,
HTTP router generation (for RESTful endpoints), and more.
WIP
Not ready for public use until version 0.1.0 - Syntax and logic are in frequent flux.
Table of Contents
Installation
Overview
API
Guide
Examples
License
Prerequisites
To use the Joint Kit, you need:
- a supported persistence solution (e.g. Postgres)
- a configured data schema (e.g. database & tables)
- a supported service interface / ORM
The Joint Kit currently supports:
---
If you wish to generate RESTful API endpoints, you need:
- a supported server framework
The Joint Kit currently supports:
Server | Required Middleware |
---|
Express | body-parser, cookie-parser |
Install
$ npm install joint-kit --save
The Joint Concept (In Code)
[TBC]
Joint in Practice
To implement solutions with the Joint Kit, you create Joints.
A Joint connects to:
The Joint Kit provides a set of data actions that are abstracted to handle common data operations for your resources. Actions are implemented using a config-like JSON syntax, making development simple and quick.
Leverage the built-in features to satisfy 100% of your required functionality, or use them as a base to augment with your own specialized logic.
Create a Joint
import express from 'express';
import Joint from 'joint-kit';
import bookshelf from './services/bookshelf';
const joint = new Joint({
service: bookshelf,
server: express,
});
---
Define Data Models
You can continue configuring data models with your service natively, or you can dynamically generate them with Joint using a JSON descriptor:
/model-config.js
export default {
models: {
Profile: {
tableName: 'blog_profiles',
timestamps: { created: 'created_at', updated: 'updated_at' },
associations: {
user: {
type: 'toOne',
path: 'user_id => User.id',
},
posts: {
type: 'toMany',
path: 'id => BlogPost.profile_id',
},
tags: {
type: 'toMany',
path: 'id => ProfileTag.profile_id => ProfileTag.tag_id => Tag.id',
},
},
},
},
};
Use the generate
function to dynamically build and register your models:
import modelConfig from './model-config';
joint.generate({ modelConfig });
if (joint.model.Profile) console.log('The Profile model exists !!!');
---
Create a Method Library
From the provided set of abstract data actions (Joint Actions), you can quickly implement a customized method library.
You can hand-roll the methods yourself:
Hand-rolling a CRUD set of methods
/methods/profile.js
export function createProfile(input) {
const spec = {
modelName: 'Profile',
fields: [
{ name: 'user_id', type: 'Number', required: true },
{ name: 'slug', type: 'String', required: true },
{ name: 'title', type: 'String', required: true },
{ name: 'tagline', type: 'String' },
{ name: 'is_live', type: 'Boolean', defaultValue: false },
],
};
return joint.createItem(spec, input);
}
export function updateProfile(input) {
const spec = {
modelName: 'Profile',
fields: [
{ name: 'id', type: 'Number', required: true, lookup: true },
{ name: 'slug', type: 'String' },
{ name: 'title', type: 'String' },
{ name: 'tagline', type: 'String' },
{ name: 'is_live', type: 'Boolean' },
],
};
return joint.updateItem(spec, input);
}
export function getProfile(input) {
const spec = {
modelName: 'Profile',
fields: [
{ name: 'id', type: 'Number', requiredOr: true },
{ name: 'slug', type: 'String', requiredOr: true },
],
};
return joint.getItem(spec, input);
}
export function getProfiles(input) {
const spec = {
modelName: 'Profile',
fields: [
{ name: 'user_id', type: 'Number' },
{ name: 'is_live', type: 'Boolean' },
],
};
return joint.getItems(spec, input);
}
export function deleteProfile(input) {
const spec = {
modelName: 'Profile',
fields: [
{ name: 'id', type: 'Number', requiredOr: true },
{ name: 'slug', type: 'String', requiredOr: true },
],
};
return joint.deleteItem(spec, input);
}
-OR-
You can dynamically generate them from a JSON descriptor:
/method-config.js
export default {
resources: [
{
modelName: 'Profile',
methods: [
{
name: 'createProfile',
action: 'createItem',
spec: {
fields: [
{ name: 'user_id', type: 'Number', required: true },
{ name: 'slug', type: 'String', required: true },
{ name: 'title', type: 'String' },
{ name: 'tagline', type: 'String' },
{ name: 'is_live', type: 'Boolean', defaultValue: false },
],
},
},
{
name: 'getProfiles',
action: 'getItems',
spec: {
fields: [
{ name: 'user_id', type: 'Number', requiredOr: true },
{ name: 'is_live', type: 'String', requiredOr: true },
],
defaultOrderBy: '-created_at,title',
},
},
],
},
],
};
Use the generate
function to dynamically generate your methods:
import methodConfig from './method-config';
joint.generate({ methodConfig });
const input = {
fields: { is_live: true },
};
joint.method.Profile.getProfiles(input)
.then((result) => { ... })
.catch((error) => { ... });
---
Create RESTful Endpoints
On top of your Joint methods, you can easily expose a RESTful API layer.
You can hand-roll the router logic yourself:
-OR-
You can dynamically generate a router from a JSON descriptor:
/route-config.js
export default {
routes: [
{
uri: '/profile',
post: { method: 'Profile.createProfile', successStatus: 201, body: true },
},
{
uri: '/profiles',
get: { method: 'Profile.getProfiles' },
},
],
};
Use the generate
function to dynamically generate your router logic:
import routeConfig from './route-config';
joint.generate({ routeConfig });
const app = express();
app.use('/api', joint.router);
Joint Constructor
The Joint Kit module is an instantiable Class. Its instances are Joints.
Multiple Joint instances can be utilized within a single application.
Example Joint Instantiation:
import express from 'express';
import Joint from 'joint-lib';
import bookshelf from './services/bookshelf';
const joint = new Joint({
service: bookshelf,
server: express,
output: 'json-api',
});
Constructor Options
Name | Description | Required? |
---|
service | The configured service instance for your persistence solution. | Yes |
server | The server instance for your HTTP router handling. | No |
output | The format of the returned data payloads. (defaults to 'native' ) | No |
settings | The configurable settings available for a Joint instance. | No |
service
[TBC]
server
[TBC]
output
[TBC]
settings
[TBC]
Joint Instance
When a Joint has been instantiated, the following properties and functions are available on the instance:
Properties
Name | Description |
---|
service | The underlying service implementation (for persistence) provided at instantiation. |
serviceKey | A string value identifying the persistence service being used. |
server | The underlying server implementation, if configured. |
serverKey | A string value identifying the server being used. null if not configured. |
output | The string value for the globally configured output format. 'native' by default. |
settings | The active settings of the instance. |
modelConfig | The active "model config" descriptor, if provided with the generate function. |
methodConfig | The active "method config" descriptor, if provided with the generate function. |
routeConfig | The active "route config" descriptor, if provided with the generate function. |
Operational Functions
Function | Description |
---|
generate( options ) | Executes the dynamic generation of models, methods, and routes, per the config descriptors provided. |
setServer( server ) | Allows configuration of the server implementation, post-instantiation. |
setOutput( output ) | Allows configuration of the output format, post-instantiation. |
updateSettings( settings ) | Allows modification of the Joint settings, post-instantiation. |
<action>( spec, input, output ) | The action logic provided by the Joint instance. This is the backbone of the solution. See Joint Actions for the full list and usage details. |
Convenience Functions
Function | Description |
---|
info( ) | |
Generated Models
Syntax | Description |
---|
model.<modelName> | The registered Model object with name <modelName>. Any existing Models registered to the service instance will be mixed-in with those generated by Joint. |
Generated Methods
Syntax | Description |
---|
method.<modelName>.<methodName>( input ) | |
Generated Router
Registries/Lookups
Name | Description |
---|
model.<modelName> | Accesses the registered Model object with name <modelName>. |
modelByTable.<tableName> | Accesses the registered Model object by its <tableName>. |
modelNameByTable.<tableName> | Accesses the registered Model name by its <tableName>. |
specByMethod.<modelName>.<methodName> | Accesses the configured spec definition for a generated method by its <modelName>.<methodName> syntax. |
Joint Actions
The Joint Action set is the backbone of the Joint Kit solution.
Each Joint instance provides a robust set of abstract data actions that hook
directly to your persistence layer, handling the core logic for common data operations.
The actions are implemented using a config-like JSON syntax.
---
Base Actions (CRUD)
Action | Description |
---|
createItem | Create operation for a single item. |
upsertItem | Upsert operation for a single item. |
updateItem | Update operation for a single item. |
getItem | Read operation for retrieving a single item. |
getItems | Read operation for retrieving a collection of items. |
deleteItem | Delete operation for single item. |
Association Actions (Relational)
Action | Description |
---|
addAssociatedItems | Operation for associating one to many items of a type to a main resource. |
hasAssociatedItem | Operation for checking the existence of an association on a main resource. |
getAllAssociatedItems | Operation for retrieving all associations of a type from a main resource. |
removeAssociatedItems | Operation for disassociating one to many items of a type from a main resource. |
removeAllAssociatedItems | Operation for removing all associations of a type from a main resource. |
Joint Action Syntax
All Joint Actions return Promises, and have the signature:
joint.<action>(spec = {}, input = {}, output = 'native')
.then((payload) => { ... })
.catch((error) => { ... });
---
Each action has two required parts: the spec
and the input
.
Each action also supports the optional parameter: output
.
- The
output
➔ specifies the format of the returned payload.
Spec Options
Option | Description | Type | Actions Supported | Required? |
---|
modelName | The model name of the resource for the action. | String | (all) | Yes |
fields | The root property for defining accepted fields. | Array | (all) | Yes (*except getItems) |
fields.name | The field name. | String | (all) | Yes |
fields.type | The field data type. | String | (all) | Yes |
fields.required | If the field is required for the action. | Boolean | (all) | No |
fields.requiredOr | If the field is required for the action (within an OR set). | Boolean | (all) | No |
fields.lookup | Denotes the field is required to pre-fetch the resource before an update can occur. | Boolean | (all) | Yes for upsertItem, updateItem |
fields.lookupOr | Denotes the field is required (within an OR set) to pre-fetch the resource before an update can occur. | Boolean | (all) | Yes for upsertItem, updateItem |
fields.defaultValue | The default value to persist, if the field is not provided in the input. | Mixed | createItem, upsertItem, getItem | No |
fieldsToReturn | The fields to return with the payload. Returns all fields when not provided. | Array -or- Object | getItem, getItems | No |
defaultOrderBy | The default sort order for a collection payload. | String | getItems | No |
forceAssociations | Binds the associations to return for all action requests. | Array | getItem, getItems | No |
forceLoadDirect | Binds the loadDirect associations to return for all action requests. | Array | getItem, getItems | No |
auth | The root property for defining authorization on the action. | Object | (all) | No |
auth.ownerCreds | Specifies the fields that can verify resource ownership (for owner auth rules). | Array | (all) | No |
main | The root property to wrap the spec options for the main resource, in an association action. | Object | (association actions) | Yes |
association | The root property to wrap the spec options for the associated resource, in an association action. | Object | (association actions) | Yes |
modelName
[TBC]
fields
[TBC]
fieldsToReturn
[TBC]
defaultOrderBy
[TBC]
forceAssociations
[TBC]
forceLoadDirect
[TBC]
auth
[TBC]
main
[TBC]
association
[TBC]
Input Options
Option | Description | Actions Supported | Required? |
---|
fields | | (all) | Yes (* except getItems) |
fieldSet | | getItem, getItems | No |
associations | | getItem, getItems | No |
loadDirect | | getItem, getItems | No |
orderBy | | getItems | No |
paginate | | getItems | No |
trx | | (all) | No |
authBundle | | (all) | No |
fields
[TBC]
fieldSet
[TBC]
associations
[TBC]
loadDirect
[TBC]
orderBy
[TBC]
paginate
[TBC]
trx
[TBC]
authBundle
[TBC]
Output Values
The output
value configures the format of the returned payload.
NOTE: This setting can be configured globally on the Joint instance itself. (See the Joint Instance API)
Value | Description |
---|
'native' | Returns the queried data in the format generated natively by the service. This is the default setting. |
'json-api' | Transforms the data into a JSON API Spec-like format, making the data suitable for HTTP transport. |
---
output = 'native' (default)
By default, the output is set to 'native'
, which effectively returns the queried data in the format generated natively by the service you are using.
Item Example:
Joint.getItem
const spec = {
modelName: 'Profile',
fields: [
{ name: 'id', type: 'Number', required: true },
],
};
const input = {
fields: { id: 1 },
associations: ['user'],
};
joint.getItem(spec, input, 'native')
.then((payload) => { ... })
.catch((error) => { ... });
**Returns:**
Item payload ( Bookshelf )
{
cid: 'c1',
_knex: null,
id: 1,
attributes: {
id: 1,
user_id: 333,
slug: 'functional-fanatic',
title: 'Functional Fanatic',
tagline: 'I don\'t have habits, I have algorithms.',
is_live: false,
},
_previousAttributes: { ... },
changed: {},
relations: {
user: {
cid: 'c2',
id: 333,
attributes: {
display_name: '|M|',
username: 'manicprone',
sites: [
{ gitlab: 'https://gitlab.com/manicprone' },
{ github: 'https://github.com/manicprone' },
],
},
_previousAttributes: { ... },
changed: {},
relations: {},
relatedData: { ... },
},
},
}
Collection Example:
Joint.getItems
const spec = {
modelName: 'Profile',
};
const input = {
associations: ['user'],
paginate: { skip: 0, limit: 3 },
};
joint.getItems(spec, input, 'native')
.then((payload) => { ... })
.catch((error) => { ... });
**Returns:**
Collection payload ( Bookshelf )
output = 'json-api'
When the output is set to 'json-api'
, the returned payload is transformed into a JSON API Spec-like format, making it suitable for HTTP data transport.
Item Example:
Joint.getItem
const spec = {
modelName: 'Profile',
fields: [
{ name: 'id', type: 'Number', required: true },
],
};
const input = {
fields: { id: 1 },
associations: ['user'],
};
joint.getItem(spec, input, 'json-api')
.then((payload) => { ... })
.catch((error) => { ... });
**Returns:**
Item payload
{
data: {
type: 'Profile',
id: 1,
attributes: {
user_id: 333,
slug: 'functional-fanatic',
title: 'Functional Fanatic',
tagline: 'I don\'t have habits, I have algorithms.',
is_live: false,
},
relationships: {
user: {
data: {
type: 'User',
id: 333,
},
},
},
},
included: [
{
type: 'User',
id: 333,
attributes: {
display_name: '|M|',
username: 'manicprone',
sites: [
{ gitlab: 'https://gitlab.com/manicprone' },
{ github: 'https://github.com/manicprone' },
],
},
},
],
}
Collection Example:
Joint.getItems
const spec = {
modelName: 'Profile',
};
const input = {
associations: ['user'],
paginate: { skip: 0, limit: 3 },
};
joint.getItems(spec, input, 'json-api')
.then((payload) => { ... })
.catch((error) => { ... });
**Returns:**
Collection payload
Joint Action Errors
[TBC]
Joint Action Authorization
[TBC]
Model Config Syntax
[TBC]
Method Config Syntax
[TBC]
Route Config Syntax
[TBC]
Defining Data Models
[TBC]
You can continue to define data models using your service implementation, or you can dynamically generate them with Joint. Both approaches are supported simultaneously. Any existing models registered to your service instance will be mixed-in with those generated by the Joint instance.
Building a Method Library
[TBC]
Building a RESTful API
[TBC]
Example Solutions
Writing a custom Express Router:
import express from 'express';
import Joint from 'joint-kit';
import bookshelf from './services/bookshelf';
import modelConfig from './model-config';
import methodConfig from './method-config';
const joint = new Joint({
service: bookshelf,
output: 'json-api',
});
joint.generate({ modelConfig, methodConfig });
const router = express.Router();
router.route('/user')
.post((req, res) => {
const input = {};
input.fields = Object.assign({}, req.body, req.query);
return joint.method.User.createUser(input)
.then(payload => handleDataResponse(payload, res, 201))
.catch(error => handleErrorResponse(error, res));
});
router.route('/user/:id')
.get((req, res) => {
const input = {
fields: {
id: req.params.id,
},
loadDirect: ['profile:{title,tagline,avatar_url,is_live}', 'roles:name'],
associations: ['friends'],
};
return joint.method.User.getUser(input)
.then(payload => handleDataResponse(payload, res, 200))
.catch(error => handleErrorResponse(error, res));
})
.post((req, res) => {
const input = {};
input.fields = Object.assign({}, req.body, req.query, { id: req.params.id });
return joint.method.User.updateUser(input)
.then(payload => handleDataResponse(payload, res, 200))
.catch(error => handleErrorResponse(error, res));
})
.delete((req, res) => {
const input = {
fields: {
id: req.params.id,
},
};
return joint.method.User.deleteUser(input)
.then(payload => handleDataResponse(payload, res, 204))
.catch(error => handleErrorResponse(error, res));
});
router.route('/users')
.get((req, res) => {
const input = {};
input.fields = Object.assign({}, req.query);
input.loadDirect = ['profile:{title,tagline,avatar_url,is_live}', 'roles:name'];
input.associations = ['friends'],
return joint.method.User.getUsers(input)
.then(payload => handleDataResponse(payload, res, 200))
.catch(error => handleErrorResponse(error, res));
});
function handleDataResponse(data, res, status = 200) {
res.status(status).json(data);
}
function handleErrorResponse(error, res) {
const status = error.status || 500;
res.status(status).json(error);
}
module.exports = router;
License
[TBD]