Skip to content

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:

  1. An object containing any of the following keys: endpoint, authorizer, schema, instance, uses, affects (Note that these correspond to the available decorator functions).
  2. 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 and password fields. Finally, we transform the resulting schema using the extend 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:

  1. 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.
  2. 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.
  3. 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.

  1. Start by importing the default fields from the Synapse library and destructuring the Id class from the object. Then, define a new class called MongoId which extends Id.

    import { fields } from '@synapsejs/synapse';
    
    const { Id } = fields;
    
    export default class MongoId extends Id {
    
    }
    
  2. In the constructor, after invoking super, use the assert method from the Text prototype to add a new regular expression rule to the instance. The assert method accepts 3 arguments: 1) an instance of RegExp (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'
        );
      }
    }
    
  3. 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.

  1. In the User module, import the Field class from the Synapse Libary and destructure the PRV flag from the Field.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;
    
  2. 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;
    

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.

  1. 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();
    };
    
  2. 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 the client_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.
  3. Finally, lets secure the GET /session/me endpoint by passing the authorize 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 GETs the value of /message and receives a collection of Message resources [ /message/0, /message/1, /message/2 ].
  • Client B POSTs 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 @affectsdecorator 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.