Serverless Framework で API Gateway & Lambda を構築する

この記事は [CodeChrysalis Advent Calendar 2019](https://qiita.com/advent-calendar/2019/codechrysalis) の記事です。

[Serverless FrameworkでDynamoDBを構築する](https://qiita.com/maaaashin324/items/014d2e5fc29d31f45cae)ことについての記事と[Serverless FrameworkでCognitoを構築する]ことについての記事もぜひ御覧ください。

# はじめに

[Serverless Framework](https://serverless.com/) はYAMLでサーバサイドの環境構築ができる、Infrastructure As Code の真骨頂です。確かにAWS等のコンソールでもポチポチクリックしながら環境構築はできますが、

1. 変更の管理ができない
2. 画面でポチポチクリックするのが本当に面倒（Lazy...）

なのでServerless Framework の CLI でコマンド一発で全てが構築されるのはとても気持ちが良い。今回はその中でもAWSのCognitoをどのように構築するかを紹介します。

この説明の前提は、データベースはDynamoDBをすでにServerless Framework(CloudFormation)で構築しているものとし、また認証はCognitoを使うこととします。

## まずはYAML

早速YAMLを紹介します。

### a

```YAML
service: cc-hello-api

plugins:
  - serverless-webpack
  - serverless-domain-manager

custom:
  stage: ${opt:stage, self:provider.stage}

  webpack:
    webpackConfig: ./webpack.config.js
    includeModules:
      packagePath: ./package.json
      forceExclude:
        - aws-sdk

  domains:
    dev: dev-api.cc.io
    prod: api.cc.io
  customDomain:
    domainName: ${self:custom.domains.${self:custom.stage}}
    basePath: "v1"
    stage: ${self:custom.stage}
    certificateName: "*.cc.io"
    createRoute53Record: false

provider:
  name: aws
  runtime: nodejs12.x
  stage: dev
  profile: cc-admin
  region: ap-northeast-1

functions:
  hello:
    handler: hello.main
    events:
      - http:
          path: hello
          method: get
          cors: true
          authorizer: aws_iam

resources:
  Outputs:
    ApiGatewayRestApiId:
      Value:
        Ref: ApiGatewayRestApi
      Export:
        Name: ${self:custom.stage}-ApiGatewayRestApiId

    ApiGatewayRestApiRootResourceId:
      Value:
        Fn::GetAtt:
          - ApiGatewayRestApi
          - RootResourceId
      Export:
        Name: ${self:custom.stage}-ApiGatewayRestApiRootResourceId
```

### b

```YAML
service: cc-users-api

plugins:
  - serverless-webpack
  - serverless-dotenv-plugin

custom:
  stage: ${opt:stage, self:provider.stage}
  dotenv:
    path: ./.env
  environment: ${file(./env.yml):${self:custom.stage}, file(./env.yml):default}

  webpack:
    webpackConfig: ./webpack.config.js
    includeModules:
      packagePath: ./package.json
      forceExclude:
        - aws-sdk

provider:
  name: aws
  runtime: nodejs12.x
  stage: dev
  profile: cc-admin
  region: ap-northeast-1

  apiGateway:
    restApiId:
      "Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiId
    restApiRootResourceId:
      "Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiRootResourceId

  environment:
    USERS_DYNAMODB_TABLE: ${file(../../database/serverless.yml):custom.usersTableName}

  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:DescribeTable
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource:
        - "Fn::ImportValue": ${self:custom.stage}-UsersDynamoDbTableArn

functions:
  createUser:
    handler: createUser.main
    events:
      - http:
          path: users
          method: post
          cors: true
          authorizer: aws_iam

  getUser:
    handler: getUser.main
    events:
      - http:
          path: users/{userId}
          method: get
          cors: true
          authorizer: aws_iam
          request:
            parameters:
              paths:
                userId: true

  listUsers:
    handler: listUsers.main
    events:
      - http:
          path: users
          method: get
          cors: true
          authorizer: aws_iam
          request:
            parameters:
              querystrings:
                userEmail: false
                paginationToken: false
                limit: false

  updateUser:
    handler: updateUser.main
    events:
      - http:
          path: users/{userId}
          method: put
          cors: true
          authorizer: aws_iam
          request:
            parameters:
              paths:
                userId: true

  deleteUser:
    handler: deleteUser.main
    events:
      - http:
          path: users/{userId}
          method: delete
          cors: true
          authorizer: aws_iam
          request:
            parameters:
              paths:
                userId: true
```

## デプロイの順番

[Serverless Framework で Cognito を構築する](https://qiita.com/maaaashin324/items/04c395eb4a2764480f0c)ことについての記事でも触れたのですが、Serverless Frameworkでマイクロサービスをデプロイするときはデプロイする順番を気にする必要があります。

[前述の記事](https://qiita.com/maaaashin324/items/04c395eb4a2764480f0c)で`ApiGatewayRestApiId`という値を使っているのですが、API Gatewayをデプロイした結果を参照させる必要があるので、まずはAPI Gatewayをデプロイします。そしてそれが終わった後にCognitoと各エンドポイントをデプロイします。

## それぞれの要素を解説

### serverless.yml - 1

1つ目のLambdaのデプロイです。これを通してAPI Gatewayもデプロイされます。

```YAML
plugins:
  - serverless-webpack
  - serverless-domain-manager
```

プラグインを2つ使っています。この説明はNode.jsを使う前提としていて、一つはWebpackを使うためのプラグインと、カスタムドメインを使うためのプラグインです。後者については使わない場合はAWSのデフォルトのドメインが提供されますが、ご自身で取得したドメインを使いたい場合はこのプラグインが使えます。

```YAML
  webpack:
    webpackConfig: ./webpack.config.js
    includeModules:
      packagePath: ./package.json
      forceExclude:
        - aws-sdk
```

WebpackのConfigがどこにあるのか、Package.jsonはどこにあるのかを設定しています。

`forceExclude`で`aws-sdk`を指定して点について説明します。`aws-sdk`は`package.json`の中では通常`devDependencies`に置かれていると思います。なぜならLambdaにすでにインクルードされているからです。しかしそれはAWS特有の話で、Serverless Frameworkは他のクラウドプラットフォームにも対応している性質からそれは気づけないので、この設定が必要になります。原文は[こちら](https://github.com/serverless-heaven/serverless-webpack)。

```YAML
  domains:
    dev: dev-api.cc.io
    prod: api.cc.io
  customDomain:
    domainName: ${self:custom.domains.${self:custom.stage}}
    basePath: "v1"
    stage: ${self:custom.stage}
    certificateName: "*.cc.io"
    createRoute53Record: false
```

ここはドメインの設定です。開発環境と本番環境のそれぞれを設定しています。`basePath`とは`domains`で設定したURIの続きを何にするかです。`certificateName`はAWSのACMで取得した証明書を指しています。SSLで接続させるので電子証明書が必要になるからです。DNSについてはRoute53を使っていないのなら、`createRoute53Record`は`false`とします。

```YAML
  runtime: nodejs12.x
```

つい最近（2019/12/24時点）ですが、Node.jsのVer.12世代がLambdaでサポートされました。

```YAML
functions:
  hello:
    handler: hello.main
    events:
      - http:
          path: hello
          method: get
          cors: true
          authorizer: aws_iam
```

ここでLambdaです。`hello`はLambda関数の名前、`handler`はどこに処理が書いてあるかのファイル名と関数名を書いています。`hello.main`は`hello`がファイル名、`main`が関数名です。

`events`に関しては詳しくは[Serverless Frameworkのドキュメント](https://serverless.com/framework/docs/providers/aws/guide/serverless.yml/)に詳細がありますが、この例だと、`path`はURI、`method`はHTTPメソッド、`cors`は`cors`を使うかどうかです。

`authorizer`はデフォルトで良ければ`aws_iam`です。なにかカスタマイズした場合は、そのリソースを指定します。

```YAML
resources:
  Outputs:
    ApiGatewayRestApiId:
      Value:
        Ref: ApiGatewayRestApi
      Export:
        Name: ${self:custom.stage}-ApiGatewayRestApiId

    ApiGatewayRestApiRootResourceId:
      Value:
        Fn::GetAtt:
          - ApiGatewayRestApi
          - RootResourceId
      Export:
        Name: ${self:custom.stage}-ApiGatewayRestApiRootResourceId
```

そして忘れずにOutputsを設定することによって、他のデプロイでもAPI Gatewayを参照できるようにします。

### serverless.yml - 2

```YAML
service: cc-users-api

plugins:
  - serverless-webpack
  - serverless-dotenv-plugin

custom:
  stage: ${opt:stage, self:provider.stage}
  dotenv:
    path: ./.env

  webpack:
    webpackConfig: ./webpack.config.js
    includeModules:
      packagePath: ./package.json
      forceExclude:
        - aws-sdk

provider:
  name: aws
  runtime: nodejs12.x
  stage: dev
  profile: cc-admin
  region: ap-northeast-1

  apiGateway:
    restApiId:
      "Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiId
    restApiRootResourceId:
      "Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiRootResourceId

  environment:
    CC_DYNAMODB_TABLE: ${file(../../database/serverless.yml):custom.ccTableName}

  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:DescribeTable
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource:
        - "Fn::ImportValue": ${self:custom.stage}-CcDynamoDbTableArn

functions:
  createUser:
    handler: createUser.main
    events:
      - http:
          path: users
          method: post
          cors: true
          authorizer: aws_iam

  getUser:
    handler: getUser.main
    events:
      - http:
          path: users/{userId}
          method: get
          cors: true
          authorizer: aws_iam
          request:
            parameters:
              paths:
                userId: true

  listUsers:
    handler: listUsers.main
    events:
      - http:
          path: users
          method: get
          cors: true
          authorizer: aws_iam
          request:
            parameters:
              querystrings:
                userEmail: false
                paginationToken: false
                limit: false

  updateUser:
    handler: updateUser.main
    events:
      - http:
          path: users/{userId}
          method: put
          cors: true
          authorizer: aws_iam
          request:
            parameters:
              paths:
                userId: true

  deleteUser:
    handler: deleteUser.main
    events:
      - http:
          path: users/{userId}
          method: delete
          cors: true
          authorizer: aws_iam
          request:
            parameters:
              paths:
                userId: true
```

さて実際のエンドポイントのデプロイです。

```YAML
plugins:
  - serverless-webpack
  - serverless-dotenv-plugin
```

今回はもちろんWebpackと`serverless-dotenv-plugin`を入れています。環境変数を使うために`dotenv`を使います。

```YAML
  dotenv:
    path: ./.env
```

これによって環境変数ファイルを読み込んでいます。

```YAML
  apiGateway:
    restApiId:
      "Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiId
    restApiRootResourceId:
      "Fn::ImportValue": ${self:custom.stage}-ApiGatewayRestApiRootResourceId
```

ここでAPI Gatewayを参照することによって、API Gatewayと関連付けます。関連付けないとまた他のAPI Gatewayが立ち上がることになり、それをIAMが参照しないことになるので、このエンドポイントは誰もコールできなくなりますのでご注意。

```YAML
  environment:
    CC_DYNAMODB_TABLE: ${file(./database/serverless.yml):custom.ccTableName}
```

これは[Serverless Framework で DynamoDB を構築する](https://qiita.com/maaaashin324/items/014d2e5fc29d31f45cae)ことについての記事でデプロイしているDynamoDBのテーブル名を参照しています。プログラムの中で使いたい意図です。

このようにしてすべてのファイルでテーブル名をハードコーディングしなくとも良いようにできます。`${file(./database/serverless.yml)`これがファイル自体を参照するための文法です。

```YAML
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:DescribeTable
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource:
        - "Fn::ImportValue": ${self:custom.stage}-CcDynamoDbTableArn
```

IAMです。DynamoDBに接続させるためにはIAMで許可する必要があります。これも[Serverless Framework で DynamoDB を構築する](https://qiita.com/maaaashin324/items/014d2e5fc29d31f45cae)ことについての記事で説明したとおり、デプロイしたテーブルをOutputsに設定しているので、`"Fn::ImportValue"`でArnを取得できるようにしています。

```YAML
  listUsers:
    handler: listUsers.main
    events:
      - http:
          path: users
          method: get
          cors: true
          authorizer: aws_iam
          request:
            parameters:
              querystrings:
                userEmail: false
                paginationToken: false
                limit: false
```

クエリパラメータを使う場合は

```YAML
            parameters:
              querystrings:
                userEmail: false
                paginationToken: false
                limit: false
```

を設定します。`false`になっているのは必須ではないという意味です。

またパス（`/users/{userId}`）を使う場合は

```YAML
            parameters:
              paths:
                userId: true
```

を使います。`true`になっているのは必須という意味です。

# さいごに

どのようにAPI Gateway、Lambdaをデプロイするのか、また`serverless-domain-manager`や`serverless-dotenv-plugin`に触れてきました。次はCodeBuildのを説明したいと思います。