【NextJS】DynamoDB with customType of AppSync using Amplify(Gen2)

1. 概要

前回は画面の一部をフルスクリーンにする内容についてでした。今回はAmplify(Gen2)AppSyncを使い、DynamoDBを操作する内容になります。

対象としては開発を1年程やってて自分で最初から開発してみたい方になります。そのため細かい用語などの説明はしません。

2. nodeのインストール

こちらを参考

3. プロジェクトを作成

3-1-1. こちらを参考

4. 必要なライブラリをインストール

npm create amplify@latest

※上記コマンドが正常に実行終わると「amplify_outputs.json」が作成される。

5. Amazon DynamoDBテーブルをセットアップ

5-1-1. こちらを参考

Create Resources

Choose 「Use existing type」

  • Primary key : id
  • Sort key : None
  • Disable Automatically generate GraphQL

Create

6. ソースコード

※Amplifyで進めると、デフォルトで「auth」と「data」が存在するが、今回は「auth」を削除し、「data」のみを扱ってみる。

6-1. データソースを追加

6-1-1. amplify/backend.ts

import { defineBackend } from "@aws-amplify/backend";
import { data } from "./data/resource";
import { aws_dynamodb } from "aws-cdk-lib";

export const backend = defineBackend({
  data,
});

const externalDataSourcesStack = backend.createStack("MyExternalDataSources");

const externalTable = aws_dynamodb.Table.fromTableName(
  externalDataSourcesStack,
  "MyExternalPostTable",
  "PostTable"
);

backend.data.addDynamoDbDataSource(
  "ExternalPostTableDataSource",
  externalTable
);

6-2. データリソースを追加

  • customType
    • queries
    • mutations
    • subscriptions

6-2-1. amplify/data/resource.ts

import { type ClientSchema, a, defineData } from "@aws-amplify/backend";

const schema = a.schema({
  Todo: a
    .model({
      content: a.string(),
      isDone: a.boolean(),
    })
    .authorization((allow) => [allow.publicApiKey()]),
  Post: a.customType({
    id: a.id().required(),
    author: a.string().required(),
    title: a.string(),
    content: a.string(),
    url: a.string(),
    ups: a.integer(),
    downs: a.integer(),
    version: a.integer(),
  }),
  ReturnData: a.customType({
    mutationType: a.string().required(),
    post: a.ref("Post"),
  }),

  PaginatedPosts: a.customType({
    posts: a.ref("Post").array(),
    nextToken: a.string(),
  }),

  createPost: a
    .mutation()
    .arguments({
      id: a.id(),
      author: a.string().required(),
      title: a.string(),
      content: a.string(),
      url: a.string(),
    })
    .returns(a.ref("ReturnData"))
    .authorization((allow) => [allow.publicApiKey()])
    .handler(
      a.handler.custom({
        dataSource: "ExternalPostTableDataSource",
        entry: "./createPost.js",
      })
    ),

  getPost: a
    .query()
    .arguments({ id: a.id().required() })
    .returns(a.ref("Post"))
    .authorization((allow) => [allow.publicApiKey()])
    .handler(
      a.handler.custom({
        dataSource: "ExternalPostTableDataSource",
        entry: "./getPost.js",
      })
    ),

  listPosts: a
    .query()
    .arguments({
      limit: a.integer(),
      nextToken: a.string(),
    })
    .returns(a.ref("PaginatedPosts"))
    .authorization((allow) => [allow.publicApiKey()])
    .handler(
      a.handler.custom({
        dataSource: "ExternalPostTableDataSource",
        entry: "./listPosts.js",
      })
    ),

  updatePost: a
    .mutation()
    .arguments({
      id: a.id().required(),
      author: a.string(),
      title: a.string(),
      content: a.string(),
      url: a.string(),
      expectedVersion: a.integer().required(),
    })
    .returns(a.ref("ReturnData"))
    .authorization((allow) => [allow.publicApiKey()])
    .handler(
      a.handler.custom({
        dataSource: "ExternalPostTableDataSource",
        entry: "./updatePost.js",
      })
    ),

  deletePost: a
    .mutation()
    .arguments({ id: a.id().required(), expectedVersion: a.integer() })
    .returns(a.ref("ReturnData"))
    .authorization((allow) => [allow.publicApiKey()])
    .handler(
      a.handler.custom({
        dataSource: "ExternalPostTableDataSource",
        entry: "./deletePost.js",
      })
    ),

  onSubscribePost: a
    .subscription()
    .for([a.ref("createPost"), a.ref("updatePost"), a.ref("deletePost")])
    .arguments({})
    .authorization((allow) => [allow.publicApiKey()])
    .handler(
      a.handler.custom({
        entry: "./subscribePost.js",
      })
    ),
});

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: "apiKey",
    apiKeyAuthorizationMode: {
      expiresInDays: 30,
    },
  },
  name: "MyPostApi",
});

6-3. カスタムハンドラーを作成

6-3-1. amplify/data/listPost.js

import * as ddb from "@aws-appsync/utils/dynamodb";

export function request(ctx) {
  const { limit = 20, nextToken } = ctx.arguments;
  return ddb.scan({ limit, nextToken });
}

export function response(ctx) {
  const { items = [], nextToken } = ctx.result;
  return { posts: items, nextToken };
}

6-3-2. amplify/data/getPost.js

import * as ddb from "@aws-appsync/utils/dynamodb";

export function request(ctx) {
  return ddb.get({ key: { id: ctx.args.id } });
}

export const response = (ctx) => ctx.result;

6-3-3. amplify/data/createPost.js

import { util } from "@aws-appsync/utils";
import * as ddb from "@aws-appsync/utils/dynamodb";

export function request(ctx) {
  const item = { ...ctx.arguments, ups: 1, downs: 0, version: 1 };
  const key = { id: ctx.args.id ?? util.autoId() };
  const post = ddb.put({ key, item });
  return post;
}

export function response(ctx) {
  const { error, result } = ctx;
  if (error) {
    util.appendError(error.message, error.type);
  }
  return { mutationType: "CREATE", post: result };
}

6-3-4. amplify/data/deletePost.js

import { util } from "@aws-appsync/utils";
import * as ddb from "@aws-appsync/utils/dynamodb";

export function request(ctx) {
  let condition = null;
  if (ctx.args.expectedVersion) {
    condition = {
      or: [
        { id: { attributeExists: false } },
        { version: { eq: ctx.args.expectedVersion } },
      ],
    };
  }
  return ddb.remove({ key: { id: ctx.args.id }, condition });
}

export function response(ctx) {
  const { error, result } = ctx;
  if (error) {
    util.appendError(error.message, error.type);
  }
  return { mutationType: "DELETE", post: result };
}

6-3-5. amplify/data/updatePost.js

import { util } from "@aws-appsync/utils";
import * as ddb from "@aws-appsync/utils/dynamodb";

export function request(ctx) {
  const { id, expectedVersion, ...rest } = ctx.args;

  const values = Object.entries(rest).reduce((obj, [key, value]) => {
    obj[key] = value ?? ddb.operations.remove();
    return obj;
  }, {});

  return ddb.update({
    key: { id },
    condition: { version: { eq: expectedVersion } },
    update: { ...values, version: ddb.operations.increment(1) },
  });
}

export function response(ctx) {
  const { error, result } = ctx;
  if (error) {
    util.appendError(error.message, error.type);
  }
  return { mutationType: "UPDATE", post: result };
}

6-3-6. amplify/data/subscribePost.js

export function request(ctx) {
  return {};
}

export function response(ctx) {
  return null;
}

6-4. クライアント側のソースコードを作成

6-4-1. src/app/amplify-configure.ts

import { Amplify } from "aws-amplify";
import outputs from "../../amplify_outputs.json";

const configureAmplify = () => {
  Amplify.configure(outputs);
};

export { configureAmplify };

6-4-2. src/app/app.css

body {
  margin: 0;
  background: linear-gradient(180deg, rgb(117, 81, 194), rgb(255, 255, 255));
  display: flex;
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  height: 100vh;
  width: 100vw;
  justify-content: center;
  align-items: center;
}

main {
  display: flex;
  flex-direction: column;
  align-items: stretch;
}

input {
  border-radius: 8px;
  border: 1px solid transparent;
  padding: 0.6em 1.2em;
  font-size: 1em;
  margin-right: 8px;
}

button {
  border-radius: 8px;
  border: 1px solid transparent;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #1a1a1a;
  cursor: pointer;
  transition: border-color 0.25s;
  color: white;
}
button:hover {
  border-color: #646cff;
}
button:focus,
button:focus-visible {
  outline: 4px auto -webkit-focus-ring-color;
}

ul {
  padding-inline-start: 0;
  margin-block-start: 0;
  margin-block-end: 0;
  list-style-type: none;
  display: flex;
  flex-direction: column;
  margin: 8px 0;
  border: 1px solid black;
  gap: 1px;
  background-color: black;
  border-radius: 8px;
  overflow: auto;
}

li {
  background-color: white;
  padding: 8px;
}

li:hover {
  background: #dadbf9;
}

a {
  font-weight: 800;
  text-decoration: none;
}

6-4-3. src/app/page.tsx

"use client";

import {
  ChangeEvent,
  MouseEvent,
  KeyboardEvent,
  useState,
  useEffect,
} from "react";

import { configureAmplify } from "@/app/amplify-configure";
import { generateClient } from "aws-amplify/api";
import { Schema } from "../../amplify/data/resource";
import "@/app/app.css";

configureAmplify();

const Home = () => {
  const client = generateClient<Schema>();

  const [content, setContent] = useState<string>("");
  const [posts, setPosts] = useState<Array<Schema["Post"]["type"]>>();

  const listPosts = async (limit: number) => {
    const { data, errors } = await client.queries.listPosts({ limit });
    if (errors) console.error(`[ERROR]:${JSON.stringify(errors)}`);
    if (data) {
      const postData = data.posts as Array<Schema["Post"]["type"]>;
      postData.map((post) => {
        setPosts((prev) => [...(prev ?? []), post]);
      });
    }
  };

  useEffect(() => {
    listPosts(10);
  }, []);

  const getPost = async (id: string, e: MouseEvent<HTMLLIElement>) => {
    e.preventDefault();
    const { data, errors } = await client.queries.getPost({
      id,
    });
    if (errors) console.error(`[ERROR]:${JSON.stringify(errors)}`);
    console.log(`[DATA]:${JSON.stringify(data)}`);
  };

  const addPost = async () => {
    if (content.length == 0) return;
    const { data, errors } = await client.mutations.createPost({
      title: "My Post",
      content,
      author: "Sondon",
    });
    if (errors) console.error(`[ERROR]:${JSON.stringify(errors)}`);
    console.log(`[DATA]:${JSON.stringify(data)}`);
    setContent("");
  };

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    e.preventDefault();
    setContent(e.currentTarget.value);
  };

  const keyDownHandlerPost = async (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.nativeEvent.isComposing || e.key !== "Enter") return;
    await addPost();
  };

  const createPost = async (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    await addPost();
  };

  const deletePost = async (id: string, e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    const { data, errors } = await client.mutations.deletePost({
      id,
    });
    if (errors) console.error(`[ERROR]:${JSON.stringify(errors)}`);
    console.log(`[DATA]:${JSON.stringify(data)}`);
  };

  useEffect(() => {
    const sub = client.subscriptions.onSubscribePost({}).subscribe({
      next: ({ mutationType, post }) => {
        if (post) {
          setPosts((prev) => {
            switch (mutationType) {
              case "CREATE":
                return [...(prev ?? []), post];
              case "DELETE":
                return prev?.filter((p) => p.id !== post.id);
            }
          });
        }
      },
      error: (error) => {
        console.error(`[ERROR] ${JSON.stringify(error)}`);
      },
    });
    return () => sub.unsubscribe();
  }, []);

  return (
    <div>
      <h2>My posts</h2>
      <input
        type="text"
        value={content}
        onChange={(e) => handleChange(e)}
        onKeyDown={keyDownHandlerPost}
        autoFocus={true}
      />
      <button onClick={(e) => createPost(e)}>Register</button>
      <ul>
        {posts &&
          posts.map((post) => (
            <li key={post.id} onClick={(e) => getPost(post.id, e)}>
              <div
                style={{
                  display: "flex",
                  flexWrap: "wrap",
                  overflow: "hidden",
                  justifyContent: "space-between",
                  alignItems: "center",
                }}
              >
                <div>{post.content}</div>
                <div>
                  <button onClick={(e) => deletePost(post.id, e)}>
                    Delete
                  </button>
                </div>
              </div>
            </li>
          ))}
      </ul>
    </div>
  );
};

export default Home;

7. サーバーを起動

7-1-1. Sandboxを立ち上げる

npx ampx sandbox --identifier appsyncDydbDemo

7-2-1. NextJSを立ち上げる

npm run dev

8. ブラウザで確認

  • http://localhost:3000

8-1-1. 画面

8-2-1. DynamoDB

8-3-1. AppSync

9. ディレクトリの構造

.
├── README.md
├── amplify
│   ├── auth
│   │   └── resource.ts
│   ├── backend.ts
│   ├── data
│   │   ├── createPost.js
│   │   ├── deletePost.js
│   │   ├── getPost.js
│   │   ├── listPosts.js
│   │   ├── resource.ts
│   │   ├── subscribePost.js
│   │   └── updatePost.js
│   ├── package.json
│   └── tsconfig.json
├── amplify_outputs.json
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
│   ├── file.svg
│   ├── globe.svg
│   ├── next.svg
│   ├── vercel.svg
│   └── window.svg
├── src
│   └── app
│       ├── amplify-configure.ts
│       ├── app.css
│       ├── favicon.ico
│       ├── globals.css
│       ├── layout.tsx
│       └── page.tsx
└── tsconfig.json

6 directories, 31 files

10. 備考

今回はAmplify(Gen2)やAppSyncを使い、DynamoDBを操作する内容についてでした。

11. 参考

投稿者プロフィール

Sondon
開発好きなシステムエンジニアです。
卓球にハマってます。

関連記事

  1. 【新米エンジニア学習記録③】Next.jsのデプロイ

  2. 【NextJS】Access nextjs app launched …

  3. 【NextJS】Error Handling

  4. 【NextJS】Hooks-useState(update separ…

  5. 【NextJS】Dynamic Routes

  6. 【新米エンジニア学習記録④】Next.jsのデプロイⅡ

最近の記事

  1. Node.js
  2. AWS

制作実績一覧

  1. Checkeys