@jambff/api
v3.0.0
Published
An OpenAPI-compliant REST API framework for Node.
Downloads
3
Readme
Jambff API
An OpenAPI-compliant REST API framework for Node.
Table of Contents
- Installation
- Usage
- Controllers
- Middleware
- Configuration
- Models
- Caching
- Authorization
- Sites
- VSCode warnings
- Forced upgrades
- Deprecating routes
- Testing
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://[email protected]/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:
- The API client provides its current major version via an
Accept
header with every request (e.g.Accept: application/vnd.jambff.v1
) - On the server we have some middleware that checks each incoming request to confirm if the client version is still supported.
- If not we respond with a 406 Not Acceptable error.
- This error is intercepted by the API client, which calls a callback provided by the consuming app.
- 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.