ちょっと話題の記事

Amplify Gen2でNextJSのアプリケーション作成まで

2024.04.02

昨年発表されたAmplify Gen2(プレビュー)ですが、日々機能が少しづつ追加されており、完成度が高まってきました。 今回はチュートリアルに従ってNextJSを構築し、動作を確認してみます。

Amplify Gen2と、従来のAmplify

従来のAmplifyは「ツールファースト」と呼ばれています。豊富なメニューが用意されており、メニューを選択するだけで、必要な機能を構築することができました。 一方、メニューで選択できない部分の修正の難易度が高かったり、メニューや内容を把握する、「Amplifyならではの知識」が必要となる、というポイントがありました。

新しいAmplify Gen2は「コードファースト」です。TypeScript等の言語で、CDKをベースとした構築を行うスタイルに変わりました。これにより、CDKの知識は必要なものの、自由度が増し、開発者がIaCの細部まで手を入れることが出来る、というスタイルに変化しています。

構築

チュートリアルの構築はよく出来ており、手順に従えば簡単にTodoアプリを構築できます。

なお、Amplify gen2はプレビュー版の為、従来のAmplifyほどに機能が充実しておらず、準備増えていく想定です。(Storage機能もほんの最近追加されました) 今回はチュートリアルに従い、Next.js App Router (Server Components)の構築を行いつつ、ポイントを解説してみました。

プロジェクトの作成

npm create amplify@beta

構築を行うと、auth(Cognito)、data(AppSync)がデフォルトで提供されていることが分かります。

Gyazo

ローカルサーバの起動

npx amplify sandbox --name hogehoge

フロントエンドの起動に加え、sandboxという、開発者個々に割当が可能な、AWS上のクラウド環境を起動します。これにより、同じAWSアカウント上で複数の開発者が開発しても、Cognitoやlambdaのコンフリクトを防ぐことができます。

実行する場合、---nameオプションで識別名を付与することをお勧めします(lambdaの関数名等に反映され、関数の特定が容易になります)。

バックエンドを構築する

AmplifyはGraphQLを推しています。以前と比べ、直感的にスキーマを定義できるようになっています。 a.model()を使うことで、GET、PUT、UPDATE、DELETE、LIST、サブスクリプション機能を追加できます。 authorization()やidentifier()、secondaryIndexes()をチェインすることで、R/W権限や、デフォルトのデータソースであるDynamoDBのPrimary Key,Sort Key、Secondery Indexの指定ができます。

なお、model()で得られるLIST機能は、内部でDynamoDBへのスキャンを行ってしまうため、indexを効かせたQueryを実行したい場合、カスタムクエリでリゾルバを作成する必要がありそうです(後述します)。

data/resource.ts

 const schema = a.schema({
   Todo: a
     .model({
       content: a.string(),
       done: a.boolean(),
       priority: a.enum(['low', 'medium', 'high'])
     })
     .authorization([a.allow.owner(), a.allow.public().to(['read'])]),
 });

認証の追加

Cognitoの設定を記述します。 Amplify Gen2は、ログイン方法にusernameを選ぶことが出来ず、emailか、phoneNumberからの選択となるようです(CDKのL1コンストラクトから書き換えを試してみましたが、不許可のエラーとなってしまいました)。

amplify/auth/resource.ts

 import { defineAuth } from '@aws-amplify/backend';
 
 export const auth = defineAuth({
   loginWith: {
    email: {
      verificationEmailSubject: 'Welcome! Verify your email!'
    },
   }
 });

UIの構築

Amplify クライアント側の構成 <ConfigureAmplifyClientSide /> コンポーネントを挟むことで、クライアント側でamplifyのAPIの実行を可能にしています.

ログインコンポーネント

クライアントサイドで認証をチェックし、認証時はホーム画面、 未認証時はログイン画面を表示するようにしています。

サーバー側リダイレクト用のミドルウェアの追加

NextJSのmiddrewareの機能を使用して、ページ遷移時にサーバ側で認証をチェックすることにより、認証のないユーザーをloginページに遷移させています。

To Do アイテムのリストを表示する

dataで作成したTodoモデルを元に、listを呼び出し、DynamoDBに格納されたデータを AppSync経由で取り出しています。

const { data: todos } = await cookiesClient.models.Todo.list();

新しい To Do アイテムを作成する

formに格納されたデータがSubmitに対して、Server Actionを直接割り当て、ToDoを作成します。 recalidatePathにより、コンポーネントの再検証が行われ、最新のデータが表示される仕組みです。

   async function addTodo(data: FormData) {
     "use server";
     const title = data.get("title") as string;
     await cookiesClient.models.Todo.create({
       content: title,
       done: false,
       priority: "medium",
     });
     revalidatePath("/");
   }

カスタムクエリ・ミューテーションを設定する

チュートリアルから外れて、カスタムクエリを実行できるように改修を行ってみます。

前述の通り、model()によるLIST機能はScanによるものなので、Indexを効かせてQueryを発行したり、Mutationに合わせて通知を発行したりしたい場合、appSyncリゾルバやLambdaによるリゾルバを設定する必要があります。 なお、パイプラインリゾルバも設定可能です。

今回は、スキーマ定義を変更して、複合キーを貼り、createDateをbegins_withで絞り込んで取得してみます。

Todo型を変更し、tagとcreateDateの複合キーに変更します。 次に、カスタムクエリ用の型と、リゾルバに対するリクエスト・レスポンスを定義します。 TodosをArrayで返却するのがポイントです。

amplify/data/resource.ts

 const schema = a.schema({
   Todo: a
     .model({
       tag: a.string().required(),
       content: a.string(),
       done: a.boolean(),
       createDate: a.string().required(),
       priority: a.enum(["low", "medium", "high"]),
     })
     .identifier(["tag", "createDate"])
     .authorization([a.allow.owner()]),
 
   Todos: a.customType({
     tag: a.string().required(),
     content: a.string(),
     done: a.boolean(),
     createDate: a.string().required(),
     priority: a.enum(["low", "medium", "high"]),
   }),
 
   RangeTodos: a
     .query()
     .arguments({
       tag: a.string().required(),
       year: a.string().required(),
     })
     .returns(a.ref("Todos").array())
     .authorization([a.allow.private()])
     .handler([
       a.handler.custom({
         dataSource: a.ref("Todo"),
         entry: "./appsyncResolver.js",
       }),
     ]),
 });

リゾルバは、関数ではなく、AppSyncリゾルバを使用しました。

amplify/data/appsyncResolver.js

export function request(ctx) {
   if (!ctx.identity) {
     runtime.earlyReturn([]);
   }
   return {
     operation: "Query",
     query: {
       expression: "tag = :tag and begins_with(createDate, :date)",
       expressionValues: {
         ":tag": { S: ctx.arguments.tag },
         ":date": { S: ctx.arguments.date },
       },
     },
   };
 }
 
 export function response(ctx) {
   return ctx.result.items;
 }

page.tsxを、クエリパラメータを使用し、rangeTodoListからデータを取得するように修正します。 日付の入力部分もServer Actionで定義します。

app/pages.tsx

import { revalidatePath } from "next/cache";
import { AuthGetCurrentUserServer, cookiesClient } from "@/utils/amplify-utils";
import Logout from "@/components/Logout";
import { redirect } from "next/navigation";

async function App({
  searchParams,
}: {
  searchParams?: { [key: string]: string };
}) {
  const user = await AuthGetCurrentUserServer();
  const dateParam = !searchParams?.date ? "20" : (searchParams?.date as string);

  const { data: rangeTodoList } =
    (await cookiesClient.queries.RangeTodos({
      tag: "test",
      date: dateParam,
    })) ?? [];

  async function addTodo(data: FormData) {
    "use server";
    await cookiesClient.models.Todo.create({
      tag: "test",
      content: data.get("title") as string,
      done: false,
      priority: "medium",
      createDate: data.get("createDate") as string,
    });
    revalidatePath("/");
  }

  async function rangeTodo(data: FormData) {
    "use server";
    const dateParam = data.get("date");
    redirect("/" + dateParam ? `?date=${data.get("date")}` : "");
  }

  return (
    <>
      <h1>Hello, Amplify 👋</h1>
      {user && <Logout />}
      <form action={rangeTodo}>
        <input
          type="text"
          name="date"
        />
        <button type="submit">Range Todo</button>
      </form>

      <form action={addTodo}>
        <input
          type="date"
          name="createDate"
        />
        <input
          type="text"
          name="title"
        />
        <button type="submit">Add Todo</button>
      </form>

      <ul>
        {rangeTodoList!.map((rangeTodo) => (
          <li key={rangeTodo!.tag + rangeTodo!.createDate}>
            {rangeTodo!.createDate}&nbsp;:&nbsp;{rangeTodo!.content}
          </li>
        ))}
      </ul>
    </>
  );
}

export default App;

通常の表示

「2023」で絞り込んで表示

begins_withによる前方一致の絞り込みが機能していることが分かります。 また、CDKでApp Syncのリゾルバを定義する場合に比べ、比較的に簡単に構築を行うことができました。

その他、構築で気になった点

今回のチュートリアルでは触れていない点となりますが、Amplify Gen2のdataでも、元のAppSyncの機能である、リアルタイムな購読を行うことができます。ただし、購読はClient Componentで実行する必要がありました。

まとめ

Amplify Gen2の構築のチュートリアルに従い、Next.js App RouterへのAmplifyの組み込みのポイントを確認してみました。

次回は、Amplify Gen2のホスティングを使用し、デプロイの解説を行います。