Skip to content

Quick Start

Hadrien Milano edited this page Jan 7, 2020 · 7 revisions

This guide shows how to use Rest.ts to create and consume a type-safe HTTP API between a Node.js app and a browser or another Node.js app.

We will first create the abstract definition of the API, then create an express.js implementation of this API, and finally we will consume this API from a JavaScript client.

0. Project structure

This part provides a context for the code samples given in the next sections. You may skip it if you already have a project, and you already have a rough idea of how you want to integrate Rest.ts in your project.

Let's create a project like so:

/
+- tsconfig.json
+- package.json
+- src
  +- shared
  | +- api.ts
  | +- dto.ts
  +- server
  | +- main-server.ts
  +- client
    +- main-client.ts

For the purpose of this example, we will consider a single TypeScript project containing two node.js applications: a client and a server. We split the code in three root directories: one for the client, one for the server, and one for the shared definitions.

ℹ️In practice, your project will likely be different: you may have a browser app, you may have split the code across different npm packages, etc. This example provides a very simple project structure so we can focus the discussion on Rest.ts usage, rather than get lost in the rabbit hole of Node.js project architecture.

Let's add the minimum required boilerplate for our project:

package.json:

{
  "name": "rest.ts-test",
  "private": true,
  "version": "0.0.0",
  "description": "Getting started with rest.ts",
  "scripts": {
    "run:server": "ts-node src/server/main-server.ts",
    "run:client": "ts-node src/client/main-client.ts"
  }
}

tsconfig.json:

{
    "compilerOptions": {
        "strict": true
    },
    "include": [
        "src/**/*.ts"
    ]
}

2. Define your API

The rest-ts-core package provides a set of utilities to create API definitions.

Start by installing this package

npm install --save rest-ts-core

Now, the first thing you want to do is to define some data models for your API. They define the types of the data that you will send over the wire.

src/shared/dto.ts:

/**
 * This class represents the main model we will work with: a scientific publication.
 */
export class Publication {
    constructor(
        public readonly authors: string[],
        public readonly title: string,
        public readonly body: string,
        public readonly isPublished: boolean
    ) {
    }
}

/**
 * This model represents a list of publications for a given category.
 */
export class PublicationsList {
    constructor(
        public readonly publications: Publication[],
        public readonly category: string
    ) {
    }
}

/**
 * This is returned by the server when a publication has been successfully published.
 * We don't need to return the full model so instead we only return the database `id` of the publication.
 */
export class CreatePublicationResponse {
    constructor(
        public readonly id: string
    ) {}
}

/**
 * As an alternative to classes, you may also use a variable. Note that the member values don't matter.
 * What matters is the **type** of the members
 */
export const RemovePublicationResponse = {
    // See how we use 'as' to make the type more precise. 
    success: 'ok' as 'ok' | 'failure',
    error: null as null | string,

    // Here, we don't use 'as' because the type 'string' is what we want.
    // The actual text in the string doesn't matter.
    userMessage: 'some string'
}

The second step consists in creating the actual API definition. An API definition consists in a series of endpoints, each allowing you to perform some action on the data model.

src/shared/api.ts:

import { GET, POST, DELETE, defineAPI } from 'rest-ts-core';
import { PublicationsList, Publication, CreatePublicationResponse, RemovePublicationResponse } from './dto';

/**
 * This is our REST API definition.
 */
export const myPublicationAPI = defineAPI({   
    
    /**
     * We have a GET endpoint at "/publications/:category", which we name "listPublications".
     * 
     * It takes a "category" path parameter and returns a `PublicationsList`.
     */
    listPublications: GET `/publications/${'category'}`
        .response(PublicationsList),

    /**
     * Next is a POST endpoint at "/publications", named "addPublication".
     * 
     * It takes a `Publication` body parameter, attempts to add this publication
     * to the database, and returns `CreatePublicationResponse` if all went well.
     */
    addPublication: POST `/publications`
        .body(Publication)
        .response(CreatePublicationResponse),

    /**
     * Finally, we define a way to remove a publication with the DELETE HTTP method.
     */
    removePublication: DELETE `/publications/${'id'}`
        .response(RemovePublicationResponse)
});

You will never use myPublicationAPI directly. This is an opaque object which encodes your API definition in the type system as well as the runtime. In order to use this object, you need a module that is able to decode it.

ℹ️ You can consider this object as a recipe that explains how your API works. In the next section, we will see how to pass this recipe to other modules so we can start doing useful stuff.

The following piece of code might appear strange if you are not familiar with advanced ECMAScript features:

GET `/publications/${'category'}`
    .response(PublicationsList),

What we are using here is called a tagged template literal. Using the dollar-bracket (${}) notation, we can define dynamic path parameters for our route. The static and dynamic chunks are passed to the GET function, which analyzes the type and encode the type information for this endpoint. Then, we invoke the .response() method on the object returned by the tagged literal.

3. Implement this API

Now that we've described how our API works, let's write an express router that implements this contract.

We will use rest-ts-express for that. We will also need express to create the HTTP server stack, and body-parser to parse the incoming data.

npm install --save rest-ts-express express body-parser @types/express

The recommended way to create a router is with the function buildRouter:

src/server/main-server.ts:

import * as express from 'express';
import { json } from 'body-parser';
import { buildRouter } from 'rest-ts-express';
import { myPublicationAPI } from '../shared/api';
import { PublicationsList } from '../shared/dto';


const router = buildRouter(myPublicationAPI, (_) => _
    
    /*
     * The builder defines one method for each endpoint. The method accepts a handler
     * which gets executed when a valid HTTP request is made to that endpoint.
     * 
     * The handler takes a `req` and `res` parameter, just like regular express handlers.
     * The difference here is that `req` contains additional type information for the input parameters.
     */
    .listPublications((req, res) => {
        console.log(`Requesting category "${req.params.category}"`);
        // Note how, contrary to regular express handlers, here we return
        // the object to send instead of using `res.send` or `res.write`pèw.
        return new PublicationsList(
            [],
            req.params.category
        );
    })

    /*
     * Another difference with express is that you can make your handler asynchronous:
     */
    .addPublication(async (req, res) => {
        // You could do something like this:
        // await someAsynchronousOperation();
        console.log(`Title: ${req.body.title}, authors: ${req.body.authors}`);
        // You don't have to use the class constructor `new CreatePublicationResponse`.
        // Rest.ts only cares about the structural type of the object you return. Therefore,
        // you may return anonymous object hashes like this:
        return {
            id: 'id_of_' + req.body.title
        };
    })

    .removePublication((req, res) => {
        // Try this: type `req.params.`, a good IDE such as VS code will provide a list of possible completions.
        // Here, it shows the `id` property we've defined in the template literal in the API definition.
        console.log(`Removing publication: ${req.params.id}`);

        // Similarly, you can just type: `return {` and hit CTRL+SPACE to get a list of valid members to return.
        // This is one of the most useful features of Rest.ts.
        return {
            userMessage: 'some string',
            error: null,
            success: 'ok'
        }; 
    })
);

You can then attach your router to an express server:

const app = express();

// Don't forget to add a body parser if you want to use `req.body`!
app.use(json());

// Declare a CORS policy to let browsers know who is allowed to use your api
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
  res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
  res.header('Access-Control-Allow-Headers', 'Content-Type');
  next();
});

// Now, bind your API implementation!
app.use('/api', router);

app.listen(3000, () => {
    console.log('Server is ready');
});

4. Consume the API

Now that you have a server serving your API, you'll want to consume it from some kind of client. It could either be a browser application, or another Node.JS service. The process is the same either way.

We will use rest-ts-axios to bind the API to an axios* instance.

npm install --save rest-ts-axios axios

First, we create an instance of axios. This is where we can configure global settings such as custom headers, authorizations, etc...

src/client/main-client.ts

import axios from 'axios';

const driver = axios.create({
    baseURL: 'http://localhost:3000/api',
    // You can add global settings here such as authentication headers
});

Then, we bind the API definition to that instance. This will create an object we can use to perform calls to the backend.

import { createConsumer } from 'rest-ts-axios';
import { myPublicationAPI } from '../shared/api';

const api = createConsumer(myPublicationAPI, driver);

The api object has one method for each endpoint. The method maps directly to an axios call so you can also pass any option accepted by axios.

// We wrap our logic in an async function so we can use `await`. 
// But you could also use the `then` property of the promise if you prefer.
async function doRun() {
    const response = await api.addPublication({
        body: {
            authors: [ 'R.L. Rivest', 'A. Shamir', 'L. Adleman' ],
            title: 'A Method for Obtaining Digital Signatures and Public-Key Cryptosystems',
            body: '...',
            isPublished: true
        }
    });

    console.log(response.data.id);
}
doRun();

*axios is the best cross-platform HTTP client for TypeScript out there.

(optional) 5. Trying it out

Follow these steps if you want to see the code in action. There is nothing too exciting going on really. Most of the cool stuff Rest.ts does happens inside your IDE.

Install the dependencies needed to run the app:

npm install --save-dev ts-node typescript

Then start the server:

npm run run:server

In an other terminal window, run the client:

npm run run:client

What next?

Once you get familiar with the basics of Rest.ts, you might want to read the guide usage with Runtypes. Runtypes is a library that helps you write type-safe Data Transfer Objects (DTOs, objects that get serialized and passed between systems). Rest.ts has first class support for Runtypes and I highly recommend using these libraries together.

If you are looking for a specific feature, take a look at the API documentation to see if it is already included. If not, feel free to file an issue on GitHub.