🔥

AWS Amplify, GraphQL, Amplify AuthでTODOアプリ

2023/10/23に公開

概要

AWS Amplify, GraphQL, Amplify AuthでTODOアプリを作成しました。

AWSアカウント作成とAmplifyアプリケーションの作成

以下を参考にAWSアカウントを作成します。画面の項目を順に入力していきます。
https://aws.amazon.com/jp/register-flow/

アカウント作成後にConsoleから新規Amplifyアプリケーションを作成します。
https://aws.amazon.com/jp/amplify/?nc=sn&loc=0

Amplify Hosting environmentとGitHubのリポジトリを紐づけて、ホスティングする。

GithubでAmplifyと紐づけるリポジトリを作成します。
フロントエンドはReactを使うので、以下公式を参考にローカルで作成していきます。

https://react.dev/learn/start-a-new-react-project

Reactのドキュメントではフレームワークを使用することが推奨されているので、Next.jsで作成します。

npx create-next-app@latest

ローカルでNext.jsのプロジェクトを作成出来たら、GitHubで新規リポジトリを作成し、ローカルで作成したNext.jsのプロジェクトをpushします。

GitHubのリポジトリと、新規Amplifyアプリケーションを作成出来たら、それらを紐づけてホスティングの設定をしていきます。

AmplifyのConsoleからGitHubを選択し、先ほど作成したリポジトリを選択します。
その後バックエンド環境やロールを選択していきます。
ホスティングの設定が完了すると、ビルドとデプロイ処理が走って数分で完了します。
Amplify ConsoleからURLが確認でき、無事ビルド出来ていればホスティング完了です。

Amplify CLIのインストール

続いてAmplify CLIをインストールしていきます。
Amplify CLIはAmplifyのバックエンド環境を作成、管理出来るコマンドラインツールです。
以下公式ドキュメントを参考にインストールしていきます。

https://docs.amplify.aws/cli/start/install/

以下コマンドでインストールします。

npm install -g @aws-amplify/cli

念の為インストールされていることを確認します。

amplify --version

インストールが完了したら次は設定をしていきます。

amplify configure
  • ブラウザが立ち上がりAWS Consoleへのログインが求められます。既にログインしている場合はターミナルに戻ります。
  • リージョンを選択します。
  • IAMユーザーの作成をします。ブラウザが立ち上がるのでユーザー名を入力します。
  • 許可のオプションを設定します。ポリシーを直接アタッチするを選択し、許可ポリシーから AdministratorAccess-Amplifyを選択、ユーザーを作成します。
  • IAMユーザーが作成されたので、アクセスキーを作成していきます。ユースケースはコマンドラインインターフェイスを選択。
  • アクセスキーが作成されたので、アクセスキー、シークレットアクセスキーをそれぞれコピーして、ターミナルに戻りそれぞれペーストします。
Enter the access key of the newly created user:
? accessKeyId:  ********************
? secretAccessKey:  ****************************************

これでAmplify CLIのインストールと設定が完了です。

ローカルでAmplifyのバックエンド環境を接続する

ローカルからAmplifyのバックエンド環境に接続していきます。
AmplifyのConsoleからBackend environmentのタブを選択します。
バックエンド環境とローカルのプロジェクトを接続するコマンドがあるので、それをコピーしてプロジェクトのルートディレクトリで実行します。

その後ターミナルで対話形式でエディタやフレームワークを選択していきます。
全て完了するとローカルにamplifyというディレクトリが作成されるので、これで接続完了です。

最後にamplifyのライブラリをインストールします。

npm install aws-amplify @aws-amplify/ui-react

ログイン画面の作成

今回はログイン画面を作成します。
以下を参考にAmplify Authを追加します。

https://docs.amplify.aws/lib/auth/getting-started/q/platform/js/

ターミナルでコマンドをAuthを追加するコマンドを実行

amplify add auth

対話形式で設定と、サインインの形式を対話形式で聞かれるので回答していきます。
そうするとamplify/backend配下にauthディレクトリが作成されるので

amplify push

でデプロイします。デプロイには数分かかります。
これでAmplifyのバックエンド環境にAuthが追加されました。

次にログイン画面のUIを作成します。
まずはフロントエンドでAmplifyの機能が使えるように以下のコードを追加します。

import { Amplify } from "aws-amplify";
import awsconfig from "../aws-exports";
Amplify.configure(awsconfig);

UIはAmplifyUIのAuthenticatorコンポーネントを使用します。

"use client";

import { Amplify } from "aws-amplify";
import awsconfig from "../aws-exports";
Amplify.configure(awsconfig);

import "@aws-amplify/ui-react/styles.css";
import { Authenticator } from "@aws-amplify/ui-react";

import styles from "./page.module.css";

export default function Home() {
  return (
    <main className={styles.main}>
      <Authenticator>
        {({ signOut }) => (
          <main>
            <h1>ログイン成功です</h1>
            <button onClick={signOut}>Sign out</button>
          </main>
        )}
      </Authenticator>
    </main>
  );
}

https://ui.docs.amplify.aws/react/connected-components/authenticator

これでログイン画面が作成出来ます。
yarn run devでローカル環境を立ち上げると、以下のような画面になっています。

CreateAccountタブからアカウントを作成して、SignInします。
SignInに成功すると

Authenticator内のHTMLが画面に表示されるようになりました。
これでログイン画面の作成が完了しました。

API作成

ドキュメントを参考にAPIを作成します。

https://docs.amplify.aws/lib/graphqlapi/getting-started/q/platform/js/

今回はQraphQLでAPIを作成します。
ターミナルで以下コマンドを入力します。

amplify add api

対話形式でQraphQLを選択します。
そうするとローカルでschema.graphqlが作成されます。

type Todo @model {
  id: ID!
  name: String!
  description: String
}

既にTodoのmodelが作成されているので、これを使用していきます。

amplify pushでAmplifyのバックエンド環境に反映しましょう。
これでGraphQLのAPIが作成完了です。

Chakra UI

画面を作るにあたってChakraUIを使っていきます。

https://chakra-ui.com/

ライブラリをインストールしていきます。

npm i @chakra-ui/react @emotion/react @emotion/styled framer-motion @chakra-ui/next-js

プロジェクトでChakraUIを使えるように、Provider.tsxを作成します。

"use client";

import { CacheProvider } from "@chakra-ui/next-js";
import { ChakraProvider } from "@chakra-ui/react";

export function Provider({ children }: { children: React.ReactNode }) {
  return (
    <CacheProvider>
      <ChakraProvider>{children}</ChakraProvider>
    </CacheProvider>
  );
}

Next.jsのAppRouterを使っているので、ChakraProviderだけでなくCacheProviderも使用します。

https://chakra-ui.com/getting-started/nextjs-guide#app-directory-setup

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className={inter.className}>
        <Provider>{children}</Provider>
      </body>
    </html>
  );
}

Layout.tsxで読み込むことで、ChakraUIが使用できるようになります。

UIの作成

TODOアプリのUIを作成します。
まずはTODOのリストコンポーネントを作成します。

List.tsx
"use client";

import { UnorderedList, ListItem } from "@chakra-ui/react";

import { ListType } from "../types";

type ListProps = {
  list: ListType;
  onOpenModal: (id: string) => void;
};

export const List = ({ list, onOpenModal }: ListProps) => {
  return (
    <UnorderedList>
      {list.map((item) => (
        <ListItem key={item.id}>
          <button onClick={() => onOpenModal(item.id)}>{item.name}</button>
        </ListItem>
      ))}
    </UnorderedList>
  );
};

TODOをクリックすると、編集モーダルを開く想定で、propsでonOpenModal関数を受け取れるようにしています。

次にTODO追加とTODO編集のコンポーネントを作成します。

AddModal.tsx
"use client";

import {
  Button,
  Stack,
  FormControl,
  FormLabel,
  Input,
  Textarea,
  Modal,
  ModalOverlay,
  ModalContent,
  ModalHeader,
  ModalFooter,
  ModalBody,
  ModalCloseButton,
} from "@chakra-ui/react";
import {
  UseFormHandleSubmit,
  SubmitHandler,
  UseFormRegister,
} from "react-hook-form";

import { FormType } from "../types";

type AddModalProps = {
  isOpen: boolean;
  onClose: () => void;
  handleSubmit: UseFormHandleSubmit<FormType>;
  onSubmit: SubmitHandler<FormType>;
  register: UseFormRegister<FormType>;
};

export const AddModal = ({
  isOpen,
  onClose,
  handleSubmit,
  onSubmit,
  register,
}: AddModalProps) => {
  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      <ModalOverlay />
      <ModalContent>
        <ModalHeader>TODO追加</ModalHeader>
        <ModalCloseButton />
        <ModalBody>
          <form id="add" onSubmit={handleSubmit(onSubmit)}>
            <Stack>
              <FormControl>
                <FormLabel>タイトル</FormLabel>
                <Input {...register("name", { required: true })} />
              </FormControl>
              <FormControl>
                <FormLabel>説明文</FormLabel>
                <Textarea {...register("description", { required: true })} />
              </FormControl>
            </Stack>
          </form>
        </ModalBody>
        <ModalFooter>
          <Button type="submit" form="add">
            追加
          </Button>
        </ModalFooter>
      </ModalContent>
    </Modal>
  );
};
EditModal.tsx
"use client";

import {
  Button,
  Stack,
  FormControl,
  FormLabel,
  Input,
  Textarea,
  Modal,
  ModalOverlay,
  ModalContent,
  ModalHeader,
  ModalFooter,
  ModalBody,
  ModalCloseButton,
} from "@chakra-ui/react";
import {
  UseFormHandleSubmit,
  SubmitHandler,
  UseFormRegister,
} from "react-hook-form";

import { FormType } from "../types";

type EditModalProps = {
  isOpen: boolean;
  onClose: () => void;
  handleSubmit: UseFormHandleSubmit<FormType>;
  onSubmit: SubmitHandler<FormType>;
  register: UseFormRegister<FormType>;
  onDeleteItem: () => void;
};

export const EditModal = ({
  isOpen,
  onClose,
  handleSubmit,
  onSubmit,
  register,
  onDeleteItem,
}: EditModalProps) => {
  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      <ModalOverlay />
      <ModalContent>
        <ModalHeader>TODO編集</ModalHeader>
        <ModalCloseButton />
        <ModalBody>
          <form id="edit" onSubmit={handleSubmit(onSubmit)}>
            <Stack>
              <FormControl>
                <FormLabel>タイトル</FormLabel>
                <Input {...register("name", { required: true })} />
              </FormControl>
              <FormControl>
                <FormLabel>説明文</FormLabel>
                <Textarea {...register("description", { required: true })} />
              </FormControl>
            </Stack>
          </form>
        </ModalBody>
        <ModalFooter>
          <Stack direction={"row"}>
            <Button type="button" onClick={onDeleteItem}>
              削除
            </Button>
            <Button type="submit" form="edit">
              保存
            </Button>
          </Stack>
        </ModalFooter>
      </ModalContent>
    </Modal>
  );
};

Chakra UIのModalコンポーネントを使っています。
入力フォームではReact Hook Formを使う想定で、propsで受け取れるようにしています。

次に処理をまとめたカスタムフックを作成します。

useTodo.tsx
import { useState } from "react";
import { useDisclosure } from "@chakra-ui/react";
import { useForm, SubmitHandler } from "react-hook-form";

import { ListType, FormType } from "../types";

export const useTodo = () => {
  // モーダルの開閉
  const {
    isOpen: isOpenAddModal,
    onOpen: onOpenAddModal,
    onClose: onCloseAddModal,
  } = useDisclosure();
  const {
    isOpen: isOpenEditModal,
    onOpen: onOpenEditModal,
    onClose: onCloseEditModal,
  } = useDisclosure();

  // TODOリストの状態管理
  const [list, setList] = useState<ListType>();

  // 編集するTODOのIDの状態管理
  const [currentId, setCurrentId] = useState<string>();

  // React Hook Formの呼び出し
  const {
    register: addRegister,
    handleSubmit: handleSubmitAdd,
    setValue: setValueAdd,
  } = useForm<FormType>();
  const {
    register: editRegister,
    handleSubmit: handleSubmitEdit,
    setValue: setValueEdit,
  } = useForm<FormType>();

  // 編集モーダルを開く際の処理
  const onOpenAndSetValueEditModal = (id: string) => {
    if (!list) return;
    const currentItem = list.find((item) => item.id === id);
    setValueEdit("name", currentItem ? currentItem.name : "");
    setValueEdit("description", currentItem ? currentItem.description : "");
    setCurrentId(id);
    onOpenEditModal();
  };

  // TODO追加処理
  const onSubmitAdd: SubmitHandler<FormType> = (data) => {
    const newItem = {
      id: (Math.floor(Math.random() * 1000000000) + 1).toString(),
      name: data.name,
      description: data.description,
    };
    setList((prev) => {
      if (prev) {
        return [...prev, newItem];
      } else {
        return [newItem];
      }
    });
    setValueAdd("name", "");
    setValueAdd("description", "");
    onCloseAddModal();
  };

  // TODO更新処理
  const onSubmitEdit: SubmitHandler<FormType> = (data) => {
    if (!list) return;
    const newList = list.map((item) =>
      item.id === currentId
        ? {
            id: item.id,
            name: data.name,
            description: data.description,
          }
        : item
    );
    setList(newList);
    onCloseEditModal();
  };

  // TODOの削除処理
  const onDeleteItem = () => {
    if (!list) return;
    const newList = list.filter((item) => item.id !== currentId);
    setList(newList);
    onCloseEditModal();
  };

  return {
    isOpenAddModal,
    onOpenAddModal,
    onCloseAddModal,
    isOpenEditModal,
    onCloseEditModal,
    list,
    addRegister,
    handleSubmitAdd,
    editRegister,
    handleSubmitEdit,
    onOpenAndSetValueEditModal,
    onSubmitAdd,
    onSubmitEdit,
    onDeleteItem,
  };
};

最後に作成した各コンポーネントと、カスタムフックを使って画面を作成します。

page.tsx
"use client";

import { Amplify } from "aws-amplify";
import awsconfig from "../aws-exports";
Amplify.configure(awsconfig);

import "@aws-amplify/ui-react/styles.css";
import { Authenticator } from "@aws-amplify/ui-react";
import { Button, Heading, Stack } from "@chakra-ui/react";

import styles from "./page.module.css";
import { List, AddModal, EditModal } from "./components";
import { useTodo } from "./hooks/useTodo";

export default function Home() {
  const {
    isOpenAddModal,
    onOpenAddModal,
    onCloseAddModal,
    isOpenEditModal,
    onCloseEditModal,
    list,
    addRegister,
    handleSubmitAdd,
    editRegister,
    handleSubmitEdit,
    onOpenAndSetValueEditModal,
    onSubmitAdd,
    onSubmitEdit,
    onDeleteItem,
  } = useTodo();

  return (
    <main className={styles.main}>
      <Authenticator>
        {({ signOut }) => (
          <>
            <Heading>TODOアプリ</Heading>
            <Stack
              minWidth={"800px"}
              alignItems={"flex-start"}
              mt={"32px"}
              spacing={"16px"}
            >
              <Button onClick={onOpenAddModal}>追加</Button>
              {list && (
                <List list={list} onOpenModal={onOpenAndSetValueEditModal} />
              )}
              <Button onClick={signOut}>サインアウト</Button>
            </Stack>
            <AddModal
              isOpen={isOpenAddModal}
              onClose={onCloseAddModal}
              handleSubmit={handleSubmitAdd}
              onSubmit={onSubmitAdd}
              register={addRegister}
            />
            <EditModal
              isOpen={isOpenEditModal}
              onClose={onCloseEditModal}
              handleSubmit={handleSubmitEdit}
              onSubmit={onSubmitEdit}
              register={editRegister}
              onDeleteItem={onDeleteItem}
            />
          </>
        )}
      </Authenticator>
    </main>
  );
}

これでTODOアプリのUI作成が完了しました。
ここまででTODOの追加、編集、削除の動作を作成出来たので、最後にAmplifyのAPIと連携していきます。

GraphQL APIとの連携

先ほど作成したGraphQL APIを用いて、実際に入力した項目でTODOを作成、編集、削除出来るようにしていきます。
こちらのドキュメントを参考にします。

https://docs.amplify.aws/lib/graphqlapi/getting-started/q/platform/js/#enable-queries-mutations-and-subscriptions

まずはuseTodo.tsで必要なライブラリをimportします。

import { API, graphqlOperation } from "aws-amplify";
import { GraphQLQuery } from "@aws-amplify/api";

API作成時にAmplifyで自動生成されたquery, mutation, typeもimportします。

import { listTodos } from "@/graphql/queries";
import { createTodo, updateTodo, deleteTodo } from "@/graphql/mutations";
import {
  ListTodosQuery,
  CreateTodoMutation,
  UpdateTodoMutation,
  DeleteTodoMutation,
} from "@/API";

これらはAPI作成時にschema.graphqlで定義した@modelから自動生成されています。

TODOを追加処理の関数を編集します。

useTodo.ts
const onSubmitAdd: SubmitHandler<FormType> = async (data) => {
    // 入力された項目で、TODOリストを作成
    const responce = await API.graphql<GraphQLQuery<CreateTodoMutation>>(
      graphqlOperation(createTodo, {
        input: { name: data.name, description: data.description },
      })
    );

    // レスポンスのデータを整形
    const createdItem = responce.data?.createTodo;
    if (!createdItem) return;

    const newItem = {
      id: createdItem.id,
      name: createdItem.name,
      description: createdItem.description || "",
    };

    // UIを更新する
    setList((prev) => {
      if (prev) {
        return [...prev, newItem];
      } else {
        return [newItem];
      }
    });

    // フォームの値を空に戻してモーダルを閉じる
    setValueAdd("name", "");
    setValueAdd("description", "");
    onCloseAddModal();
  };

API.graphqlメソッドの引数にgraphqlOperationを指定します。
graphqlOperationの第一引数には、自動生成されたcreateTodoというmutationを渡して、第二引数に入力された値を渡すことで、新規で新しくTODOを追加できます。
responseから表示用にデータを整形してから、TODOリストのstateも更新して、UIにリアルタイムで反映できるようにします。

これでTODOを新規作成できるようになったので、作成したTODOをAPIから取得できるようにします。

useTodo.ts
// TODOリストをQueryで取得してセットする。
  useEffect(() => {
    const queryTodos = async () => {
      const todoData = await API.graphql<GraphQLQuery<ListTodosQuery>>(
        graphqlOperation(listTodos)
      );

      const todoList = todoData.data?.listTodos?.items;
      if (!todoList) return;

      // データの整形
      const transformedTodos = todoList.map((item) => ({
        id: item?.id || "",
        name: item?.name || "",
        description: item?.description || "",
      }));
      setList(transformedTodos);
    };

    queryTodos();
  }, []);

新規作成と同様にAPI.graphqlメソッドの引数にgraphqlOperationを指定します。
データを取得するので、graphqlOperationの引数に、自動生成されたlistTodosqueryを渡します。
取得したデータを整形して、listのstateを更新します。
これで作成したTODOが画面に表示されるようになりました。

最後にTODOの更新と削除もできるようにします。

useTodo.ts
// TODO更新処理
  const onSubmitEdit: SubmitHandler<FormType> = async (data) => {
    if (!list) return;
    // 現在編集しているTODOのIDと、入力された項目で更新する
    const responce = await API.graphql<GraphQLQuery<UpdateTodoMutation>>(
      graphqlOperation(updateTodo, {
        input: {
          id: currentId,
          name: data.name,
          description: data.description,
        },
      })
    );

    // レスポンスのデータを整形
    const updatedItem = responce.data?.updateTodo;
    if (!updatedItem) return;

    // UIを更新してモーダルを閉じる
    const newList = list.map((item) =>
      item.id === currentId
        ? {
            id: updatedItem.id,
            name: updatedItem.name,
            description: updatedItem.description || "",
          }
        : item
    );

    setList(newList);
    onCloseEditModal();
  };

  // TODOの削除処理
  const onDeleteItem = async () => {
    if (!list) return;

    // 現在編集しているTODOのIDを渡して削除する
    const responce = await API.graphql<GraphQLQuery<DeleteTodoMutation>>(
      graphqlOperation(deleteTodo, { input: { id: currentId } })
    );

    // UIを更新してモーダルを閉じる
    const deletedId = responce.data?.deleteTodo?.id;
    if (!deletedId) return;

    const newList = list.filter((item) => item.id !== deletedId);
    setList(newList);
    onCloseEditModal();
  };

更新と削除も、新規追加時と同様にAPI.graphqlgraphqlOperationを使用します。
それぞれ更新はupdateTodo、削除はdeleteTodomutationを渡します。
第二引数にはそれぞれ必要な値を渡して、レスポンスからlistのstateを更新することで、UIもリアルタイムで更新します。

これでAmplifyを使ったTODOアプリの完成です!

Discussion