0.3.77 • Published 1 year ago

tyx-synergi v0.3.77

Weekly downloads
-
License
MIT
Repository
github
Last release
1 year ago

TyX Core Framework

Serverless back-end framework in TypeScript for AWS Lambda. Declarative dependency injection and event binding.

Table of Contents

1. Installation

Install module:

npm install tyx --save

reflect-metadata shim is required:

npm install reflect-metadata --save

and make sure to import it before you use tyx:

import "reflect-metadata";

Its important to set these options in tsconfig.json file of your project:

{
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true
}

2. Examples of Usage

The following examples are constructed to cover all features of TyX.

TODO: How to build and run the examples

2.1. Simple service

The most basic use scenario is a REST service. It is not required to inherit any base classes; use of the provided decorators is sufficient to bind service methods to corresponding HTTP methods and paths: @Get, @Post, @Put, @Delete. Method arguments bind to request elements using decorators as well; @QueryParam, @PathParam and @Body are the core binding.

Use of @Service() decorator is mandatory to mark the class as service and enable proper collection of metadata emitted from decorators. As security consideration TyX does not default to public access for service methods, @Public() decorator must be explicitly provided otherwise the HTTP binding is effectively disabled.

For simplicity in this example all files are in the same folder package.json, service.ts, function.ts, local.ts and serverless.yml

Service implementation

import { Service, Public, PathParam, QueryParam, Body, Get, Post, Put, Delete } from "tyx";

@Service()
export class NoteService {

    @Public()
    @Get("/notes")
    public getAll(@QueryParam("filter") filter?: string) {
        return { action: "This action returns all notes", filter };
    }

    @Public()
    @Get("/notes/{id}")
    public getOne(@PathParam("id") id: string) {
        return { action: "This action returns note", id };
    }

    @Public()
    @Post("/notes")
    public post(@Body() note: any) {
        return { action: "Saving note...", note };
    }

    @Public()
    @Put("/notes/{id}")
    public put(@PathParam("id") id: string, @Body() note: any) {
        return { action: "Updating a note...", id, note };
    }

    @Public()
    @Delete("/notes/{id}")
    public remove(@PathParam("id") id: string) {
        return { action: "Removing note...", id };
    }
}

Lambda function

Services are plain decorated classes unaware of the specifics of AWS Lambda, the provided LambdaContainer class takes care of managing the service and dispatching the trigger events. The container export() provides the handler entry point for the lambda function.

import { LambdaContainer, LambdaHandler } from "tyx";
import { NoteService } from "./service";

// Creates an Lambda container and publish the service.
let container = new LambdaContainer("tyx-sample1")
    .publish(NoteService);
// Export the lambda handler function
export const handler: LambdaHandler = container.export();

Express container

For local testing developers can use the ExpressContainer class, it exposes routes based on service method decorations. When run in debug mode from an IDE (such as VS Code) it allows convenient debugging experience.

import { ExpressContainer } from "tyx";
import { NoteService } from "./service";

// Creates an Express container and publish the service.
let express = new ExpressContainer("tyx-sample1")
    .publish(NoteService);
// Start express server
express.start(5000);

Open in browser http://localhost:5000/notes or http://localhost:5000/notes/1.

Serverless file

Serverless Framework is used to package and deploy functions developed in TyX. Events declared in serverless.yml should match those exposed by services published in the function LambdaContainer. Missing to declare the event will result in ApiGateway rejecting the request, having events (paths) not bound to any service method will result in the container rejecting the request.

service: tyx-sample1

provider:
  name: aws
  region: us-east-1
  stage: demo
  runtime: nodejs6.10
  memorySize: 128
  timeout: 5
  
  environment:
    STAGE: ${self:service}-${opt:stage, self:provider.stage}
    LOG_LEVEL: INFO
  
functions:
  notes-function:
    handler: function.handler
    events:
      - http:
          path: notes
          method: GET
          cors: true
      - http:
          path: notes/{id}
          method: GET
          cors: true
      - http:
          path: notes/{id}
          method: POST
          cors: true
      - http:
          path: notes/{id}
          method: PUT
          cors: true
      - http:
          path: notes/{id}
          method: DELETE
          cors: true

2.2. Dependency injection

It is possible write an entire application as single service but this is rarely justified. It make sense to split the application logic into multiple services each encapsulating related actions and responsibilities.

This example has a more elaborate structure, separate service API definition and implementation using dependency injection. Two of of the services are for private use within the same container not exposing any event bindings.

The folder structure used starting with this example:

  • api/ scripts with service API definition
  • services/ implementations
  • functions/ scripts with LambdaContainer exporting a handler function
  • local/ local run using ExpressContainer
  • serverless.yml Serverless Framework

Following examples will further build on this to split services into dedicated functions and then into separate applications (Serverless projects).

API definition

TypeScript interfaces have no corresponding representation once code compiles to JavaScript; so to use the interface as service identifier it is also declared and exported as a constant. This is allowed as TypeScript supports declaration merging.

The services API are returning Promises, this should be a default practice as real life service implementations will certainly use external libraries that are predominantly asynchronous. TypeScript support for async and await makes the code concise and clean.

  • api/box.ts
export const BoxApi = "box";

export interface BoxApi {
    produce(type: string): Promise<Box>;
}

export interface Box {
    service: string;
    id: string;
    type: string;
}
  • api/item.ts
export const ItemApi = "item";

export interface ItemApi {
    produce(type: string): Promise<Item>;
}

export interface Item {
    service: string;
    id: string;
    name: string;
}
  • api/factory.ts
import { Box } from "./box";
import { Item } from "./item";

export const FactoryApi = "factory";

export interface FactoryApi {
    produce(boxType: string, itemName: string): Promise<Product>;
}

export interface Product {
    service: string;
    timestamp: string;
    box: Box;
    item: Item;
}

Services implementation

Interfaces are used as service names @Service(BoxApi) and @Inject(ItemApi) as well as types of the injected properties (dependencies). Dependencies are not part of the service API but its implementation. TypeScript access modifiers (public, private, protected) are not enforced in runtime so injected properties can be declared protected as a convention choice.

Imports of API declarations are skipped in the code samples.

  • services/box.ts
@Service(BoxApi)
export class BoxService implements BoxApi {
    private type: string;
    constructor(type: string) {
        this.type = type || "default";
    }

    @Private()
    public async produce(type: string): Promise<Box> {
        return {
            service: ServiceMetadata.service(this),
            id: Utils.uuid(),
            type: type || this.type
        };
    }
}
  • services/item.ts
@Service(ItemApi)
export class ItemService implements ItemApi {
    @Private()
    public async produce(name: string): Promise<Item> {
        return {
            service: ServiceMetadata.service(this),
            id: Utils.uuid(),
            name
        };
    }
}

The @Private() declaration documents that the methods are intended for invocation within the container.

  • services/factory.ts
@Service(FactoryApi)
export class FactoryService implements FactoryApi {

    @Inject(BoxApi)
    protected boxProducer: BoxApi;

    @Inject(ItemApi)
    protected itemProducer: ItemApi;

    @Public()
    @Get("/product")
    public async produce(@QueryParam("box") boxType: string, @QueryParam("item") itemName: string): Promise<Product> {
        let box: Box = await this.boxProducer.produce(boxType);
        let item: Item = await this.itemProducer.produce(itemName || "item");
        return {
            service: ServiceMetadata.service(this),
            timestamp: new Date().toISOString(),
            box,
            item
        };
    }
}

Lambda function

The container can host multiple services but only those provided with publish() are exposed for external requests. Both register() and publish() should be called with the service constructor function (class), followed by any constructor arguments if required.

let container = new LambdaContainer("tyx-sample2")
    // Internal services
    .register(BoxService, "simple")
    .register(ItemService)
    // Public service
    .publish(FactoryService);

export const handler: LambdaHandler = container.export();

Express container

let express = new ExpressContainer("tyx-sample2")
    // Internal services
    .register(BoxService, "simple")
    .register(ItemService)
    // Public service
    .publish(FactoryService);

express.start(5000);

Serverless file

The Serverless file is only concerned with Lambda functions not the individual TyX services within those functions.

service: tyx-sample2

provider:
  name: aws
  region: us-east-1
  stage: demo
  runtime: nodejs6.10
  memorySize: 128
  timeout: 5
  
  environment:
    STAGE: ${self:service}-${opt:stage, self:provider.stage}
    LOG_LEVEL: INFO

functions:
  factory-function:
    handler: functions/factory.handler
    events:
      - http:
          path: product
          method: GET
          cors: true

2.3. Function per service

Decoupling the service API and implementation in the previous example allows to split the services into their own dedicated functions. This is useful when service functions need to have fine tuned settings; starting from the basic, memory and timeout, environment variables (configuration) up to IAM role configuration.

When deploying services in dedicated functions service-to-service communication is no longer a method call inside the same Node.js process. To allow transparent dependency injection TyX provides for proxy service implementation that using direct Lambda to Lambda function invocations supported by AWS SDK.

API definition

Identical to example 2.2. Dependency injection

Services implementation

The only difference from previous example is that BoxService and ItemService have their method decorated with @Remote() instead of @Private(). This allows the method to be called outside of the host Lambda functions.

  • services/box.ts
@Service(BoxApi)
export class BoxService implements BoxApi {
    private type: string;
    constructor(type: string) {
        this.type = type || "default";
    }

    @Remote()
    public async produce(type: string): Promise<Box> {
        return {
            service: ServiceMetadata.service(this),
            id: Utils.uuid(),
            type: type || this.type
        };
    }
}
  • services/item.ts
@Service(ItemApi)
export class ItemService implements ItemApi {
    @Remote()
    public async produce(name: string): Promise<Item> {
        return {
            service: ServiceMetadata.service(this),
            id: Utils.uuid(),
            name
        };
    }
}
  • services/factory.ts
@Service(FactoryApi)
export class FactoryService implements FactoryApi {

    @Inject(BoxApi)
    protected boxProducer: BoxApi;

    @Inject(ItemApi)
    protected itemProducer: ItemApi;

    @Public()
    @Get("/product")
    public async produce(@QueryParam("box") boxType: string, @QueryParam("item") itemName: string): Promise<Product> {
        let box: Box = await this.boxProducer.produce(boxType);
        let item: Item = await this.itemProducer.produce(itemName || "item");
        return {
            service: ServiceMetadata.service(this),
            timestamp: new Date().toISOString(),
            box,
            item
        };
    }
}

Proxy implementation

The provided LambdaProxy class takes care of invoking the remote function and converting back the received result or error thrown. The @Proxy decorator is mandatory and requires at minimum the name of the proxied service.

The full signature however is @Proxy(service: string, application?: string, functionName?: string), when not provided application defaults to the identifier specified in LambdaContainer constructor; functionName defaults to {service}-function, in this example box-function and item-function respectively.

  • proxies/box.ts
@Proxy(BoxApi)
export class BoxProxy extends LambdaProxy implements BoxApi {
    public async produce(type: string): Promise<Box> {
        return this.proxy(this.produce, arguments);
    }
}

- `proxies/item.ts`
@Proxy(ItemApi)
export class ItemProxy extends LambdaProxy implements ItemApi {
    public async produce(name: string): Promise<Item> {
        return this.proxy(this.produce, arguments);
    }
}

Lambda functions

The argument passed to LambdaContainer is application id and should correspond to the service setting in serverless.yml. Function-to-function requests within the same application are considered internal, while between different applications as remote. TyX has an authorization mechanism that distinguishes these two cases requiring additional settings. This example is about internal calls, next one covers remote calls. When internal function-to-function calls are used INTERNAL_SECRET configuration variable must be set; this is a secret key that both the invoking and invoked function must share so LambdaContainer can authorize the requests.

  • Box function
let container = new LambdaContainer("tyx-sample3")
    .publish(BoxService, "simple");

export const handler: LambdaHandler = container.export();
  • Item function
let container = new LambdaContainer("tyx-sample3")
    .publish(ItemService);

export const handler: LambdaHandler = container.export();
  • Factory function
let container = new LambdaContainer("tyx-sample3")
    // Use proxy instead of service implementation
    .register(BoxProxy)
    .register(ItemProxy)
    .publish(FactoryService);

export const handler: LambdaHandler = container.export();

Express container

The following code allows to execute the FactoryService in the local container while the proxies will interact with the deployed functions on AWS. The provided config.ts provides the needed environment variables defined in serverless.yml.

  • local/main.ts
import { Config } from "./config";

// Required for accessing Lambda via proxy on AWS
import AWS = require("aws-sdk");
AWS.config.region = "us-east-1";

let express = new ExpressContainer("tyx-sample3")
    .register(DefaultConfiguration, Config)
    .register(BoxProxy)
    .register(ItemProxy)
    .publish(FactoryService);

express.start(5000);
  • local/config.ts
export const Config = {
    STAGE: "tyx-sample3-demo",
    INTERNAL_SECRET: "7B2A62EF85274FA0AA97A1A33E09C95F",
    LOG_LEVEL: "INFO"
};

Serverless file

Since internal function-to-function requests are use INTERNAL_SECRET is set; this should be an application specific random value (e.g. UUID). The additional setting REMOTE_SECRET_TYX_SAMPLE4 is to allow remote requests from the next example.

It is necessary to allow the IAM role to lambda:InvokeFunction, of course it is recommended to be more specific about the Resource than in this example.

service: tyx-sample3

provider:
  name: aws
  region: us-east-1
  stage: demo
  runtime: nodejs6.10
  memorySize: 128
  timeout: 10
  
  environment:
    STAGE: ${self:service}-${opt:stage, self:provider.stage}
    INTERNAL_SECRET: 7B2A62EF85274FA0AA97A1A33E09C95F
    REMOTE_SECRET_TYX_SAMPLE4: D718F4BBCC7345749378EF88E660F701
    LOG_LEVEL: DEBUG
  
  iamRoleStatements: 
    - Effect: Allow
      Action:
        - lambda:InvokeFunction
      Resource: "arn:aws:lambda:${opt:region, self:provider.region}:*:*"

functions:
  box-function:
    handler: functions/box.handler
  item-function:
    handler: functions/item.handler
  factory-function:
    handler: functions/factory.handler
    events:
      - http:
          path: product
          method: GET
          cors: true

2.4. Remote service

Building upon the previous example this one demonstrates a remote request via LambdaProxy. The request is considered remote because involved services are deployed as separate serverless project - the previous example.

When remote function-to-function calls are used REMOTE_SECRET_(APPID) and REMOTE_STAGE_(APPID) configuration variables must be set. The first is a secret key that both the invoking and invoked function must share so LambdaContainer can authorize the calls; the second is (service)-(stage) prefix that Serverless Framework prepends to function names by default.

API definition

Identical to example 2.2. Dependency injection

Services implementation

BoxApi and ItemApi are not implemented as services in this example project, those provided and deployed by the previous example will be used via function-to-function calls.

@Service(FactoryApi)
export class FactoryService implements FactoryApi {

    @Inject(BoxApi, "tyx-sample3")
    protected boxProducer: BoxApi;

    @Inject(ItemApi, "tyx-sample3")
    protected itemProducer: ItemApi;

    @Public()
    @Get("/product")
    public async produce(@QueryParam("box") boxType: string, @QueryParam("item") itemName: string): Promise<Product> {
        let box: Box = await this.boxProducer.produce(boxType);
        let item: Item = await this.itemProducer.produce(itemName || "item");
        return {
            service: ServiceMetadata.service(this),
            timestamp: new Date().toISOString(),
            box,
            item
        };
    }
}

Proxy implementation

The second parameter of @Proxy() decorator is provided as the target service is not in this serverless project.

@Proxy(BoxApi, "tyx-sample3")
export class BoxProxy extends LambdaProxy implements BoxApi {
    public async produce(type: string): Promise<Box> {
        return this.proxy(this.produce, arguments);
    }
}

@Proxy(ItemApi, "tyx-sample3")
export class ItemProxy extends LambdaProxy implements ItemApi {
    public async produce(name: string): Promise<Item> {
        return this.proxy(this.produce, arguments);
    }
}

Lambda function

Only the factory service is exposed as a function.

let container = new LambdaContainer("tyx-sample4")
    // Use proxy instead of service implementation
    .register(BoxProxy)
    .register(ItemProxy)
    .publish(FactoryService);

export const handler: LambdaHandler = container.export();

Express container

import { Config } from "./config";

// Required for accessing Lambda via proxy on AWS
import AWS = require("aws-sdk");
AWS.config.region = "us-east-1";

let express = new ExpressContainer("tyx-sample4")
    .register(DefaultConfiguration, Config)
    .register(BoxProxy)
    .register(ItemProxy)
    .publish(FactoryService);

express.start(5000);

Serverless file

The environment variables provide the remote secret and stage for application tyx-sample3. In the previous example there is a matching REMOTE_SECRET_TYX_SAMPLE4 with the same value as REMOTE_SECRET_TYX_SAMPLE3 here, this pairs the applications. When a remote request is being prepared the secret for the target application is being used; when a remote request is received the secret corresponding to the requesting application is used to authorize the request.

service: tyx-sample4

provider:
  name: aws
  region: us-east-1
  stage: demo
  runtime: nodejs6.10
  memorySize: 128
  timeout: 10
  
  environment:
    STAGE: ${self:service}-${opt:stage, self:provider.stage}
    REMOTE_SECRET_TYX_SAMPLE3: D718F4BBCC7345749378EF88E660F701
    REMOTE_STAGE_TYX_SAMPLE3: tyx-sample3-demo
    LOG_LEVEL: DEBUG
  
  iamRoleStatements: 
    - Effect: Allow
      Action:
        - lambda:InvokeFunction
      Resource: "arn:aws:lambda:${opt:region, self:provider.region}:*:*"

functions:
  factory-function:
    handler: functions/factory.handler
    events:
      - http:
          path: product
          method: GET
          cors: true

2.5. Authorization

TyX supports role-based authorization allowing to control access on service method level. There is no build-in authentication support, for the purpose of this example a hard-coded login service is used. The authorization is using JSON Web Token that are issued and validated by a build-in Security service. The container instantiate the default security service if non is registered and use it to validate all requests to non-public service methods.

Apart from the @Public() permission decorator @Query<R>() and @Command<R>() are provided to decorate service methods reflecting if the execution results in data retrieval or manipulation (changes).

API definition

  • api/app.ts Definition of application roles interface, used as generic parameter of permission decorators.
import { Roles } from "tyx";

export interface AppRoles extends Roles {
    Admin: boolean;
    Manager: boolean;
    Operator: boolean;
}
  • api/login.ts Login service API
export const LoginApi = "login";

export interface LoginApi {
    login(userId: string, password: string): Promise<string>;
}
  • api/factory.ts Extended factory API
export const FactoryApi = "factory";

export interface FactoryApi {
    // Admin only
    reset(userId: string): Promise<Response>;
    createProduct(userId: string, productId: string, name: string): Promise<Confirmation>;
    removeProduct(userId: string, productId: string): Promise<Confirmation>;

    // Admin & Manager
    startProduction(userId: string, role: string, productId: string, order: any): Promise<Confirmation>;
    stopProduction(userId: string, role: string, productId: string, order: any): Promise<Confirmation>;

    // Operator
    produce(userId: string, role: string, productId: string): Promise<Item>;

    // Public
    status(userId: string, role: string): Promise<Status>;
}

export interface Response {
    userId: string;
    role: string;
    status: string;
}

export interface Product {
    productId: string;
    name: string;
    creator: string;
    production: boolean;
}

export interface Confirmation extends Response {
    product: Product;
    order?: any;
}

export interface Item extends Response {
    product: Product;
    itemId: string;
    timestamp: string;
}

export interface Status extends Response {
    products: Product[];
}

Login implementation

The login service provides a public entry point for users to obtain an access token. The injected Security service is always present in the container.

@Service(LoginApi)
export class LoginService implements LoginApi {

    @Inject(Security)
    protected security: Security;

    @Public()
    @Post("/login")
    @ContentType("text/plain")
    public async login(
            @BodyParam("userId") userId: string,
            @BodyParam("password") password: string): Promise<string> {
        let role: string = undefined;
        switch (userId) {
            case "admin": role = password === "nimda" && "Admin"; break;
            case "manager": role = password === "reganam" && "Manager"; break;
            case "operator": role = password === "rotarepo" && "Operator"; break;
        }
        if (!role) throw new Unauthorized("Unknown user or invalid password");
       return await this.security.issueToken({ subject: "user:internal", userId, role });
    }
}

Service implementation

Methods reset(), createProduct() and removeProduct() are decorated with @Command<AppRoles>({ Admin: true, Manager: false, Operator: false }) allowing only Admin users to invoke them via the HTTP bindings specified with @Post() and @Delete() decorators.

Methods startProduction() and stopProduction() are allowed to Admin and Manager role; both bind to the same path @Put("/product/{id}", true) however the second param set to true instructs the container that Content-Type header will have an additional parameter domain-model equal to the method name so to select the desired action, e.g. Content-Type: application/json;domain-model=startProduction.

Method produce() is allowed for all three roles but not for public access. Public access is allowed to method status() with @Query<AppRoles>({ Public: true, Admin: true, Manager: true, Operator: true }).

When the userId, role or other authorization attributes are needed in method logic @ContextParam("auth.{param}") can be used to bind the arguments. Other option is to use @ContextObject() and so get the entire context object as single attribute.

Having a service state products is for example purposes only, Lambda functions must persist the state in cloud services (e.g. DynamoDB).

@Service(FactoryApi)
export class FactoryService implements FactoryApi {

    private products: Record<string, Product> = {};

    // Admin only

    @Command<AppRoles>({ Admin: true, Manager: false, Operator: false })
    @Post("/reset")
    public async reset(
        @ContextParam("auth.userId") userId: string): Promise<Response> {
        this.products = {};
        return { userId, role: "Admin", status: "Reset" };
    }

    @Command<AppRoles>({ Admin: true, Manager: false, Operator: false })
    @Post("/product")
    public async createProduct(
        @ContextParam("auth.userId") userId: string,
        @BodyParam("id") productId: string,
        @BodyParam("name") name: string): Promise<Confirmation> {

        if (this.products[productId]) throw new BadRequest("Duplicate product");
        let product = { productId, name, creator: userId, production: false, orders: [] };
        this.products[productId] = product;
        return { userId, role: "Admin", status: "Create product", product };
    }

    @Command<AppRoles>({ Admin: true, Manager: false, Operator: false })
    @Delete("/product/{id}")
    public async removeProduct(
        @ContextParam("auth.userId") userId: string,
        @PathParam("id") productId: string): Promise<Confirmation> {

        let product = this.products[productId];
        if (!product) throw new NotFound("Product not found");
        delete this.products[productId];
        return { userId, role: "Admin", status: "Remove product", product };
    }

    // Admin & Manager

    @Command<AppRoles>({ Admin: true, Manager: true, Operator: false })
    @Put("/product/{id}", true)
    public async startProduction(
        @ContextParam("auth.userId") userId: string,
        @ContextParam("auth.role") role: string,
        @PathParam("id") productId: string,
        @Body() order: any): Promise<Confirmation> {

        let product = this.products[productId];
        if (!product) throw new NotFound("Product not found");
        product.production = true;
        product.orders.push(order);
        return { userId, role, status: "Production started", product, order };
    }

    @Command<AppRoles>({ Admin: true, Manager: true, Operator: false })
    @Put("/product/{id}", true)
    public async stopProduction(
        @ContextParam("auth.userId") userId: string,
        @ContextParam("auth.role") role: string,
        @PathParam("id") productId: string,
        @Body() order: any): Promise<Confirmation> {

        let product = this.products[productId];
        if (!product) throw new NotFound("Product not found");
        product.production = false;
        product.orders.push(order);
        return { userId, role, status: "Production stopped", product, order };
    }

    // + Operator

    @Command<AppRoles>({ Admin: true, Manager: true, Operator: true })
    @Get("/product/{id}")
    public async produce(
        @ContextParam("auth.userId") userId: string,
        @ContextParam("auth.role") role: string,
        @PathParam("id") productId: string): Promise<Item> {

        let product = this.products[productId];
        if (!product) throw new NotFound("Product not found");
        if (!product.production) throw new BadRequest("Product not in production");
        let item: Item = {
            userId, role,
            status: "Item produced",
            product,
            itemId: Utils.uuid(),
            timestamp: new Date().toISOString()
        };
        return item;
    }

    @Query<AppRoles>({ Public: true, Admin: true, Manager: true, Operator: true })
    @Get("/status")
    public async status(
        @ContextParam("auth.userId") userId: string,
        @ContextParam("auth.role") role: string): Promise<Status> {

        let products = [];
        Object.keys(this.products).forEach(k => products.push(this.products[k]));
        return { userId, role, status: "Status", products };
    }
}

Lambda functions

Login and Factory services are independent so can be deployed as separate functions.

  • services/factory.ts
let container = new LambdaContainer("tyx-sample5")
    .publish(FactoryService);

export const handler: LambdaHandler = container.export();
  • services/login.ts
let container = new LambdaContainer("tyx-sample5")
    .publish(LoginService);

export const handler: LambdaHandler = container.export();

Express container

The container constructor accepts an additional argument that is path prefix for all exposed routes. In this case /demo match how Api Gateway by default will include the stage name in the path.

import { Config } from "./config";

let express = new ExpressContainer("tyx-sample5", "/demo")
    .register(DefaultConfiguration, Config)
    .publish(LoginService)
    .publish(FactoryService);

express.start(5000);

Serverless file

When using authorization it is necessary to provide a HTTP_SECRET that is used to sign and verify the web tokens as well as HTTP_TIMEOUT how long the tokens are valid.

service: tyx-sample5

provider:
  name: aws
  region: us-east-1
  stage: demo
  runtime: nodejs6.10
  memorySize: 128
  timeout: 10
  
  environment:
    STAGE: ${self:service}-${opt:stage, self:provider.stage}
    HTTP_SECRET: 3B2709157BD8444BAD42DE246D41BB35
    HTTP_TIMEOUT: 2h
    LOG_LEVEL: DEBUG
  
functions:
  login-function:
    handler: functions/login.handler
    events:
      - http:
          path: login
          method: POST
          cors: true
  factory-function:
    handler: functions/factory.handler
    events:
      - http:
          path: reset
          method: POST
          cors: true
      - http:
          path: product
          method: POST
          cors: true
      - http:
          path: product/{id}
          method: DELETE
          cors: true
      - http:
          path: product/{id}
          method: PUT
          cors: true
      - http:
          path: product/{id}
          method: GET
          cors: true
      - http:
          path: status
          method: GET
          cors: true

Token sample

When posting to example LoginService at /demo/login with json body { userId: "admin", password: "nimda" } a token is received as plain text:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJvaWQiOiJhZG1pbiIsInJvbGUiOiJBZG1pbiIsImlhdCI6MTUwODk0NDI5NywiZX
hwIjoxNTA4OTUxNDk3LCJhdWQiOiJ0eXgtc2FtcGxlNSIsImlzcyI6InR5eC1zYW1w
bGU1Iiwic3ViIjoidXNlcjppbnRlcm5hbCIsImp0aSI6ImI4N2U1MDYyLTYwNjItND
k0Ny1iMTU1LWZmNzA0NzBhMTEzZCJ9.
b8H27N26QKFbFofuMPd1PGQHG7UeB5J1FIoQIte-dss

Decoded it contains the minimum info for the security service:

  • oid is user identifier
  • role application role
  • iat issued-at timestamp
  • exp expiry timestamp
  • aud application id the token is intended to
  • iss application id issuing the token
  • sub subject / token type
  • jti unique token id
{
  "alg": "HS256",
  "typ": "JWT"
}
{
  "oid": "admin",
  "role": "Admin",
  "iat": 1508944297,
  "exp": 1508951497,
  "aud": "tyx-sample5",
  "iss": "tyx-sample5",
  "sub": "user:internal",
  "jti": "b87e5062-6062-4947-b155-ff70470a113d"
}

2.6. Express service

Express is an established node.js web framework and there is a wealth of third party middleware packages that may not be available in other form. The ExpressService base class uses aws-serverless-express to host an Express application. This is not intended to host existing Express applications but more as a solution to bridge the gap for specific functionalities, for example use Passport.js to implement user authentication.

API definition

Service methods delegating to Express must have a signature method(ctx: Context, req: HttpRequest): Promise<HttpResponse>, the service can have ordinary methods as well.

export const ExampleApi = "example";

export interface ExampleApi {
    hello(): string;
    onGet(ctx: Context, req: HttpRequest): Promise<HttpResponse>;
    onPost(ctx: Context, req: HttpRequest): Promise<HttpResponse>;
    other(ctx: Context, req: HttpRequest): Promise<HttpResponse>;
}

Service implementation

Multiple routes delegated to Express for processing can be declared with decorators over a single method, such as other() in the example or over dedicated methods such as onGet() and onPost() to allow for logic preceding or following the Express processing. Presence of @ContentType("RAW") is required to pass the result verbatim to the user (statusCode, headers, body as generated by Express) otherwise the returned object will be treated as a json body.

The base class requires to implement the abstract method setup(app: Express, ctx: Context, req: HttpRequest): void that setup the Express app to be used for request processing. Each instance of the express app is used for a single request, no state can be maintained inside Lambda functions.

import BodyParser = require("body-parser");

@Service(ExampleApi)
export class ExampleService extends ExpressService implements ExampleApi {

    @Public()
    @Get("/hello")
    @ContentType("text/plain")
    public hello(): string {
        return "Express service ...";
    }

    @Public()
    @Get("/app")
    @ContentType("RAW")
    public async onGet(@ContextObject() ctx: Context, @RequestObject() req: HttpRequest): Promise<HttpResponse> {
        return super.process(ctx, req);
    }

    @Public()
    @Post("/app")
    @ContentType("RAW")
    public async onPost(@ContextObject() ctx: Context, @RequestObject() req: HttpRequest): Promise<HttpResponse> {
        return super.process(ctx, req);
    }

    @Public()
    @Put("/app")
    @Delete("/app/{id}")
    @ContentType("RAW")
    public async other(@ContextObject() ctx: Context, @RequestObject() req: HttpRequest): Promise<HttpResponse> {
        return super.process(ctx, req);
    }

    protected setup(app: Express, ctx: Context, req: HttpRequest): void {
        app.register(BodyParser.json());

        app.get("/app", (xreq, xres) => this.flush(xreq, xres, ctx, req));
        app.post("/app", (xreq, xres) => this.flush(xreq, xres, ctx, req));
        app.put("/app", (xreq, xres) => this.flush(xreq, xres, ctx, req));
        app.delete("/app/:id", (xreq, xres) => this.flush(xreq, xres, ctx, req));
    }

    private flush(xreq: Request, xres: Response, ctx: Context, req: HttpRequest) {
        let result = {
            msg: `Express ${req.method} method`,
            path: xreq.path,
            method: xreq.method,
            headers: xreq.headers,
            params: xreq.params,
            query: xreq.query,
            body: xreq.body,
            lambda: { ctx, req }
        };
        xres.send(result);
    }
}

Lambda function

let container = new LambdaContainer("tyx-sample6")
    .publish(ExampleService);

export const handler: LambdaHandler = container.export();

Express container

Applications containing express services can be run with the ExpressContainer, the container express instance and the internal service instance remain completely separate.

let express = new ExpressContainer("tyx-sample6")
    .publish(ExampleService);

express.start(5000);

Serverless file

There no special requirements for functions hosting Express services.

service: tyx-sample6

provider:
  name: aws
  region: us-east-1
  stage: demo
  runtime: nodejs6.10
  memorySize: 128
  timeout: 10
  
  environment:
    STAGE: ${self:service}-${opt:stage, self:provider.stage}
    LOG_LEVEL: DEBUG
  
functions:
  example-function:
    handler: functions/example.handler
    events:
      - http:
          path: hello
          method: GET
          cors: true
      - http:
          path: app
          method: GET
          cors: true
      - http:
          path: app
          method: POST
          cors: true
      - http:
          path: app
          method: PUT
          cors: true
      - http:
          path: app/{id}
          method: DELETE
          cors: true

2.7. Error handling

Error handling is implemented in TyX containers to ensure both explicitly thrown errors and runtime errors are propagated in unified format to the calling party. Classes corespnding to standard HTTP error responses are provided:

  • 400 BadRequest
  • 401 Unauthorized
  • 403 Forbidden
  • 404 NotFound
  • 409 Conflict
  • 500 InternalServerError
  • 501 NotImplemented
  • 503 ServiceUnavailable

API definition

  • api/calculator.ts combined service
export const CalculatorApi = "calculator";

export interface CalculatorApi {
    mortgage(amount: any, nMonths: any, interestRate: any, precision: any): Promise<MortgageResponse>;
    missing(req: any): Promise<number>;
    unhandled(req: any): Promise<number>;
}

export interface MortgageResponse {
    monthlyPayment: number;
    total: number;
    totalInterest: number;
}
  • api/mortgage.ts mortgage calculation service
export const MortgageApi = "mortgage";

export interface MortgageApi {
    calculate(amount: number, nMonths: number, interestRate: number, precision: number): Promise<number>;
}
  • api/missing.ts used for proxy to missing function
export const MissingApi = "missing";

export interface MissingApi {
    calculate(req: any): Promise<number>;
}
  • api/missing.ts used for proxy to a function throwing unhandled exception
export const UnhandledApi = "unhandled";

export interface UnhandledApi {
    calculate(req: any): Promise<number>;
}

Services implementation

  • services/calculator.ts validates that body parameters are present and expected type.
@Service(CalculatorApi)
export class CalculatorService implements CalculatorApi {

    @Inject(MortgageApi)
    protected mortgageService: MortgageApi;

    @Inject(MissingApi)
    protected missingService: MissingApi;

    @Inject(UnhandledApi)
    protected unhandledService: UnhandledApi;

    @Public()
    @Post("/mortgage")
    public async mortgage(@BodyParam("amount") amount: any,
        @BodyParam("nMonths") nMonths: any,
        @BodyParam("interestRate") interestRate: any,
        @BodyParam("precision") precision: any): Promise<MortgageResponse> {

        let _amount = Number.parseFloat(amount);
        let _nMonths = Number.parseFloat(nMonths);
        let _interestRate = Number.parseFloat(interestRate);
        let _precision = precision && Number.parseFloat(precision);

        // Type validation
        let errors: ApiErrorBuilder = BadRequest.builder();
        if (!Number.isFinite(_amount)) errors.detail("amount", "Amount required and must be a number, got: {input}.", { input: amount || null });
        if (!Number.isInteger(_nMonths)) errors.detail("nMonths", "Number of months required and must be a integer, got: {input}.", { input: nMonths || null });
        if (!Number.isFinite(_interestRate)) errors.detail("interestRate", "Interest rate required and must be a number, got: {input}.", { input: interestRate || null });
        if (_precision && !Number.isInteger(_precision)) errors.detail("precision", "Precision must be an integer, got: {input}.", { input: precision || null });
        if (errors.count()) throw errors.reason("calculator.mortgage.validation", "Parameters validation failed").create();

        let monthlyPayment = await this.mortgageService.calculate(_amount, _nMonths, _interestRate, _precision);

        return {
            monthlyPayment,
            total: monthlyPayment * _nMonths,
            totalInterest: (monthlyPayment * _nMonths) - _amount
        };
    }

    @Public()
    @Post("/missing")
    public async missing(@Body() req: any): Promise<number> {
        return this.missingService.calculate(req);
    }

    @Public()
    @Post("/unhandled")
    public async unhandled(@Body() req: any): Promise<number> {
        return this.unhandledService.calculate(req);
    }
}
  • services/mortgage.ts simple mortgage monthly payment calculator service, BadRequest.builder() returns a instance of ApiErrorBuilder allowing to progressively compose validation errors; in this case inputs are expected to be positive numbers.
@Service(MortgageApi)
export class MortgageService implements MortgageApi {

    @Remote()
    public async calculate(amount: number, nMonths: number, interestRate: number, precision: number = 5): Promise<number> {

        // Range validation
        let errors: ApiErrorBuilder = BadRequest.builder();
        if (amount <= 0) errors.detail("amount", "Amount must be grater than zero." );
        if (nMonths <= 0) errors.detail("nMonths", "Number of months  must be grater than zero.");
        if (interestRate <= 0) errors.detail("interestRate", "Interest rate must be grater than zero.");
        if (errors.count()) throw errors.reason("mortgage.calculate.validation", "Invalid parameters values").create();

        interestRate = interestRate / 100 / 12;
        let x = Math.pow(1 + interestRate, nMonths);
        return +((amount * x * interestRate) / (x - 1)).toFixed(precision);
    }
}

Proxy implementation

  • proxies/mortgage.ts Mortgage calculator is deployed as a dedicated Lambda function, to demonstrate that errors just as the return value is transparently passed.
@Proxy(MortgageApi)
export class MortgageProxy extends LambdaProxy implements MortgageApi {
    public calculate(amount: any, nMonths: any, interestRate: any): Promise<number> {
        return this.proxy(this.calculate, arguments);
    }
}
  • proxies/missing.ts Calling the proxy results in error due to non-existence of the target function
@Proxy(MissingApi)
export class MissingProxy extends LambdaProxy implements MissingApi {
    public calculate(req: any): Promise<number> {
        return this.proxy(this.calculate, arguments);
    }
}
  • proxies/unhandled.ts Calling the proxy results in unhandled error
@Proxy(UnhandledApi)
export class UnhandledProxy extends LambdaProxy implements UnhandledApi {
    public calculate(req: any): Promise<number> {
        return this.proxy(this.calculate, arguments);
    }
}

Lambda function

  • functions/calculator.ts
let container = new LambdaContainer("tyx-sample7")
    .register(MortgageProxy)
    .publish(CalculatorService);

export const handler: LambdaHandler = container.export();
  • functions/mortgage.ts
let container = new LambdaContainer("tyx-sample7")
    .publish(MortgageService);

export const handler: LambdaHandler = container.export();
  • functions/unhandled.ts Unhandled error is thrown instead using callback(err, null) for handled errors
export function handler(event: any, ctx: any, callback: (err, data) => void) {
    throw new Error("Not Implemented");
}

Serverless file

service: tyx-sample7

provider:
  name: aws
  region: us-east-1
  stage: demo
  runtime: nodejs6.10
  memorySize: 128
  timeout: 10
  
  environment:
    STAGE: ${self:service}-${opt:stage, self:provider.stage}
    INTERNAL_SECRET: 7B2A62EF85274FA0AA97A1A33E09C95F
    INTERNAL_TIMEOUT: 5s
    LOG_LEVEL: DEBUG
  
  # permissions for all functions
  iamRoleStatements: 
    - Effect: Allow
      Action:
        - lambda:InvokeFunction
      Resource: "arn:aws:lambda:${opt:region, self:provider.region}:*:*"

functions:
  mortgage-function:
    handler: functions/mortgage.handler
  unhandled-function:
    handler: functions/unhandled.handler
  calculator-function:
    handler: functions/calculator.handler
    events:
      - http:
          path: mortgage
          method: POST
          cors: true
      - http:
          path: missing
          method: POST
          cors: true
      - http:
          path: unhandled
          method: POST
          cors: true

Sample responses

When posting to /demo/mortgage a valid request:

{
    "amount": "15000",
    "nMonths": "15",
    "interestRate": "7",
    "precision": "2"
}

response is received:

{
    "monthlyPayment": 1047.3,
    "total": 15709.5,
    "totalInterest": 709.5
}

When any of required inputs is missing or not a number:

{
    "amount": "15000",
    "interestRate": "zero",
    "precision": "2"
}

HTTP 404 Bad Request is received with the error as json body:

{
	"code": 400,
	"message": "Parameters validation failed",
	"reason": {
		"code": "calculator.mortgage.validation",
		"message": "Parameters validation failed"
	},
	"details": [{
		"code": "nMonths",
		"message": "Number of months required and must be a integer, got: null.",
		"params": {
			"input": null
		}
	}, {
		"code": "interestRate",
		"message": "Interest rate required and must be a number, got: zero.",
		"params": {
			"input": "zero"
		}
	}],
	"stack": "BadRequest: Parameters validation failed\n    at CalculatorService.<anonymous> (/var/task/services/calculator.js:44:103)\n    at next (native)\n    at /var/task/services/calculator.js:19:71\n    at __awaiter (/var/task/services/calculator.js:15:12)\n    at CalculatorService.mortgage (/var/task/services/calculator.js:28:16)\n    at /var/task/node_modules/tyx/core/container/instance.js:202:47\n    at next (native)",
	"__class__": "BadRequest"
}

Sending a negative value will return an error generated in MortgageService:

{
    "amount": "15000",
    "nMonths": "15",
    "interestRate": "-7",
    "precision": "2"
}

Error repsonse:

{
	"code": 400,
	"message": "Invalid parameters values",
	"proxy": true,
	"reason": {
		"code": "mortgage.calculate.validation",
		"message": "Invalid parameters values"
	},
	"details": [{
		"code": "interestRate",
		"message": "Interest rate must be grater than zero."
	}],
	"stack": "BadRequest: Invalid parameters values\n    at MortgageService.<anonymous> (/var/task/services/mortgage.js:34:99)\n    at next (native)\n    at /var/task/services/mortgage.js:16:71\n    at __awaiter (/var/task/services/mortgage.js:12:12)\n    at MortgageService.calculate (/var/task/services/mortgage.js:24:16)\n    at /var/task/node_modules/tyx/core/container/instance.js:173:47\n    at next (native)",
	"__class__": "BadRequest"
}

On purpose there is no check on valid range for precision to demonstrate handling of runtime errors:

{
    "amount": "15000",
    "nMonths": "15",
    "interestRate": "7",
    "precision": "25"
}

Responds with 500 Internal Server Error:

{
	"code": 500,
	"message": "toFixed() digits argument must be between 0 and 20",
	"proxy": true,
	"cause": {
		"stack": "RangeError: toFixed() digits argument must be between 0 and 20\n    at Number.toFixed (native)\n    at MortgageService.<anonymous> (/var/task/services/mortgage.js:37:61)\n    at next (native)\n    at /var/task/services/mortgage.js:16:71\n    at __awaiter (/var/task/services/mortgage.js:12:12)\n    at MortgageService.calculate (/var/task/services/mortgage.js:24:16)\n    at /var/task/node_modules/tyx/core/container/instance.js:173:47\n    at next (native)\n    at /var/task/node_modules/tyx/core/container/instance.js:7:71\n    at __awaiter (/var/task/node_modules/tyx/core/container/instance.js:3:12)",
		"message": "toFixed() digits argument must be between 0 and 20",
		"__class__": "RangeError"
	},
	"stack": "InternalServerError: toFixed() digits argument must be between 0 and 20\n    at LambdaContainer.<anonymous> (/var/task/node_modules/tyx/aws/container.js:49:56)\n    at throw (native)\n    at rejected (/var/task/node_modules/tyx/aws/container.js:5:65)",
	"__class__": "InternalServerError"
}

Posting to /demo/missing with any json body will result in:

{
	"code": 500,
	"message": "Function not found: arn:aws:lambda:us-east-1:9999999999:function:tyx-sample7-demo-missing-function",
	"cause": {
		"stack": "ResourceNotFoundException: Function not found: arn:aws:lambda:us-east-1:9999999999:function:tyx-sample7-demo-missing-function\n    at Object.extractError (/var/runtime/node_modules/aws-sdk/lib/protocol/json.js:48:27)\n    at Request.extractError (/var/runtime/node_modules/aws-sdk/lib/protocol/rest_json.js:45:8)\n    at Request.callListeners (/var/runtime/node_modules/aws-sdk/lib/sequential_executor.js:105:20)\n    at Request.emit (/var/runtime/node_modules/aws-sdk/lib/sequential_executor.js:77:10)\n    at Request.emit (/var/runtime/node_modules/aws-sdk/lib/request.js:683:14)\n    at Request.transition (/var/runtime/node_modules/aws-sdk/lib/request.js:22:10)\n    at AcceptorStateMachine.runTo (/var/runtime/node_modules/aws-sdk/lib/state_machine.js:14:12)\n    at /var/runtime/node_modules/aws-sdk/lib/state_machine.js:26:10\n    at Request.<anonymous> (/var/runtime/node_modules/aws-sdk/lib/request.js:38:9)\n    at Request.<anonymous> (/var/runtime/node_modules/aws-sdk/lib/request.js:685:12)",
		"message": "Function not found: arn:aws:lambda:us-east-1:9999999999:function:tyx-sample7-demo-missing-function",
		"code": "ResourceNotFoundException",
		"name": "ResourceNotFoundException",
		"time": "2017-10-31T08:48:27.688Z",
		"requestId": "435de987-be18-11e7-b667-657e489d6573",
		"statusCode": 404,
		"retryable": false,
		"retryDelay": 75.5332334968972,
		"__class__": "Error"
	},
	"stack": "InternalServerError: Function not found: arn:aws:lambda:us-east-1:9999999999:function:tyx-sample7-demo-missing-function\n    at MissingProxy.<anonymous> (/var/task/node_modules/tyx/aws/proxy.js:45:52)\n    at throw (native)\n    at rejected (/var/task/node_modules/tyx/aws/proxy.js:5:65)\n    at process._tickDomainCallback (internal/process/next_tick.js:135:7)",
	"__class__": "InternalServerError"
}

Posting to /demo/unhandled with any json body will result in:

{
	"code": 500,
	"message": "RequestId: 726809f7-be19-11e7-bdb3-c7331d9214c8 Process exited before completing request",
	"stack": "InternalServerError: RequestId: 726809f7-be19-11e7-bdb3-c7331d9214c8 Process exited before completing request\n    at UnhandledProxy.<anonymous> (/var/task/node_modules/tyx/aws/proxy.js:52:52)\n    at next (native)\n    at fulfilled (/var/task/node_modules/tyx/aws/proxy.js:4:58)\n    at process._tickDomainCallback (internal/process/next_tick.js:135:7)",
	"__class__": "InternalServerError"
}

2.8. Configuration

TyX containers require the presence of the Configuration service, if one is not provided a default implementation is being used. In this example the Configuration service is extended with properties relevant to the simple timestamp service.

API definition

Recommended convention is to name extension as ConfigApi and ConfigService. The API must extend the Configuration interface, so it merged constant (service name) equals Configuration as well.

  • api/config.ts extended configuration
export const ConfigApi = Configuration;

export interface ConfigApi extends Configuration {
    timestampSecret: string;
    timestampStrength: number;
}
  • api/timestamp.ts example timestamp service
export interface TimestampApi {
    issue(data: any): TimestampResult;
    verify(input: TimestampResult): TimestampResult;
}

export interface TimestampResult {
    id: string;
    timestamp: string;
    hash: string;
    signature: string;
    data: any;
    valid?: boolean;
    error?: string;
}

Config service implementation

The implementation extends the provided BaseConfiguration class that is simple wrapper around a json object this.config which is process.env by default. This approach uses Environment Variables of Lambda functions that are also supported by the Serverless Framework, so configurations can be modified via AWS Console or API without a need to redeploy the function. Developers can directly implement the Configuration interface to use different storage.

@Service(ConfigApi)
export class ConfigService extends BaseConfiguration implements ConfigApi {

    constructor(config?: any) {
        super(config);
    }

    get timestampSecret() { return this.config.TIMESTAMP_SECRET; }

    get timestampStrength() { return parseInt(this.config.TIMESTAMP_STRENGTH || 0); }
}

Timestamp service implementation

Example timestamp service based on SHA256.

@Service(TimestampApi)
export class TimestampService extends BaseService implements TimestampApi {

    @Inject(ConfigApi)
    protected config: ConfigApi;

    @Public()
    @Post("/issue")
    public issue( @Body() data: any): TimestampResult {
        let result = { id: UUID(), timestamp: new Date().toISOString(), hash: null, signature: null, data };
        let text = JSON.stringify(data);
        [result.hash, result.signature] = this.sign(result.id, result.timestamp, text);
        return result;
    }

    @Public()
    @Post("/verify")
    public verify( @Body() input: TimestampResult): TimestampResult {
        if (!input.id || !input.timestamp || !input.hash || !input.signature || !input.data)
            throw new BadRequest("Invalid input format");
        let hash: string, signature: string;
        [hash, signature] = this.sign(input.id, input.timestamp, JSON.stringify(input.data));
        if (hash !== input.hash) input.error = "Hash mismatch";
        else if (signature !== input.signature) input.error = "Invalid signature";
        else input.valid = true;
        return input;
    }

    private sign(id: string, timestamp: string, input: string): [string, string] {
        if (!this.config.timestampSecret) throw new InternalServerError("Signature secret not configured");
        if (!this.config.timestampStrength) throw new InternalServerError("Signature strength not configured");
        let hash: string = SHA256(input || "");
        let signature: string = id + "/" + timestamp + "/" + hash;
        for (let i = 0; i < this.config.timestampStrength; i++)
            signature = SHA256(signature + "/" + i + "/" + this.config.timestampSecret);
        return [hash, signature];
    }
}

Lambda function

  • functions/timestamp.ts
let container = new LambdaContainer("tyx-sample8")
    .register(ConfigService)
    .publish(TimestampService);

export const handler: LambdaHandler = container.export();

Serverless file

The two configuration parameters are defined on function level where they are used. There is a limitation that "total size of the set does not exceed 4 KB" per function so better define variables under provider only when used by all or significant number of functions.

service: tyx-sample8

provider:
  name: aws
  region: us-east-1
  stage: demo
  runtime: nodejs6.10
  memorySize: 128
  timeout: 10
  
  environment:
    STAGE: ${self:service}-${opt:stage, self:provider.stage}
    LOG_LEVEL: DEBUG

functions:
  timestamp-function:
    handler: functions/timestamp.handler
    environment:
      TIMESTAMP_SECRET: F72001057DDA40D3B7B81E7BF06CF495
      TIMESTAMP_STRENGTH: 3
    events:
      - http:
          path: issue
          method: POST
          cors: true
      - http:
          path: verify
          method: POST
          cors: true

Sample responses

When posting to /demo/issue a json object:

{
    "from": "tyx",
    "to": "world",
    "message": "Hello World ..."
}

signed timestamp is received:

{
    "id": "c43c1de9-9561-47d3-8aed-10e4e7080b59",
    "timestamp": "2017-10-31T10:48:03.903Z",
    "hash": "760c891dd1061a843bf9a778e2fb42d28ea6aa57654474cd176ee5385c674875",
    "signature": "a3d71713bca7830d9f8b10f7841758db0e7bfd0bfcb2a450fd0caa3d8a72eca2",
    "data": {
        "from": "tyx",
        "to": "world",
        "message": "Hello World ..."
    }
}

3. Concepts Overview

TyX Core Framework aims to provide a programming model for back-end serverless solutions by leveraging TypeScript support for object oriented programming. TyX addresses how to write and structure the application back-end into services deployed as Lambda functions.

Decorators are extensively used while inheritance from base classes is minimized. Services so written are abstracted from details how HTTP events arrive, how the response is propagated back and the internal routing in case of multiple events being served by the hosting Lambda function. These responsibilities are handled by a Container specific to the hosting environment (Lambda). As proof-of-concept and to serve as development tool an Express based container is provided allowing to run the unmodified services code.

TyX was developed with intent to be used together with Serverless Framework that provides rapid deployment. There is no direct dependency on the Serverless Framework so developers can opt for alternative deployment tools.

3.1. Serverless Environment

AWS Lambda and API Gateway are the core component of AWS Serverless Platform. API Gateway takes most of the responsibilities traditionally handled by HTTP Web Servers (e.g. Apache, nginx, IIS ...), it is the entry point for HTTP requests; however it does not directly host or manage code responsible for handling those requests. AWS Lambda is a compute service for running code without provisioning or managing servers. Lambda functions react on events, API Gateway being one of the supported sources. On each HTTP request arriving on API Gateway an event object is dispatched to an instance of a Lambda function, on its completion the function provides the response (statusCode, headers, body).

Lambda functions are subject to limitation on memory and allowed execution time, and are inherently stateless. AWS Lambda may create, destroy or reuse instances of the Lambda function to accommodate the demand (traffic). The function instance is not aware of its life-cycle. At most it can detect when handler function is first loaded but there is no notification/events when the instance is to be frozen for reuse or about to be destroyed. This prevents any meaningful internal state or cache as the function is not a continuously running process. Limited access to the file system is allowed but should not be used with assumption that stored files will be available for the next request; certainly not to run a local database.

Number of concurrently running function instances is also limited (per AWS account). Developers have no control over the max number of instances a given function can have, which is a challenge when other services or resources used by the function (e.g. database) can not support or scale to match the concurrent requests. Serverless concept removes the need to manage servers or containers for the function (business logic) execution but this does not cover the management of services those functions use. For example S3 most likely can accommodate any load of object access and manipulation concurrent Lambda functions can generate; on the contrary databases usually have limits on concurrent connections and/or the allowed read/write throughput. The Serverless environment may look very restricted and even hostile toward some traditional practices (like the mentioned in-memory caching, or local disk databases). Lambda functions are not intended to serve static files or render HTML content as with MVC frameworks, handling file uploads is also best avoided.

TyX framework does not shield the developer from the specifics and challenges of the serverless architecture, nor does it attempt to abstract the limitations of the execution environment.

3.2. Service

Services are the building block of application back-end and together with dependency injection provides for structured and flexible code organization. A service can expose only a single method with event binding, and if hosted in a dedicated Lambda function will abide to the single responsibility principle. However it may group together methods corresponding to related actions or entities, following microservice principles. TyX does not enforce a specific style or paradigm; a Lambda function can host arbitrary number of services each with its own event bindings.

Traditionally web frameworks especially those based on MVC pattern make a distinction betwe