2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

AWS AppSync+TerraformでサーバレスなWebアプリケーションを自動作成する

Posted at

はじめに

サーバレスやるならAPI GatewayだけじゃなくてAppSync知っておけよ、という天の声が聞こえてきたので、作ってみた。

たぶん慣れればお手軽。GraphQLとVTLを覚えるコストがかかるといったところか。

全体構成

以下のような構成にする。AppSyncを使うので、Lambdaすら不要。
静的コンテンツはS3から取得し、DynamoDBからAppSyncをデータを取得する。
別にCloudFrontを経由する必要はないのだが、AppSyncがCORSを扱えないため、静的コンテンツと同一オリジンになるよう、CloudFrontにぶら下げる。

構成図.png

さて、それぞれのTerraformリソースを見ていこう。

Terraformリソース定義

DynamoDB

DynamoDBは、ユーザIDをハッシュキーとしたテーブルにする。
ここでは、名前と年齢を登録しておく。

resource "aws_dynamodb_table" "user" {
  name         = local.dynamodb_table_name
  billing_mode = "PAY_PER_REQUEST"

  hash_key = "id"

  attribute {
    name = "id"
    type = "S"
  }
}

resource "aws_dynamodb_table_item" "user" {
  count = 1

  table_name = aws_dynamodb_table.user.name
  hash_key   = aws_dynamodb_table.user.hash_key
  range_key  = aws_dynamodb_table.user.range_key

  item = <<ITEM
{
  "id": {"S": "00001"},
  "name": {"S": "Taro"},
  "age": {"S": "35"}
}
ITEM
}

S3バケット

静的コンテンツを入れるS3バケットの定義。
本当なら、CloudFrontを使うなら直接アクセスをさせないよう private にしておき、Origin Access Identity を設定する方が良いが、今回はおためしなので端折っている。

なお、Webアプリのコンテンツ側もお手軽に、今回は Vue.js 2.x の CDN 版で作る。

resource "aws_s3_bucket" "contents" {
  bucket = local.bucket_name
  acl    = "public-read"

  website {
    index_document = "index.html"
  }
}

resource "aws_s3_bucket_object" "index" {
  bucket       = aws_s3_bucket.contents.id
  source       = "../contents/index.html"
  key          = "contents/index.html"
  acl          = "public-read"
  content_type = "text/html"
  etag         = filemd5("../contents/index.html")
}

resource "aws_s3_bucket_object" "app" {
  bucket       = aws_s3_bucket.contents.id
  source       = "../contents/app.js"
  key          = "contents/app.js"
  acl          = "public-read"
  content_type = "text/javascript"
  etag         = filemd5("../contents/app.js")
}

AppSync

今回のキモになるAppSync。
アクセスは APIキー をヘッダに設定することでアクセスできるように設定する。この場合、aws_appsync_api_key を併せて定義しておこう。aws_appsync_api_key.test.key でキー情報を参照可能なので、output しておくと良い。

resource "aws_appsync_graphql_api" "test" {
  name                = local.appsync_graphql_api_name
  authentication_type = "API_KEY"
  schema              = data.local_file.graphql_schema.content
}

data "local_file" "graphql_schema" {
  filename = "./appsync_schema.graphql"
}

resource "aws_appsync_api_key" "test" {
  api_id      = aws_appsync_graphql_api.test.id
  description = "${var.prefix}用APIキー"
}

resource "aws_appsync_datasource" "dynamodb" {
  api_id           = aws_appsync_graphql_api.test.id
  name             = local.appsync_dynamodb_datasource_name
  service_role_arn = aws_iam_role.appsync.arn
  type             = "AMAZON_DYNAMODB"

  dynamodb_config {
    table_name = local.dynamodb_table_name
  }
}

resource "aws_appsync_resolver" "createuser" {
  api_id      = aws_appsync_graphql_api.test.id
  field       = "createUser"
  type        = "Mutation"
  data_source = aws_appsync_datasource.dynamodb.name

  request_template  = file("./createuser_request.template")
  response_template = file("./createuser_response.template")
}

resource "aws_appsync_resolver" "user" {
  api_id      = aws_appsync_graphql_api.test.id
  field       = "user"
  type        = "Query"
  data_source = aws_appsync_datasource.dynamodb.name

  request_template  = file("./user_request.template")
  response_template = file("./user_response.template")
}

aws_appsync_datasource で DynamoDB に接続する設定をしているが、サービスロールに DynamoDB にアクセスする権限を与える必要があるので、以下のような感じで定義しておく。

resource "aws_iam_role" "appsync" {
  name               = local.appsync_role_name
  assume_role_policy = data.aws_iam_policy_document.appsync_assume.json
}

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

    actions = [
      "sts:AssumeRole",
    ]

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

resource "aws_iam_role_policy_attachment" "appsync" {
  role       = aws_iam_role.appsync.name
  policy_arn = aws_iam_policy.appsync_custom.arn
}

resource "aws_iam_policy" "appsync_custom" {
  name   = local.appsync_policy_name
  policy = data.aws_iam_policy_document.appsync_custom.json
}

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

    actions = [
      "dynamodb:BatchGetItem",
      "dynamodb:GetItem",
      "dynamodb:Query",
      "dynamodb:Scan",
      "dynamodb:BatchWriteItem",
      "dynamodb:PutItem",
      "dynamodb:UpdateItem",
      "dynamodb:DeleteItem",
    ]

    resources = [
      "*",
    ]
  }
}

aws_appsync_graphql_api では以下のスキーマを定義する。

appsync_schema.graphql
schema {
  query: Query
  mutation: Mutation
}

type Query {
  user(id: ID!): User
}

type Mutation {
  createUser(name: String!): User
}

type User {
  id: ID!
  name: String!
  age: String!
}

さて、これでデータソースとスキーマの定義が終わったので、これをリゾルバに設定していく。
リゾルバでは、VTLで以下のようにリクエストとレスポンスの情報を制御する。

createuser_request.template
{
    "version" : "2017-02-28",
    "operation" : "PutItem",
    "key" : {
        "id" : { "S" : "$util.autoId()" }
    },
    "attributeValues" : {
        "name" : { "S" : "${context.arguments.name}" },
    }
}
createuser_resonse.template
$utils.toJson($context.result)
user_request.template
{
    "version" : "2017-02-28",
    "operation" : "GetItem",
    "key" : {
        "id" : $util.dynamodb.toDynamoDBJson($ctx.args.id)
    }
}
user_response.template
$utils.toJson($ctx.result)

ここまで作ると、AppSyncとしての動作確認はできるので、以下のような感じで正常性確認をしてみよう。
x-api-key は、↑の方で払い出したAPIキーだ。

curl \
  -w "\n" \
  -H 'Content-Type: application/json' \
  -H "x-api-key: xxx-xxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -X POST -d '
    {
      "query": "query { user(id: \"00001\") { id name } }"
    }' \
  https://xxxxxxxxxxxxxxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql
curl \
  -w "\n" \
  -H 'Content-Type: application/json' \
  -H "x-api-key: xxx-xxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -X POST -d '
    {
      "query": "mutation { createUser(name: \"Jiro-san\") { name } }"
    }' \
  https://xxxxxxxxxxxxxxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql

CloudFront

CloudFront は以下のようにマルチオリジンの定義をする。AppSync の domain_name で変なことをやっているのは、Terraform が AppSync の domain_name を良い感じに取る属性を持っていないためだ……残念。

resource "aws_cloudfront_distribution" "appsync" {
  origin {
    domain_name = trimsuffix(trimprefix(aws_appsync_graphql_api.test.uris["GRAPHQL"], "https://"), "/graphql")
    origin_id   = local.cloudfront_appsync_origin_id

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1", "TLSv1.1", "TLSv1.2"]
    }
  }

  origin {
    domain_name = aws_s3_bucket.contents.bucket_regional_domain_name
    origin_id   = local.cloudfront_s3_origin_id
  }

  enabled = true
  comment = "AppSync用CloudFront"

  default_cache_behavior {
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.cloudfront_appsync_origin_id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "allow-all"
  }

  ordered_cache_behavior {
    path_pattern     = "/contents/*"
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.cloudfront_s3_origin_id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "allow-all"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

ここまでくればあと一歩。

静的コンテンツ

静的コンテンツは、以下のように Vue.js を作る。
払い出された CloudFront のドメインと APIキー を忘れず書いておこう。

GraphQL とは言え、所詮は JSON を POST リクエストしているだけなので、axios でサクッとデータを引っ張ってくることができる。

Javascriptのコードは、eslint してなかったりエラーハンドリングが不真面目だったりするけど、おためしなのでそこは気にしないでいただきたい。

index.html
<html>
  <head>
    <style>
      [v-cloak] { display: none }
    </style>
    <meta charset="utf-8">
    <title>Vue TEST</title>
  </head>
  <body>
    <div id="myapp" v-cloak>
      <input type="text" v-model="user_id" placeholder="ユーザID(5桁)を入力">
      <button v-on:click="check_employee" v-bind:disabled="is_invalid">確認</button>
      <table border="1" v-if="user_info">
        <tr><th>id</th><th>name</th><th>age</th></tr>
        <tr v-model="item"><td>{{ item['id'] }}</td><td>{{ item['name'] }}</td><td>{{ item['age'] }}</td></tr>
      </table>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/vue"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="/contents/app.js"></script>
  </body>
</html>
App.js
const APIGATEWAY_INVOKE_URL = 'https://[CloudFrontのドメイン]/graphql'
const APPSYNC_API_KEY = '[払い出したAPIキー]'

const app = new Vue({
  el: '#myapp',
  data: {
    user_info: false,
    user_id: '',
    is_invalid: true,
    item: null
  },
  watch: {
    user_id: function (newVal, oldVal) {
      this.is_invalid = newVal.length !== 5
    }
  },
  methods: {
    check_employee: function () {
      const headers = {
        'x-api-key': `${APPSYNC_API_KEY}`
      }

      const body = {
        query: `query{
          user(id: "${this.user_id}\")
          { 
            id
            name
            age
          }
        }`
      }

      axios
        .post(`${APIGATEWAY_INVOKE_URL}`, body, { headers: headers })
        .then(response => {
          this.item = response.data.data.user
          this.user_info = true
        })
    }
  }
})

app.$mount('#app')

これで完成!
ブラウザから CloudFront のコンテンツにアクセスしてみると…

キャプチャ.png

動いた!

GraphQL でどこまで複雑なことを実装できるかまだ深追いできていないけど、少なくとも API Gateway のAWSサービス統合よりは柔軟なことができそうな雰囲気。

2
2
0

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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?