🐷

Amplify SNS Workshop をAWS CDKでやってみる②

2022/12/25に公開

はじめに

Amplify SNS WorkshopをCDKをやってみるの第2回です。一回目は認証まですすめました。

一回目の記事はこちらです

https://zenn.dev/ashizaki/articles/dbd41ebd85f2a8

今回は、AppSyncを使った投稿機能と、そのフロントエンドまで進めます。

なお、元となったAmplify SNS Workshopはこちらです。

https://amplify-sns.workshop.aws/ja/

今回の記事は、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 to SchemaFile that implements ISchema. Removes all addXxx type methods from GraphQlApi.

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:新規に投稿する

  1. APIをコールしたユーザー(記事を投稿したユーザー)のユーザーIDを、Cognitoのidentityから取得する
  2. 投稿内容に、投稿者のユーザーID、投稿したタイムスタンプ、IDなどを自動的に割り当てて、DynamoDBに登録する

listPosts:投稿の一覧を取得する

  1. 引数の内容に応じて、投稿の一覧を取得したい

getPost:指定のIDの投稿を取得する

  1. 指定の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