LoginSignup
3

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-12-07

この記事は ぷりぷりあぷりけーしょんず Advent Calendar 2020 の8日目の記事です。

はじめに

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

NodejsFunctionとは?

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

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

ディレクトリ構成

ディレクトリは最終的には以下のようになってます。CDKプロジェクトの作成方法などは紹介しませんので、初期化手順などはこちらの公式手順をご覧ください。

├── 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(検索)が実行できるようになります。

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が指定できます。

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になります。

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キーに対しては利用制限をかけるようにしています。

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の全体像としては以下になります。

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

まとめ

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3