Terraform と Amazon API Gateway でS3バケットにファイルアップロードする


はじめに

Amazon API Gateway の AWS サービス統合では、Lambda を通さずに直接サービスにアクセスすることができて、No-Code、Low-Code な感じでサービスを構築することができる(実際には YAML とか VTL とか知らないといけないから、No-Code ではないか)。

今回は、S3バケットにファイルをアップロードする API Gateway を構築してみる。

なお、参考にしたのは以下の公式ドキュメントだ。

Amazon API Gatewayの構築

アップロードする API は API Gateway の prod/uploader に PUT するメソッドを作ることにしよう。
logformat.json はあってもなくても良いが、サービス統合するときには、ログをちゃんと出しておかないと、トラブったときに何もわからなくなるので、出しておくことを推奨する。

API Gateway を構築する HCL ファイルの全体
resource "aws_api_gateway_rest_api" "uploader" {
  name        = local.api_gateway_name
  description = "S3アップロードAPI用API Gateway"

  binary_media_types = [
    "application/octet-stream",
  ]
}

resource "aws_api_gateway_account" "my_api" {
  cloudwatch_role_arn = aws_iam_role.apigateway_putlog.arn
}

resource "aws_api_gateway_stage" "prod" {
  stage_name    = "prod"
  rest_api_id   = aws_api_gateway_rest_api.uploader.id
  deployment_id = aws_api_gateway_deployment.for_prod.id

  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.apigateway_accesslog.arn
    format          = file("${path.module}/logformat.json")
  }
}

resource "aws_api_gateway_deployment" "for_prod" {
  rest_api_id = aws_api_gateway_rest_api.uploader.id

  triggers = {
    redeployment = sha1(join(",", list(
      jsonencode(aws_api_gateway_rest_api.uploader),
      jsonencode(aws_api_gateway_integration.uploader_put_s3),
    )))
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_api_gateway_method_settings" "all" {
  depends_on = [
    aws_api_gateway_account.my_api,
  ]

  rest_api_id = aws_api_gateway_rest_api.uploader.id
  stage_name  = aws_api_gateway_stage.prod.stage_name
  method_path = "*/*"

  settings {
    logging_level      = "INFO"
    data_trace_enabled = true
  }
}

resource "aws_api_gateway_request_validator" "uploader" {
  name                        = "Validator"
  rest_api_id                 = aws_api_gateway_rest_api.uploader.id
  validate_request_body       = true
  validate_request_parameters = false
}

resource "aws_api_gateway_resource" "uploader" {
  rest_api_id = aws_api_gateway_rest_api.uploader.id
  parent_id   = aws_api_gateway_rest_api.uploader.root_resource_id
  path_part   = "uploader"
}

resource "aws_api_gateway_method" "uploader_put" {
  rest_api_id   = aws_api_gateway_rest_api.uploader.id
  resource_id   = aws_api_gateway_resource.uploader.id
  http_method   = "PUT"
  authorization = "NONE"
}

resource "aws_api_gateway_method_response" "uploader_put_200" {
  rest_api_id = aws_api_gateway_rest_api.uploader.id
  resource_id = aws_api_gateway_resource.uploader.id
  http_method = aws_api_gateway_method.uploader_put.http_method
  status_code = "200"
}

resource "aws_api_gateway_integration" "uploader_put_s3" {
  rest_api_id = aws_api_gateway_rest_api.uploader.id
  resource_id = aws_api_gateway_resource.uploader.id
  http_method = aws_api_gateway_method.uploader_put.http_method

  type                    = "AWS"
  uri                     = "arn:aws:apigateway:${data.aws_region.current.name}:s3:path/${aws_s3_bucket.uploader.bucket}/test_object"
  integration_http_method = "PUT"

  credentials          = aws_iam_role.apigateway_s3_access.arn
  passthrough_behavior = "WHEN_NO_MATCH"
  content_handling     = "CONVERT_TO_BINARY"
}

アップロード先になる S3 バケットは以下のように作っておく。
これも当然ながら、アクセス制御は API Gateway 側で実施するので、バケットの設定は private で問題ない。

resource "aws_s3_bucket" "uploader" {
  bucket = local.s3_uploader_bucket_name
  acl    = "private"
}

また、IAM は CloudWatchLogs にアクセスできるよう、aws_iam_role.apigateway_putlog.arnAmazonAPIGatewayPushToCloudWatchLogs のマネージドポリシーをアタッチしておこう。

ここで肝になるのが、aws_api_gateway_rest_api.uploader

  binary_media_types = [
    "application/octet-stream",
  ]

の部分と、aws_api_gateway_integration.uploader_put_s3

  passthrough_behavior = "WHEN_NO_MATCH"
  content_handling     = "CONVERT_TO_BINARY"

の部分だ。上記の参考ドキュメントでの方式では、content-type: application/octet-stream のアップロードをしているため、以下の公式ドキュメントの

に書いてあるのに従い、今回は以下の通りのアップロード使用を考えているため、上記の通りの設定を行う。

メソッドリクエストペイロード リクエスト Content-Type ヘッダー binaryMediaTypes contentHandling 統合リクエストペイロード
バイナリデータ バイナリデータ型 一致するメディアタイプで設定 CONVERT_TO_BINARY バイナリデータ

aws_api_gateway_integration.uploader_put_s3 は、API Gateway のサービス統合が S3 バケットにアクセスできるよう、以下の通りのロールを作っておく。

resource "aws_iam_role" "apigateway_s3_access" {
  name               = local.rest_api_s3_access_role_name
  assume_role_policy = data.aws_iam_policy_document.apigateway_s3_access_assume.json
}

data "aws_iam_policy_document" "apigateway_s3_access_assume" {
  statement {
    effect = "Allow"

    actions = [
      "sts:AssumeRole",
    ]

    principals {
      type = "Service"
      identifiers = [
        "apigateway.amazonaws.com",
      ]
    }
  }
}

resource "aws_iam_role_policy_attachment" "apigateway_s3_access" {
  role       = aws_iam_role.apigateway_s3_access.name
  policy_arn = aws_iam_policy.apigateway_s3_access_custom.arn
}

resource "aws_iam_policy" "apigateway_s3_access_custom" {
  name   = local.rest_api_s3_access_policy_name
  policy = data.aws_iam_policy_document.apigateway_s3_access_custom.json
}

data "aws_iam_policy_document" "apigateway_s3_access_custom" {
  statement {
    effect = "Allow"

    actions = [
      "s3:PutObject",
    ]

    resources = [
      "${aws_s3_bucket.uploader.arn}/*",
    ]
  }
}

さて、これで API Gateway は準備完了だ。

ファイルアップロードをする Javascript

シンプルに以下のように作成する。
コンテンツは、Nginx なり S3 の静的Webサイトホスティングなりに入れておけば良いだろう。
${apigateway_invoke_url} の部分は適宜置換を行う。

静的Webサイトホスティングについては以下の記事を参照。

また、上記の API Gateway はかなり雑につくっているので、このようにファイルアップロードする Javascript を作る場合は、当然ながら CORS の設定が必要になる。
CORS も Amazon API Gateway であれば、サクッと対応することができる。詳細は以下の記事を参照。

index.html
<html>
  <head>
    <style>
      [v-cloak] { display: none }
    </style>
    <meta charset="utf-8">
    <title>S3アップローダ</title>
  </head>
  <body>
    <div id="app">
      <h1>S3アップローダ</h1>
      <input v-on:change="selectedFile" type="file" name="file">
      <button v-on:click="upload" type="submit">アップロード</button>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.js"></script>
    <script src="app.js"></script>
  </body>
</html>
app.js
const app = new Vue({
  el: '#app',
  data: {
    uploadFile: null
  },
  methods: {
    selectedFile: function (e) {
      e.preventDefault()
      const files = e.target.files
      this.uploadFile = files[0]
    },
    upload: function () {
      var fileReader = new FileReader()
      fileReader.addEventListener('load', function (e) {
        const config = {
          url: '${apigateway_invoke_url}/uploader',
          method: 'put',
          headers: {
            'content-type': 'application/octet-stream',
            accept: 'application/json'
          },
          data: e.target.result
        }
        axios
          .request(config)
          .then(function (response) {
            console.log(response)
          })
          .catch(function (error) {
            console.log(error)
          })
      })
      fileReader.readAsArrayBuffer(this.uploadFile)
    }
  }
})

app.$mount('#app')

これで、index.html のコンテンツからファイルアップロードすると、S3バケットにそのコンテンツが格納される。
バイナリ格納しているので、画像でも何でも渡せるはずだ!