@alterior/web-server
v3.12.0
Published
Declarative framework for building REST services
Downloads
164
Maintainers
Readme
@alterior/web-server
A framework for building HTTP services in Typescript. Build REST APIs with this.
Getting started
Install the Alterior runtime, the DI library, and the web-server module:
npm install reflect-metadata
npm install @alterior/runtime @alterior/di @alterior/web-server
Configuring Typescript
You must enable enableExperimentalDecorators
and emitDecoratorMetadata
,
and esModuleInterop
Typescript compiler options to use this library. Do
this within tsconfig.json
:
{
"compilerOptions": {
"enableExperimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true
}
}
A minimal example
For simple use cases, you can build a web service using Alterior in a single file:
// my-web-service.ts
import { WebService } from '@alterior/web-server';
@WebService({
providers: []
})
export class MyWebService implements OnInit {
@Get('/version')
version() {
return { version: "1.0.0" };
}
@Get('/')
version() {
return { hello: 'world' };
}
}
Application.bootstrap(MyWebService);
How do I run it?
Make an entry point for your application if you don't already have one:
// main.ts
import 'reflect-metadata';
import { Application } from '@alterior/runtime';
import { MyWebService } from './my-web-service';
Application.bootstrap(MyWebService);
After you compile your application using Typescript, run your app using Node.js:
node dist/main
You should use NPM scripts to manage building, testing and running your application per the conventions of the Node.js community.
Mechanics of @WebService
When classes marked @WebService()
are bootstrapped as part of an Alterior application (via Application#bootstrap()
), a new Alterior role is registered with RolesService
which is responsible for starting up and shutting down a web server using one of the supported WebEngines (currently Express and Fastify). The class itself acts both as a @Module()
and the root @Controller()
of the service. The class can then use @Mount()
to add additional controllers to the service. Each @WebService()
has its own separate server instance, which means you can host multiple web services (on different ports) within the same overall application. Each service will be given its own role which can be controlled independently.
Delegation via Mounting
You can delegate parts of your web service to dedicated controllers by mounting them within your main service class. Doing so will route specific URLs to specific controllers. Any controller can @Mount()
, providing an intuitive way to construct a complete web service from a tree of controllers:
@WebService(...)
export class MyWebService implements OnInit {
@Mount('/users')
usersController : UsersController;
@Mount('/some-plugin')
somePlugin : SomePluginController;
}
When you nest controllers using @Mount()
, each subcontroller inherits the path prefix defined by all parents, like below:
@Controller('/f')
class SubSubController {
@Get('/g')
get() {
return { message: 'you requested: GET /a/b/c/d/e/f/g' };
}
}
@Controller('/c')
class SubController {
@Get('/d')
get() {
return { message: 'you requested: GET /a/b/c/d' };
}
@Mount('/e')
subsub : SubSubController;
}
@Controller('/a')
class MainController {
@Mount('/b')
sub : SubController;
}
Note: You do not always have to specify a path for @Controller()
, @Mount()
or @Get()
, we have done so here for to keep the example clear. If you omit a path or set it to ''
, the element will not contribute any path segments to the final path registered for your routes.
When to use base paths with @Mount
To ensure your app is easy to maintain as it grows, we recommend that you design your mounted controllers to operate from the root of your web service. This means omitting a base path when using @Mount()
. Doing so ensures that you will not need to rewrite all of your route definitions if you need to add a route that falls outside of your controller's expected base path.
Passing a base path can be useful however when consuming a controller which is intended to be consumed by many web services.
Promises & Async
/**
* You can return promises. Alterior will wait for the promise to resolve
* before responding to the HTTP request.
*/
@Get('/promises')
public canUsePromises()
{
return Promise.resolve({ nifty: 123 });
}
/**
* Or use async/await (the recommended way!)
*/
@Get('/async')
public async canUseAsync()
{
return await someFunction();
}
Path Parameters
The parameters specified by your route methods are automatically analyzed, and the correct value is provided depending on what type (and in some cases what name) your parameter has.
For example, you can receive path parameters. Alterior knows that
since you have a path parameter named nameOfCar
and an otherwise
undecorated method parameter also named nameOfCar
that these two are
related
@Get('/cars/:nameOfCar')
public canUseRouteParams(nameOfCar : string)
{
return `you asked for car named ${nameOfCar}`;
}
Accessing the Request/Response
Sometimes you need to check or set an HTTP header, interact directly with middleware, or handle parsing the request body or serializing the response body yourself. Alterior lets you do that using the WebEvent
class.
@Get()
public whoAmI() {
WebEvent.response.status(200).send(`hello ${WebEvent.request.header('user-agent')}`);
}
Is this done via global variables? Wouldn't that not work with async requests?
WebEvent
uses Zone-local variables to accomplish its task, so there is no risk that you will access the wrong request/response when using it unlike when global variables are used for this purpose.
Complex Responses
The recommended way to handle HTTP error statuses is using the HttpException
class. You can throw the exception from your route method and Alterior will recognize this and fulfill the HTTP response as you specify.
/**
* Promises can reject with an HttpException to specify HTTP errors...
*/
@Get('/error')
public async errorExample()
{
throw new HttpException(301, {message: "No, over there"}));
}
For successful responses, throwing HttpException (while supported) is not idiomatic. Instead you should return a special response value from your method using the Response
class
@Get()
public responseExample()
{
return Response.movedPermanently('https://example.com/');
}
Parameters Matching
Alterior inspects the parameters of controller methods to determine what values need to be provided while handling a request.
Parameters decorated with
@PathParam('a')
will be fulfilled with the value of path parameter:a
from the route path (as in/some/path/:a
). The path parameter can be defined in any parent controller/mount context. Since path parameters are the most common, and there is a high degree of linkage between path parameters and method parameters, you can omit@PathParam()
if the name of your path parameters is the same as your method parameter as shown aboveNote: If a path parameter is defined directly in the path passed to
@Get()
decorator and an (otherwise unfulfilled) parameter with the same name is defined on the method, the method parameter is fulfilled with the path parameter for the current request. Method parameters meant to be fulfilled from any parent controller/mount-defined parameters must be decorated with@PathParam()
Parameters decorated with
@QueryParam('q')
will be fulfilled with the query parameterq
if provided (?q=...
). If the query parameter was not provided in the request, the value of the parameter will beundefined
.Parameters decorated with
@QueryParams()
will be fulfilled with an object containing all query parameters found within the URL. The type of this object is effectivelyRecord<string,string>
, but you can use any interface type for the parameter for convenience purposes.Note: No coercion of parameter types is performed- all values within the
@QueryParams()
object will be stringsParameters which are decorated with
@Body()
will be fulfilled with the value ofWebEvent.request.body
. If the type of the method parameter isstring
, Alterior will automatically connect a text body parsing middleware (bodyParser.text()
). If the type of the method parameter isBuffer
, Alterior will automatically connect a raw body parsing middleware (bodyParser.raw()
). For any other parameter type, Alterior adds a JSON body parsing middleware (bodyParser.json()
). If you need other body parsing middleware, you can add it directly to themiddleware
property of the route decorator'soptions
parameter and useWebEvent.request.body
directly instead.
When combined with value returns, you can achieve a very natural style:
import * as bodyParser from 'body-parser';
interface MyRequestType {
action : string;
foo? : number;
}
@Controller()
export class MyController {
@Get('/do/:action')
doThings(
@Body() body : MyRequestType,
@PathParam('action') action : string,
@QueryParam('message') message : string
) {
return {status: "success"};
}
}
WebSockets
WebSocket support is built in. You can call WebServer.startSocket()
while handling a request to upgrade the current request into a WebSocket connection.
@Get()
mySocket() {
let socket = WebServer.startSocket();
socket.addEventListener('message', ev => {
console.log(`Received message from client: ${ev.data}`);
});
}
TLS (HTTPS)
Typically it is best to terminate HTTPS at a reverse proxy running on the same machine as your application server, or at an external load balancer. However Alterior does allow you to do it within the application server (which is required for native HTTP/2)
@WebService({
server: {
certificate: `---BEGIN...`,
privateKey: `---BEGIN...`,
port: 443
}
})
export class MyService {
// ...
}
HTTP/2
HTTP/2 support is built-in. Specify protocols
to enable it. If you provide a TLS certificate HTTP/2 is enabled by default. Otherwise only HTTP is enabled. However, if you add h2
(or one of the spdy/*
versions) to protocols
but you do not specify a TLS certificate, then Alterior will automatically generate and use a self-signed certificate which is useful for testing HTTP/2 services in development.
@WebService({
server: {
protocols: [`h2`, `spdy/3.1`, `spdy/3`, `spdy/2`, `http/1.1`, `http/1.0`]
}
})
export class MyService {
// ...
}
Listening on both HTTPS and HTTP
When TLS is enabled, you can provide the insecurePort
option to also listen on another port using HTTP.
In that case, Alterior will create two HTTP servers, but both will use the same bootstrapped application.
Older WebServerEngines don't have this capability, so you may need to upgrade yours if you need this. Both
the ExpressEngine and FastifyEngine have been upgraded to support this capability as of Alterior 3.5.0.
Server-Sent Events
You can use WebEvent.sendEvent()
to send an event stream response back to the client. For more information about
server-sent events, see https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
The data
field is serialized into JSON for you. Note that Server-Sent Events over HTTP/1.1 is not ideal as modern
browsers only allow a maximum of six (6) connections to a given server. However, with HTTP/2 this is not an issue.
@Get('/sse')
async sse() {
while (WebEvent.connected) {
await timeout(1000);
await WebEvent.sendEvent({ event: 'ping', data: { message: 'are you still there?' } });
}
}
Dependency Injection
Modules, controllers and services all participate in dependency injection. For more information about how DI works in Alterior apps, see the documentation for @alterior/di.
Middleware
Alterior supports Connect-style middleware (as used in Connect, Express, Fastify, etc) as well as middleware classes (which support dependency injection). Middleware can be connected globally or declared as part of a route.
Dependency Injection
Middleware can use dependency injection when they are declared as a middleware class. A middleware class must contain
a handle()
function which takes the same arguments as a normal Connect-style middleware function (request, response, next).
import { inject } from '@alterior/di';
class MyMiddleware {
private service = inject(MyService);
handle(request: IncomingMessage, response: ServerResponse, next: (err?: any) => void) {
// ...
next();
}
}
Global vs Route Middleware
Alterior handles middleware differently depending on whether it is mounted as global middleware or route middleware.
Global middleware:
- Is run as part of the underlying web server engine (Express, Fastify, etc)
- Supports path prefixing
- Does not have access to Alterior's
WebEvent.current
API
Route middleware:
- Is run by Alterior as part of executing a route
- Does not support path prefixing
- Has full access to Alterior's
WebEvent.current
API
Within global middleware WebEvent.current
will be undefined
. Accessing the current WebEvent
allows a middleware
to introspect the route that Alterior is about to run. This can be used to enable custom decorators and metadata
scenarios that otherwise would be impossible.
Global Middleware
To add global middleware across your application you can declare the middleware
property on your @WebService
.
import * as myConnectMiddleware from 'my-connect-middleware';
@WebService({
middleware: [myConnectMiddleware()]
})
export class MyService {
// ...
}
Path-specific Global Middleware
You can also connect middleware globally, but limit it to specific paths:
import fileUpload = require('express-fileupload');
@WebService({
middleware: [
['/files', fileUpload]
]
})
export class MyService {
// ...
}
Route Middleware
To add middleware to a specific route, use the middleware
property of the options object:
@Get('/foo', { middleware: [fileUpload] })
public getFoo() {
// ...
}
Apply middleware to all routes
You can apply route-specific middleware just before all routes of a @Controller()
or @WebService()
using
preRouteMiddleware
. Similarly you can add middleware after all route-specific middleware using
postRouteMiddleware
.
import fileUpload = require('express-fileupload');
@Controller('/files', {
preRouteMiddleware: [ myAuthMiddleware ]
})
export class MyController {
// ...
}
Route Middleware is inherited through @Mount()
Middleware is inherited from parent controllers when using @Mount()
,
you can use this to avoid repeating yourself when building more complex
services:
@Controller()
class FeatureController {
@Get()
get() {
// corsExampleMiddleware runs for this and all requests on this
// controller
return {
service: 'feature'
}
}
}
@Controller('', {
preRouteMiddleware: [
corsExampleMiddleware({ allowOrigin: '*' })
]
})
class ApiController {
@Mount('/feature')
feature : FeatureController;
}
Global Middleware within @Controller()
You can also connect global middleware at the @Controller()
level which is automatically limited to the path prefix
of the controller. Caution: If you have multiple controllers with the same path prefix (especially common when
using prefix-less controllers, which is a recommended practice), you may inadvertently apply middleware to more
routes than you are expecting.. You should almost always use preRouteMiddleware
instead (see above).
import fileUpload = require('express-fileupload');
@Controller('/files', {
globalMiddleware: [ //
fileUpload
]
})
export class MyController {
// ...
}
Uncaught Exceptions
When an exception occurs while executing a controller route method (excluding HttpExceptions), Alterior will respond
with an HTTP 500 error. By default, exception information will be included with the response. If the caught exception
has a toString()
method, it will be executed and its return value will be sent. If it does not, the error object will
be included directly, being converted along with the rest of the response to JSON.
throw new Error('This is the error text')
would produce:
{
"message":"An exception occurred while handling this request.",
"error":"Error: This is the error text\n at FooController.sampleRequest (music.js:36:29)"
}
throw { foo: 'bar' }
would product:
{"message":"An exception occurred while handling this request.","error":{"foo":"bar"}}
You can disable the inclusion of exception information in responses (and this is recommended for production).
To do so, set WebServerOptions.hideExceptions
to true
. The error
field will then be excluded from 500 responses.
{"message":"An exception occurred while handling this request."}
Sessions
WARNING: Generally APIs should not use cookies. If your API is used by a by a browser application (which is the main reason you would use cookies in the first place), it is essential that you restrict the allowed origins of your API using CORS to ensure that other (potentially malicious) origins cannot request your API using credentials saved in the browser of your authorized users. Known as Cross Site Request Forgery (CSRF), this is a serious security vulnerability and it should be treated with care.
It is far better to use the Authorization
header to pass an explicit auth token and if necessary correlate that token to a server-managed session instead. Authorization headers are managed by the calling application, not by the user agent and are not automatically sent with requests to your API. Doing so can avoid many of the pitfalls that using cookies can cause.
Nonetheless, if you understand the security risks and have taken the proper precautions, it is possible to use cookie-driven sessions with Alterior, though managing the session itself is not included by default.
To add session support, use express-session
:
npm i express-session --save
npm i @types/express-session --save-dev
Include it as middleware:
import * as session from 'express-session';
@WebService({
middleware: [session({ secret: SESSION_SECRET })]
})
You can then use the session via the Session
class which is provided for you. The simplest way to use it is via the get()
and set()
methods:
@Controller()
class SampleController {
@Get('/')
home() {
return Session.current.get('cartTotal');
}
}
Using get()
and set()
provide no benefits via Typescript. The Session
class can be subclassed however:
class MySession extends Session {
cartTotal : number;
}
You can then access that session from within your route methods like so:
MySession.current.cartTotal
Note that both Session.current
and MySession.current
only have meaning when called from within a route method while an HTTP request is being processed.
OpenAPI / Swagger
Alterior can automatically generate an OpenAPI v2 schema for your defined REST endpoints. To do so, mount the included OpenApiController:
@Mount('/openapi')
openapi: OpenApiController;
Testing
Use teststrap()
to test endpoints in your web service. Since the caller and the server are in the same process, the actual HTTP server is skipped, with requests passed directly from the teststrap()
test to an instance of your web service.
teststrap()
uses supertest as its core testing mechanism. The type of values returned by teststrap()
is supertest.Supertest<supertest.Test>
.
import { teststrap } from '@alterior/web-server/dist/testing';
@WebService()
class ExampleService {
@Get('/')
info() {
return { name: 'example', version: '1.0' };
}
}
// suite/it/describe are from razmin (https://github.com/rezonant/razmin)
// you could use any test framework to encapsulate the
// teststrap() assertions.
suite(describe => {
describe('ExampleService', it => {
it('returns its name', async () => {
await teststrap(ExampleService)
.get('/')
.expect(200, { name: 'example', version: '1.0' })
});
});
});
You can reuse a teststrap()
test should you need to perform multiple requests in your tests:
let test = teststrap(ExampleService);
await test.get('/')
.expect(200, { name: 'example', version: '1.0' })
;
await test.get('/foo')
.expect(200, { other: 123 })
;
supertest
offers a number of convenient expectations, but sometimes you need to do something more complex:
import { expect } from 'chai';
let res : express.Response = await teststrap(ExampleService)
.get('/')
.expect(200)
;
expect(res.body).to.contain({ name: })
For more information about the capabilities of teststrap()
, consult the supertest documentation.
Accessing the underlying Express application
Perhaps you need access to the Express (or other web engine) application object to do something Alterior doesn't support:
import
@WebService()
export class MyService {
constructor(
) {
let server = WebServer.for(this);
this.expressApp = server.engine.app;
this.expressApp.get('/something', (req, res) => {
res.status(200).send('/something works!');
});
}
private expressApp : express.Application;
}
You can call WebServer.for()
and pass any web service or any controller mounted within a web service. Always pass the object instance (this
) in order to ensure you get the correct web server instance. Note that if your controller is used in multiple web services, different instances of your controller will correspond to different instances of WebServer
.
Accessing the http.Server instance
The http.Server
instance is only available after the server has begun listening to the configured port. This happens
after all modules receive the OnStart
event (ie via altOnStart
), so getting access to it during startup cannot be
done using the usual means. For this reason the service class can receive an additional event when the web service
has begun listening.
altOnListen(server : WebServer) {
// access http.Server instance via `server.httpServer`
}
If you serve over HTTPS and make use of the insecurePort
option to also listen on HTTP, there will be two http.Server instances. You can access the insecure one using
insecureServer
.
Setting the global timeout policy
Node.js http.Server has a default timeout of 2 minutes (120 seconds). You may need to increase/decrease this timeout depending on your use case and desired policies. You can control this from the top level Service class for your web service:
altOnListen(server : WebServer) {
server.httpServer.setTimeout(1000 * 25); // set global request timeout to 25 seconds
}
Deploying to a Cloud Function
You can deploy an Alterior web service as a Cloud Function (Google Cloud Functions, AWS Lambda, or other Function-as-a-Service (FaaS) providers) using WebServer.bootstrapCloudFunction()
:
// main.ts
import { MyWebService } from './my-web-service';
import { WebServer } from '@alterior/web-server';
export const cloudFunction = WebServer.bootstrapCloudFunction(MyWebService);
bootstrapCloudFunction()
will handle constructing a function which takes an Express request
and response
and routes the given request
through the given Alterior WebService module and populating data into response
. This is suitable for exporting into a general cloud function environment like GCF or Lambda.
Note: You can only pass a @WebService()
class (ie, the top level of your web service). You cannot pass a @Controller()
class, as controllers by themselves are not Alterior modules, and do not automatically