[Nest.js] API Gateway + Lambda + DynamoDB 構成を実現してみた

#概要

普段、API Gateway + Lambda でAPIを作成する時は、特にフレームワーク等は使用せず一から作成していたのですが、今回機会があり Nest.js を利用したので備忘録として残しておきます。
Next.jsとはなのですが、
・TypeScriptで書けるNode.jsフレームワーク
・GraphQL、TypeORMなどもサポートしている
・基本的な構成が用意されていたり、デコレータを利用したりと、効果的かつ安易に作成することができる
みたいな感じとのことです。

公式サイト
https://docs.nestjs.com/

#前提
・AWSを利用できる環境
・基本的なTypeScriptの知識


##NestJSインストール
NestJSはnpmパッケージとして提供されています

```
npm install @nestjs/cli -g
```

インストールしたCLIを使って、プロジェクトの作成を行います。

```
nest new my-app
```
インストレーションに従ったあと、プリケーションサーバーとして、以下コマンドで起動してみます。

```
npm run start
```
http://localhost:3000/ にアクセスすると、Hello worldが表示されます。

##NestJSの構成
プロジェクトの作成をすると、srcフォルダ配下に以下の4つのファイルが生成されます。
NestJSではこれらの構成を利用して開発をすることが可能です。

###main.ts

nestjsのエントリーポイントとなり、ここでサーバのインスタンスを作成し、listenします。

```main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();
```

### app.controller.ts

ルーティングを定義します。
TypeScriptのデコレ―タを利用して、機能を簡単に実装することができます。

```app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

```

###app.service.ts
各種機能をControllerに提供します。
Nest.jsではprovidersにあたります。

```app.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

```

###app.module.ts
controllerやproviderの依存関係を定義します。1つ以上のapp.module.tsが必要となっています。

```app.module.ts

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

```


#実装
それでは実際に、簡単なアプリケーションをAWS上に構築してみます。

■ 仕様
[POST] /users ：ユーザ情報追加
[GET] /users ：ユーザ情報取得
※ リクエスト時は、x-api-keyをヘッダーに付与。（x-api-keyはコード内部に埋め込み）

##インフラ構築
インフラ環境は、Serverless Frameworkを利用します。
以下のコードで、API Gateway + Lambda + DynamoDBが一撃で作成されます。
DynamoDBはプライマリパーティションキーをnameとしています。
(lambdaに展開するコードを格納するS3バケットはあらかじめ手動で作成しました。ここでは、layersDeploymentBucket: test-api-service-lambda-layers になります。)

```serverless.yml
service: test-api-service

provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
  environment:
    USER_TABLE_NAME: 'test-api-table'
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:*
      Resource: 'arn:aws:dynamodb:*:*:table/test-api-table'

plugins:
  - serverless-layers
custom:
  serverless-layers:
    layersDeploymentBucket: test-api-service-lambda-layers

package:
  individually: true
  include:
    - dist/**
  exclude:
    - '**'
functions:
  index:
    handler: dist/index.handler
    events:
      - http:
          cors: true
          path: '/'
          method: any
      - http:
          cors: true
          path: '{proxy+}'
          method: any

resources:
  Resources:
    DynamoDbTable:
      Type: 'AWS::DynamoDB::Table'
      Properties:
        AttributeDefinitions:
          - AttributeName: name
            AttributeType: S
        KeySchema:
          - AttributeName: name
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
        TableName: 'test-api-table'

```

##アプリケーション構築

API Gateway + Lambda環境でNest.jsを動作させるため、src配下にindex.tsを作成します。

```index.ts
import { APIGatewayProxyHandler } from 'aws-lambda';
import { NestFactory } from '@nestjs/core';
import { Server } from 'http';
import { ExpressAdapter } from '@nestjs/platform-express';
import * as serverless from 'aws-serverless-express';
import * as express from 'express';
import { AppModule } from './app.module';

let cachedServer: Server;

const bootstrapServer = async (): Promise<Server> => {
  const expressApp = express();
  const adapter = new ExpressAdapter(expressApp);
  const app = await NestFactory.create(AppModule, adapter);
  app.enableCors();
  app.init();
  return serverless.createServer(expressApp);
};

export const handler: APIGatewayProxyHandler = async (event, context) => {
  if (!cachedServer) {
    cachedServer = await bootstrapServer();
  }
  const result = await serverless.proxy(cachedServer, event, context, 'PROMISE')
    .promise;
  return result;
};
```

app.service.ts と app.controller.ts を以下の様に修正します。
@Post('/users') と @Get('/users')を指定して、2つのAPIを実装しています。

```app.service.ts
import { Injectable } from '@nestjs/common';
import { AppRepository } from './app.repository';

@Injectable()
export class AppService {
  async createUser(body): Promise<string> {
    if (!body || !body.name) {
      return JSON.stringify({ error: 'name は必須項目です' });
    }
    const result = await new AppRepository().create(body);
    return JSON.stringify(result.Attributes);
  }

  async getUsers(): Promise<string> {
    const result = await new AppRepository().getAll();
    return JSON.stringify(result.Items);
  }
}

```

```app.controller.ts
import { Controller, Get, Post, Delete, Req, Body } from '@nestjs/common';
import { AppService } from './app.service';
import { Request } from 'express';

const API_KEY = '*****';
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post('/users')
  async createUser(@Body() body, @Req() request: Request): Promise<string> {
    this.checkApiKey(request);
    return await this.appService.createUser(body);
  }

  @Get('/users')
  async getUsers(@Req() request: Request): Promise<string> {
    this.checkApiKey(request);
    return await this.appService.getUsers();
  }

  private checkApiKey(request: Request): void {
    if ((request.headers['x-api-key'] as string) !== API_KEY) {
      throw new Error('API KEY ERROR');
    }
  }
}

```
DynamoDB操作用に app.repository.ts を作成します。

```app.repository.ts
import * as AWS from 'aws-sdk';
import { InternalServerErrorException } from '@nestjs/common';

export class AppRepository {
  private tableName = process.env.USER_TABLE_NAME;

  async create(user): Promise<AWS.DynamoDB.DocumentClient.UpdateItemOutput> {
    let updateExpression = 'set ';
    const expressionAttributeValues = {} as any;
    const expressionAttributeNames = {} as any;
    for (const [key, value] of Object.entries(user)) {
      if (!(key === 'name')) {
        updateExpression += `#${key} = :${key}, `;
        expressionAttributeValues[`:${key}`] = value;
        expressionAttributeNames[`#${key}`] = `${key}`;
      }
    }
    updateExpression = updateExpression.slice(0, -2);

    try {
      return await new AWS.DynamoDB.DocumentClient()
        .update({
          TableName: this.tableName,
          Key: {
            name: user.name,
          },
          UpdateExpression: updateExpression,
          ExpressionAttributeValues: expressionAttributeValues,
          ExpressionAttributeNames: expressionAttributeNames,
          ReturnValues: 'ALL_NEW',
        })
        .promise();
    } catch (error) {
      throw new InternalServerErrorException(error);
    }
  }

  async getAll(): Promise<AWS.DynamoDB.DocumentClient.ScanOutput> {
    return await new AWS.DynamoDB.DocumentClient()
      .scan({
        TableName: this.tableName,
      })
      .promise();
  }
}

```

##デプロイ
それでは、AWS環境にデプロイしてみます。

```
$ serverless deploy
```

##動作確認
以下のcurlコマンドで意図した通りにデータを登録・取得できることが確認できました。

[POST] /users ：ユーザ情報追加

```
curl --request POST \
   --url https://{API_GATEWAY_ENDPOINT}/dev/users  \
  --header 'Content-Type: application/json' \
  --header 'x-api-key: *****' \
  --data '{
	"name": "test",
	"ID": "12345"
}'

> {"ID":"12345","name":"test"}
```

[GET] /users ：ユーザ情報取得

```
curl --request GET \
  --url https://{API_GATEWAY_ENDPOINT}/dev/users \
  --header 'x-api-key: *****'

> [{"ID":"12345","name":"test"}]
```

#まとめ
TypeScriptで書けるNode.jsフレームワークのNest.jsを利用して、簡単なAPIをAWS上に構築してみました。
普段API Gateway + Lambdaを利用するときは、ライブラリ等利用せず、一からAPI作成することが多かったのですが、
今回シンプルかつ読みやすいコードで実装できたので、こういったライブラリを利用することも良いかなと感じました。
単体テストはまだかけていないので、次はそちらを触ってみようと思います。