NestJS + GraphQL + Lambda

The goal of this article is to create a GraphQL application built on the NestJS framework. And also load it into a Lambda function using Terraform. I hope this example will save many people a lot of time.

The application will work with a PostgreSQL relational database. For local use, let’s take docker-compose:

version: '3.1'

services:
  db:
    image: 'postgres:14.1'
    restart: unless-stopped
    volumes:
      - ./volumes/postgresql/data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: example
      POSTGRES_DB: nest
    ports:
      - 5432:5432
    networks:
      - postgres

networks:
  postgres:
    driver: bridge

Let’s create a new project (for this you need to install Nest CLI):

nest new app

Let’s add a module and a user service:

nest generate module user
nest generate service user

Let’s add a user model, which will be both a base model and an object description for GraphQL:

@Entity()
@ObjectType()
export class User {
    @PrimaryGeneratedColumn()
    @Field(type => Int)
    id: number;

    @Column({nullable: false})
    @Field({nullable: false})
    name: string;

    @Column({nullable: true})
    @Field({nullable: true})
    dob: Date;

    @Column({nullable: true})
    @Field({nullable: true})
    address: string;

    @Column({nullable: true})
    @Field({nullable: true})
    description: string;

    @Column({nullable: true})
    @Field({nullable: true})
    imageUrl: string;

    @Column({nullable: true, default: new Date()})
    @Field({nullable: true})
    createdAt: Date;

    @Column({nullable: true, default: new Date()})
    @Field({nullable: true})
    updatedAt: Date;
}

Let’s describe the user’s service so that it can solve standard CRUD tasks, as well as search by username with pagination

@Injectable()
export class UserService {
    constructor(
        @Inject(USER_REPOSITORY)
        private userRepository: Repository<User>
    ) {
    }

    create(data: TUserCreate): Promise<User> {
        const user = this.userRepository.create(data)
        return this.userRepository.save(user)
    }

    findById(id: number): Promise<User> {
        return this.userRepository.findOne(id)
    }

    async findAll(searchText: string = '', take: number = 10, skip: number = 0): Promise<UserSearchResult> {

        const query = searchText ? {
            where: [
                {name: ILike('%'+searchText+'%')}
            ]
        } : {}

        const getQuery = {
            ...query,
            take,
            skip,
            order: {
                name: "ASC",
            }
        }
        const [total, list] = await Promise.all([this.userRepository.count(query), this.userRepository.find(getQuery as FindManyOptions)])
        return {
            total, list
        } as UserSearchResult
    }

    async updateById(id: number, data: TUserUpdate): Promise<User> {
        await this.userRepository.update({id}, data)
        return this.findById(id)
    }

    async deleteById(id: number): Promise<boolean> {
        return !!(await this.userRepository.delete({id}))
    }
}

And now let’s connect them using the resolver class:

@Resolver(of => User)
export class UsersResolver {
    constructor(
        private userService: UserService,
    ) {
    }

    @Mutation(returns => User)
    async createUser(
        @Args('name', {type: () => String}) name: string,
        @Args('address', {type: () => String}) address: string = '',
        @Args('description', {type: () => String}) description: string = '',
        @Args('imageUrl', {type: () => String}) imageUrl: string = '',
        @Args('dob', {type: () => String}) dob: string = null,
    ) {
        return this.userService.create({
            name,
            address,
            description,
            imageUrl,
            dob: dob ? new Date(dob) : null
        } as TUserCreate);
    }

    @Query(returns => User)
    async getUser(@Args('id', {type: () => Int}) id: number) {
        return this.userService.findById(id);
    }

    @Query(returns => UserSearchResult)
    async getAllUsers(
        @Args('searchText', {type: () => String}) searchText: string,
        @Args('take', {type: () => Int}) take: number=10,
        @Args('skip', {type: () => Int}) skip: number=0,
    ) {
        return this.userService.findAll(searchText, take, skip)
    }

    @Mutation(returns => User)
    async updateUser(
        @Args('id', {type: () => Int}) id: number,
        @Args('name', {type: () => String}) name: string,
        @Args('address', {type: () => String}) address: string = '',
        @Args('description', {type: () => String}) description: string = '',
        @Args('imageUrl', {type: () => String}) imageUrl: string = '',
        @Args('dob', {type: () => String}) dob: string = null,
    ) {
        return this.userService.updateById(id, {
            name,
            address,
            description,
            imageUrl,
            dob: dob ? new Date(dob) : null
        } as TUserUpdate);
    }

    @Mutation(returns => Boolean)
    async deleteUser(@Args('id', {type: () => Int}) id: number) {
        return this.userService.deleteById(id);
    }

}

Add GraphQL initialization to the user module. The parameters are standard and this is all very well described in the documentation for GraphQL for NestJS, but there is a very important point.

By default, the GraphQL Playground runs along the path /graphql… We will deploy our application to Lambda via ApiGateway, which should have a stage with some name, which prefixes any path, for example / api. Therefore, you need to move the path for the GraphQL Playground from /graphql v /api/graphql… For this we use the parameter useGlobalPrefix:true… And also when initializing express, add app.setGlobalPrefix('api');

@Module({
    imports: [
        DatabaseModule,
        GraphQLModule.forRootAsync({
            useFactory: () => {
                const schemaModuleOptions: Partial<GqlModuleOptions> = {};

                // If we are in development, we want to generate the schema.graphql
                if (process.env.NODE_ENV !== 'production' || process.env.IS_OFFLINE) {
                    schemaModuleOptions.autoSchemaFile="src/user/user.schema.gql";
                } else {
                    // For production, the file should be generated
                    schemaModuleOptions.typePaths = ['*.gql'];
                }

                return {
                    context: ({req}) => ({req}),
                    useGlobalPrefix:true, // <==
                    playground: true, // Allow playground in production
                    introspection: true, // Allow introspection in production
                    ...schemaModuleOptions,
                };
            }
        } as GqlModuleAsyncOptions),
    ],
    providers: [
        ...userProviders,
        UserService,
        UsersResolver
    ]
})

Let’s start the Playground locally:

User creation
User creation
Page search
Page search

To run in Lambda, you need to override the server creation express on the aws-serverless-express

Let’s create app.ts:

import {NestFactory} from '@nestjs/core';
import {ExpressAdapter} from '@nestjs/platform-express';
import {INestApplication} from '@nestjs/common';
import {AppModule} from './app.module';
import * as express from 'express';
import {Express} from 'express';
import {Server} from "http";
import {createServer} from 'aws-serverless-express';

export async function createApp(
    expressApp: Express,
): Promise<INestApplication> {
    const app = await NestFactory.create(
        AppModule,
        new ExpressAdapter(expressApp),
    );
    app.setGlobalPrefix('api');
    return app;
}

export async function bootstrap(): Promise<Server> {
    const expressApp = express();
    const app = await createApp(expressApp);
    await app.init();
    return createServer(expressApp);
}

And also a file with a handler lambda function:

import {Server} from 'http';
import {Context} from 'aws-lambda';
import {proxy, Response} from 'aws-serverless-express';
import {bootstrap} from './app';

let cachedServer: Server;

export async function handler(event: any, context: Context): Promise<Response> {
    if (!cachedServer) {
        cachedServer = await bootstrap();
    }
    return proxy(cachedServer, event, context, 'PROMISE').promise;
}

It remains to deploy it all in AWS. For this we will use Terraform… Create a terraform folder, and in it a file main.tf … Then I throw in the ready-made config with comments for each action:

# Зададим регион по умолчанию
provider "aws" {
  region = "us-east-1"
}

# Деплоить лямбду будем через zip архив. Поэтому необходимо положить наш код в архив
data "archive_file" "app_zip" {
  type        = "zip"
  source_dir  = "../app/dist"
  output_path = "./app.zip"
}

# Создадим API GW
resource "aws_apigatewayv2_api" "app" {
  name          = "api"
  protocol_type = "HTTP"
}

# И добавим в него stage. 
resource "aws_apigatewayv2_stage" "app" {
  api_id = aws_apigatewayv2_api.app.id

  name        = "api"
  auto_deploy = true

  # добавим логирования API GW в CloudWatch
  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.api_gw.arn

    format = jsonencode({
      requestId               = "$context.requestId"
      sourceIp                = "$context.identity.sourceIp"
      requestTime             = "$context.requestTime"
      protocol                = "$context.protocol"
      httpMethod              = "$context.httpMethod"
      resourcePath            = "$context.resourcePath"
      routeKey                = "$context.routeKey"
      status                  = "$context.status"
      responseLength          = "$context.responseLength"
      integrationErrorMessage = "$context.integrationErrorMessage"
    }
    )
  }
}

# Создадим интеграцию Lambda в API GW
resource "aws_apigatewayv2_integration" "app" {
  api_id = aws_apigatewayv2_api.app.id

  integration_uri    = aws_lambda_function.app.invoke_arn
  integration_type   = "AWS_PROXY"
  integration_method = "POST"
}
# Добавим Route - любой route должен вызывать нашу лямбду
resource "aws_apigatewayv2_route" "app" {
  api_id = aws_apigatewayv2_api.app.id

  route_key = "ANY /{proxy+}"
  target    = "integrations/${aws_apigatewayv2_integration.app.id}"
}
# Добавим лог группу в Cloud Watch для API GW
resource "aws_cloudwatch_log_group" "api_gw" {
  name = "/aws/api_gw/${aws_apigatewayv2_api.app.name}"
  retention_in_days = 30
}

# Добавим достум API GW вызывать лямбда функцию
resource "aws_lambda_permission" "api_gw" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.app.function_name
  principal     = "apigateway.amazonaws.com"

  source_arn = "${aws_apigatewayv2_api.app.execution_arn}/*/*"
}
# Создадим Security Group для базы данных и настроем ее так, чтоб можно было достучаться до нее из вне
# Внимание это настройка только для демо. для продакшн так делать нельзя.
resource "aws_security_group" "allow_db" {
  name        = "allow_db"
  description = "Allow DB"

  ingress {
    from_port        = 5430
    to_port          = 5440
    protocol         = "tcp"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
}
# Создадим рандомный пароль для базы
resource "random_password" "password" {
  length           = 20
  special          = false
  override_special = "_%@"
}
# Создадим инстанс базы
resource "aws_db_instance" "default" {
  allocated_storage      = 20
  db_subnet_group_name   = aws_db_subnet_group.db_subnet_group.name
  engine                 = "postgres"
  identifier             = "dev-db"
  engine_version         = "13"
  instance_class         = "db.t3.micro"
  name                   = "nest"
  username               = "postgres"
  password               = random_password.password.result
  skip_final_snapshot    = true
  publicly_accessible    = true
  vpc_security_group_ids = [aws_security_group.allow_db.id]

}

# Настроим подсеть 'a' для региона us-east-1
resource "aws_default_subnet" "db_subnet_a" {
  availability_zone = "us-east-1a"
  tags = {
    Name = "Default subnet for us-east-1a"
  }
}

# Настроим подсеть 'b' для региона us-east-1
resource "aws_default_subnet" "db_subnet_b" {
  availability_zone = "us-east-1b"

  tags = {
    Name = "Default subnet for us-east-1b"
  }
}

# Объеденим подсети в группу
resource "aws_db_subnet_group" "db_subnet_group" {
  name       = "db_subnet_group"
  subnet_ids = [aws_default_subnet.db_subnet_a.id, aws_default_subnet.db_subnet_b.id]
}

# Создать лямбда функцию, используя архив с кодом
resource "aws_lambda_function" "app" {
  filename         = data.archive_file.app_zip.output_path
  source_code_hash = data.archive_file.app_zip.output_base64sha256
  function_name    = "app"
  handler          = "serverless.handler"
  runtime          = "nodejs14.x"
  memory_size      = 1024
  role             = aws_iam_role.lambda_exec.arn
  timeout          = 30
  # зададим перенные окружения, указав доступ к базе
  environment {
    variables = {
      POSTGRES_HOST     = aws_db_instance.default.address
      POSTGRES_PORT     = aws_db_instance.default.port
      POSTGRES_USER     = aws_db_instance.default.username
      POSTGRES_PASSWORD = random_password.password.result
      POSTGRES_DATABASE = aws_db_instance.default.name
      NODE_ENV          = "production"
    }
  }
}

# Добавим лог группу в CloudWatch для лямбда-функции
resource "aws_cloudwatch_log_group" "app" {
  name = "/aws/lambda/${aws_lambda_function.app.function_name}"
  retention_in_days = 30
}

# Создать роль для лямбды
resource "aws_iam_role" "lambda_exec" {
  name = "serverless_lambda"

  assume_role_policy = jsonencode({
    Version   = "2012-10-17"
    Statement = [
      {
        Action    = "sts:AssumeRole"
        Effect    = "Allow"
        Sid       = ""
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })
}

# Присоединим стандартный полиси к роли с доступ к VPC
resource "aws_iam_role_policy_attachment" "lambda_policy" {
  role       = aws_iam_role.lambda_exec.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}
terraform apply

After that, the necessary resources will be created in your AWS account:

As you can see, Terraform is very convenient for creating and managing resources in the cloud. You can easily change and deploy everything in AWS account, and destroy all resources with one command terraform destroy

Now let’s run the GraphQL Playground in the lambda:

As a result, we got a lambda function with GraphQL based on the NestJS framework and deployed using Terraform. Using this example, you can implement your projects on similar technologies. The full code can be viewed here

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *