Amplify SNS Workshop をAWS CDKでやってみる②
はじめに
Amplify SNS WorkshopをCDKをやってみるの第2回です。一回目は認証まですすめました。
一回目の記事はこちらです
今回は、AppSyncを使った投稿機能と、そのフロントエンドまで進めます。
なお、元となったAmplify SNS Workshopはこちらです。
今回の記事は、3.MVPを作ろうの、中盤部分の箇所になります。
Post機能:Back-end
元記事では、Amplify CLIを使って、amplify add api
から、AppSyncを追加し、Amplify用のgraphqlのスキーマを作成することで、自動的にAppSyncのリゾルバーからDynamoDBまで作成してくれます。
なお、元記事のAmplifyは、GraphQL Transformer v1を対象として説明されているので、現状のGraphQL Transformer v2でやる場合、かなりアップデートされており、記述方法が異なるので注意してください。
はじめて、このWorkshopをやった時、これで何をやっているのか、何ができるのか、なかなか理解できませんでした。
同じことを、CDKでやってみます。
AppSync APIの作成
最初に、appsyncのcdkをパッケージを追加します。
yarn add @aws-cdk/aws-appsync-alpha
そして、次に以下のようなコードを作成し、AppSyncのAPIを作成します。
const api = new GraphqlApi(this, "GraphqlApi", {
name: `${this.stackName}-GraphqlApi`,
schema: SchemaFile.fromAsset("schema.graphql"),
authorizationConfig: {
defaultAuthorization: {
authorizationType: AuthorizationType.USER_POOL,
userPoolConfig: {
userPool: userPool,
},
},
},
logConfig: {
fieldLogLevel: FieldLogLevel.ALL,
},
xrayEnabled: false,
})
schemaのところは、aws-cdk 2.55.0の修正で、変更になっているので注意してください。
- appsync: Renames
Schema
toSchemaFile
that implementsISchema
. Removes alladdXxx
type methods fromGraphQlApi
.
schema.graphqlは、プロジェクトのルートディレクトリにおいてます。中身は以下のようにします。なお、元記事では、deletePostという、投稿を削除するMutationも作成していますが、めんどくさいので、削除機能は作りません。
input CreatePostInput {
content: String!
}
type Post {
id: ID!
type: String
content: String
owner: String
timestamp: Int
}
type PostConnection {
items: [Post!]!
nextToken: String
}
type Mutation {
createPost(input: CreatePostInput!): Post
}
enum SortDirection {
ASC
DESC
}
type Query {
getPost(id: ID!): Post
listPosts(
limit: Int,
owner: String,
sortDirection: SortDirection,
nextToken: String,
): PostConnection
}
type Subscription {
onCreate(owner: String): Post @aws_subscribe(mutations: ["createPost"])
}
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
なお、Amplify CLIからAppSyncを構築した場合、
input CreatePostInput {
type: String!
content: String!
owner: String
timestamp: Int!
id: ID
}
のようなcreatePost Mutationのinputの定義が自動生成されます。記事をポストするというユースケースを考えた場合
- 他人に成りすまして投稿することはあり得ないので、ownerをわざわざ渡せるようにする必要はないのでは?
- 投稿時間を偽造するようなことはあり得ないので、timestampをわざわざ渡せるようにする必要はないのでは?
- ソート用に毎回、"post"という固定の文字列をセットしているtypeをわざわざ渡せるようにする必要はないのでは?
- IDも投稿時に、AppSync側で自動生成すればよいので、わざわざ渡せるようにする必要はないのでは?
と考えられます。しかしながらAmplify CLIで、AppSyncのAPIを作成した場合、必要のない項目までInputの定義に追加され、それが、リゾルバーの処理をより複雑にしています(Amplify CLIは、そこまで詳しくないので、このあたりをもっと柔軟に実施する方法があるかもしれません・・。)。
CDKで作成する場合、こういった細かい部分まで、自分で設計しながら定義できるのが良い部分です。
DynamoDBのテーブルと、GSIを作成する
次に、DynamoDBのテーブルと、GSIを作成します。スタックに以下のようなコードを追加します。
const postTable = new Table(this, "PostTable", {
removalPolicy: RemovalPolicy.DESTROY,
billingMode: BillingMode.PAY_PER_REQUEST,
partitionKey: { name: "id", type: AttributeType.STRING },
})
postTable.addGlobalSecondaryIndex({
indexName: "sortByTimestamp",
partitionKey: { name: "type", type: AttributeType.STRING },
sortKey: { name: "timestamp", type: AttributeType.NUMBER },
})
postTable.addGlobalSecondaryIndex({
indexName: "bySpecificOwner",
partitionKey: { name: "owner", type: AttributeType.STRING },
sortKey: { name: "timestamp", type: AttributeType.NUMBER },
})
データソースを作成する
次にデータソースを作成します。スタックに以下のコードを追加します。noneDateSourceは、データソースを必要としないFunction用に作成します。
const postTableDataSource = api.addDynamoDbDataSource("PostTableDataSource", postTable)
const noneDateSource = api.addNoneDataSource("NoneDataSource")
リゾルバーから呼ばれるFunctionを作成する
次に、AppSyncのリゾルバーからコールされるFunctionを作成します。AppSyncは、パイプラインリゾルバーとう機能で、あらかじめ作成した関数を、パイプラインとして、順番に実行することができます。
設計を考える
今回のAPIのユースケースを考える場合
createPost:新規に投稿する
- APIをコールしたユーザー(記事を投稿したユーザー)のユーザーIDを、Cognitoのidentityから取得する
- 投稿内容に、投稿者のユーザーID、投稿したタイムスタンプ、IDなどを自動的に割り当てて、DynamoDBに登録する
listPosts:投稿の一覧を取得する
- 引数の内容に応じて、投稿の一覧を取得したい
getPost:指定のIDの投稿を取得する
- 指定のIDの投稿を取得する
のような機能が必要そうです。
なお、このようなSNSでは、
- 自分の投稿だけ編集・削除できる
のように、APIをコールしたのが誰なのか?という処理は、いろいろな箇所で、必要になります。
そのため、createPostをあえて、2つの関数に分割しています(今回は作成しませんでしたが、deletePostを実装する場合、APIをコールしたユーザーのユーザーIDを取得 ⇒ 削除対象のPostのオーナーが、APIコール実施者と一致すれば削除、のようなパイプラインリゾルバーを作成することになります)。
APIをコールしたユーザー(記事を投稿したユーザー)のユーザーIDを、Cognitoのidentityから取得する
以下のような定義をスタックに追加します。Functionは、RequestとResponseのマッピングテンプレートと呼ばれる定義で、処理を実装します。マッピングテンプレートは、VTLと呼ばれる言語で記載するのですが、これを今回は、MappingTemplate.fromString()
を使って、直接文字列で指定しています。
MappingTemplate.fromFile()
を使って、VTLを別ファイルに記載することも可能です。AppSyncを使う場合、このVTLを書くのが一番の苦行です。
なお、AppSyncは、2022年秋に、JavaScriptでリゾルバーを書けるようになっており、AppSyncのWebコンソールで手動でFunctionや、リゾルバーを作成すると、デフォルトでJavaScriptで作成されます。
ただ、現時点で、CDKでJavaScriptを使って、マッピングテンプレートを作成する方法が提供されていないみたいなので、VTLを使っていますが、おそらく近い将来に、マッピングテンプレートもCDKでTypeScriptで書けるようになるのでは?と予想しています(このissue?)。。
それが可能になれば、マッピングテンプレートの記述もかなり楽になるはずです。
ここでは、ctx.identityのクレームからusernameを取ってきて、ctx.stack.ownerにセットしています。なお、Amplifyで自動生成されたVTLのマッピングテンプレートを参考にしているので、必要ない処理を実施したりしていますが、ご容赦ください。
const checkOwnerFunction = new AppsyncFunction(this, "CheckOwnerFunction", {
api: api,
dataSource: noneDateSource,
name: "CheckOwnerFunction",
requestMappingTemplate: MappingTemplate.fromString(`
$util.qr($ctx.stash.put("hasAuth", true))
#set($isAuthorized = false)
#set($allowedFields = [])
#if($util.authType() == "User Pool Authorization")
#set($username = $util.defaultIfNull($ctx.identity.claims.get("username"), $util.defaultIfNull($ctx.identity.claims.get("cognito:username"), null)))
#if(!$util.isNull($username))
$util.qr($ctx.stash.put("owner", $username))
#set($isAuthorized = true)
#end
#end
#if( !$isAuthorized )
$util.unauthorized()
#end
$util.toJson({"version":"2018-05-29","payload":{}})`),
responseMappingTemplate: MappingTemplate.fromString("$util.toJson({})"),
})
投稿内容をDynamoDBに保存する
以下のような定義をスタックに追加します。
const createPostFn = new AppsyncFunction(this, "CreatePostFunction", {
api: api,
dataSource: postTableDataSource,
name: "CreatePostFunction",
requestMappingTemplate: MappingTemplate.fromString(`
#set($input = $ctx.args.input)
$util.qr($input.put("timestamp", $util.time.nowEpochSeconds()))
$util.qr($input.put("owner", $ctx.stash.owner))
$util.qr($input.put("type", "post"))
{
"version": "2017-02-28",
"operation": "PutItem",
"key": {
"id": $util.dynamodb.toDynamoDBJson($util.autoUlid())
},
"attributeValues": $util.dynamodb.toMapValuesJson($input)
}`),
responseMappingTemplate: MappingTemplate.dynamoDbResultItem(),
})
元記事のAmplifyの自動生成と違うのは、
- timestamp, owner, typeをセットしている
- IDをulid(UUIDと違い、タイムスタンプでソート可能なIDを自動生成している)
だけです。
Responseのマッピングテンプレートは、MappingTemplate.dynamoDbResultItem()
で、DynamoDBへのPutItemの結果をそのまま返せばよいです。
条件に応じて、投稿の一覧を取得する
以下のような定義をスタックに追加します。なお、元記事のAmplify CLIを使った場合、
- listPosts
- listPostsSortedByTimestamp
- listPostsBySpecificOwner
のように、3つのQueryが生成され、それぞれ
- 全件取得(取得順序ID順)
- 全件取得(タイムスタンプ順)
- 特定のユーザーごとの投稿の一覧を取得
のように、用途別に、別のQueryが定義されてしまいますが、自分で定義すると、マッピングテンプレート内で、引数に応じて、挙動を変えることが容易になるので、一つのQueryに集約することが可能です。
const listPostsFn = new AppsyncFunction(this, "ListPostsFunction", {
api: api,
dataSource: postTableDataSource,
name: "ListPostsFunction",
requestMappingTemplate: MappingTemplate.fromString(`
#set($QueryRequest = {
"version": "2018-05-29",
"operation": "Query",
"limit": $util.defaultIfNull($ctx.args.limit, 20),
"query": {}
})
#if($util.isNull($ctx.args.owner))
#set($QueryRequest.query.expression = "#type = :type")
#set($QueryRequest.query.expressionNames = {"#type": "type"})
#set($QueryRequest.query.expressionValues = {":type": $util.dynamodb.toDynamoDB("post")})
#set($QueryRequest.index = "sortByTimestamp")
#else
#set($QueryRequest.query.expression = "#owner = :owner")
#set($QueryRequest.query.expressionNames = {"#owner": "owner"})
#set($QueryRequest.query.expressionValues = {":owner": $util.dynamodb.toDynamoDB($ctx.args.owner)})
#set($QueryRequest.index = "bySpecificOwner")
#end
#if(!$util.isNull($ctx.args.sortDirection) && $ctx.args.sortDirection == "DESC")
#set($QueryRequest.scanIndexForward = false)
#else
#set($QueryRequest.scanIndexForward = true)
#end
#if($ctx.args.nextToken)
#set($QueryRequest.nextToken = $ctx.args.nextToken )
#end
$util.toJson($QueryRequest)
`),
responseMappingTemplate: MappingTemplate.dynamoDbResultItem(),
})
}
}
IDを指定して、投稿を取得する
以下のような定義をスタックに追加します。DynamoDBからプライマリーキーを指定して、結果を取得するだけであれば、VTLを記載しなくても、MappingTemplate.dynamoDbGetItem()
で自動生成してくれます。
const getPostFn = new AppsyncFunction(this, "GetPostFunction", {
api: api,
dataSource: postTableDataSource,
name: "GetPostFunction",
requestMappingTemplate: MappingTemplate.dynamoDbGetItem("id", "id"),
responseMappingTemplate: MappingTemplate.dynamoDbResultItem(),
})
リゾルバーを作成する
次にリゾルバーを作成します。
createPost Mutationのリゾルバー
以下のコードをスタックに追加します。pipelineConfigに2つの関数をセットしています。
new Resolver(this, "CreatePostResolver", {
api: api,
typeName: "Mutation",
fieldName: "createPost",
pipelineConfig: [checkOwnerFunction, createPostFn],
requestMappingTemplate: MappingTemplate.fromString(`$util.toJson({})`),
responseMappingTemplate: MappingTemplate.fromString("$util.toJson($ctx.prev.result)"),
})
listPosts Queryのリゾルバー
以下のコードをスタックに追加します。
new Resolver(this, "ListPostsResolver", {
api: api,
typeName: "Query",
fieldName: "listPosts",
pipelineConfig: [listPostsFn],
requestMappingTemplate: MappingTemplate.fromString(`$util.toJson({})`),
responseMappingTemplate: MappingTemplate.fromString("$util.toJson($ctx.prev.result)"),
})
getPost Queryのリゾルバー
以下のコードをスタックに追加します。
new Resolver(this, "GetPostResolver", {
api: api,
typeName: "Query",
fieldName: "getPost",
pipelineConfig: [getPostFn],
requestMappingTemplate: MappingTemplate.fromString(`$util.toJson({})`),
responseMappingTemplate: MappingTemplate.fromString("$util.toJson($ctx.prev.result)"),
})
AppSyncのコンソールでテストする
ここまで出来たらcdk deploy
で環境を更新します。残念ながらAmplifyのように、Mockでテストはできません。
WebコンソールのAppSyncのクエリで、createPost, ListPostsを試してみましょう。
なお、Cognitoのusernameと、passwordは、前回の記事でログインしたものをつかってください。
listPostsでownerを指定する場合は、2人以上のユーザーをあらかじめ登録しておく必要があります。
最後に
今回は、CDKで、関数と、リゾルバの作成まで実施しました。AppSyncのCDKの記事は、VTLを記述している例があまりなかったので、いざ自分でVTLを書くときや、パイプラインを設計するときにどうすればよいのか、最初は戸惑いましたが、Amplify CLIで作られるパイプラインリゾルバーを参照しつつ、それっぽい実装ができました。
ただ、リゾルバーを、TypeScriptで書けるようになるのも、もうすぐな気もしているので、それができれようになれば、もっと簡単に、AppSyncを利用できるようになると思われます。
Discussion