3.0.0 • Published 6 months ago

@jambff/api v3.0.0

Weekly downloads
-
License
MIT
Repository
github
Last release
6 months ago

Jambff API

An OpenAPI-compliant REST API framework for Node.

Table of Contents

Installation

Install with your favourite package manager:

yarn add @jambff/api

You should also install all peerDependencies.

Usage

To launch the server:

import { launchServer } from '@jambff/api';

launchServer();

The application should now be available at http://127.0.0.1:7000.

Controllers

A controller is a class exported from a .js or .ts file and added to the controllers array when calling launchServer().

Controllers are defined using the routing-controllers and routing-controllers-openapi packages. These packages form part of our mechanism for generating an OpenAPI-compliant REST API. Please see the documentation for those packages, along with the examples in this repo, to understand what the various decorators do.

All controller functions should be decorated with the @SuccessResponse decorator, where the first argument is a status code and the second one of our models.

Any request bodies (e.g. those used for POST or PUT requests) should be decorated with the @Body decorator, where the type is one of our models.

Example

// /controlers/example
import { JsonController, Get, Post, Body } from 'routing-controllers';
import { ResponseSchema } from 'routing-controllers-openapi';
import { Example } from '../entities/example';

@JsonController()
export class ExampleController {
  @Get('/example')
  @SuccessResponse(200, Example)
  get(): Example {
    return 'This is my thing';
  }

  @Post('/example')
  post(@Body() body: Example) {
    return 'Created a new thing';
  }
}

Middleware

Similar to controllers, middleware can be added by exporting a class from a .js or .ts file and adding it to the middlewares array when calling launchServer().

Example

import { Response, Request, NextFunction } from 'express';
import log from 'some-logger';
import { ExpressMiddlewareInterface, Middleware } from 'routing-controllers';

@Middleware({ type: 'after' })
export class LoggerMiddleware implements ExpressMiddlewareInterface {
  use(req: Request, res: Response, next: NextFunction): void {
    log(req, res);
    next();
  }
}

Configuration

The following settings are available when launching the server with launchServer.

app

An override for the Express application (mostly used for testing).

Example:

import express from express;

const app = express();

launchServer({
  app,
});

host

The server host.

Example:

launchServer({
  host: 'www.example.com',
});

port

The server port.

Example:

launchServer({
  port: 1234,
});

throwOnInvalidOpenApiResponse

Throw when a response does not match the OpenAPI spec.

Example:

launchServer({
  throwOnInvalidOpenApiResponse: true,
});

api

An object that defines various properties for the OpenAPI specs. All of the properties are optional.

Example:

launchServer({
  api: {
    name: 'My API',
    description: 'The purpose my API serves',
  },
});

controllers

Add controllers.

Example:

import * as controllers from './my/controllers';

launchServer({
  controllers,
});

See controllers.

middlewares

Add middlewares.

Example:

import * as middlewares from './my/middlewares';

launchServer({
  middlewares,
});

See middlewares.

cors

Configure CORS.

Example:

launchServer({
  cors: {
    origin: 'http://example.com',
  },
});

See Express cors docs for the available options.

minimumClientVersion

Specify the minimum supported version of the client app (see forced upgrades).

Example:

launchServer({
  minimumClientVersion: '>1.2.3',
});

auth

Auth settings used to to identify and authorise users (see Authorization).

Example:

launchServer({
  auth: {
    secretOrPublicKey: 'my-secret-key',
    algorithms: ['RSA256'],
    parseAccessToken(decodedToken) {
      return {
        email: decodedToken.email,
        name: decodedToken.user_metadata.name,
        roles: [decodedToken.user_metadata.role],
      },
    },
  },
});

accessLogs

Enable access logs. The default is to enable them in development mode and disable in production (i.e. when NODE_ENV === 'production') as there is a small overhead involved in logging every request, which you may or may not want to pay the cost of in a production environment.

launchServer({
  accessLogs: true,
});

cacheControl

Set cache headers. By default, no caching is enabled for any route.

launchServer({
  cacheControl: {
    maxAge: '15m', // Default max age
  },
});

Note that the default maxAge in the config can be overridden on a per-route basis by using the @DisableCache() and @SetCache() decorators.

timeout

Set the default global request timeout.

launchServer({
  timeout: 10000, // 10 seconds (default)
});

rawBodyRoutes

Mark certain routes where the body should not be parsed as JSON.

launchServer({
  rawBodyRoutes: ['/webhook']
});

sentry

Sentry configuration.

launchServer({
  sentry: {
    dsn: 'https://123@456.ingest.sentry.io/789',
  },
});

Models

All input and output data is modeled using classes where each property is decorated with one or more decorators from the class-validator package.

The models are used to perform validation on query params and request bodies, generate OpenAPI schema objects, and provide the interface for our API client.

Be careful when modifying models as any breaking changes introduced may be difficult to rectify once users have downloaded a particular app version.

Example

import { IsString, IsArray, isInt, isOptional } from 'class-validator';

export class MyThing {
  @IsString()
  title: string;

  @IsArray()
  @IsInt({ each: true })
  @IsOptional()
  optionalNumbers?: number[];
}

These models will generally be used to decorate controllers with decorators such as @SuccessResponse and @ErrorResponse, as well as using them as the return type for each controller operation.

Seee the class-validator and class-transformer docs for more details.

Caching

To set cache headers for a particular route we can use the @DisableCache() and @SetCache() decorators.

The @DisableCache() decorator accepts no parameters and, unsurprisingly, adds headers to disable edge caching.

The @SetCache() decorator accepts an options parameter containing maxAge, staleWhileRevalidate and staleIfError properties. Generally, we only need to concern ourselves with the maxAge property (the other two will be set to a day by default). All properties accept a human-readable time string, for example, 15m or 1h 15m (see the timestring package for more details).

Example

// controllers/my-thing.ts
import { JsonController, Get, Post, Body } from 'routing-controllers';
import { MyThing } from '../entities/my-thing';
import { SetCache, DisableCache } from '../decorators';

@JsonController()
export class MyThingController {
  @Get('/my-thing')
  @SetCache({ maxAge: '30m' })
  get(): MyThing {
    return 'This is my thing';
  }

  @Post('/my-thing')
  @DisableCache()
  post(@Body() body: MyThing) {
    return 'Created a new thing';
  }
}

Authorization

To mark a particular endpoint as requiring authorization we can use the @Secure decorator, for example:

// controllers/my-thing.ts
import { JsonController, Get, Post, Body } from 'routing-controllers';
import { MyThing } from '../entities/my-thing';
import { Secure } from '../decorators';

@JsonController()
export class MyThingController {
  @Post('/my-thing')
  @Secure()
  createMyThing(
    @CurrentUser() user: User,
  ): MyThing {
    return 'This is my thing';
  }
}

This endpoint will now require an Authorization header to be sent with the request in the format:

Authorization: Bearer <ACCESS-TOKEN>

Where ACCESS-TOKEN is a valid JWT. Validity is determined by verifying the token against a key provided via the auth.secretOrPublicKey configuration option. We also confirm that the token has not expired.

Current user

From your controller, when the @Secure decorator is added you can retrieve the current user via the @CurrentUser decorator, which is also shown in the example above. By default, this current user object will include just an id property, which comes from the sub claim of the access token. If you would like custom claims to be included in your current user object see the auth.parseAccessToken setting, which receives the decoded access token and can return whatever you like.

Note that the roles property is special in that it is used to perform further authorization by role, which you can do by passing one or more roles to the @Secure decorator, as @Secure('admin') or @Secure(['admin', 'editor']).

VSCode warnings

Even when the relevant TypeScript config exists, VSCode doesn't play too nicely with decorators, such as those we use for our controllers. If you're warnings about decorator support you can try going to your VSCode setting, searching for "experimentalDecorators" and ticking the box to enable.

Forced upgrades

While an evolution strategy is generally preferred for any API, sometimes we have a case where we need to force the consumer to upgrade (e.g. mobile apps).

The forced upgrade mechanism works by the API specifying the version(s) of a consuming app that it supports. In general this will be in the format >1.2.3 (i.e. greater than version 1.2.3). The consuming app sends a header specifying its current version and if the API determines this version is unsupported it responds with a 406 request, at which point the consuming app can decide to force users to upgrade.

Functionality to handle the client-side of this is built into @jambff/oac.

The overall flow is as follows:

  1. The API client provides its current major version via an Accept header with every request (e.g. Accept: application/vnd.jambff.v1)
  2. On the server we have some middleware that checks each incoming request to confirm if the client version is still supported.
  3. If not we respond with a 406 Not Acceptable error.
  4. This error is intercepted by the API client, which calls a callback provided by the consuming app.
  5. Within the app itself we choose how to handle this issue, likely by presenting an alert that forces users to upgrade.

The currently supported API client version is added to the info section of the Open API spec for reference, against the x-supported-api-client-version custom property.

See the minimumClientVersion Jambff config setting to enable this behaviour on the API side.

Deprecating routes

Routes can be deprecated by using the @DeprecateRoute() decorator. This is a feature that we will want to avoid using too often but is useful for the case when we want to keep an existing operation but migrate to a new route for that operation.

Once a route is deprecated any subsequent versions of the API client will consume the new route, while the deprecated route will continue to work for any previous client versions.

Once the deprecated route is no longer receiving any (significant) traffic the decorator can be removed.

Example

import { JsonController, Get, Post, Body } from 'routing-controllers';
import { ResponseSchema } from 'routing-controllers-openapi';
import { MyThing } from '../entities/my-thing';

@JsonController()
export class MyThingController {
  @Get('/my-thing')
  @DeprecateRoute('/my-old-thing', 'get')
  get(): MyThing {
    return 'This is my thing';
  }
}

Testing

If working on the package all unit and integration tests can be run using the following command:

yarn test

We define integration tests here as those that actually launch an API server, make some HTTP requests, perform assertions on the response and then shut it down again.

As a general rule, we should write integration tests for all API endpoints. These tests form our primary mechanism for validating all code up to and including the controllers.

Lower-level unit tests can be added for any particularly complex or important pieces of code, or those where the amount of potential variations would make integration testing too cumbersome.

Following are some additional notes on how we test some of the more unique aspects of the Jambff API.

Type conversion

We assert JSON Schema conversion for all models is correct using class-validator-jsonschema This will happen for all models automatically (assuming you export them from the models index file). Please study the snapshots closely when they change. It is important we get these models right as once they are out there in the wild they potentially become quite difficult to change!

OpenAPI schema

At a high level the entire OpenAPI schema is validated using openapi-schema-validator.

For individual responses we can use jest-openapi to assert that HTTP responses actually satisfy our OpenAPI spec. Search the code for toSatisfyApiSpec() to see some examples.

2.0.0

6 months ago

3.0.0

6 months ago

1.19.0

6 months ago

1.20.0

6 months ago

1.18.1

1 year ago

1.18.0

1 year ago

1.15.3

1 year ago

1.17.0

1 year ago

1.16.0

1 year ago

1.15.0

1 year ago

1.14.1

1 year ago

1.15.2

1 year ago

1.14.3

1 year ago

1.15.1

1 year ago

1.14.2

1 year ago

1.14.0

1 year ago

1.13.1

1 year ago

1.13.0

1 year ago

1.12.2

1 year ago

1.12.1

1 year ago

1.12.0

1 year ago

1.11.3

1 year ago

1.11.2

1 year ago

1.11.1

1 year ago

1.11.0

1 year ago

1.10.0

1 year ago

1.9.0

1 year ago

1.8.0

1 year ago

1.7.0

1 year ago

1.6.2

1 year ago

1.6.1

1 year ago

1.6.0

1 year ago

1.5.0

1 year ago

1.4.2

1 year ago

1.4.1

1 year ago

1.4.0

1 year ago

1.3.1

1 year ago

1.3.0

1 year ago

1.2.3

1 year ago

1.2.2

1 year ago

1.2.1

1 year ago

1.2.0

1 year ago

1.1.5

1 year ago

1.1.4

1 year ago

1.1.3

1 year ago

1.1.2

1 year ago

1.1.1

1 year ago