@ebryn/jsonapi-ts
v0.1.46
Published
> _We need a better name!_
Downloads
64
Readme
jsonapi-ts
We need a better name!
This is a TypeScript framework to create APIs following the 1.1 Spec of JSONAPI + the Operations proposal spec.
Table of contents
- Features
- Getting started
- Data flow
- Resources
- Operations
- Transport layers
- Processors
- Serialization
- Authentication and authorization
- The JSONAPI application
- Extending the framework
Features
- Operation-driven API: JSONAPI is transport-layer independent, so it can be used for HTTP, WebSockets or any transport protocol of your choice.
- Declarative style for resource definition: Every queryable object in JSONAPI is a resource, a representation of data that may come from any source, be it a database, an external API, etc. JSONAPI-TS defines these resources in a declarative style.
- CRUD database operations: Baked into JSONAPI-TS, there is an operation processor which takes care of basic CRUD actions by using Knex. This allows the framework to support any database engine compatible with Knex. Also includes support for filtering fields by using common SQL operators, sorting and pagination.
- Transport layer integrations: The framework supports JSONAPI operations via WebSockets, and it includes a middleware for Koa and another for Express to automatically add HTTP endpoints for each declared resource and processor.
- Relationships and sideloading: Resources can be related with
belongsTo
/hasMany
helpers on their declarations. JSONAPI-TS provides proper, compliant serialization to connect resources and even serve them all together on a single response. - Error handling: The framework includes some basic error responses to handle cases equivalent to HTTP status codes 401 (Unauthorized), 403 (Forbidden), 404 (Not Found) and 500 (Internal Server Error).
- User/Role presence authorization: By building on top of the decorators syntax, JSONAPI-TS allows you to inject user detection on specific operations or whole processors. The framework uses JSON Web Tokens as a way of verifying if a user is valid for a given operation.
- Extensibility: Both resources and processors are open classes that you can extend to suit your use case. For example, do you need to serialize an existing, external API into JSONAPI format? Create a
MyExternalApiProcessor
extending fromOperationProcessor
and implement the necessary calls et voilà!.
Getting started
ℹ️ The following examples are written in TypeScript.
Install
jsonapi-ts
withnpm
oryarn
:$ npm i @ebryn/jsonapi-ts
$ yarn add @ebryn/jsonapi-ts
Create a Resource:
// resources/author.ts import { Resource } from "@ebryn/jsonapi-ts"; export default class Author extends Resource { static schema = { attributes: { firstName: String, lastName: String }, relationships: {} }; }
Create an Application and inject it into your server. For example, let's say you've installed Koa in your Node application and want to expose JSONAPI via HTTP:
import { Application, jsonApiKoa as jsonApi, KnexProcessor } from "@ebryn/jsonapi-ts"; import Koa from "koa"; import Author from "./resources/author"; const app = new Application({ namespace: "api", types: [Author], defaultProcessor: new KnexProcessor(/* knex options */) }); const api = new Koa(); api.use(jsonApi(app)); api.listen(3000);
Run the Node app, open a browser and navigate to
http://localhost:3000/api/authors
. You should get an empty response like this:{ "data": [], "included": [] }
Add some data to the "authors" table and go back to the previous URL. You'll start seeing your data!
{ "data": [ { "id": 1, "type": "author", "attributes": { "firstName": "John", "lastName": "Katzenbach" } } ], "included": [] }
Data flow
This diagram represents how a full request/response cycle works with JSONAPI-TS:
Resources
What is a resource?
A resource can be understood as follows:
Any information that can be named can be a resource: a document or image, a temporal service (e.g. “today’s weather in Los Angeles”), a collection of other resources, a non-virtual object (e.g. a person), and so on. In other words, (...) A resource is a conceptual mapping to a set of entities (...).
A resource is comprised of:
A unique identifier
It distinguishes a given resource from another. Usually it manifests as auto-incremental integer numbers, GUIDs or UUIDs.
A type
A type is a human-readable name that describes the kind of entity the resource represents.
A list of attributes
An attribute is something that helps describe different aspects of a resource. For example, if we're creating a Book resource, some possible attributes would be its title, its year of publication and its price.
A list of relationships
A resource can exist on its own or be expanded through relations with other resources. Following up on our Book resource example, we could state that a book belongs to a certain author. That author could be described as a resource itself. On a reverse point of view, we could also state than an author has many books.
Declaring a resource
This is how our Book resource would look like, without relationships:
// resources/book.ts
import { Resource } from "@ebryn/jsonapi-ts";
import User from "./user";
import Comment from "./comment";
export default class Book extends Resource {
// The *type* is usually inferred automatically from the resource's
// class name. Nonetheless, if we need/want to, we can override it.
static get type(): string {
return "libraryBook";
}
// Every field we declare in a resource is contained in a *schema*.
// A schema comprises attributes and relationships.
static schema = {
primaryKeyName: "_id",
// The primary key for each resource is by default "id", but you can overwrite that default
// with the primaryKeyName property
attributes: {
// An attribute has a name and a primitive type.
// Accepted types are String, Number and Boolean.
title: String,
yearOfPublication: Number,
price: Number
},
relationships: {
author: {
type: () => User,
belongsTo: true,
foreignKeyName: "authorId"
},
comments: {
type: () => Comment,
hasMany: true
// for hasMany relationships declarations, the FK is in the related object, so it's
// recommendable to assign a custom FK. In this case, assuming that we use the default serializer,
// the fk name would be "book_id". Read more below this example.
}
}
};
}
Relationship Declarations
Any number of relationships can be declared for a resource. Each relationship must have a type function which returns a Class, the kind of relationship, which can be belongsTo or hasMany, and
The expected foreign key depends on the serializer, and the type of relationship, which can be customized, but on a belongsTo relationship, the default FK is ${relationshipName}_${primaryKeyName}
. And for a hasMany relationship, the default FK name expected in the related resource is ${baseType}_${primaryKeyName}
.
A relationship should be defined on its two ends. For example, on the example, with the above code in the Book resource definition, a GET request to books?include=author
, would include in the response the related user for each book, but for the inverse filter, in the User resource schema definition, we should include:
static schema = {
attributes: { /* ... */ },
relationships: {
// ...,
books: {
type: () => Book,
hasMany: true,
foreignKeyName: "authorId"
},
}
};
For a GET request to users?include=books
to include the books related to each user.
Declaring a relationship is necessary to parse each resource and return a JSONAPI-compliant response. Also, it gives the API the necessary information so that the
include
clause works correctly.
Accepted attribute types
The JSONAPI spec restricts any attribute value to "any valid JSON value".
JSONAPI-TS supports the following primitive types: String
, Number
, Boolean
, Array
and Object
. null
is also a valid value.
Dates are supported in the ISO 8601 format (YYYY-MM-DDTHH:MM:SS.sss
+ the time zone).
⚠️ While we support arrays and objects, you might want to reconsider and think of those array/object items as different resources of the same type and relate them to the parent resource.
Operations
What is an operation?
An operation is an action that affects one or more resources.
Every operation is written in JSON, and contains the following properties:
op
: the type of action to execute (see Operation types just below this section).ref
: a reference to a resource or kind of resource.id
: the unique identifier of the affected resource.type
: the affected resource's type
data
: a Resource object to be written into the data store.params
: a key/value object used to configure how resources should be fetched from the data store. See theget
operation
The JSONAPI spec defines four elemental operations:
- get: Retrieves a list of resources. Can be filtered by
id
or any definedattribute
. - add: Inserts a new resource in the data store.
- remove: Removes a resource from the data store.
- update: Edits one or more attributes of a given resource and saves the changes in the data store.
You can define your own operations as well. See the Processors section below.
The get
operation
A get
operation can retrieve:
all resources of a given type:
// Get all books. { "op": "get", "ref": { "type": "book" } }
a subset of resources of given type which satisfy a certain criteria:
// Get all books with a price greater than 100. { "op": "get", "ref": { "type": "book" }, "params": { "filter": { "price": "gt:100" } } }
a single, uniquely identified resource of a given type:
// Get a single book. { "op": "get", "ref": { "type": "book", "id": "ef70e4a4-5016-467b-958d-449ead0ce08e" } }
The following filter operations are supported:
| Operator | Comparison type |
| -------- | ---------------------------------------------------------------------------- |
| eq
| Equals |
| ne
| Not equals |
| lt
| Less than |
| gt
| Greater than |
| le
| Less than or equal |
| ge
| Greater than or equal |
| like
| like:%foo%
: Containslike:foo%
: Starts withlike:%foo
: Ends with |
| in
| Value is in a list of possible values |
| nin
| Value is not in a list of possible values |
Results can also be sorted, paginated or partially retrieved using params.sort
, params.page
and params.fields
respectively:
// Get the first 5 books' name, sorted by name.
{
"op": "get",
"ref": {
"type": "book"
},
"params": {
"sort": ["name"],
"fields": ["name"],
"page": {
"number": 0,
"size": 5
}
}
}
Also, if the resource being retrieved is related to other resources, it's possible to sideload the related resources using params.include
:
// Get all books and their respective authors.
{
"op": "get",
"ref": {
"type": "book"
},
"params": {
"include": ["author"]
}
}
The response, if successful, will be a list of one or more resources, mathing the specified parameters.
The add
operation
An add
operation represents the intent of writing a new resource of a given type into the data store.
// Add a new book. Notice that by default, you don't need
// to provide an ID. JSONAPI-TS can generate it automatically.
// Also, we're relating this new resource to an existing
// "author" resource.
{
"op": "add",
"ref": {
"type": "book"
},
"data": {
"type": "book",
"attributes": {
"title": "Learning JSONAPI",
"yearPublished": 2019,
"price": 100.0
},
"relationships": {
"author": {
"data": {
"type": "author",
"id": "888a7106-c797-4b22-b31e-0244483cf108"
}
}
}
}
}
The response, if successful, will be a single resource object, with either a generated id
or the id
provided in the operation.
The update
operation
An update
operation represents the intent of changing some or all of the data for an existing resource of a given type from the data store.
// Increase the price of "Learning JSONAPI" to 200.
{
"op": "update",
"ref": {
"type": "book",
"id": "ef70e4a4-5016-467b-958d-449ead0ce08e"
},
"data": {
"type": "book",
"id": "ef70e4a4-5016-467b-958d-449ead0ce08e",
"attributes": {
"price": 200.0
}
}
}
The response, if successful, will be a single resource object, reflecting the changes the update
operation requested.
The delete
operation
A delete
operation represents the intent to destroy an existing resources in the data store.
// Remove the "Learning JSONAPI" book.
{
"op": "remove",
"ref": {
"type": "book",
"id": "ef70e4a4-5016-467b-958d-449ead0ce08e"
}
}
The response, if successful, will be typeof void
.
Running multiple operations
JSONAPI-TS supports a bulk mode that allows the execution of a list of operations in a single request. This mode is exposed differently according to the transport layer (see the next section for more details on this).
A bulk request payload is essentially a wrapper around a list of operations:
{
"meta": {
// ...
},
"operations": [
// Add a book.
{
"op": "add",
"ref": {
"type": "book"
},
"data": {
// ...
}
},
// Adding an author...
{
"op": "add",
"ref": {
"type": "author"
},
"data": {
// ...
}
},
// Getting all authors.
{
"op": "get",
"ref": {
"type": "author"
}
}
// ...and maybe even more stuff.
]
}
Transport layers
JSONAPI-TS is built following a decoupled, modular approach, providing somewhat opinionated methodologies regarding how to make the API usable to consumers.
HTTP Protocol
Currently, for HTTP, we support integrating with Koa and Express by providing jsonApiKoa
and jsonApiExpress
middlewares, respectively, that can be imported and piped through your Koa or Express server, along with other middlewares.
ℹ️ Most examples in the docs use the jsonApiKoa middleware, but it's up to you which one you use.
Using jsonApiKoa
As seen in the Getting started section, once your JSONAPI application is instantiated, you can simply do:
// Assume `api` is a Koa server,
// `app` is a JSONAPI application instance.
api.use(jsonApiKoa(app));
Using jsonApiExpress
Like in the previous example, to pipe the middleware you can simply do:
// Assume `api` is an Express server,
// `app` is a JSONAPI application instance.
api.use(jsonApiExpress(app));
Converting operations into HTTP endpoints
Both jsonApiKoa
and jsonApiExpress
take care of mapping the fundamental operation types (get
, add
, update
, remove
) into valid HTTP verbs and endpoints.
This is the basic pattern for any endpoint:
<verb> /:type[/:id][?params...]
Any operation payload is parsed as follows:
| Operation property | HTTP property | Comments |
| ------------------ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| op
| HTTP verb | get
=> GET
add
=> POST
update
=> PUT
remove
=> DELETE
|
| data
, included
| HTTP body | Any resources are returned into the response body. |
| ref.type
| :type
| Type is inflected into its plural form, so book
becomes books
. |
| ref.id
| :id
| ID is used to affect a single resource. |
| params.*
| ?params...
| Everything related to filters, sorting, pagination,partial resource retrieval and sideloadingis expressed as query parameters. |
Request/response mapping
The following examples show HTTP requests that can be converted into JSONAPI operations.
Any operation can return the following error codes:
400 Bad Request
: the operation is malformed and cannot be executed.404 Not Found
: the requested resource does not exist.401 Unauthorized
: the request resource/operation requires authorization.403 Forbidden
: the request's credentials do not have enough privileges to execute the operation.500 Internal Server Error
: an operation crashed and didn't execute properly.
get
operations
# Get all books.
GET /books
# Get all books with a price greater than 100.
GET /books?filter[price]=gt:100
# Get a single book.
GET /books/ef70e4a4-5016-467b-958d-449ead0ce08e
GET /books?filter[id]=ef70e4a4-5016-467b-958d-449ead0ce08e
# Get the first 5 book names, sorted by name.
GET /books?fields[book]=name&page[number]=0&page[size]=5&sort=name
# Skip 2 books, then get the next 5 books.
GET /books?page[offset]=2&page[limit]=5
The middleware, if successful, will respond with a 200 OK
HTTP status code.
add
operations
# Add a new book.
POST /books
Content-Type: application/json; charset=utf-8
{
"data": {
"type": "book",
"attributes": {
"title": "Learning JSONAPI",
"yearPublished": 2019,
"price": 100.0
},
"relationships": {
"author": {
"data": {
"type": "author",
"id": "888a7106-c797-4b22-b31e-0244483cf108"
}
}
}
}
}
The middleware, if successful, will respond with a 201 Created
HTTP status code.
update
operations
# Increase the price of "Learning JSONAPI" to 200.
PUT /books/ef70e4a4-5016-467b-958d-449ead0ce08e
Content-Type: application/json; charset=utf-8
{
"data": {
"type": "book",
"id": "ef70e4a4-5016-467b-958d-449ead0ce08e",
"attributes": {
"price": 200.0
}
}
}
The middleware, if successful, will respond with a 200 OK
HTTP status code.
delete
operations
# Remove the "Learning JSONAPI" book.
DELETE /books/ef70e4a4-5016-467b-958d-449ead0ce08e
The middleware, if successful, will respond with a 204 No Content
HTTP status code.
Bulk operations in HTTP
Both jsonApiKoa
and jsonApiExpress
expose a /bulk
endpoint which can be used to execute multiple operations. The request must use the PATCH
method, using the JSON payload shown earlier.
WebSocket Protocol
The framework supports JSONAPI operations via WebSockets, using the ws
package.
We recommend installing the
@types/ws
package as well to have the proper typings available in your IDE.
Using jsonApiWebSocket
The wrapper function jsonApiWebSocket
takes a WebSocket.Server
instance, bound to an HTTP server (so you can combine it with either the jsonApiKoa
or jsonApiExpress
middlewares), and manipulates the Application
object to wire it up with the connection
and message
events provided by ws
.
So, after instantiating your application, you can enable WebSockets support with just a couple of extra lines of code:
import { Server as WebSocketServer } from "ws";
import { jsonApiWebSocket } from "@ebryn/jsonapi-ts";
// Assume an app has been configured with its resources
// and processors, etc.
// .
// .
// .
// Also, assume `httpServer` is a Koa server,
// `app` is a JSONAPI application instance.
httpServer.use(jsonApiKoa(app));
// Create a WebSockets server.
const ws = new WebSocketServer({ server: httpServer });
// Let JSONAPI-TS connect your API.
jsonApiWebSocket(ws, app);
Executing operations over sockets
Unlike its HTTP counterpart, jsonApiWebSocket
works with bulk requests. Since there's no need for a RESTful protocol, you send and receive raw operation payloads.
Processors
What is a processor?
A processor is responsable of executing JSONAPI operations for certain resource types. If you're familiar with the Model-View-Controller pattern, processor can be somewhat compared to the C
in MVC
.
JSONAPI-TS includes two built-in processors:
- an abstract
OperationProcessor
which defines an API capable of executing the fundamental operations on a resource; - a concrete
KnexProcessor
, which is a Knex-powered DB-capable implementation of theOperationProcessor
.
The OperationProcessor
class
This class defines the basic API any processor needs to implement.
Each operation type is handled by a separate async function, which receives the current operation payload as an argument, and returns either a list of resources of a given type, a single resource of that type or nothing at all.
class OperationProcessor<ResourceT> {
async get(op: Operation): Promise<ResourceT[]>;
async remove(op: Operation): Promise<void>;
async update(op: Operation): Promise<ResourceT>;
async add(op: Operation): Promise<ResourceT>;
}
Also, the OperationProcessor
exposes an app
property that allows access to the JSONAPI application instance.
How does an operation gets executed?
Any operation is the result of a call to a method named executeOperations
, which lives in the JSONAPI application instance.
By default, the OperationProcessor
only offers the methods' signature for every operation, but does not implement any of them. So, for example, for a get
operation to actually do something, you should extend from this class and write some code that in its return value, returns a list of resources of a given type.
Let's assume for example your data source is the filesystem. For each type
, you have a subdirectory in a data
directory, and for each resource, you have a JSON file with a filename of any UUID value.
You could implement a generic ReadOnlyProcessor
with something like this:
import { OperationProcessor, Operation } from "@ebryn/jsonapi-ts";
import { readdirSync, readFileSync } from "fs";
import { resolve as resolvePath, basename } from "path";
export default class ReadOnlyProcessor extends OperationProcessor<Resource> {
async get(op: Operation): Promise<Resource[]> {
const files = readdirSync(resolvePath(__dirname, `data/${op.ref.type}`));
return files.map(file => ({
type: op.ref.type,
id: basename(file),
attributes: JSON.parse(readFileSync(file).toString()),
relationships: {}
}));
}
}
Controlling errors while executing an operation
What happens if in the previous example something goes wrong? For example, a record in our super filesystem-based storage does not contain valid JSON? We can create an error response using try/catch and JsonApiErrors
:
import { OperationProcessor, Operation, JsonApiErrors, Resource } from "@ebryn/jsonapi-ts";
import { readdirSync, readFileSync } from "fs";
import { resolve as resolvePath, basename } from "path";
export default class ReadOnlyProcessor extends OperationProcessor<Resource> {
async get(op: Operation): Promise<Resource[]> {
const files = readdirSync(resolvePath(__dirname, `data/${op.ref.type}`));
return files.map((file: string) => {
try {
const attributes = JSON.parse(readFileSync(file).toString());
return {
type: op.ref.type,
id: basename(file),
attributes,
relationships: {}
};
} catch {
throw JsonApiErrors.UnhandledError("Error while reading file");
}
});
}
}
ℹ️ Notice that you can provide details (like in the previous example) but it's not mandatory.
You can also create an error by using the JsonApiError
type:
// Assumes you've imported HttpStatusCodes and JsonApiError
// from @ebryn/jsonapi-ts.
throw {
// At the very least, you must declare a status and a code.
status: HttpStatusCode.UnprocessableEntity,
code: "invalid_json_in_record"
} as JsonApiError;
The full JsonApiError type supports the following properties:
id
: A unique identifier to this error response. Useful for tracking down a problem via logs.title
: A human-readable, brief summary of what went wrong.detail
: A human-readable, expanded information about the specifics of the error.source
: A reference to locate the code block that triggered the error.pointer
: An expression to point towards the point of failure. It can be anything useful for a developer to track down the problem. Common examples arefilename.ext:line:col
orfilename.ext:methodName()
.parameter
: If the failure occured at a specific method and it's triggered due to a bad parameter value, you can set here which parameter was badly set.
Extending the OperationProcessor
class
Our ReadOnlyProcessor class is a fair example of how to extend the OperationProcessor in a generic way. What if we want to build a resource-specific, OperationProcessor
-derived processor?
Let's assume we have a Moment
resource:
import { Resource } from "@ebryn/jsonapi-ts";
export default class Moment extends Resource {
static schema = {
attributes: {
date: String,
time: String
},
relationships: {}
};
}
All you need to do is extend the Processor, set the generic type to Moment
, and bind the processor to the resource:
import { OperationProcessor, Operation } from "@ebryn/jsonapi-ts";
import Moment from "../resources/moment";
export default class MomentProcessor extends OperationProcessor<Moment> {
// This property binds the processor to the resource. This way the JSONAPI
// application knows how to resolve operations for the `Moment`
// resource.
public resourceClass = Moment;
// Notice that the return type is `Moment` and not a generic.
async get(op: Operation): Promise<Moment[]> {
const now = new Date();
const id = now.valueOf().toString();
const [date] = now.toJSON().split("T");
const [, time] = now
.toJSON()
.replace(/Z/g, "")
.split("T");
return [
{
type: "moment",
id,
attributes: {
date,
time
},
relationships: {}
}
];
}
}
Using computed properties in a processor
In addition to whatever attributes you declare in a resource, you can use a custom processor to extend it with computed properties.
Every processor derived from OperationProcessor
includes an attributes
object, where you can define functions to compute the value of the properties you want the resource to have:
// Let's create a Comment resource.
class Comment extends Resource {
static schema = {
text: String;
}
static relationships = {
// Assume we also have a Vote resource.
vote: {
type: () => Vote,
hasMany: true
}
}
}
// And a CommentProcessor to handle it.
class CommentProcessor<T extends Comment> extends KnexProcessor<T> {
public static resourceClass = Comment;
attributes = {
// You can define computed properties with simple logic in them...
async isLongComment(this: CommentProcessor<Comment>, comment: HasId) {
return comment.text.length > 100;
},
// ...or more, data-driven ones.
async voteCount(this: CommentProcessor<Comment>, comment: hasId) {
const processor = this.processorFor("vote") as KnexProcessor<Vote>;
const [result] = await processor
.getQuery()
.where({ comment_id: comment.id })
.count();
return result["count(*)"];
}
}
}
Any computed properties you define will be included in the resource on any operation. Do not declare these computed properties in the resource's schema, as JSONAPI-TS will interpret them as columns in a table and fail due to non-existing columns.
The KnexProcessor
class
This processor is a fully-implemented, database-driven extension of the OperationProcessor
class seen before. It takes care of creating the necessary SQL queries to resolve any given operation.
It maps operations to queries like this:
| Operation | SQL command |
| --------- | ---------------------------------------------------- |
| get
| SELECT
, supporting WHERE
, ORDER BY
and LIMIT
|
| add
| INSERT
, supporting RETURNING
|
| update
| UPDATE
, supporting WHERE
|
| remove
| DELETE
, supporting WHERE
|
| | |
It receives a single argument, options
, which is passed to the Knex
constructor. See the Knex documentation for detailed examples.
In addition to the operation handlers, this processor has some other methods that you can use while making custom operations. Note that all operations use these functions, so tread carefully here if you're interested in overriding them.
| Method | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| getQuery
| Returns an instance of Knex.QueryBuilder, scoped to the table specified by tableName
(the processor resource's data source) |
| tableName
| Returns the table name for the resource handled by the processor |
Using these two methods and the standard Knex functions, you can extend a processor anyway you want to.
Extending the KnexProcessor
class
Like the OperationProcessor
class, the KnexProcessor
can be extended to support custom operations. Suppose we want to count how many books an author has. We could implement a count()
method.
import { KnexProcessor, Operation } from "@ebryn/jsonapi-ts";
import { Book, BookCount } from "./resources";
export default class BookProcessor extends KnexProcessor<Book> {
async count(op: Operation): Promise<BookCount> {
return {
type: "bookCount",
attributes: {
count: (await super.get(op)).length
},
relationships: {}
};
}
}
The call to super.get(op)
allows to reuse the behavior of the KnexProcessor and then do other actions around it.
ℹ️ Naturally, there are better ways to do a count. This is just an example to show the extensibility capabilities of the processor.
ℹ️ A thing to remember, is that neither JsonApiKoa nor JsonApiExpress will parse the custom operations into endpoints, so to reach the custom operation from a HTTP request, you should use the bulk endpoint (or a JsonApiWebsockets operation), or execute the operation inside some of the default methods of the processor (with an if inside a GET, for example).
Serialization
When converting a request or an operation into a database query, there are several transformations that occur in order to match attribute and type names to column and table names, respectively.
The JsonApiSerializer class
This class implements the default serialization behavior for the framework through several functions.
Let's use our Book resource as an example.
| Function | Description | Default behaviour | Example |
| ------------------------------- | ------------------------------------------------ | --------------------------------------- | ------------------------------------------------- |
| resourceTypeToTableName()
| Transforms a type name into a table name. | underscore
then pluralize
| book
=> books
comicBook
=> comic_books
|
| attributeToColumn()
| Converts an attribute name into a column name. | underscore
| datePublished
=> date_published
|
| relationshipToColumn()
| Converts a relationship type into a column name. | underscore(type + primaryKeyName)
| authorId
=> author_id
|
| columnToAttribute()
| Transforms a column name into an attribute name. | camelize
| date_published
=> datePublished
|
| columnToRelationship()
| Converts a column name into a relationship type. | camelize(columnName - primaryKeyName)
| author_id
=> author
|
Extending the serializer
You can modify the serializer's behavior to adapt to an existing database by overriding the previously described functions and then passing it to the App:
serializer.ts
import {
JsonApiSerializer,
camelize, capitalize, classify, dasherize, underscore, pluralize, singularize
} from "@ebyrn/jsonapi-ts";
export default MySerializer extends JsonApiSerializer {
// Overrides here...
}
app.ts
// ...
import MySerializer from "./serializer";
// ...
const app = new Application({
// ...other settings...
serializer: MySerializer // Pass the serializer here.
});
JSONAPI-TS exports the following string utilities:
| Function | Example |
| ----------------- | ---------------------------------------- |
| camelize
| camelized text
=> camelizedText
|
| capitalize
| capitalized text
=> Capitalized Text
|
| classify
| classified text
=> Classified text
|
| dasherize
| dasherized text
=> dasherized-text
|
| underscore
| underscored text
=> underscored_text
|
| pluralize
| book
=> books
|
| singularize
| books
=> book
|
Authentication and authorization
JSONAPI-TS supports authentication and authorization capabilities by using JSON Web Tokens. Basically, it allows you to allow or deny operation execution based on user/role presence in a token.
For this feature to work, you'll need at least to:
- Declare an
User
resource - Apply the
@Authorize
decorator and theIfUser()
helper where necessary - Apply the
UserManagement
addon to your application - Have your front-end send requests with an
Authorization
header
Defining an User
resource
A minimal, bare-bones declaration of an User
resource could look something like this:
import { User as JsonApiUser, Password } from "@ebryn/jsonapi-ts";
export default class User extends JsonApiUser {
static schema = {
attributes: {
username: String,
emailAddress: String,
passphrase: Password
},
relationships: {}
};
}
Note that the resource must extend from JsonApiUser
instead of Resource
.
⚠️ Be sure to mark sensitive fields such as the user's password with the
Password
type! This prevents the data in those fields to be leaked through the transport layer.
Using the @Authorize
decorator
Now, for any processor you have in your API, for example, our BookProcessor, we can use @Authorize
to reject execution if there's no user detected:
import { KnexProcessor, Operation, Authorize } from "@ebryn/jsonapi-ts";
import { Book } from "./resources";
export default class BookProcessor extends KnexProcessor<Book> {
// This operation will return an `Unauthorized` error if there's
// no user in the JSONAPI application instance.
@Authorize()
async get(op: Operation): Promise<Book[]> {
// You can use `this.app.user` to get user data.
console.log(`User ${this.app.user.id} is reading data`);
return super.get(op);
}
}
Using the UserManagement
addon
In order to put all the pieces together, JSONAPI-TS provides an addon to manage both user and session concerns.
You'll need to define at least two functions:
A
login
callback which allows a user to identify itself with their credentials. Internally, it receives anadd
operation for thesession
resource and an attribute hash containing user data. This callback must return a boolean and somehow compare if the user and password (or whatever identification means you need) are a match:// Assume `hash` is a function that takes care of hashing a plain-text // password with a given salt. export default async function login(op: Operation, user: ResourceAttributes) { return ( op.data.attributes.email === user.email && hash(op.data.attributes.password, process.env.SESSION_KEY) === user.password ); }
An
encryptPassword
callback which takes care of transforming the plain-text password when the API receives a request to create a new user. Internally, it receives anadd
operation for theuser
resource. This callback must return an object containing a key with the column name for your password field, with a value of an encrypted version of your password, using a cryptographic algorithm of your choice:// Assume `hash` is a function that takes care of hashing a plain-text // password with a given salt. export default async function encryptPassword(op: Operation) { return { password: hash(op.data.attributes.password, process.env.SESSION_KEY) }; }
Optionally, you can define a generateId
callback, which must return a string with a unique ID, used when a new user is being registered. An example of it could be:
// This is not production-ready.
export default async function generateId() {
return Date.now().toString();
}
Once you've got these functions, you can apply the UserManagementAddon
like this:
// ...other imports...
import { UserManagementAddon, UserManagementAddonOptions } from "@ebryn/jsonapi-ts";
import { login, encryptPassword, generateId } from "./user-callbacks";
import User from "./resources/user";
// ...app definition...
app.use(UserManagementAddon, {
userResource: User,
userLoginCallback: login,
userEncryptPasswordCallback: encryptPassword,
userGenerateIdCallback: generateId // optional
} as UserManagementAddonOptions);
If you don't want to use loose functions like this, you can create a UserProcessor
that implements these functions and pass it to the addon options as userProcessor
:
// Note that MyVeryOwnUserProcessor extends from JSONAPI-TS's own UserProcessor.
import { UserProcessor, Operation } from "@ebryn/jsonapi-ts";
import User from "./resources/user";
export default class MyVeryOwnUserProcessor<T extends User> extends UserProcessor<T> {
protected async generateId() {
return Date.now().toString();
}
protected async encryptPassword(op: Operation) {
// Assume `hash` is a function that takes care of hashing a plain-text
// password with a given salt.
return {
password: hash(op.data.attributes.password, process.env.SESSION_KEY)
};
}
// Login is not here because it's part of the Session resource's operations.
}
Then, you can simply do:
app.use(UserManagementAddon, {
userResource: User,
userProcessor: MyVeryOwnUserProcessor,
userLoginCallback: login
} as UserManagementAddonOptions);
Configuring roles and permissions
This framework provides support for more granular access control via roles and permissions. These layers allow to fine-tune the @Authorize
decorator to more specific conditions.
In order to enable this feature, you'll need to supply two additional callbacks, called providers: userRolesProvider
and userPermissionsProvider
. These functions operate with the scope of an ApplicationInstance
and receive a User
resource; they must return an array of strings, containing the names of the roles and permissions, respectively.
👆️ Depending on your data sources, you might need to define a
Role
and aPermission
resource.
For example, a role provider could look like this:
role-provider.ts
import { ApplicationInstance, User } from "@ebryn/jsonapi-ts";
export default async function roleProvider(this: ApplicationInstance, user: User): Promise<string[]> {
const userRoleProcessor = this.processorFor("userRole");
return (await roleProcessor
.getQuery()
.where({ user_id: user.id })
.select("role_name")).map(record => record["role_name"]);
}
This will inject the roles into the ApplicationInstance object, specifically in appInstance.user.data.attributes.roles
and appInstance.user.data.attributes.permissions
. Note that these two special attributes are only available in the context of the @Authorize
decorator. They won't be part of any JSONAPI response.
Once you've defined your providers, you can pass them along the rest of the UserManagementAddon options:
app.use(UserManagementAddon, {
userResource: User,
userProcessor: MyVeryOwnUserProcessor,
userLoginCallback: login,
userRolesProvider: roleProvider,
userPermissionsProvider: permissionsProvider
} as UserManagementAddonOptions);
Using the IfUser-*
helpers
You might want to restrict an operation to a specific subset of users who match a certain criteria. For that purpose, you can augment the @Authorize
decorator with the IfUser()
helper:
import { KnexProcessor, Operation, Authorize, IfUser } from "@ebryn/jsonapi-ts";
import { Book } from "./resources";
export default class BookProcessor extends KnexProcessor<Book> {
// This operation will return an `Unauthorized` error if there's
// no user with the role "librarian" in the JSONAPI application
// instance.
@Authorize(IfUser("role", "librarian"))
async get(op: Operation): Promise<Book[]> {
return super.get(op);
}
}
These are the available helpers:
| Helper | Parameters | Description |
| --------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| IfUser
| attribute
(string)value
: any primitive value/array | Checks if a user's attribute matches at least one of the provided values. |
| IfUserDoesNotMatches
| attribute
(string)value
: any primitive value/array | Checks if a user's attribute does not matches any of the provided values. |
| IfUserMatchesEvery
| attribute
(string)value
: any primitive value/array | Checks if a user's attribute matches every single one of the provided values. |
| IfUserHasRole
| roleName
(string, string[]) | Checks if a user has at least one of the provided roles. |
| IfUserHasEveryRole
| roleNames
(string[]) | Checks if a user has all of the provided roles. |
| IfUserDoesNotHaveRole
| roleName
(string, string[]) | Checks if a user has none of the provided roles. |
| IfUserHasPermission
| permissionName
(string, string[]) | Checks if a user has at least one of the provided permissions. |
| IfUserHasEveryPermission
| permissionNames
(string[]) | Checks if a user has all of the provided permissions. |
| IfUserDoesNotHavePermission
| permissionName
(string, string[]) | Checks if a user has none of the provided permissions. |
Front-end requirements
In order for authorization to work, whichever app is consuming the JSONAPI exposed via HTTP will need to send the token created with the SessionProcessor
in an Authorization
header, like this:
Authorization: Bearer JWT_HASH_GOES_HERE
For authorization with websockets, the token should be provided inside a meta object property, like this:
{
"meta":{
"token":"JWT_HASH_GOES_HERE"
},
"operations":[...]
}
The JSONAPI Application
The last piece of the framework is the Application
object. This component wraps and connects everything we've described so far.
What is a JSONAPI application?
It's what orchestrates, routes and executes operations. In code, we're talking about something like this:
import { Application, jsonApiKoa as jsonApi, KnexProcessor } from "@ebryn/jsonapi-ts";
import Koa from "koa";
import Author from "./resources/author";
// This is what any transport layer like jsonApiKoa will use
// to process all operations.
const app = new Application({
namespace: "api",
types: [Author],
defaultProcessor: new KnexProcessor(/* knex options */)
});
const api = new Koa();
api.use(jsonApi(app));
api.listen(3000);
The Application
object is instantiated with the following settings:
namespace
: Used in HTTP transport layers. It prefixes the resource URI with a string. If set, the base URI pattern is:namespace/:type/:id
. If not, it goes straight to:type/:id
.types
: A list of all resource types declared and handled by this app.processors
: If you define custom processors, they have to be registered here as instances.defaultProcessor
: All non-bound-to-processor resources will be handled by this processor.
Referencing types and processors
This is how you register your resources and processors in an application:
// Assumes all necessary imports are in place.
const app = new Application({
namespace: "api",
types: [Author, Book, BookCount],
processors: {
new BookProcessor(/* processor args */)
}
});
Using a default processor
If you do not need custom processors, you can simply declare your resources and have them all work with a built-in processor:
const app = new Application({
namespace: "api",
types: [Author, Book, BookCount],
defaultProcessor: new KnexProcessor(/* db settings */)
});
Extending the framework
Beyond the fact that JSONAPI-TS allows you to extend any of its primitives, the framework provides a simple yet effective way of injecting custom behavior with an addon system.
What is an addon?
An addon is a piece of code that is aware of a JSONAPI Application object that can be tweaked externally, without subclassing it directly.
You can build an addon by deriving a new class extending from the Addon
primitive type:
export default class MyAddon extends Addon {
constructor(public readonly app: Application, public readonly options?: MyAddonOptions) {
super(app, options);
}
async install() {}
}
You're required to implement an async method called install()
, which will take care of any manipulation you intend to apply through the addon.
You can inject resources and processors or alter any element of the public API.
You can take a look at the UserManagementAddon provided with the framework as a blueprint for building your own addons.
Using an addon
Once you've finished working on your addon, you can use with your JSONAPI Application following a similar pattern to those of HTTP middlewares:
import { MyAddon, MyAddonOptions } from "./my-addon";
// Assume `app` is a JSONAPI {Application} object.
app.use(MyAddon, {
// Addon options.
foo: 3
} as MyAddonOptions);