AWS CDKでAppSyncを扱う

2023/02/05に公開

AmplifyではなくAWS CDKAppSyncを試したのでメモ

準備

  • cdk のプロジェクトを作成
npx cdk init app --language typescript

front 側の実装はこの記事の趣旨ではないので簡単に書いておきます。
Next.jsのプロジェクトをfrontendというフォルダに作成

npx create-next-app@latest frontend --typescript

適当に Chakra UI を入れておきます

yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion

あとは参考記事を見ながら、graphqlのコード生成や@apollo/clientなどをインストールしてコード生成して適当に CRUD します(適当)

yarn add -D graphql @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo @graphql-codegen/named-operations-object

yarn add @apollo/client

https://github.com/tokku5552/appsync-cdk-sample/tree/main/frontend

CDK 側

本題の CDK の解説です。

全 CDK のコード
import * as cdk from "aws-cdk-lib";
import * as appsync from "aws-cdk-lib/aws-appsync";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as nodejs from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";
import path = require("path");

export class AppsyncCdkSampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const api = new appsync.GraphqlApi(this, "Api", {
      name: "Todo",
      schema: appsync.SchemaFile.fromAsset(
        path.join(__dirname, "../schemas/schema.graphql")
      ),
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: appsync.AuthorizationType.API_KEY,
        },
      },
      xrayEnabled: true,
    });

    const table = new dynamodb.Table(this, "TodoTable", {
      tableName: "todo-table",
      partitionKey: {
        name: "id",
        type: dynamodb.AttributeType.STRING,
      },
    });

    const dataSource = api.addDynamoDbDataSource("DynamoDataSource", table);

    // query
    dataSource.createResolver("getTodosResolver", {
      typeName: "Query",
      fieldName: "getAll",
      requestMappingTemplate: appsync.MappingTemplate.dynamoDbScanTable(),
      responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultList(),
    });

    dataSource.createResolver("getTodoResolver", {
      typeName: "Query",
      fieldName: "get",
      requestMappingTemplate: appsync.MappingTemplate.dynamoDbGetItem(
        "id",
        "id"
      ),
      responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
    });

    // mutation
    dataSource.createResolver("MutationResolver", {
      typeName: "Mutation",
      fieldName: "add",
      requestMappingTemplate: appsync.MappingTemplate.dynamoDbPutItem(
        appsync.PrimaryKey.partition("id").auto(),
        appsync.Values.projecting("input")
      ),
      responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
    });

    dataSource.createResolver("Delete", {
      typeName: "Mutation",
      fieldName: "delete",
      requestMappingTemplate: appsync.MappingTemplate.dynamoDbDeleteItem(
        "id",
        "id"
      ),
      responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
    });

    const updateFunction = new nodejs.NodejsFunction(this, "UpdateFunction", {
      functionName: "update-function",
      entry: path.join(__dirname, "../lambda/update.ts"),
      runtime: lambda.Runtime.NODEJS_18_X,
      tracing: lambda.Tracing.ACTIVE,
      environment: {
        TABLE_NAME: table.tableName,
      },
    });

    table.grantReadWriteData(updateFunction);
    const lambdaDataSource = api.addLambdaDataSource(
      "LambdaDataSource",
      updateFunction
    );
    lambdaDataSource.createResolver("Update", {
      typeName: "Mutation",
      fieldName: "update",
    });
  }
}

まず、今回のサンプルアプリのスキーマは以下のような感じです。Todo アプリです

schemas/schema.graphql
type Todo {
  id: ID!
  title: String!
  detail: String
  isDone: Boolean!
}
type Query {
  getAll: [Todo!]
  get(id: ID!): Todo!
}
input TodoInput {
  title: String!
  isDone: Boolean!
  detail: String
}

input UpdateInput {
  id: ID!
  title: String
  isDone: Boolean
  detail: String
}

type Mutation {
  add(input: TodoInput!): Todo
  update(input: UpdateInput!): Todo
  delete(id: ID!): Todo
}

AppSyncの公式の書き方ほぼまんまですが、API 作成自体は簡単です。
aws-cdk-lib.aws_appsync module · AWS CDK

lib/appsync-cdk-sample-stack.ts
import * as appsync from "aws-cdk-lib/aws-appsync";

const api = new appsync.GraphqlApi(this, "Api", {
    name: "Todo",
    schema: appsync.SchemaFile.fromAsset(
    path.join(__dirname, "../schemas/schema.graphql")
    ),
    authorizationConfig: {
    defaultAuthorization: {
        authorizationType: appsync.AuthorizationType.API_KEY,
    },
    },
    xrayEnabled: true,
});

今回は簡単のために認証はAPI_KEYにしていますが、Cognito や IAM 認証、OpenID Connect、Lambda 認証などが使えるようです。
承認と認証 - AWS AppSync

続いてDynamoDBを作成して紐付けのための DataSource を定義します。

const table = new dynamodb.Table(this, "TodoTable", {
    tableName: "todo-table",
    partitionKey: {
    name: "id",
    type: dynamodb.AttributeType.STRING,
    },
});

const dataSource = api.addDynamoDbDataSource("DynamoDataSource", table);

こちらも特に難しいことはないですが、データソースには他にRedisRDSLambdaなどが使えます。

そして、schema.graphqlに従ってResolverを定義していきます。

// query
dataSource.createResolver("getTodosResolver", {
    typeName: "Query",
    fieldName: "getAll",
    requestMappingTemplate: appsync.MappingTemplate.dynamoDbScanTable(),
    responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultList(),
});

dataSource.createResolver("getTodoResolver", {
    typeName: "Query",
    fieldName: "get",
    requestMappingTemplate: appsync.MappingTemplate.dynamoDbGetItem(
    "id",
    "id"
    ),
    responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});

typeNameにはQueryMutationSubscribeを記載します。fieldNameにはschema.graphqlで定義したQueryMutationのフィールドを記載します。
DynamoDBであればappsync.MappingTemplateに定義されているdynamoDbScanTabledynamoDbResultListを使うと直感的にマッピングすることができます。

Mutationの方はこんな感じ

// mutation
dataSource.createResolver("MutationResolver", {
    typeName: "Mutation",
    fieldName: "add",
    requestMappingTemplate: appsync.MappingTemplate.dynamoDbPutItem(
    appsync.PrimaryKey.partition("id").auto(),
    appsync.Values.projecting("input")
    ),
    responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});

dataSource.createResolver("Delete", {
    typeName: "Mutation",
    fieldName: "delete",
    requestMappingTemplate: appsync.MappingTemplate.dynamoDbDeleteItem(
    "id",
    "id"
    ),
    responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});

dynamoDbPutItemでは

appsync.PrimaryKey.partition("id").auto(),
appsync.Values.projecting("input")

を渡してやることで、idを自動で付与しながら他の受け取った値を渡す事ができます。

最後にUpdateですが、マッピングテンプレートに update のものがなかったので、これだけ Lambda にしました。

 const updateFunction = new nodejs.NodejsFunction(this, "UpdateFunction", {
    functionName: "update-function",
    entry: path.join(__dirname, "../lambda/update.ts"),
    runtime: lambda.Runtime.NODEJS_18_X,
    tracing: lambda.Tracing.ACTIVE,
    environment: {
        TABLE_NAME: table.tableName,
    },
});

table.grantReadWriteData(updateFunction);
const lambdaDataSource = api.addLambdaDataSource(
    "LambdaDataSource",
    updateFunction
);
lambdaDataSource.createResolver("Update", {
    typeName: "Mutation",
    fieldName: "update",
});

lambda では普通にDynamoDBUpdateCommandを呼んで値を更新しています。

lambda のコードはこちら
lambda/update.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DynamoDBDocumentClient,
  UpdateCommand,
  UpdateCommandInput,
} from "@aws-sdk/lib-dynamodb";
import { AppSyncResolverHandler } from "aws-lambda";

import {
  MutationUpdateArgs,
  Todo,
} from "../frontend/src/types/generated/generated-types";

const db = new DynamoDBClient({});
const ddb = DynamoDBDocumentClient.from(db);

const TABLE_NAME = process.env.TABLE_NAME || "";

export const handler: AppSyncResolverHandler<
  MutationUpdateArgs,
  Todo | null
> = async (event) => {
  const todo = event.arguments.input;
  console.log(todo);

  const params: UpdateCommandInput = {
    TableName: TABLE_NAME,
    Key: {
      id: todo.id,
    },
    UpdateExpression: "set isDone = :c",
    ExpressionAttributeValues: {
      ":c": todo.isDone,
    },
  };
  console.log(params);

  const data = await ddb.send(new UpdateCommand(params));
  return data.Attributes as Todo;
};

デプロイしてAppSyncのコンソールからクエリを実行することができます。
image

スキーマ定義も見ることができます。
image

つまったポイント

  • createResolverの中であとから Construct のidを変えて同じ fieldName でデプロイしようとすると以下のようなエラーが出る
failed: Error: The stack named AppsyncCdkSampleStack failed to deploy: UPDATE_ROLLBACK_COMPLETE: Only one resolver is allowed per field.

この辺は CDK 側でなんとかして欲しい気もするが、一度Resolverをコメントアウトしてデプロイした後、再度コメントアウトを外してデプロイするなどすれば回避可能。

まとめ

Next.jsでフロントを作る際の BFF としてAppSyncを使うのが良さそうだと言うことで、さらっと一通り試してみました。
あまり凝ったことをしないのであればAmplifyを使えば良い気がしますが、AWS CDKでも割と簡単にかけたので、このまま CDK で運用したいなと思います。
ゆくゆくはNxのワークスペースに組み込みたいので、schemaの置き場所とコード生成については別で完結させたいなと思っています 🤔

Discussion