こんにちは!
AppSync 開発の情報は、API Gateway と比較すると少ないと思います。GraphQL がまだまだ浸透していない日本の開発市場では、経験値のない開発チームが AppSync の開発方法を整理するのには苦労しますよね。
筆者も最初の AppSync 開発案件は非常に緊張しましたが、最近ではある程度開発手法が固まり、開発に安定感がついてきました。
本記事では、AppSync での API 開発の流れをソースコード交えて解説します!
わたし達は、Amplify フレームワークの機能で GraphQL のコードを自動生成しています。
AppSync?GraphQL が難しそう…なんてことはありません。
注意点としては、自動生成できるのはあくまでもフロントエンドの GraphQL クライアントで指定する GraphQL コード(Query, Mutation, Subscription)のみです。GraphQL スキーマファイルは自分で作成する必要があります。
※GraphQL スキーマファイルとはバリデーションルールを記述した API 定義書と考えておいてください(後述)
まず、Amplify は schema.graphql ファイルを起点に、DynamoDB, AppSync, ElasticSearch などを自動的に構築してくれますが、スキーマの変更には制約を伴います。(例えば後から GSI 変更が非常に難しい等)
Amplify の Github の Issue を見るとまだまだ不具合も多く、プロダクション運用には慎重にならなければいけません。
わたし達は、Amplify は GraphQL のクライアント利用に留めています。
AppSync では、既存の DynamoDB、Cognito、IAM と簡単に連携できます。
もし既存環境の構築が、CloudFormation で自動化されているなら、Ref などを用いて ARN を取得、AppSync の CloudFormation コードに設定するだけで簡単に各サービスと連携できます。
また、RestAP I開発経験者の方は、AppSync VS API Gateway の記事を読んでおくと、以降の理解がスムーズかと思います。
ToDo アプリケーションを題材に、サンプルコードと共に解説を行います。
尚、わたし達は AWS インフラ構築を普段 Serverless Framework で行っていますので、Serverless Framework のコードで解説を行います。
プロジェクトのルートディレクトリに作成しましょう。
※ServerlessFramework の serverless-appsync-plugin を使用しますので、事前に npm install serverless-appsync-plugin
を実行してください
service: todo-serverless
provider:
name: aws
runtime: nodejs12.
stage: ${opt:stage,"dev"}
region: ap-northeast-1
profile: ragate
environment:
DYNAMODB_TABLE_PREFIX: ${self:provider.stage}
plugins:
- serverless-appsync-plugin
custom:
appSync: ${file(./appsync/stack.yml)}
resources:
- ${file(./resources/cognito.yml)}
- ${file(./resources/dynamodb-tables.yml)}
- ${file(./resources/iam-role-statements.yml)}
プロジェクトに応じて設定調整を行ってください。下記のサンプルでは、Email でのサインインが可能なユーザープールを作成しています。
また余談ですが、Cognito を AppSync の提供 API への認証のみで利用する場合、ID プール構築は不要です。IAM 認可が必要な多くのケースは、サイト管理者のアクセスを厳重に調整したいケースです。(IAM 認可でもユーザーの権限管理は実現可能ですが工数を多く消費するので必要な時にのみ認可は設定しましょう)
Resources:
ExampleUserPool:
Type: 'AWS::Cognito::UserPool'
Properties:
AccountRecoverySetting:
RecoveryMechanisms:
- Name: 'verified_email'
Priority: 1
AdminCreateUserConfig:
AllowAdminCreateUserOnly: false
InviteMessageTemplate:
EmailMessage: 'Your username is {username} and temporary password is {####}.'
EmailSubject: 'Your temporary password'
SMSMessage: 'Your username is {username} and temporary password is {####}.'
AliasAttributes:
- email
- preferred_username
AutoVerifiedAttributes:
- email
DeviceConfiguration:
ChallengeRequiredOnNewDevice: false
DeviceOnlyRememberedOnUserPrompt: false
EmailConfiguration:
EmailSendingAccount: COGNITO_DEFAULT
EmailVerificationMessage: 'Your verification code is {####}.'
EmailVerificationSubject: 'Your verification code'
MfaConfiguration: OFF
Policies:
PasswordPolicy:
MinimumLength: 8
RequireLowercase: false
RequireNumbers: false
RequireSymbols: false
RequireUppercase: false
TemporaryPasswordValidityDays: 365
Schema:
- AttributeDataType: String
DeveloperOnlyAttribute: false
Mutable: true
Name: email
Required: true
SmsAuthenticationMessage: 'Your verification code is {####}.'
SmsVerificationMessage: 'Your verification code is {####}.'
UsernameConfiguration:
CaseSensitive: true
UserPoolAddOns:
AdvancedSecurityMode: AUDIT
UserPoolName: ${self:service}-${self:provider.stage}-user-pool
UserPoolTags:
Service: ${self:service}-${self:provider.stage}
VerificationMessageTemplate:
DefaultEmailOption: CONFIRM_WITH_CODE
EmailMessage: 'Your verification code is {####}.'
EmailSubject: 'Your verification code'
SmsMessage: 'Your verification code is {####}.'
ExampleUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
CallbackURLs:
- 'http://localhost:3000'
ClientName: ${self:service}-${self:provider.stage}-user-pool-client
DefaultRedirectURI: 'http://localhost:3000'
ExplicitAuthFlows:
- ALLOW_USER_PASSWORD_AUTH
- ALLOW_ADMIN_USER_PASSWORD_AUTH
- ALLOW_USER_SRP_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
LogoutURLs:
- 'http://localhost:3000'
PreventUserExistenceErrors: ENABLED
ReadAttributes:
- email
- preferred_username
RefreshTokenValidity: 10
SupportedIdentityProviders:
- COGNITO
UserPoolId:
Ref: ExampleUserPool
WriteAttributes:
- email
- preferred_username
ExampleUserPoolGroupCustomer:
Type: AWS::Cognito::UserPoolGroup
Properties:
Description: '一般ユーザー(ToDo閲覧のみ可能)'
GroupName: Customer
Precedence: 1
UserPoolId: !Ref ExampleUserPool
ExampleUserPoolGroupAdmin:
Type: AWS::Cognito::UserPoolGroup
Properties:
Description: '管理者(ToDo閲覧及び作成が可能)'
GroupName: Admin
Precedence: 0
UserPoolId: !Ref ExampleUserPool
DynamoDB テーブルは、シンプルに todo テーブルのみ作成します。
Resources:
todo:
Type: 'AWS::DynamoDB::Table'
DeletionPolicy: Retain
Properties:
TableName: ${self:provider.environment.DYNAMODB_TABLE_PREFIX}-example-todo
AttributeDefinitions:
- AttributeName: id
AttributeType: S
BillingMode: PAY_PER_REQUEST
KeySchema:
- AttributeName: id
KeyType: HASH
AppSync サービスをプリシンバルに、Assume ロールを設定します。
Resources:
AppSyncLoggingServiceRole:
Type: AWS::IAM::Role
Properties:
Path: /
RoleName: AppSyncLoggingServiceRole-${self:provider.stage}
ManagedPolicyArns:
- "arn:aws:iam::aws:policy/service-role/AWSAppSyncPushToCloudWatchLogs"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Principal:
Service:
- "appsync.amazonaws.com"
Action:
- "sts:AssumeRole"
Serverless Framework の AppSync プラグインを利用して AppSync を構築します。
今回、AppSync の構築は ServerlessFramework のプラグインで行っています。(CloudFormation に近いシンタックスですが CloudFormation ではありません)
ゴリゴリ AppSync の CloudFormation を作成するよりも、シンプルに描きやすい構文を提供してくれるので Serverless Framework 環境をもし利用できるのならプラグイン利用を推奨します。
- name: "PrivateAppsyncEndpoint"
authenticationType: AMAZON_COGNITO_USER_POOLS
schema:
- appsync/schemas/todo.graphql
caching:
behavior: FULL_REQUEST_CACHING
ttl: 3600
atRestEncryption:
transitEncryption:
type: 'T2_SMALL'
userPoolConfig:
defaultAction: ALLOW
userPoolId: !Ref ExampleUserPool
logConfig:
loggingRoleArn: { Fn::GetAtt: [AppSyncLoggingServiceRole, Arn] }
level: ERROR
excludeVerboseContent: false
mappingTemplatesLocation: appsync/resolvers
mappingTemplates:
- ${file(./appsync/mappingtemplates.yml)}
dataSources:
- ${file(./appsync/datasources.yml)}
注意点としては、上記サンプルでは全てのリクエストにキャッシュを適用してますが、リゾルバー単位でキャッシュ指定できる指定方法もあります。
また、「PrivateAppsyncEndpoint」という名称で GraphQLAPI を構築していますが、GraphQL をパブリック公開するケースが発生した時のためにそのような名称にしています。実際、会員限定コンテンツではなく SEO を意識したアプリ構築を行うケースでは PublicAPI を別途構築します。
先ほど構築した DynamoDB を指定していますが、ここに既存で構築されている DynamoDB を指定すれば既存環境へも簡単に AppSync を導入することが可能です。
例えば、すでに RestAPI で構築されているが、社内事情で AppSync へ乗り換えたいなんて時も安心ですよね。(試験的に AppSync 使ってみたいなんて時も問題ないです)
- type: AMAZON_DYNAMODB
name: "ToDo"
description: "It is toDo table."
config:
tableName: { Ref: todo }
versioned: false
恐らくここがもっともつまずくと思います。ただ、ポイントをを抑えれば作成することはそんなに大変ではありません。(ポイントは後述しますね)
type Mutation {
createTodo(input: CreateTodoInput!, condition: ModelTodoConditionInput): Todo
@aws_auth(cognito_groups: ["Admin"])
}
type Query {
getTodo(id: ID!): Todo
listTodos(filter: ModelTodoFilterInput, limit: Int, nextToken: String): ModelTodoConnection
}
type Todo {
id: ID!
name: String!
description: String
}
type ModelTodoConnection {
items: [Todo]
nextToken: String
}
input CreateTodoInput {
id: ID
name: String!
description: String
}
input ModelBooleanInput {
ne: Boolean
eq: Boolean
attributeExists: Boolean
attributeType: ModelAttributeTypes
}
input ModelFloatInput {
ne: Float
eq: Float
le: Float
lt: Float
ge: Float
gt: Float
between: [Float]
attributeExists: Boolean
attributeType: ModelAttributeTypes
}
input ModelIDInput {
ne: ID
eq: ID
le: ID
lt: ID
ge: ID
gt: ID
contains: ID
notContains: ID
between: [ID]
beginsWith: ID
attributeExists: Boolean
attributeType: ModelAttributeTypes
size: ModelSizeInput
}
input ModelIntInput {
ne: Int
eq: Int
le: Int
lt: Int
ge: Int
gt: Int
between: [Int]
attributeExists: Boolean
attributeType: ModelAttributeTypes
}
input ModelSizeInput {
ne: Int
eq: Int
le: Int
lt: Int
ge: Int
gt: Int
between: [Int]
}
input ModelStringInput {
ne: String
eq: String
le: String
lt: String
ge: String
gt: String
contains: String
notContains: String
between: [String]
beginsWith: String
attributeExists: Boolean
attributeType: ModelAttributeTypes
size: ModelSizeInput
}
input ModelTodoConditionInput {
name: ModelStringInput
description: ModelStringInput
and: [ModelTodoConditionInput]
or: [ModelTodoConditionInput]
not: ModelTodoConditionInput
}
input ModelTodoFilterInput {
id: ModelIDInput
name: ModelStringInput
description: ModelStringInput
and: [ModelTodoFilterInput]
or: [ModelTodoFilterInput]
not: ModelTodoFilterInput
}
enum ModelSortDirection {
ASC
DESC
}
enum ModelAttributeTypes {
binary
binarySet
bool
list
map
number
numberSet
string
stringSet
_null
}
ポイントその1:type を最初に作成すること
インプット周りはフロントエンドの事情で微調整する可能性があるので、先に欲しいデータ内容とフロントエンドが利用したい GraphQL の機能(Query, Mutation, Subcscription)を定義します。
type Mutation {
createTodo(input: CreateTodoInput!, condition: ModelTodoConditionInput): Todo
@aws_auth(cognito_groups: ["Admin"])
}
type Query {
getTodo(id: ID!): Todo
listTodos(filter: ModelTodoFilterInput, limit: Int, nextToken: String): ModelTodoConnection
}
type Todo {
id: ID!
name: String!
description: String
}
type ModelTodoConnection {
items: [Todo]
nextToken: String
}
ポイントその2:単一取得系及び複数取得系はコピペ作成
DynamoDB、ElasticSearch へのリクエストの際にフロントエンドから指定するパラメーター(input オブジェクト)は多くのケースで共通なので、コピペで作成が可能です。
input CreateTodoInput {
...
}
input ModelBooleanInput {
...
}
input ModelFloatInput {
...
}
input ModelIDInput {
...
}
input ModelIntInput {
...
}
input ModelSizeInput {
...
}
input ModelStringInput {
...
}
input ModelTodoConditionInput {
...
}
input ModelTodoFilterInput {
...
}
enum ModelSortDirection {
...
}
enum ModelAttributeTypes {
...
}
スキーマ定義を作成しただけでは、AppSync はバックエンドと通信しません。スキーマはあくまでもインプットとアウトプットの I/F 仕様を書いたものなので、実際に動作させるプログラムを VTL 形式で作成する必要があります。(後述)
※VTL を介さず、直接 Lambda と AppSync を接続させる方法もあります
スキーマに定義したルートオブジェクト(type Query, type Mutation)とデータソースをマッピングします。ここで、例えば ElasticSearch を指定すれば後続の処理は ElasticSearch と通信しますし、DynamoDB または Lambda を指定することも可能です。(Lambda にすれば単一のデータソースに縛られずに複数のデータソースと通信し処理を行うことが可能です)
- dataSource: "ToDo"
type: "Query"
field: "listTodos"
- dataSource: "ToDo"
type: "Mutation"
field: "createTodo"
ここでは、絞り込みなどは指定せず Scan し Items を返却します。ここに、DynamoDB に関する探索条件を指定すれば、プログラミングせずに JSON 定義のみで DynamoDB と通信することが可能です。
VTL ファイルでは、input オブジェクトに指定した各種パラメーターを利用することができます。
尚、本サンプルではファイル名を Query.xxxx request.vtl とするだけで、mappingtemplates.yml に指定したフィールド名に対応したリゾルバーに自動的に設定されるようになっています。詳しくはこちら
{
"version": "2017-02-28",
"operation": "Scan"
}
デプロイすると、自動的にリゾルバーにアタッチされていることがわかります。(デプロイ方法は後述)
また、マッピングリゾルバーテンプレート(VTLファイル)のコードは AppSync 管理コンソールで雛形を取得することが可能です。補足として、完全に自動化はできず、自動生成されたコードに手を加えることが必要なケースが多いです。
レスポンスはリクエストマッピングリゾルバーの返り値をそのまま返却します。
AppSync はここで返却したオブジェクトを、GraphQL スキーマで定義したオブジェクトへ変換してくれますが、もしGraphQL スキーマで定義したオブジェクト形式を満たしていない場合はエラーをフロントエンドへ返却するので注意しましょう。(レスポンスのバリデーションも AppSync にお任せです)
$util.toJson($ctx.result)
ポイントなのは、DynamoDB に設定するPK(id)を、AppSync の $util.autoId() で自動生成している点です。AppSync の VTL では、$util に様々な便利関数が存在するのでチェックしておきましょう。
{
"version" : "2017-02-28",
"operation" : "PutItem",
"key" : {
## If object "id" should come from GraphQL arguments, change to $util.dynamodb.toDynamoDBJson($ctx.args.id)
"id": $util.dynamodb.toDynamoDBJson($util.autoId()),
},
"attributeValues" : $util.dynamodb.toMapValuesJson($context.args.input)
}
$util.toJson($ctx.result)
Serverless Framework の公式サイトに則り、下記のコマンドを実行しましょう。
npm install -g serverless
cd [serverless.yml_directory]
sls deploy -v
デプロイが成功したら、あとはフロントエンドの SDK で呼び出すだけです。
AppSync とフロントエンドの通信方法は、別記事で公開予定です。
また、buildspec.yml を使用して Code Build でデプロイを自動化することも可能です。
version: 0.2
phases:
install:
runtime-versions:
nodejs: 10
docker: 18
pre_build:
commands:
- yarn
- npm install -g serverless
build:
commands:
- yarn deploy
post_build:
commands:
- echo Build completed on `date`
例えばアジャイル形式のプロジェクトで週一回程度定例があるとして、そこで生まれた機能要件に対して下記のようなワークフローで AppSync 開発へアプローチします。
尚、データソースに Lambda を指定することで、AppSync→Lambda のようなリクエストを行うことも可能なので、基本的に API Gateway で出来ることは AppSync で実現可能です。
注意点としては、RestAPI のような API のバージョニング(/v1/xxxxx)は AppSync 及び GraphQL では使用しません。GraphQL での API 開発では基本的に既存の API を随時更新しながら開発するのがベターとされているので、API のバージョニングの思想は存在しません。(リーンスタートアップ的な考え方ですね)
正直、初めのうちはとっつきにくいところは多々あるかと思いますが、慣れてくると新規事業の立ち上げは RestAPI 開発よりも早く行えるようになり、事実としてわたし達は GraphQL を採用してから、新規開発の速度は30%以上早くなっています。(早く作ることで結果、お客様もコスト安になりウィンウィンです)
GraphQL、AppSyncの開発はお気軽にご相談ください。
スモールスタート開発支援、サーバーレス・NoSQLのことなら
ラーゲイトまでご相談ください
低コスト、サーバーレスの
モダナイズ開発をご検討なら
下請け対応可能
Sler企業様からの依頼も歓迎