CDKでAppSync Pipeline Resolverを構築する

2020.09.09

AppSyncを利用すると簡単にGraphQL APIを構築することができます.  またCDKを利用することでTypeScriptでインフラを管理できます.  つまり合わせるととてもすごいです.
今回はAppSyncでGraphQL APIの構築とPipeline Resolverの構築をCDKで構築する方法と気をつけポイントを綴ります.

スタックの作成

では実際にどのようなスタックを作成するかを記載していきます.
流れはCDKの初期設定をしてコードを書いてデプロイするだけです.

初期設定

まずは必要な依存関係の導入などを進めていきます.
CDKの初期設定をして@aws-cdk/aws-appsyncのライブラリを導入するだけです.

$ npx cdk init language=typescript
$ yarn add -D @aws-cdk/aws-appsync

検証で利用したCDKのバージョンなどを確認しましょう. package.jsonの中身を転記します.

{
  // ...
  "devDependencies": {
    "@aws-cdk/core": "1.60.0",
    "@aws-cdk/assert": "1.60.0",
    "@aws-cdk/aws-appsync": "1.60.0",
    "@types/jest": "^26.0.10",
    "@types/node": "10.17.27",
    "jest": "^26.4.2",
    "ts-jest": "^26.2.0",
    "aws-cdk": "1.60.0",
    "ts-node": "^8.1.0",
    "typescript": "~3.9.7",
    "source-map-support": "^0.5.16"
  },
  // ...
}

GraphQL Schemaの定義

次にGraphQL Schemaを定義していきます.
クエリで取得できる情報は, GitHubのユーザ情報とユーザに紐づくリポジトリ一覧を結合したデータを受け取るものです.

lib/constructs/schema.graphql

schema {
	query: Query
}

type Query {
	user(username: String!): GitHubUser!
}

type GitHubUser {
	id: String
	name: String
	bio: String
	repos: [Repository!]
}

type Repository {
	name: String
	homepage: String
	language: String
}

constructの作成

AppSyncのリソースを作成するためにconstructを作成します.
ここが要なのでゆっくりじっくり見ていきましょう.

まずはGraphQL Endpoint自体の定義をします.
特筆することはありませんが, ログの出力とX-Rayを有効にすると開発段階でデバッグや結果の確認が行いやすいです.
料金的にもちゃんと管理すれば跳ね上がらないので設定するのがおすすめです.

lib/constructs/graph.ts

export async function appSyncBFFConstruct(scope: cdk.Construct): Promise<void> {
  const api = new appsync.GraphQLApi(scope, 'GitHubAPI', {
        name: 'GitHub',
        schemaDefinition: appsync.SchemaDefinition.FILE,
        schemaDefinitionFile: path.join(__dirname, 'schema.graphql'),
        logConfig: {
            fieldLogLevel: appsync.FieldLogLevel.ALL,
            excludeVerboseContent: true,
        },
        xrayEnabled: true,
    });
  // ...
}

次にデータソースと関数, パイプラインリゾルバの設定を行います.
今段階ですは関数の定義は低レベルでしかできないのでCfnFunctionConfigurationを利用して定義します.

そのため高レベルのと異なり, Mapping TemplateクラスのrenderTemplateメソッドを利用して

  1. ファイルからMapping Templateに変換する
  2. Mapping Templateから文字列に変換する

といった2重の処理をかけています.

そして作った関数をリゾルバのpipelineConfigに実行したい順序で渡すことでパイプラインリゾルバが出来上がります. pipelineConfigに関数名の配列を渡すことでパイプラインに, 渡さないとユニットリゾルバになる魔法が詰まっています.

lib/constructs/graph.ts

export async function appSyncBFFConstruct(scope: cdk.Construct): Promise<void> {
  // ...
    const githubDataSource = api.addHttpDataSource(
        'GitHub',
        'https://api.github.com/',
    )
    const githubUserFunction =  new appsync.CfnFunctionConfiguration(scope, 'GitHubUserFunction', {
        apiId: api.apiId,
        name: 'GitHubUserFunction',
        description: 'obtain the GitHub User information',
        dataSourceName: githubDataSource.name,
        functionVersion: '2018-05-29',
        requestMappingTemplate:  appsync.MappingTemplate.fromFile(path.join(__dirname, 'resolvers/user/github-user-req.vtl')).renderTemplate(),
        responseMappingTemplate: appsync.MappingTemplate.fromFile(path.join(__dirname, 'resolvers/user/github-user-res.vtl')).renderTemplate(),
    })
    const githubRepoFunction =  new appsync.CfnFunctionConfiguration(scope, 'GitHubRepoFunction', {
        apiId: api.apiId,
        name: 'GitHubRepoFunction',
        description: 'obtain repositories',
        dataSourceName: githubDataSource.name,
        functionVersion: '2018-05-29',
        requestMappingTemplate:  appsync.MappingTemplate.fromFile(path.join(__dirname, 'resolvers/user/github-repo-req.vtl')).renderTemplate(),
        responseMappingTemplate: appsync.MappingTemplate.fromFile(path.join(__dirname, 'resolvers/user/github-repo-res.vtl')).renderTemplate(),
    })

    new appsync.Resolver(scope, 'GitHubResolver', {
        api: api,
        typeName: 'Query',
        fieldName: 'user',
        pipelineConfig: [githubUserFunction.attrFunctionId, githubRepoFunction.attrFunctionId],
        requestMappingTemplate: appsync.MappingTemplate.fromFile(path.join(__dirname, 'resolvers/user/before.vtl')),
        responseMappingTemplate: appsync.MappingTemplate.fromFile(path.join(__dirname, 'resolvers/user/after.vtl')),
    })

    githubUserFunction.addDependsOn(githubDataSource.ds)
    githubRepoFunction.addDependsOn(githubDataSource.ds)
  // ...
}

1点注意するべきこととして, パイプラインリゾルバは特定の1つのデータソースに紐づけることができません.
なのでユニットリゾルバの気持ちで下記のように書くとスタック作成時にエラーがでます. 悲しいですね. 誤ってデータソースでパイプラインリゾルバを作ろうとしてn時間溶かした人もいるんです.

const githubDataSource = api.addHttpDataSource(
  'GitHub',
  'https://api.github.com/',
)

githubDataSource.createResolver({
	typeName: 'Query',
	fieldName: 'user',
	pipelineConfig: [/**.... */],
})

今までのコードを合わせると下記のようになりますね.
シンプル...とは言い切れませんが, 通常のIaCツールより見通しがよくかけているなぁという気持ちになります.

lib/constructs/graph.ts

import * as appsync from '@aws-cdk/aws-appsync';
import * as cdk from '@aws-cdk/core';
import * as path from 'path';

export async function appSyncBFFConstruct(scope: cdk.Construct): Promise<void> {
    const api = new appsync.GraphQLApi(scope, 'GitHubAPI', {
        name: 'GitHub',
        schemaDefinition: appsync.SchemaDefinition.FILE,
        schemaDefinitionFile: path.join(__dirname, 'schema.graphql'),
        logConfig: {
            fieldLogLevel: appsync.FieldLogLevel.ALL,
            excludeVerboseContent: true,
        },
        xrayEnabled: true,
    });

    const githubDataSource = api.addHttpDataSource(
        'GitHub',
        'https://api.github.com/',
    )
    const githubUserFunction =  new appsync.CfnFunctionConfiguration(scope, 'GitHubUserFunction', {
        apiId: api.apiId,
        name: 'GitHubUserFunction',
        description: 'obtain the GitHub User information',
        dataSourceName: githubDataSource.name,
        functionVersion: '2018-05-29',
        requestMappingTemplate:  appsync.MappingTemplate.fromFile(path.join(__dirname, 'resolvers/user/github-user-req.vtl')).renderTemplate(),
        responseMappingTemplate: appsync.MappingTemplate.fromFile(path.join(__dirname, 'resolvers/user/github-user-res.vtl')).renderTemplate(),
    })
    const githubRepoFunction =  new appsync.CfnFunctionConfiguration(scope, 'GitHubRepoFunction', {
        apiId: api.apiId,
        name: 'GitHubRepoFunction',
        description: 'obtain repositories',
        dataSourceName: githubDataSource.name,
        functionVersion: '2018-05-29',
        requestMappingTemplate:  appsync.MappingTemplate.fromFile(path.join(__dirname, 'resolvers/user/github-repo-req.vtl')).renderTemplate(),
        responseMappingTemplate: appsync.MappingTemplate.fromFile(path.join(__dirname, 'resolvers/user/github-repo-res.vtl')).renderTemplate(),
    })

    new appsync.Resolver(scope, 'GitHubResolver', {
        api: api,
        typeName: 'Query',
        fieldName: 'user',
        pipelineConfig: [githubUserFunction.attrFunctionId, githubRepoFunction.attrFunctionId],
        requestMappingTemplate: appsync.MappingTemplate.fromFile(path.join(__dirname, 'resolvers/user/before.vtl')),
        responseMappingTemplate: appsync.MappingTemplate.fromFile(path.join(__dirname, 'resolvers/user/after.vtl')),
    })

    githubUserFunction.addDependsOn(githubDataSource.ds)
    githubRepoFunction.addDependsOn(githubDataSource.ds)
}

Mapping Templatesの定義

最後に実際にデータソースに問い合わせをしてデータを整形するテンプレートを定義していきます. まずはパイプラインの始めと開始部分, 関数にデータを渡す前と後処理をするテンプレートを書きます.

最初の処理では受け取った引数をstashにいれて利用しやすくします.
次の関数に渡す値はないので空のオブジェクトをレスポンスとして定義します.

lib/constructs/resolvers/before.vtl

$util.qr($ctx.stash.put("username", $ctx.args.username))

{}

最後の処理は今回は特に何もする必要がないので前の関数から渡された値をオブジェクトとして返して終わりです.

lib/constructs/resolvers/after.vtll

$util.toJson($ctx.result)

次に実際の関数の中身を定義していきます.
まずは引数として受け取ったユーザIDを元にユーザ情報を取得します.
GitHub API V3だと 「/users/:user」で情報が取得できるのでこのパスにそってリクエストを投げます.

lib/constructs/resolvers/github-user-req.vtl

{
  "version": "2018-05-29",
  "method": "GET",
  "params": {
      "query": {},
      "headers": {
          "Content-Type": "application/json"
      }
  },
  "resourcePath": $util.toJson("/users/${ctx.stash.username}")
}

ユーザ情報については何も整形する必要がないのでレスポンスの中身をそのまま次の関数に渡します.

lib/constructs/resolvers/github-user-res.vtl

$util.toJson($context.result.body)

次にリポジトリの一覧を取得します.
GitHub API V3だと 「/users/:user/repos」で情報が取得できるのでこのパスにそってリクエストを投げます. またユーザIDはstashに入っているのでここも簡単にかけますね.

lib/constructs/resolvers/githu-repo-req.vtl

{
  "version": "2018-05-29",
  "method": "GET",
  "params": {
      "query": {},
      "headers": {
          "Content-Type": "application/json"
      }
  },
  "resourcePath": "/users/${ctx.stash.username}/repos"
}

最後に若干煩雑なユーザ情報とリポジトリ情報の結合部分になります.

lib/constructs/resolvers/githu-repo-res.vtl

#set($repos = [])
#foreach($repo in $util.parseJson($context.result.body))
  $util.qr(
    $repos.add({
      "name": $repo.name,
      "homepage": $repo.homepage,
      "language": $repo.language
    })
  )
#end

#set($prev = $util.parseJson($ctx.prev.result))

{
  "id"   : $util.toJson($prev.login),
  "name" : $util.toJson($prev.name),
  "bio"  : $util.toJson($prev.bio),
  "repos": $util.toJson($repos)
}

foreachの部分でまずは受け取ったレスポンスから必要なデータのみをreposに格納させます.
この時レスポンスの本文は文字列として入ってくるので一度$util.parseJsonを利用してオブジェクト変換させてからループを開始させます.

後半部分では以前の結果を一度オブジェクトに格納してから, 最終的なレスポンスを定義させています.

デプロイと確認

これで全ての準備が整ったのでスタックの設定をして, デプロイします.

まずはスタックに先ほど定義したconstructを渡します.

infra.ts

import * as cdk from '@aws-cdk/core';
import { appSyncBFFConstruct } from './constructs/graph';

export class InfraStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    const api = appSyncBFFConstruct(this);
  }
}

ここまで完了したら実際にデプロイをしましょう.

$ yarn cdk deploy

デプロイしたらクエリを投げましょう.

query {
  user(username: "37108") {
    id
    repos {
      name
    }
  }
}

正常にレスポンスが返ってきますね.
よかったです.

{
  "data": {
    "user": {
      "id": "37108",
      "repos": [
        {
          "name": "37108"
        },
        {
          "name": "amplify-form-example"
        },
        {
          "name": "appsync_experimental"
        },
        // ....
      ]
    }
  }
}

AppSyncのデバッグについて

テンプレートで最も気を遣う部分の1つが型になります. ですがAppSyncでcontextの値などを途中で確認する方法やログで見ることは難しいです.
ここではちょっとした対処方法を記載します.

GraphQLではエラーメッセージを返す方法が, AppSyncでは指定したエラーメッセージでエラーを返す方法があります.
なのでこの2つを利用して, エラーメッセージに表示したい情報, 例えばcontextなどを渡してGraphQLのエラーを通じてデータを確認します.

手法としてはまず, デバッグしたいタイミングでMapping Templateに$util.errorメソッドを差し込みます.
例えば先ほどのgithub-repo-res.vtlの先頭に挟み込み, contextを見ることでこのテンプレートで処理するデータを確認してみます.

$util.error($utils.toJson($context))

#set($repos = [])
#foreach($repo in $util.parseJson($context.result.body))
## ...

この状態で通常のようにクエリを投げてみます.

query {
  user(username: "37108") {
    id
    repos {
      name
    }
  }
}

先ほどまでは正常に返ってきたレスポンスがエラーを返すようになっていますね.
レスポンス本文がかなり長いのでほとんど端折っていますが, message以下に閲覧したい情報が入っています.

{
  "data": null,
  "errors": [
    {
      "path": [
        "user"
      ],
      "data": null,
      "errorType": null,
      "errorInfo": null,
      "locations": [
        {
          "line": 2,
          "column": 3,
          "sourceName": null
        }
      ],
      "message": "{\"arguments\":{\"username\":\"37108\"},\"identity\":null,\"source\":null,\"result\":{...
    }
  ]
}

あとはX-Rayのトレーシングを確認することでリクエストの流れと概要が掴めるのでだいぶ状況把握がしやすくなります.
HTTP Data Sourceの時はエンドポイントにリクエストが投げられているかの確認や, パイプラインがどこで止まっているかが簡単に推測するので本当に有効にするのはおすすめです.

さいごに

AppSyncは非常に便利ですが, ちょっとしたハマりどころがあったので今回はブログを書いてみました.
皆様のお役に立てたら幸いです.