npm package: @jambff/api
Description: An OpenAPI-compliant REST API framework for Node.
Author: Alex Mendes
Latest version: 3.0.0

# Jambff API

An OpenAPI-compliant REST API framework for Node.

**Table of Contents**

- [Installation](#installation)
- [Usage](#usage)
- [Controllers](#controllers)
- [Middleware](#middleware)
- [Configuration](#configuration)
- [Models](#models)
- [Caching](#caching)
- [Authorization](#authorization)
- [Sites](#sites)
- [VSCode warnings](#vscode-warnings)
- [Forced upgrades](#forced-upgrades)
- [Deprecating routes](#deprecating-routes)
- [Testing](#testing)

## Installation

Install with your favourite package manager:

```text
yarn add @jambff/api
```

You should also install all `peerDependencies`.

## Usage

To launch the server:

```ts
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](https://www.npmjs.com/package/routing-controllers) and
[routing-controllers-openapi](https://www.npmjs.com/package/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

```ts
// /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

```ts
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:

```ts
import express from express;

const app = express();

launchServer({
  app,
});
```

### `host`

The server host.

Example:

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

### `port`

The server port.

Example:

```ts
launchServer({
  port: 1234,
});
```

### `throwOnInvalidOpenApiResponse`

Throw when a response does not match the OpenAPI spec.

Example:

```ts
launchServer({
  throwOnInvalidOpenApiResponse: true,
});
```

### `api`

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

Example:

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

### `controllers`

Add controllers.

Example:

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

launchServer({
  controllers,
});
```

See [controllers](#controllers).

### `middlewares`

Add middlewares.

Example:

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

launchServer({
  middlewares,
});
```

See [middlewares](#middlewares).

### `cors`

Configure CORS.

Example:

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

See [Express cors docs](https://expressjs.com/en/resources/middleware/cors.html)
for the available options.

### `minimumClientVersion`

Specify the minimum supported version of the client app
(see [forced upgrades](#forced-upgrades)).

Example:

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

### `auth`

Auth settings used to to identify and authorise users (see [Authorization](#authorization)).

Example:

```ts
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.

```ts
launchServer({
  accessLogs: true,
});
```

### `cacheControl`

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

```ts
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.

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

### `rawBodyRoutes`

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

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

### `sentry`

Sentry configuration.

```ts
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](https://www.npmjs.com/package/class-validator) package.

The models are used to perform validation
on query params and request bodies, generate
[OpenAPI schema objects](https://swagger.io/specification/#schema-object), 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

```ts
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](https://www.npmjs.com/package/class-validator)
and [class-transformer](https://www.npmjs.com/package/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](https://www.npmjs.com/package/timestring)
package for more details).

#### Example

```ts
// 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:

```ts
// 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:

```text
Authorization: Bearer <ACCESS-TOKEN>
```

Where `ACCESS-TOKEN` is a valid [JWT](https://jwt.io/). 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](https://phil.tech/2018/api-evolution-for-rest-http-apis/)
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`](https://www.npmjs.com/package/@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

```ts
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:

```text
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](#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](https://www.npmjs.com/package/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](https://www.npmjs.com/package/openapi-schema-validator).

For individual responses we can use
[jest-openapi](https://www.npmjs.com/package/jest-openapi) to assert that HTTP
responses actually satisfy our OpenAPI spec. Search the code for `toSatisfyApiSpec()`
to see some examples.