Intermediate Topics
Defining Resources in JavaScript
Although JavaScript does not currently support decorators, it's still possible to write Synapse applications without using TypeScript. Decorators in Synapse are a form of "syntactic sugar" used to a) add fields to the static instance of Schema on a Resource-derived class, and b) to convert its endpoint methods to instances of Controller—a special type of callable object. We can achieve the same functionality by a) defining the static schema
property manually, and b) using the static controller
method on the Resource base class to create instances of Controller.
The controller
method accepts two arguments:
- An object containing any of the following keys:
endpoint
,authorizer
,schema
,instance
,uses
,affects
(Note that these correspond to the available decorator functions). - A function to be exposed by the API.
In this way, our example User
class can be rewritten to the following equivalent version in JavaScript:
const { Resource, fields, decorators } = require('@synapsejs/synapse');
const { Id, Word, Email, Text } = fields;
const { field, schema, expose } = decorators;
class User extends Resource {
static schema = new Schema({
_id: new Id(24, 24),
username: new Username(3, 16),
email: new Email(),
password: new Text()
});
static find = User.controller(
{
endpoint: 'GET /:_id',
schema: User.schema.select('_id')
},
async find({ _id }) {
const document = await collection.findById({ _id });
if (!document) {
return State.NOT_FOUND();
}
return User.restore(document.toObject());
}
);
static register = User.controller(
{
endpoint: 'POST /',
schema: User.schema.exclude('_id', 'password').extend({ password: new Hash(6) })
},
async register({ username, email, password }) {
const document = await collection.create({ username, email, password });
return User.create(document.toObject());
}
);
}
Schema Transformation
In Synapse, schema transformation refers to the process of using existing schemas to define new ones, and it can help to keep our code more concise. We've already seen two examples of schema transformation in our hypothetical User
class:
User.schema.select('_id')
and:
User.schema.exclude('_id', 'password').extend({ password: new Hash(6) })
- In each case, we begin by accessing the static
schema
property on the User class, which is an instance of Schema. Remember that the User schema was created by applying the@field
decorator to the class's properties. - In the first example, we use the
select
method of the Schema class to create and return a new schema containing only the fields from the User schema whose names are passed in as arguments—in this case, just the_id
field. - In the second example, we first use the
exclude
method to create a new schema containing all of the fields from the User schema except the fields whose names are passed in as arguments—in this case, the_id
andpassword
fields. Finally, we transform the resulting schema using theextend
method on the Schema class to create a new schema containing all of the fields of the called instance, plus those passed in on the argument object.
There are three other ways to transform schemas:
- The
default
method on the Schema class creates a copy of the called instance, but applies default values to the fields as specified by the passed in argument object. - The
flags
method on the Schema class creates a copy of the called instance, but overrides the existing flags as specified by the passed in argument object. See the Field API documentation for more details. - The static
union
method on the Resource class creates a new schema by combining the fields of multiple Resource schema's.
See the Schema API documentation for more details.
Custom Field Types
One of the most powerful features of Synapse is the ability to define custom Field types by extending existing ones. In our hypothetical User class, we used an instance of the Id
Field type with a min and max length of 24 characters to define a property on the User class that would accept id strings generated by MongoDB. Indeed, MongoDB ids are 24 characters long, but they also have another well-defined property that we can enforce: they can contain only the the letters a-f and digits 0-9.
-
Start by importing the default fields from the Synapse library and destructuring the
Id
class from the object. Then, define a new class calledMongoId
which extendsId
.import { fields } from '@synapsejs/synapse'; const { Id } = fields; export default class MongoId extends Id { }
-
In the constructor, after invoking
super
, use theassert
method from theText
prototype to add a new regular expression rule to the instance. Theassert
method accepts 3 arguments: 1) an instance ofRegExp
(or a string which can be used to construct one), 2) a boolean representing the expected result of testing a valid input against the the regular expression, and 3) a human-readable explanation of the rule which will be used in error messages.import { fields } from '@synapsejs/synapse'; const { Id } = fields; export default class MongoId extends Id { constructor(flags = null) { super(); this.assert( /^[0-9a-f]{24}$/i, true, 'must be a string of 24 hex characters' ); } }
-
We can now change the
_id
field on the User schema to use this more-explicit type:import MongoId from '../fields/MongoId'; ... @field(new MongoId()) _id;
- Because our method schemas were defined by transforming the overall User schema, we don't need to change their definitions.
Whenever possible, Field rules should be defined in this way, using regular expressions, because these rules can be easily exposed to a client for use in client-side validation. However, not all rules can be represented as a regular expression. In such cases, it will be neccessary to override the parse
method from the Field
prototype in order to define more complex validation behavior. See the Field API documentation for more details.
Authorization and Security
Private Fields
You may have noticed a security flaw our User example—when instances of User are returned from the API, all of their properties are revealed to the client, including the hashed password. There are two ways to change the public representation of a resource. In most cases, we can use a flag to prevent certain fields from being exposed to the client.
-
In the User module, import the
Field
class from the Synapse Libary and destructure thePRV
flag from theField.Flags
enum object.import { Resource, Field, fields, decorators } from '@synapsejs/synapse'; const { Id, Word, Email, Text } = fields; const { field, schema, expose } = decorators; const { PRV } = Field.Flags;
-
Add the
PRV
flag to the password field. This can be done in two ways:- As the fourth argument to the
Text
constructor
@field(new Text(null, null, undefined, PRV)) password;
- Or, as the second argument to the
@field
decorator method
@field(new Text(), PRV) password;
- As the fourth argument to the
In some cases, if more complex behavior is required, it will be necessary to override the render
method from the Resource prototype. See the Resource API documentation for more details.
Middleware
In most cases, we will also want to add some type of authorization layer to our APIs. This can be accomplished using middlware functions. Consider the following hypothetical Session class. For the purpose of this example, sessions will contain just two properties: a client id and a user id, and will be stored in memory. It will also expose two endpoints: POST /session
creates a session from a username, password, and client id, while GET /session/me
returns information about the currently authenticated user, given a client id.
NOTE: While middleware functions in Express are used to improve code reusability and modularization, that is not their purpose in Synapse. Synapse relies on an object-oriented approach to code reuse, as shown in the below example, where the Session class reuses functionality of the User class by invoking one of its methods.
import { Resource, State, fields, decorators } from '@synapsejs/synapse';
import User from './User';
const { Id } = fields;
const { field, expose, schema } = decorators;
const sessions = {};
export default class Session extends Resource {
@field(new Id(36)) client_id: string;
@field(new Id(36)) user_id: string;
@endpoint('POST ./')
@schema(Session.union(User).select('username', 'password', 'client_id'))
static async open({ username, password, client_id }) {
const result = await User.authenticate({ username, password });
if (result instanceof User) {
sessions[client_id] = result;
}
return result;
}
@endpoint('GET ./me')
@schema(Session.schema.select('client_id'))
static async read({ client_id }) {
return sessions[client_id];
}
}
But where will the client_id
come from? In this example, we will assign the client_id
using cookies, but the endpoint method doesn't have to know this. Since Synapse provides a protocol-agnostic abstraction of a REST API, there is no concept of query paramaters, request bodies, or cookies within the Resource domain—all request arguments are combined into a single object and passed to the endpoint's schema for validation. Thus, the above schemas simply look for a client_id
property anywhere on the incoming request, and then check that it meets the requirements specified by the associated field.
-
You might use an Express middleware function like the following to assign a unique id to all new clients, before they have been authenticated:
import { v4 as uuidv4 } from 'uuid'; export const identify = (req, res, next) => { if (!req.cookies.client_id) { res.cookie('client_id', req.cookies.client_id = uuidv4()); } next(); };
-
Now, in our Session module, lets define a Synapse middleware function which will authorize incoming requests by a) ensuring that the
client_id
property is present on the request, and b) that theclient_id
is associated with a valid session.export const authorize = (args) => { const { client_id } = args; const client = sessions[client_id]; if (!client) { return State.UNAUTHORIZED(); } return [args]; };
- Note that Synapse middleware functions are different from Express middleware functions; Synapse middleware functions accept a single argument—an amalgamation of all request arguments including the request body, query params, path params, and cookies. Synapse middlware functions should return an array containing the argument object for the next middleware function in the chain, or an instance of
State
to abort the operation.
- Note that Synapse middleware functions are different from Express middleware functions; Synapse middleware functions accept a single argument—an amalgamation of all request arguments including the request body, query params, path params, and cookies. Synapse middlware functions should return an array containing the argument object for the next middleware function in the chain, or an instance of
-
Finally, lets secure the
GET /session/me
endpoint by passing theauthorize
Synapse middleware function to the@authorizer
decorator. (The@authorizer
decorator can also accept a variable number arguments, all middleware functions to be executed in order).@endpoint('GET ./me') @authorizer(authorize) @schema(Session.schema.select('client_id')) static async read({ client_id }) { return sessions[client_id]; }
The completed Session module should look something like this:
import { Resource, State, fields, decorators } from '@synapsejs/synapse';
import User from './User';
const { Id } = fields;
const { field, expose, schema } = decorators;
const sessions = {};
export const identify = (req, res, next) => {
res.cookie('client_id', req.cookies.client_id || uuidv4());
next();
};
export const authorize = (args) => {
const { client_id } = args;
const client = sessions[client_id];
if (!client) {
return State.UNAUTHORIZED();
}
return [args];
};
export default class Session extends Resource {
@field(new Id(36)) client_id: string;
@field(new Id(36)) user_id: string;
@endpoint('POST ./')
@schema(Session.union(User).select('username', 'password', 'client_id'))
static async open({ username, password, client_id }) {
const result = await User.authenticate({ username, password });
if (result instanceof User) {
sessions[client_id] = result;
}
return result;
}
@endpoint('GET ./me')
@authorizer(authorize)
@schema(Session.schema.select('client_id'))
static async read({ client_id }) {
return sessions[client_id];
}
}
Resource Dependencies
Whenever a read (i.e. GET
) request is made to a path within a Synapse API, the resulting State will have certain dependencies—that is, other paths to which a write request will cause said State to be invalidated. All non-error States have at least one dependency, which is the same path from which the State was read. This is demonstrated by the following example:
- Client A
GET
s the value of/message
and receives a collection of Message resources [/message/0
,/message/1
,/message/2
]. - Client B
POST
s to/message
, creating new message. - The number of messages in the collection has changed, so client A's copy of the the state is no longer valid.
In the case of a collection, the paths of each Resource contained within that collection are also dependencies of its state. That is:
- If Client B were to
PATCH /message/0
, that request would also invalidate the state of/message
held by client A.
This default behavior is inherent to RESTful systems and cannot be changed within Synapse; however, it can be extended. Lets consider a complete example of a Message
resource. In this example, the messages will be stored in memory and we will intend for them to be subscribed to in a real-time application.
import { Resource, State, fields, decorators } from '@synapsejs/synapse';
const { Id, Text, Integer } = fields;
const { field, expose, schema, affects, uses } = decorators;
const pageSize = 10;
const ledger = [];
export default class Message extends Resource {
@field(new Id()) id: string;
@field(new Text()) text: string;
@endpoint('GET ./')
static get() {
const start = ledger.length - pageSize * index;
return Message.collection([...ledger].reverse());
}
@endpoint('POST ./')
@schema(Message.schema.select('text'))
static async post({ text }) {
const comment = await Message.create({ id: `${ledger.length}`, text });
ledger.push(comment.export());
return comment;
}
}
This bare-bones example could be used to create a chat application. A subscription request to /message
would return all of the messages in memory, and whenever a new message was posted, the client would receive the entire new state of the collection. Obviously, this wouldn't be very efficient. Let's improve the design by adding an endpoint which returns only the last message in memory:
import { Resource, State, fields, decorators } from '@synapsejs/synapse';
const { Id, Text, Integer } = fields;
const { field, expose, schema, affects, uses } = decorators;
const ledger = [];
export default class Message extends Resource {
@field(new Id()) id: string;
@field(new Text()) text: string;
@endpoint('GET ./last')
static last() {
if (!ledger[ledger.length - 1]) {
return State.NOT_FOUND();
}
return Message.restore(ledger[ledger.length - 1]);
}
@endpoint('GET ./')
static get() {
return Message.collection([...ledger].reverse());
}
@endpoint('POST ./')
@schema(Message.schema.select('text'))
static async post({ text }) {
const comment = await Message.create({ id: `${ledger.length}`, text });
ledger.push(comment.export());
return comment;
}
}
Now, our clients can initially request the entire message collection, but then subscribe to only the last message in the collection. However, as currently written, they will not receive live updates to that state, because Synapse can't automatically deduce the dependency between the state returned from /message/last
and the overall /message
path. We will have to declare this dependency using either the @uses
decorator on the read endpoint:
@endpoint('GET ./last')
@uses('./')
static last() {
...
}
or the @affects
decorator on the write endpoint:
@endpoint('POST ./')
@schema(Message.schema.select('text'))
@affects('./last')
static async post({ text }) {
...
}
These are functionally equivalent in this case, but note that the paths passed to @uses
and @affects
can reference arguments validated by the schema (e.g. @uses('/:id)
), which may affect your decision to use one or the other in a given scenario.
Clustering
Synapse supports clustering natively. To synchronize state between multiple instances of a Synapse API, you will need to add two arguments to the invocation of synapse
in your Express server file;
const resources = path.resolve(__dirname, './resources');
const accept = [
// an array containing the IP addresses of all peer servers
];
const join = [
// an array containing the WebSocket connection URIs of all peer servers
];
const api = synapse(resources, accept, join);
Now, the instance of the Synapse API will attempt to connect to all peers using the WebSocket URIs in join
and will accept peer connections from any IP address in accept
. Note that it is also acceptable to invoke Synapse with Promises that resolve to these arrays if the peer servers have to be discovered dynamically.