Skip to content

Getting Started

Defining Resources

Consider the following hypothetical User class modeling a user stored in a MongoDB collection. Instances of User have 4 properties: _id, username, email and password, while the User class provides 2 static methods for finding and creating User instances in the database.

class User {
  _id;
  username;
  email;
  password;

  static async find(_id) {
    const document = await collection.findById({ _id });
    ...
  }

  static async register(username, email, password) {
    const document = await collection.create({ username, email, password });
    ...
  }
}

In order to allow this resource to be served by the Synapse API, we will have to make a few changes to the class definition.

  1. Begin by importing the Resource base class and necessary Fields and decorators from the Synapse library.

    import { Resource, fields, decorators } from '@synapsejs/synapse';
    
    const { Id, Word, Email, Text } = fields;
    const { field, schema, expose } = decorators;
    
  2. Ensure that the User class extends the Resource base class, and that it is the default export of the module.

    export default class User extends Resource {
      ...
    
  3. For each property of the class, use the @field decorator to add that property to the class's schema, passing in an instance of Field defining the property's data type.

    export default class User extends Resource {
      @field(new Id(24, 24)) _id;
      @field(new Word(3, 16)) username;
      @field(new Email()) email;
      @field(new Text()) password;
      ...
    

    Field classes define broad categories of data types, while their instances represent validators for specific cases of that data type. The properties necessary to define a specific case are passed in to the constructor. For example, in the above code snippet:

    • The Id instance accepts only ids exactly 24 characters long.
    • The Word instance accepts only alphanumeric characters between 3 and 16 characters long with no spaces.
  4. For each method that should be accessible via the API:

    1. First, use the @schema decorator to define the schema that will validate requests to that method, passing in either a) an instance of Schema, or b) an object whose values are instances of Field, which will be used to construct a Schema, or c) a callback function which evaluates to either a) or b). When a callback function is provided, the function will not be executed until a request is made to that endpoint. This feature can be levereged to prevent dependency cycles when your application is initializing. In the example below, schemas are created for each method by transforming the overall schema already defined for the User class in general. See Schema Transformation for more details.
    2. Then, use the @endpoint decorator to define the HTTP verb and the path at which the method will be available. For methods used internally but not exposed to the client-facing API, use the READ or WRITE method in place of the HTTP method. See Control Flow for more details.
    3. Finally, ensure that all of the method paramaters are wrapped in braces. The methods will always be invoked with a single argument—an object containing the key-value pairs validated by the Schema.
      @endpoint('GET ./:_id')
      @schema(User.schema.select('_id'))
      static async find({ _id }) {
        const document = await collection.findById({ _id });
        ...
      }
    
      @endpoint('POST ./')
      @schema(User.schema.exclude('_id', 'password').extend({ password: new Hash(6) }))
      static async register({ username, email, password }) {
        const document = await collection.create({ username, email, password });
        ...
      }
    
  5. Lastly, complete the business logic of each method. Endpoint methods must always return an instance of State—a base class encompassing Resources, Collections, errors and any other custom response types you may decide to implement. For the purpose of this example, we will return either an instance of the User class or an error.

    • Use the static create factory method on the derived class when creating a Resource instance from new data. This ensures that the HTTP response status is correctly set to 201 CREATED.
    • Use the static restore factory method on the derived class when creating a Resource instance from pre-exisiting data.
    • Use the static collection factory method on the derived class to create a Collection of resources of the derived type.
    • Use one of many static factory methods on the State class to respond with an error or other generic HTTP response.
      @endpoint('GET ./:_id')
      @schema(User.schema.select('_id'))
      static async find({ _id }) {
        const document = await collection.findById({ _id });
        if (!document) {
          return State.NOT_FOUND();
        }
        return User.restore(document.toObject());
      }
    
      @endpoint('POST ./')
      @schema(User.schema.exclude('_id', 'password').extend({ password: new Hash(6) }))
      static async register({ username, email, password }) {
        const document = await collection.create({ username, email, password });
        return User.create(document.toObject());
      }
    
  6. The completed module should look something like this:

    import { Resource, fields, decorators } from '@synapsejs/synapse';
    
    const { Id, Word, Email, Text } = fields;
    const { field, schema, expose } = decorators;
    
    export default class User extends Resource {
      @field(new Id(24, 24)) _id;
      @field(new Word(3, 16)) username;
      @field(new Email()) email;
      @field(new Text()) password;
    
      @endpoint('GET ./:_id')
      @schema(User.schema.select('_id'))
      static async find({ _id }) {
        const document = await collection.findById({ _id });
        if (!document) {
          return State.NOT_FOUND();
        }
        return User.restore(document.toObject());
      }
    
      @endpoint('POST ./')
      @schema(User.schema.exclude('_id', 'password').extend({ password: new Hash(6) }))
      static async register({ username, email, password }) {
        const document = await collection.create({ username, email, password });
        return User.create(document.toObject());
      }
    }
    

Server Setup

  1. Within the server file, require Synapse and invoke it, passing in the directory containing your resource definitions.

    const path = require('path');
    const synapse = require('@synapsejs/synapse');
    
    const api = synapse(path.resolve(__dirname, './resources'));
    
  2. This creates an instance of the Synapse server. Add a global middleware function to the instance that will handle sending all responses to the client.

    • The result of every API request is an instance of State and will be assigned to res.locals.
    • If the request was made to one of the streaming interfaces, it's res argument will be different than the standard object passed by Express for HTTP requests. You can determine the type of request that was made by checking for the existence of a stream method on the res object. See Streaming Connections for more details.
    • Properties of the State instance defining metadata associated with the request/response are prefixed with a $. For example, $status contains the HTTP status code.
    • Use the serialize method to convert the instance of State to a public, serial representation of that instance ready for network transport.
    api.use((req, res) => {
      const state = res.locals;
      if (res.stream) {
        return res.stream(state);
      }
      return res.status(state.$status).send(state.serialize());
    });
    
  3. Route incoming HTTP API requests to the http handler on the Synapse instance.

    app.use('/api', api.http);
    
  4. Your application is now prepared to handle HTTP requests for the resources you defined.

    const path = require('path');
    const express = require('express');
    const synapse = require('@synapsejs/synapse');
    
    const app = express();
    const api = synapse(path.resolve(__dirname, './resources'));
    
    app.use('/api', api.http);
    
    api.use((req, res) => {
      const state = res.locals;
      if (res.stream) {
        return res.stream(state);
      }
      return res.status(state.$status).send(state.serialize());
    });
    
  5. To enable subscriptions to state updates via SSE, simply add the sse handler before the http handler.

    app.use('/api', api.sse, api.http);
    
  6. To enable WebSocket connections, route incoming WebSocket requests to the ws handler. This can be accomplished using express-ws.

    const enableWs = require('express-ws');
    
    enableWs(app);
    app.ws('/api', api.ws);
    
  7. Your completed server file should look something like this:

    const path = require('path');
    const express = require('express');
    const synapse = require('@synapsejs/synapse');
    const enableWs = require('express-ws');
    
    const app = express();
    const api = synapse(path.resolve(__dirname, './resources'));
    
    enableWs(app);
    app.ws('/api', api.ws);
    app.use('/api', api.sse, api.http);
    
    api.use((req, res) => {
      const state = res.locals;
      if (res.stream) {
        return res.stream(state);
      }
      return res.status(state.$status).send(state.serialize());
    });
    
    app.listen(3000, () => console.log(`listening on port 3000...`));
    

Streaming Connections

As previously mentioned, Synapse provides two interfaces for streaming protocols in addition to the standard HTTP interface. The primary purpose of these real-time interfaces is to allow subscriptions to resources—that is, the ability to obtain state updates continuously whenever a given resource changes. However, the websocket interface in particular can also be used to process standard requests at a reduced latency.

Server-Sent Events (SSE)

The SSE interface is used only for subscriptions and works on a one-per-connection basis. The SSE interface will not accept requests for write operations, as they can't be subscribed to.

  • To create a subscription, simply add the Content-Type: text/event-stream header to an otherwise normal HTTP GET request for a given Resource, and you will receive its initial state, as well as its new state whenever a change occurs.
  • To cancel a subscription, simply close the associated connection.
  • To subscribe to multiple resources, either create multiple SSE connections, or use WebSockets.
WebSockets (WS)

The WS interface can process any request that the standard HTTP interface can, in addition to subscription requests.

  • To connect from your client application, create a WebSocket connection to the path exposed by the Express server. For example:

    const api = new WebSocket('ws://[hostname]/api');
    ...
    
  • The WS interface accepts requests in the form of a JSON object whose keys are endpoints strings (METHOD /path) and whose values are other objects containing the arguments to be included with the request. For example:

    {
      "POST /user": {
        "username": "john",
        "password": "secret"
      }
    }
    
  • The response to each request will also be a JSON object, and the key on that object will exactly match the key on the request object.

  • The request keys are assumed to be space-delimited lists, where the first two values in that list are the HTTP method and resource path, respectively. Additional values after the resource path will be ignored; however, they will be retained in the key on the response object. This allows requests to be uniquely identified by appending an id to the end of the request key (e.g. POST /user ${request_id}).
  • The WebSocket interface accepts two custom methods SUBSCRIBE and UNSUBSCRIBE. The subscribe method is intitially identical to a GET request. A request to SUBSCRIBE /message, then, would first return the result of the request:

    {
      "SUBSCRIBE /message/123": {
        "status": 200,
        "query": "/message/123?",
        "payload": {
          "text": "hello world!"
        },
        ...
      }
    }
    
  • Notice that the reponse to the initial request contains a metadata property called query. This is the normalized request path and argument set that uniquely represents, and will be used to identify, the subscription.

  • In our example, whenever the state of /message/123 would change, the client would receive a message object with a key equal to the query string and whose associated value was the state of the resource at that path, with no metadata attached (e.g. status code):

    {
      "/message/123?": {
        "text": "hello again!"
      }
    }
    
  • The query string is also used to cancel a subscription, using the UNSUBSCRIBE method:

    {
      "UNSUBSCRIBE /message/123?": {}
    }