CDKのNodejsFunctionを使ってREST API作ってみた

この記事は [ぷりぷりあぷりけーしょんず Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ppap) の8日目の記事です。

# はじめに

久しぶりにCDKでLambdaを構築しようとしたらNodejsFunctionというConstructが増えていて、少し触ってみたので記事にまとめます。
今回はサンプルとしてToDoアプリ用のREST APIをサーバーレスで作ってみましたが、この記事ではその一部を紹介しようと思います。ソース全体は以下のGitHubに公開していますのでご興味のある方はご参照いただければと思います。
https://github.com/misaosyushi/todo

# NodejsFunctionとは？

NodejsFunctionとは、[Parcel](https://parceljs.org/)を使用してバンドルされたNode.js製Lambda Functionを構築するためのモジュールです。[@aws-cdk/aws-lambda-nodejs](https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-lambda-nodejs.NodejsFunction.html) を追加することで使用できるようになります。
こちらを使うことで、`cdk deploy`時にLambdaのソースを**自動でトランスパイル&バンドル**してデプロイできるようになります。

いままではLambdaのコードをTypeScriptで書いたときはデプロイ前に事前にトランスパイルする必要があったり、外部パッケージを使用していたら`node_modules`をデプロイパッケージに含めるか、Lamda Layersに事前に指定の構成でのzipを作成する必要があったりと、かゆいところに手が届かないような状態だったと思います。それが、NodejsFunctionを使うことで解消できるのでとても素敵な機能かと個人的には感じています。
ただし2020/12/8現在、NodejsFunctionは実験的機能となっているのでプロダクションへの導入には注意が必要そうです。

# ディレクトリ構成

ディレクトリは最終的には以下のようになってます。CDKプロジェクトの作成方法などは紹介しませんので、初期化手順などは[こちら](https://cdkworkshop.com/20-typescript/20-create-project/100-cdk-init.html)の公式手順をご覧ください。

```
├── bin
│   └── todo.ts
├── cdk.json
├── docs
│   └── api-spec.md
├── jest.config.js
├── lambda
│   └── api
│       ├── config.ts
│       └── todo
│           ├── delete.ts
│           ├── get.ts
│           ├── list.ts
│           ├── post.ts
│           ├── put.ts
│           └── todo.ts
├── lib
│   └── todo-stack.ts
├── package-lock.json
├── package.json
├── test
│   └── todo.test.ts
└── tsconfig.json
```

# 必要なパッケージをインストール

今回はLambdaとAPI GatewayとDynamoDBを使用しました。

```
npm i @aws-cdk/aws-lambda-nodejs @aws-cdk/aws-dynamodb @aws-cdk/aws-apigateway
```

# DynamoDBを定義する

`lib/todo-stack.ts`にDynamoDBのテーブルを作成するためのコードを追加します。
`addGlobalSecondaryIndex`は名前の通りGSIを作成しています。これでToDoテーブルの`title`という項目でQuery(検索)が実行できるようになります。

```ts:todo-stack.ts
import * as cdk from '@aws-cdk/core';
import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs';
import { AttributeType, Table } from '@aws-cdk/aws-dynamodb';
import { ApiKeySourceType, LambdaIntegration, RestApi, Cors } from '@aws-cdk/aws-apigateway';

export class TodoStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const tableName = 'ToDoTable';
    const primaryKey = 'id';
    const indexName = 'title-index';

    const todoTable = new Table(this, 'toDoTable', {
      tableName: tableName,
      partitionKey: {
        name: primaryKey,
        type: AttributeType.STRING,
      },
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    todoTable.addGlobalSecondaryIndex({
      indexName: indexName,
      partitionKey: {
        name: 'title',
        type: AttributeType.STRING
      },
    });
  }
}
```

# Lambdaを定義する

さて、本題のNodejsFunctionを使用したLambdaの定義です。といってもNodejsFunctionは`lamda.Function`クラスを継承しているクラスなので、基本的には同じように使えます。

`entry`にはlambdaの実装ファイルまでものパスを指定します。jsまたはtsが指定できます。

```ts:todo-stack.ts
const getToDoFunction = new NodejsFunction(this, 'getToDoFunction', {
  entry: 'lambda/api/todo/get.ts',
  handler: 'getHandler',
  timeout: cdk.Duration.seconds(30),
  environment: {
    'TABLE_NAME': tableName,
    'PRIMARY_KEY': primaryKey,
  },
});

const listToDoFunction = new NodejsFunction(this, 'listToDoFunction', {
  entry: 'lambda/api/todo/list.ts',
  handler: 'listHandler',
  timeout: cdk.Duration.seconds(30),
  environment: {
    'TABLE_NAME': tableName,
    'INDEX_NAME': indexName,
  },
});

const postToDoFunction = new NodejsFunction(this, 'postToDoFunction', {
  entry: 'lambda/api/todo/post.ts',
  handler: 'postHandler',
  timeout: cdk.Duration.seconds(30),
  environment: {
    'TABLE_NAME': tableName,
  },
});

const putToDoFunction = new NodejsFunction(this, 'putToDoFunction', {
  entry: 'lambda/api/todo/put.ts',
  handler: 'putHandler',
  timeout: cdk.Duration.seconds(30),
  environment: {
    'TABLE_NAME': tableName,
    'PRIMARY_KEY': primaryKey,
  },
});

const deleteToDoFunction = new NodejsFunction(this, 'deleteToDoFunction', {
  entry: 'lambda/api/todo/delete.ts',
  handler: 'deleteHandler',
  timeout: cdk.Duration.seconds(30),
  environment: {
    'TABLE_NAME': tableName,
    'PRIMARY_KEY': primaryKey,
  },
});
```

# Lambdaの実装

ここではToDo APIのPOSTメソッドに使用するFunctionを例として紹介します。もし他のメソッドが気になる方はGitHubをご参照ください🙇‍♀️

このFunctionは外部パッケージを参照しているので、従来であればデプロイパッケージに`node_modules`を含めるか、Lambda Layersにzipをアップロードしてそれを参照するようにしないと実行時にパッケージが参照できなくてエラーになっていました。

ですが、NodejsFunctionを使用することでトランスパイルとバンドルをやってからデプロイしてくれるので、そういった事前準備が不要となり、以下のようなコードでもそのままデプロイコマンド実行してOKになります。

```ts:post.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { v4 as uuid } from 'uuid';
import { DB } from '../config';
import { ToDo, HEADER } from './todo';

export async function postHandler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
  if (!event.body) {
    return {
      statusCode: 400,
      body: 'Error: bad request',
    };
  }

  const toDo: ToDo = JSON.parse(event.body)
  const params = {
    TableName: process.env.TABLE_NAME,
    Item: {
      'id': uuid(),
      'title': toDo.title,
      'detail': toDo.detail,
      'deadlineDate': toDo.deadlineDate,
      'status': toDo.status,
    }
  }

  try {
    await DB.put(params).promise();
    return {
      statusCode: 200,
      headers: HEADER,
      body: 'Success',
    };
  } catch (dbError) {
    return {statusCode: 500, body: JSON.stringify(dbError)};
  }
}

```

# API Gatewayを定義する

API Gatewayのリソースを追加します。簡易的な認証としてAPIキー必須にし、APIキーに対しては利用制限をかけるようにしています。

```ts:todo-stack.ts
const api = new RestApi(this, "todoApi", {
  restApiName: "ToDoAPI",
  apiKeySourceType: ApiKeySourceType.HEADER,
  defaultCorsPreflightOptions: {
    allowOrigins: Cors.ALL_ORIGINS,
    allowMethods: Cors.ALL_METHODS,
    allowHeaders: Cors.DEFAULT_HEADERS,
    statusCode: 200,
  }
});

const todos = api.root.addResource("todo");
// list
todos.addMethod("GET", new LambdaIntegration(listToDoFunction), {
  apiKeyRequired: true,
});
// post
todos.addMethod("POST", new LambdaIntegration(postToDoFunction), {
  apiKeyRequired: true,
});

const todo = todos.addResource("{id}");
// get
todo.addMethod("GET", new LambdaIntegration(getToDoFunction), {
  apiKeyRequired: true,
});
// put
todo.addMethod("PUT", new LambdaIntegration(putToDoFunction), {
  apiKeyRequired: true,
});
// delete
todo.addMethod("DELETE", new LambdaIntegration(deleteToDoFunction), {
  apiKeyRequired: true,
})

// APIキーの追加
const apiKey =  api.addApiKey('apiKey', {
  apiKeyName: 'ToDoAPIKey',
})

// 追加したAPIキーに対して使用量の制限を付与する
api.addUsagePlan('forAPIKey', {
  apiKey,
  throttle: {
    rateLimit: 20,
    burstLimit: 200
  },
}).addApiStage({
  stage: api.deploymentStage
})
```

# 全体

`lib/todo-stack.ts`の全体像としては以下になります。

```ts:todo-stack.ts
import * as cdk from '@aws-cdk/core';
import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs';
import { AttributeType, Table } from '@aws-cdk/aws-dynamodb';
import { ApiKeySourceType, LambdaIntegration, RestApi, Cors } from '@aws-cdk/aws-apigateway';

export class TodoStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const tableName = 'ToDoTable';
    const primaryKey = 'id';
    const indexName = 'title-index';

    const todoTable = new Table(this, 'toDoTable', {
      tableName: tableName,
      partitionKey: {
        name: primaryKey,
        type: AttributeType.STRING,
      },
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    todoTable.addGlobalSecondaryIndex({
      indexName: indexName,
      partitionKey: {
        name: 'title',
        type: AttributeType.STRING
      },
    });

    const getToDoFunction = new NodejsFunction(this, 'getToDoFunction', {
      entry: 'lambda/api/todo/get.ts',
      handler: 'getHandler',
      timeout: cdk.Duration.seconds(30),
      environment: {
        'TABLE_NAME': tableName,
        'PRIMARY_KEY': primaryKey,
      },
    });

    const listToDoFunction = new NodejsFunction(this, 'listToDoFunction', {
      entry: 'lambda/api/todo/list.ts',
      handler: 'listHandler',
      timeout: cdk.Duration.seconds(30),
      environment: {
        'TABLE_NAME': tableName,
        'INDEX_NAME': indexName,
      },
    });

    const postToDoFunction = new NodejsFunction(this, 'postToDoFunction', {
      entry: 'lambda/api/todo/post.ts',
      handler: 'postHandler',
      timeout: cdk.Duration.seconds(30),
      environment: {
        'TABLE_NAME': tableName,
      },
    });

    const putToDoFunction = new NodejsFunction(this, 'putToDoFunction', {
      entry: 'lambda/api/todo/put.ts',
      handler: 'putHandler',
      timeout: cdk.Duration.seconds(30),
      environment: {
        'TABLE_NAME': tableName,
        'PRIMARY_KEY': primaryKey,
      },
    });

    const deleteToDoFunction = new NodejsFunction(this, 'deleteToDoFunction', {
      entry: 'lambda/api/todo/delete.ts',
      handler: 'deleteHandler',
      timeout: cdk.Duration.seconds(30),
      environment: {
        'TABLE_NAME': tableName,
        'PRIMARY_KEY': primaryKey,
      },
    });

    todoTable.grantReadData(getToDoFunction)
    todoTable.grantReadData(listToDoFunction)
    todoTable.grantWriteData(postToDoFunction)
    todoTable.grantWriteData(putToDoFunction)
    todoTable.grantWriteData(deleteToDoFunction)

    const api = new RestApi(this, "todoApi", {
      restApiName: "ToDoAPI",
      apiKeySourceType: ApiKeySourceType.HEADER,
      defaultCorsPreflightOptions: {
        allowOrigins: Cors.ALL_ORIGINS,
        allowMethods: Cors.ALL_METHODS,
        allowHeaders: Cors.DEFAULT_HEADERS,
        statusCode: 200,
      }
    });

    const todos = api.root.addResource("todo");
    // list
    todos.addMethod("GET", new LambdaIntegration(listToDoFunction), {
      apiKeyRequired: true,
    });
    // post
    todos.addMethod("POST", new LambdaIntegration(postToDoFunction), {
      apiKeyRequired: true,
    });

    const todo = todos.addResource("{id}");
    // get
    todo.addMethod("GET", new LambdaIntegration(getToDoFunction), {
      apiKeyRequired: true,
    });
    // put
    todo.addMethod("PUT", new LambdaIntegration(putToDoFunction), {
      apiKeyRequired: true,
    });
    // delete
    todo.addMethod("DELETE", new LambdaIntegration(deleteToDoFunction), {
      apiKeyRequired: true,
    });

    const apiKey =  api.addApiKey('apiKey', {
      apiKeyName: 'ToDoAPIKey',
    })

    api.addUsagePlan('forAPIKey', {
      apiKey,
      throttle: {
        rateLimit: 20,
        burstLimit: 200
      },
    }).addApiStage({
      stage: api.deploymentStage
    })
  }
}
```


# デプロイ

```
cdk deploy
```

普通のLambda Functionのデプロイより少し時間はかかりますが、デプロイコマンド実行後にトランスパイルし忘れてた！と気づいてやり直したりしていたことを考えれば圧倒的に楽かと思います。

デプロイされたLambdaのコードはこんな感じです。行数が多いので一部のスクショしか載せませんが、ちゃんとバンドルされたコードがデプロイされました🎉

![スクリーンショット 2020-12-06 1.31.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356412/de7deab8-0311-19e9-b3fe-8b9c3e7337eb.png)


# まとめ

NodejsFunctionを使用することで開発者はコード書くことだけに集中することができるので、いままで以上に開発が捗るのではないでしょうか。あと、とりあえずサーバーレス&CDKでインフラ構築すれば簡単なAPIなら爆速で作れてしまうので、個人的にこういった構成は大好きです。気になる方は是非触ってみてください！