API Gateway と DynamoDB でかんたん Slack slash command をつくった

API Gateway はダイレクトに DynamoDB へアクセスできます。動的処理を含まない簡単な文章を返すだけの Slash command ならば API Gateway と DynamoDB で完結できます。

image

ということで実際に登録して使っていますが、便利です。特に絶対に使うけれどもたまにしか使わないので忘れるというものをサッと取りだせるのがいいですね。Slack は他の通知系を集約していたりするので、そこに機能を集めていくと便利さが増すような気がします。

日記代わりに書き残しておきます。

さて

API Gateway と DynamoDB の準備は Terraform で、DynamoDB への値の登録は TypeScript を用いました。しかし設定内容はそれほど多くないので、いずれも手作業で行えるでしょう。特に DynamoDB はコンソールから手軽に編集できるため、管理が容易だと感じました。

まず Terraform ファイルを用意します。簡単な内容なので実際は一枚ものにしていますが、各セクションに分解しました。

一枚もの https://gist.github.com/mmmpa/4d472e0d844d894ebbdc4de8166010f3

環境変数を展開

ss_token には Slack slash command が送信する token を設定します。

$ TF_VAR_ss_token=xxxx terraform apply
variable "api_name" { default = "librarian_api" }
variable "table_name" { default = "librarian_db" }
variable "ss_token" {}

DynamoDB を用意する

doc_nameGetItem し、content というプロパティからコマンドで表示するテキストを取りだす想定です。

resource "aws_dynamodb_table" "db" {
  name = "${var.table_name}"
  read_capacity = 1
  write_capacity = 1
  hash_key = "doc_name"
  attribute {
    name = "doc_name"
    type = "S"
  }
}

API Gateway の構築

基礎部分

resource "aws_api_gateway_rest_api" "main" {
  name = "${var.api_name}"
  description = "${var.api_name}"
}
resource "aws_api_gateway_method" "method" {
  rest_api_id = "${aws_api_gateway_rest_api.main.id}"
  resource_id = "${aws_api_gateway_rest_api.main.root_resource_id}"
  http_method = "GET"
  authorization = "NONE"
  request_parameters = {
    "method.request.querystring.text" = false
    "method.request.querystring.token" = true
  }
}

クエリ文字列を変形し、DynamoDB へリクエストを送信する部分

Slack が送信する token の検証は #if($input.params('token') != "${var.ss_token}") で行い、正しくなければ処理を切りあげます。

resource "aws_api_gateway_integration" "integration" {
  rest_api_id = "${aws_api_gateway_rest_api.main.id}"
  resource_id = "${aws_api_gateway_rest_api.main.root_resource_id}"
  http_method = "${aws_api_gateway_method.method.http_method}"
  type = "AWS"
  uri = "arn:aws:apigateway:ap-northeast-1:dynamodb:action/GetItem"
  integration_http_method = "POST"
  credentials = "${aws_iam_role.role.arn}"
  passthrough_behavior = "NEVER"
  request_templates = {
    "application/json" = <<EOF
#if($input.params('token') != "${var.ss_token}")
#stop
#end
#if($input.params('text') == '')
#set($key = 'help')
#else
#set($key = $input.params('text'))
#end
{
  "TableName": "${var.table_name}",
  "Key": {
    "doc_name": {
      "S": "$key"
    }
  }
}
EOF
  }
}

DynamoDB からのレスポンスを Slack のコメントフォーマットに変形する部分

resource "aws_api_gateway_method_response" "res" {
  rest_api_id = "${aws_api_gateway_rest_api.main.id}"
  resource_id = "${aws_api_gateway_rest_api.main.root_resource_id}"
  http_method = "${aws_api_gateway_method.method.http_method}"
  status_code = "200"
  response_models = {
    "application/json" = "Empty"
  }
}
resource "aws_api_gateway_integration_response" "integration_res" {
  depends_on = [
    "aws_api_gateway_integration.integration",
  ]
  rest_api_id = "${aws_api_gateway_rest_api.main.id}"
  resource_id = "${aws_api_gateway_rest_api.main.root_resource_id}"
  http_method = "${aws_api_gateway_method.method.http_method}"
  status_code = "${aws_api_gateway_method_response.res.status_code}"
  selection_pattern = "200"
  response_templates = {
    "application/json" = <<EOF
#set($text = $input.path('$.Item.content').S)
#if($text == "")
{ "text": "command not found" }
#else
{ "text": $text }
#end
EOF
  }
}

API Gateway が DynamoDB にアクセスするためのロール

特に信頼関係と称される AssumeRole を忘れがちなので注意しましょう。

resource "aws_iam_role" "role" {
  name = "${var.api_name}_api_role"
  path = "/"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "1",
      "Effect": "Allow",
      "Principal": {
        "Service": "apigateway.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}
resource "aws_iam_role_policy" "policy" {
  name = "get-sample"
  role = "${aws_iam_role.role.id}"
  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "1",
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem"
      ],
      "Resource": [
        "${aws_dynamodb_table.db.arn}"
      ]
    }
  ]
}
EOF
}

API Gateway を terraform apply ごとにデプロイし、URL を出力する

resource "aws_api_gateway_deployment" "deploy" {
  depends_on = [
    "aws_api_gateway_integration.integration",
  ]
  rest_api_id = "${aws_api_gateway_rest_api.main.id}"
  stage_name = "doc"
  description = "Deployed at ${timestamp()}"
}
output "endpoint" {
  value = "${aws_api_gateway_deployment.deploy.invoke_url}"
}

Terraform の設定内容は以上です。

DynamoDB へ登録する

ここからは値の登録です。

今回は JSON を用意して TypeScript (ts-node) で登録しました。

{
  "doc1": {
    "content": "doc1 の内容"
  },
  "doc2": {
    "content": "doc2 の内容"
  }
}
import { DynamoDB } from 'aws-sdk'
import * as fs from 'fs'
export type DocT = {
  description: string
  content: string
}
export type DocsT = { [key: string]: DocT }
const { TABLE_NAME: TableName = 'librarian_db' } = process.env
function put (db: DynamoDB, item: DynamoDB.PutItemInput): Promise<DynamoDB.PutItemOutput> {
  return new Promise((resolve, reject) =>
    db.putItem(item, (err, data) => (err ? reject(err) : resolve(data))))
}
async function main (): Promise {
  const db = new DynamoDB()
  const data: DocsT = JSON.parse(fs.readFileSync('./built/content.json').toString())
  const items = Object.keys(data).map(k => ({
    TableName,
    Item: {
      doc_name: { S: k },
      content: { S: JSON.stringify(data[k].content) },
    },
  }))
  for (let i = 0, l = items.length; i < l; i += 1) {
    const item = items[i]
    await put(db, items[i]).catch(console.error)
    console.log(`put: ${JSON.stringify(item, null, ' ')}`)
  }
}
main().catch(console.error)