# Platform Serverless
Ts.ED provides two packages to reach specific use cases:
@tsed/platform-serverless
to deploy optimized AWS Lambda functions,@tsed/platform-serverless-http
to deploy serverless applications with a full HTTP Server like Express.js or Koa.js.
This documentation will be essentially focused on the @tsed/platform-serverless
package.
For more information about the @tsed/platform-serverless-http
package, please refer to
the Serverless HTTP documentation.
# Features
This package provides a way to deploy your Ts.ED application on AWS using the AWS API Gateway and AWS Lambda. It's useful if you want to deploy your application on AWS without any dependencies on Express or Koa.
This is the best choice if you want to deploy optimized AWS Lambda.
Here the supported features:
- Creating multiple lambda in one file,
- Support routing using find-my-way (the awesome router supported by Fastify),
- DI injection with
@tsed/di
, - Models mapping using
@tsed/schema
and@tsed/json-mapper
, - Params decorators can be used from
@tsed/platform-params
to get Query, Body, etc... - Response can be modified by using
@tsed/platform-response-filter
, - All error can be handled by using
@tsed/platform-exceptions
, - Operation descriptions like
@Returns
, @tsed/async-hook-context
to inject Context anywhere in your class!- All ORM already available for other Ts.ED platform,
- All kind of lambda can be handled (APIGateway, Lambda Auth, SQS, etc...),
- Streaming response
# Unsupported features
# Middlewares
This mechanism is specific to Koa.js / Express.js and doesn't exist with the Serverless approach.
Serverless provides a number of features such as Cors management via the configuration of serverless.yml
and doesn't
need to use middleware. In addition, serverless plugins (opens new window) are also available and
allow you to manage advanced scenarios without having to develop anything!
If you can't find what you are looking for on the Serverless side, you can use @tsed/di
'
s Interceptors to decorate the methods and add shareable features.
# Passport/OIDC/GraphQL, etc.
Because middlewares aren't supported Passport.js, OIDC, GraphQL won't be usable in this platform either.
# Upload files
File upload isn't covered at this time. Any help is welcome to improve this platform 😃.
# Statics files
The goal of lambda isn't to expose static files. We do not plan to support this use in the near future.
# Rule
WARNING
By convention, try to not import something from @tsed/common
. @tsed/common
embed a lot of codes designed
for the Full server platform which are not necessary in the Serverless context and aren't optimized for it.
The recent version of Ts.ED expose all necessary decorators from @tsed/schema
, @tsed/platform-params
or @tsed/di
.
For example,
Get
or
Post
are commonly imported like this:
import {Get} from "@tsed/common";
Now, you have to import the decorator from @tsed/schema
.
import {Get} from "@tsed/schema";
# Projects example
# Installation
The best option is to checkout the full terraform project available on GitHub (opens new window). This quick start project provides:
- Stack to build and deploy lambda using localstack,
- Monorepo structure to develop lambda, commands and standard Ts.ED application,
- DynamoDB code example,
- Unit tests and e2e tests examples,
- GitHub actions to run tests,
- Terraform configuration to deploy lambda on AWS,
- Lambda Authorizer example.
It's also a full ESM stack!
You can also start from an existing project or generate a new project with the CLI:
tsed init .
? Choose the target platform: Express.js
? Choose the architecture for your project: Ts.ED
? Choose the convention file styling: Ts.ED
? Check the features needed for your project Swagger, Testing, Linter
? Choose unit framework Jest
? Choose linter tools framework EsLint
? Choose extra linter tools Prettier, Lint on commit
? Choose the package manager: Yarn
2
3
4
5
6
7
8
9
Or you can install the package manually:
# Create your first Lambda
Create a new folder src/lambda
and create a new Ts.ED controller:
import {Controller, Inject} from "@tsed/di";
import {Get, Returns, Summary} from "@tsed/schema";
import {QueryParams} from "@tsed/platform-params"; // /!\ don't import decorators from @tsed/common
import {TimeslotsService} from "../services/TimeslotsService";
import {TimeslotModel} from "../models/TimeslotModel";
@Controller("/timeslots")
export class TimeslotsController {
@Inject()
protected timeslotsService: TimeslotsService;
@Get("/")
@Summary("Return a list of timeslots")
@Returns(200, Array).Of(TimeslotModel)
getAll(@QueryParams("date_start") dateStart: Date, @QueryParams("date_end") dateEnd: Date) {
return this.timeslotsService.find({
dateStart,
dateEnd
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Create new handler.ts
to expose your lambda:
import "@tsed/ajv"; // enable validation
import {PlatformServerless} from "@tsed/platform-serverless";
import {TimeslotsController} from "./TimeslotsController.js";
const config = {
envs: process.env
};
export const timeslots = PlatformServerless.callback(TimeslotsController, "getAll", config);
2
3
4
5
6
7
8
9
That's it! You can now deploy your lambda on AWS.
TIP
If you want to have mode details on how to deploy your lambda using the Serverless framework, please read the dedicated tutorial here.
TIP
Our project example provides a full terraform configuration to deploy your lambda on AWS. See more details on https://github.com/tsedio/tsed-aws-lambda/tree/main/packages/lambda/terraform.
# Expose multiple lambda in one file
You can expose multiple lambda in one file. Just create a new controller with different methods like that:
import {Controller, Inject} from "@tsed/di";
import {Get, Returns, Summary} from "@tsed/schema";
import {QueryParams} from "@tsed/platform-params"; // /!\ don't import decorators from @tsed/common
import {TimeslotsService} from "../services/TimeslotsService";
import {TimeslotModel} from "../models/TimeslotModel";
@Controller("/timeslots")
export class TimeslotsController {
@Inject()
protected timeslotsService: TimeslotsService;
@Get("/:id")
getById() {
// code
}
@Get("/")
@Summary("Return a list of timeslots")
@Returns(200, Array).Of(TimeslotModel)
getAll(@QueryParams("date_start") dateStart: Date, @QueryParams("date_end") dateEnd: Date) {
return this.timeslotsService.find({
dateStart,
dateEnd
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Now we need to expose the all controller methods in the handler.ts
file:
import "@tsed/ajv"; // enable validation
import {PlatformServerless} from "@tsed/platform-serverless";
import {TimeslotsController} from "./TimeslotsController.js";
// shared configuration
const config = {
envs: process.env,
lambda: [TimeslotsController],
imports: [
{
token: TimeslotsRepository,
useClass: DynamoDBTimeslotsRepository
}
]
};
const platform = PlatformServerless.bootstrap(config);
export const timeslots = platform.handler();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
When you use the PlatformServerless.bootstrap
method, Ts.ED expose an handler
that handle all request to the appropriate controller methods based on the request path.
To allow that, you have to configure AWS API Gateway to route all requests to the same lambda.
- using terraform: https://github.com/tsedio/tsed-aws-lambda/blob/main/packages/lambda/terraform/gateway.tf
- using serverless: https://github.com/tsedio/tsed-example-aws/blob/master/simple-proxy-api.yaml
# Get AwsContext and AwsEvent
Ts.ED provides a ServerlessContext
abstraction to access the AWS Context and AWS Event.
Here an example to access the AWS Context and AWS Event:
import {Controller} from "@tsed/di";
import {QueryParams, Context} from "@tsed/platform-params"; // /!\ don't import decorators from @tsed/common
import {ServerlessContext} from "@tsed/platform-serverless";
import type {APIGatewayProxyEventV2} from "aws-lambda";
@Controller()
export class TimeslotsController {
get(@Context() $ctx: ServerlessContext) {
console.log($ctx.context); // AWS Context
console.log($ctx.event); // AWS Event
console.log($ctx.getEvent<APIGatewayProxyEventV2>()); // AWS Event with the correct typings
console.log($ctx.response); // Response Platform abstraction layer
console.log($ctx.request); // Request Platform abstraction layer
$ctx.response.setHeader("x-test", "test");
return {};
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Lambda Authorizer
A Lambda authorizer is a Lambda function that you provide to control access to your API. It's a good way to implement a custom authorization scheme.
Source: https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html
To implement a lambda authorizer you have to create a handler as follows:
import "@tsed/ajv"; // enable validation
import {LambdaAuthorizerController} from "@project/controllers/LambdaAuthorizerController.js";
import {PlatformServerless} from "@tsed/platform-serverless";
// shared configuration
const config = {
envs: process.env,
auth: {}
};
export const authorizer = PlatformServerless.callback(LambdaAuthorizerController, "authorizer", config);
2
3
4
5
6
7
8
9
10
11
12
Then create a new controller to process the Lambda Auth event.
import {JwtService} from "@project/infra/auth/services/JwtService.js";
import {AwsPolicyService} from "@project/infra/aws/auth/AwsPolicyService.js";
import {Controller, Inject} from "@tsed/di";
import {Context} from "@tsed/platform-params";
import {ServerlessContext} from "@tsed/platform-serverless";
import {Description, Get} from "@tsed/schema";
import type {APIGatewayTokenAuthorizerEvent} from "aws-lambda";
export type AuthorizeServerlessContext = ServerlessContext<APIGatewayTokenAuthorizerEvent>;
@Controller("/")
export class LambdaAuthorizerController {
@Inject()
protected awsPolicyService: AwsPolicyService;
@Inject()
protected jwtService: JwtService;
@Get("/authorizer")
@Description("this endpoint is used to authorize the request using Lambda aws")
async authorizer(@Context() $ctx: AuthorizeServerlessContext) {
const decodedToken = await this.jwtService.decode($ctx.event.authorizationToken.replace("Bearer ", ""));
const {user} = decodedToken.payload;
// example on how to manage auth and format the response depending on the user scope
if (!user.scopes.includes("api")) {
// not allowed to consume API
return $ctx.response.status(401).body(this.awsPolicyService.generateDeny("me", $ctx.event.methodArn));
}
if (user.scopes.includes("timeslots")) {
// allowed to consume timeslots
return this.awsPolicyService.generateAllow("me", $ctx.event.methodArn, {
user
});
}
// no API resource allowed
return this.awsPolicyService.generateDeny("me", $ctx.event.methodArn);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
In this example we implement an authentication strategy to consume the JWT token. The jwtService
validate/decode the token and extract the scopes. Then we return a policy to allow or deny the request.
WARNING
Note here, the example use a TOKEN
event type. This is why we use APIGatewayTokenAuthorizerEvent
interface to enable the correct
type checking. If you use a REQUEST
event type use the appropriate interface.
Let's see the AwsPolicyService:
import {UserInfo} from "@project/domain/users/UserInfo.js";
import {Injectable} from "@tsed/di";
import {serialize} from "@tsed/json-mapper";
export interface AwsAuthResponse {
principalId: string;
policyDocument?: {
Version: string;
Statement: {
Action: string;
Effect: string;
Resource: string;
}[];
};
context: {
user?: UserInfo;
};
}
@Injectable()
export class AwsPolicyService {
// Help function to generate an IAM policy
generatePolicy(principalId: string, effect: string, resource: string, context: AwsAuthResponse["context"]): AwsAuthResponse {
// Required output:
const authResponse: AwsAuthResponse = {
principalId,
// Optional output with custom properties of the String, Number or Boolean type.
context: {
...context,
user: serialize(context.user)
}
};
if (effect && resource) {
authResponse.policyDocument = {
Version: "2012-10-17",
Statement: [
{
Action: "execute-api:Invoke",
Effect: effect,
Resource: resource
}
]
};
}
return authResponse;
}
generateAllow(principalId: string, resource: string, context: AwsAuthResponse["context"]) {
return this.generatePolicy(principalId, "Allow", resource, context);
}
generateDeny(principalId: string, resource: string, context: AwsAuthResponse["context"] = {}) {
return this.generatePolicy(principalId, "Deny", resource, context);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
The most important part is the generatePolicy
method that generates the policy response for the AWS Lambda Authorizer.
This payload will be used by the AWS API Gateway to allow or deny the request and will be forwarded to the protected lambda.
Here is an example to retrieve the auth context in your lambda:
import {Controller} from "@tsed/di";
import {Context} from "@tsed/platform-params";
import {ServerlessContext} from "@tsed/platform-serverless";
@Controller()
export class TimeslotsController {
get(@Context() $ctx: ServerlessContext) {
console.log($ctx.event.authorizer.user); // Show user info serialized by the AwsPolicyService
return {};
}
}
2
3
4
5
6
7
8
9
10
11
12
# Stream response
AWS support streaming response. To enable this feature, you have to configure your lambda to return a stream response (see the AWS documentation (opens new window)) and configure your controller method to tell Ts.ED to generate handler for streaming response.
Here an example to stream a response:
import {Controller} from "@tsed/di";
import {BodyParams} from "@tsed/platform-params";
import {ServerlessContext} from "@tsed/platform-serverless";
@Controller()
class StreamLambda {
@Post("/scenario-1/:id")
@Returns(200, String).Binary()
scenario1(@BodyParams("id") id: string) {
return Readable.from(
Buffer.from(
JSON.stringify({
id: "HELLO"
})
)
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
TIP
.Binary()
is used to tell Ts.ED to stream the response.
Here is also an example to test your code:
describe("Stream", () => {
beforeEach(
PlatformServerlessTest.bootstrap(PlatformServerless, {
lambda: [StreamLambda]
})
);
afterEach(() => PlatformServerlessTest.reset());
describe("scenario1: Post lambda with body and handle response", () => {
it("should return data", async () => {
const response = await PlatformServerlessTest.request.call("scenario1").post("/").body({
id: "1",
name: "Test"
});
expect(response).toEqual({
body: '{"id":"HELLO"}',
headers: {
"content-type": "application/octet-stream",
"x-request-id": "requestId"
},
statusCode: 200
});
});
});
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Testing
Ts.ED provide a way to test you lambda with mocked Aws event and context by using the PlatformServerlessTest util.
Here an example to test a Lambda controller:
import {PlatformServerlessTest} from "@tsed/platform-serverless-testing";
import {PlatformServerless} from "@tsed/platform-serverless";
@Controller("/")
class TimeslotsLambdaController {
@Get("/")
getAll() {
return [];
}
@Get("/:id")
getById(@PathParams("id") id: string, @QueryParams("start_date") startDate: Date, @QueryParams("end_date") endDate: Date) {
return {
id,
startDate,
endDate
};
}
}
describe("TimeslotsLambdaController", () => {
beforeEach(
PlatformServerlessTest.bootstrap(PlatformServerless, {
lambda: [TimeslotsLambdaController]
})
);
afterEach(() => PlatformServerlessTest.reset());
describe("Invoke by lambda name", () => {
it("should call getAll Lambda", async () => {
const response = await PlatformServerlessTest.request.call("getAll");
expect(response.statusCode).toEqual(200);
expect(response.headers).toEqual({
"x-request-id": "requestId",
"content-type": "application/json"
});
expect(JSON.parse(response.body)).toEqual([]);
});
it("should call getById Lambda", async () => {
const response = await PlatformServerlessTest.request
.call("getById")
.params({
id: "1"
})
.query({
start_date: new Date("2020-01-01"),
end_date: new Date("2020-01-10")
});
expect(response.statusCode).toEqual(200);
expect(response.headers).toEqual({
"x-request-id": "requestId",
"content-type": "application/json"
});
expect(JSON.parse(response.body)).toEqual({
id: "1",
endDate: "2020-01-10T00:00:00.000Z",
startDate: "2020-01-01T00:00:00.000Z"
});
});
});
describe("invoke using the router", () => {
it("should call getAll Lambda", async () => {
const response = await PlatformServerlessTest.request.get("/");
expect(response.statusCode).toEqual(200);
expect(response.headers).toEqual({
"x-request-id": "requestId",
"content-type": "application/json"
});
expect(JSON.parse(response.body)).toEqual([]);
});
});
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
Last Updated: 10/24/2024, 6:33:40 AM
Other topics
- Session & cookies
- Passport.js
- Keycloak
- Prisma
- TypeORM
- MikroORM
- Mongoose
- GraphQL
- GraphQL WS
- Apollo
- TypeGraphQL
- GraphQL Nexus
- Socket.io
- Swagger
- AJV
- Multer
- Serve static files
- Templating
- Serverless HTTP
- Seq
- OIDC
- Stripe
- Agenda
- Terminus
- Serverless
- Server-sent events
- IORedis
- Vike
- Jest
- Vitest
- Controllers
- Providers
- Model
- JsonMapper
- Middlewares
- Pipes
- Interceptors
- Authentication
- Hooks
- Exceptions
- Throw HTTP Exceptions
- Cache
- Command
- Response Filter
- Injection scopes
- Custom providers
- Lazy-loading provider
- Custom endpoint decorator
- Testing
- Customize 404