24
32

Next.js 13とAWS CDK + Amplify Hosting + Cognito + DynamoDB + Lambdaを使ってTODOアプリを作る

Last updated at Posted at 2023-09-20

株式会社Neverのshoheiです。

株式会社Neverは「NEVER STOP CREATE 作りつづけること」をビジョンに掲げ、理想を実現するためにプロダクトを作り続ける組織です。モバイルアプリケーションの受託開発、技術支援、コンサルティングを行っております。アプリ開発のご依頼や開発面でのお困りの際はお気楽にお問合せください。

概要

自社開発でどうしてもAWSを使う事情があり、扱う技術を一通り触ったので備忘録として記録します。

自分の勉強も兼ねてNext.js 13とAWS CDK + Amplify hosting + Cognito + DynamoDB + Lambdaを使ってTODOアプリを作ります。

弊社内のメンバー向けなので、細かい説明は割愛します。

構成

種類 技術スタック
フロントエンド Next.js + SSR
ウェブホスティング Amplify Hosting
API API Gateway(REST) + Lambda(TypeScript)
認証 Cognito
DB DynamoDB
# npx
npx --version
9.6.7

# node.js
node --version
v18.17.1

# AWS CLI
aws --version
aws-cli/2.13.14 Python/3.11.4 Darwin/22.6.0 exe/x86_64 prompt/off

# CDK CLI
cdk --version
2.126.0 (build 3edd240)

# SAM CLI
sam --version
SAM CLI, version 1.96.0

DB設計

DynamoDBのテーブル設計は以下の通りです。

TODO テーブル

AttributeName AttributeType KeyType KeyType(GSI) Value Remarks
todoId S HASH 83dbdd79-ff3e-fe89-1061-161a75eb8ad7 UUID
userId S HASH 0c10b5e5-e803-21c1-1b74-abed81e35265 CognitoのuserId
todoText S サウナへ行く
createdAt S RANGE 2023-10-07T12:12:12Z ISO 8601
updatedAt S 2023-10-07T12:12:12Z ISO 8601

セキュリティ的にLSItodoIdHASHuserIdRANGEにしても良かったかも。

AWS CDKでインフラリソースを構築

AWS CDKとは

AWS Cloud Development Kit は、使い慣れたプログラミング言語を使用してクラウド インフラストラクチャ リソースを定義およびプロビジョニングするために、Amazon Web Services によって開発されたオープンソースのソフトウェア開発フレームワークです。
引用元

AWS CDKを使う事で好きな言語でインフラリソースの定義を記述でき、AWS上に構築できます。

aws configureで確認

ご自身の環境で、AWSを操作できるIAMの認証情報が設定されているか確認します。

aws configure list-profiles
default
shohei

設定したプロファイルリストが表示されていればAWSへアクセスできますが、アクセス権限の細かい設定はAWSコンソールから設定してください。

アプリを作成してデプロイ

CDKをTypeScriptで記述できるようアプリを作成します。

# 専用ディレクトリを作成
mkdir cdk-todo-app
cd cdk-todo-app

# アプリを作成する
cdk init app --language typescript

作成したアプリのテンプレートをCloudFormationへデプロイします。

# CDK Toolkit ステージングスタックをデプロイします。Cloud Formationに展開されます。
cdk bootstrap

# スタックをデプロイします
cdk deploy

cdkコマンドについてはツールキットコマンドを参照してください。

cdk-todo-app-stack.tsを確認します。

lib/cdk-todo-app-stack.ts
import * as cdk from 'aws-cdk-lib'
import { Construct } from 'constructs'
// import * as sqs from 'aws-cdk-lib/aws-sqs';

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

    // The code that defines your stack goes here

    // example resource
    // const queue = new sqs.Queue(this, 'CdkTodoAppQueue', {
    //   visibilityTimeout: cdk.Duration.seconds(300)
    // });
  }
}

ここにインフラリソースの設定をしていきます。

Cognitoの設定

メールアドレスとパスワードで認証できるようにします。

lib/cdk-todo-app-stack.ts
import * as cdk from 'aws-cdk-lib'
import { Construct } from 'constructs'
import { UserPool, UserPoolClient, UserPoolClientIdentityProvider, AccountRecovery } from 'aws-cdk-lib/aws-cognito'

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

    /**
     * Cognito
     */
    // Amazon Cognito のユーザーディレクトリ
    const userPool = new UserPool(this, 'cdk-todo-app-user-pool', {
      userPoolName: 'cdk-todo-app-user-pool',
      selfSignUpEnabled: true, // Eメールで確認コードを受信し、それを使ってユーザーが確認済みとなり使用出来るようになる
      standardAttributes: {
        email: {
          required: true, // サインアップ時にemailアドレスを必須にする
          mutable: true, // emailアドレスの変更が可能
        },
      },
      signInAliases: { email: true, username: false }, // email:true とするとユーザー名にemailが使える emailのみでサインアップ、サインインしたい場合は username: falseにする
      autoVerify: { email: true }, // autoVerifyを記述しない場合、emailアドレスの検証が必要
      accountRecovery: AccountRecovery.EMAIL_ONLY,
      removalPolicy: cdk.RemovalPolicy.DESTROY, // DESTROYの場合はスタックを削除するとuserPoolも削除される。本番環境はRetainを推奨
    })

    // アプリクライアントの定義
    const userPoolClient = new UserPoolClient(this, 'cdk-todo-app-user-pool-client', {
      userPool,
      authFlows: { adminUserPassword: true, userPassword: true, userSrp: true }, // adminUserPasswordがfalse場合、ユーザー名とパスワードでトークンの取得ができない
      supportedIdentityProviders: [UserPoolClientIdentityProvider.COGNITO],
    })
  }
}

DynamoDBの設定

Todoテーブルを設定します。GSIも一緒に設定します。

lib/cdk-todo-app-stack.ts
...

import { Table, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb'

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

    /**
     * DynamoDB
     */
    const todoTable = new Table(this, 'todo-app-todo', {
      tableName: 'Todo',
      partitionKey: {
        name: 'todoId',
        type: AttributeType.STRING,
      },
      billingMode: BillingMode.PAY_PER_REQUEST, // オンデマンド請求(使用した分だけ支払う)
      pointInTimeRecovery: true, // PITRを有効化(自動バックアップ)
      removalPolicy: cdk.RemovalPolicy.DESTROY, // DESTROYの場合はスタックを削除するとDBも削除される。本番環境はRetainを推奨
    })
    // GSIを設定
    todoTable.addGlobalSecondaryIndex({
      indexName: 'userIdCreatedAtIndex',
      partitionKey: {
        name: 'userId',
        type: AttributeType.STRING,
      },
      sortKey: {
        name: 'createdAt',
        type: AttributeType.STRING,
      },
    })
  }
}

Lambda + API Gatewayの設定

LambdaとAPI Gatewayの設定します。

lib/cdk-todo-app-stack.ts
...

import { Runtime } from 'aws-cdk-lib/aws-lambda'
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'
import { LambdaRestApi } from 'aws-cdk-lib/aws-apigateway'

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

    /**
     * Lambda
     */
    const lambdaFunction = new NodejsFunction(this, 'todo-app-lambda', {
      entry: 'lambda/index.ts', // lambda関数のエントリーポイント
      handler: 'handler', // 実行する関数名
      runtime: Runtime.NODEJS_18_X,
      memorySize: 128,
      environment: {
        COGNITO_USER_POOL_ID: userPool.userPoolId,
        COGNITO_USER_POOL_CLIENT_ID: userPoolClient.userPoolClientId,
      },
      timeout: cdk.Duration.seconds(30),
    })
    todoTable.grantReadWriteData(lambdaFunction) // DBの読み取り書き込み権限をLambdaに付与

    /**
     * API Gateway
     */
    const restApi = new LambdaRestApi(this, 'todo-app-rest-api', {
      handler: lambdaFunction,
      deployOptions: {
        tracingEnabled: true, // X-Ray トレースの有効化
        stageName: 'v1',
      },
    })
  }
}

Lambdaの実装

Lambdaにexpressを導入します。フロントエンドから送られてくるアクセストークンからCognitoのuserIdを取得するためにaws-jwt-verifyも導入します。

npm i --save @vendia/serverless-express express aws-jwt-verify
npm i -D @types/express

DynamoDBのSDKも導入します。

npm i --save @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb

import express from 'express'の記述でエラーがでるので、tsconfig.json"esModuleInterop": trueを追加

tsconfig.json
{
  "compilerOptions": {
...
+    "esModuleInterop": true,
    "typeRoots": ["./node_modules/@types"]
  },
  "exclude": ["node_modules", "cdk.out"]
}

プロジェクト直下にlambdaディレクトリとその中にindex.tsを作成します。index.tsは後で実装するので空のままで大丈夫です。

Cognitoの実装

Cognitoのアクセストークンから、CognitoのuserIdを取得できるラッパーを作ります。

lambda/repositories/auth_repository.ts
import { CognitoJwtVerifier } from 'aws-jwt-verify'

export const verifyAccessToken = async (accessToken: string) => {
  const verifier = CognitoJwtVerifier.create({
    userPoolId: process.env.COGNITO_USER_POOL_ID ?? '',
    tokenUse: 'access',
    clientId: process.env.COGNITO_USER_POOL_CLIENT_ID ?? '',
  })
  const payload = await verifier.verify(accessToken)
  const userId = payload.sub
  console.log('userId', userId)
  return userId
}

DynamoDBの実装

DynamoDBのTodoテーブルにアクセスするラッパーを作ります。

lambda/repositories/dynamo_db_provider.ts
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'
import { DynamoDBClient, DynamoDBClientConfig } from '@aws-sdk/client-dynamodb'

export const getDocumentClient = (config: DynamoDBClientConfig = {}) =>
  DynamoDBDocumentClient.from(new DynamoDBClient(config))
lambda/repositories/todo_repository.ts
import { PutCommand, GetCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'
import { getDocumentClient } from './dynamo_db_provider'

const documentClient = getDocumentClient()
const tableName = 'Todo'

export const setTodo = async (todoId: string, userId: string, todoText: string) => {
  const dateISOString = new Date().toISOString()
  const command = new PutCommand({
    TableName: tableName,
    Item: {
      todoId: todoId,
      userId: userId,
      todoText: todoText,
      createdAt: dateISOString,
      updatedAt: dateISOString,
    },
    ConditionExpression: 'attribute_not_exists(todoId)', // todoIdが重複しないようにする
  })
  const output = await documentClient.send(command)
  console.log(JSON.stringify(output))
  return output
}

export const fetchTodo = async (todoId: string) => {
  const command = new GetCommand({
    TableName: tableName,
    Key: {
      todoId: todoId,
    },
  })
  const output = await documentClient.send(command)
  console.log(JSON.stringify(output))
  return output
}

export const deleteTodo = async (todoId: string, userId: string) => {
  const command = new DeleteCommand({
    TableName: tableName,
    Key: {
      todoId: todoId,
    },
    ConditionExpression: 'userId = :userId',
    ExpressionAttributeValues: {
      ':userId': userId,
    },
    ReturnValues: 'ALL_OLD',
  })
  const output = await documentClient.send(command)
  console.log(JSON.stringify(output))
  return output
}

エンドポイントの実装

エンドポイントのindex.tsを実装します。

lambda/index.ts
import 'source-map-support/register'
import serverlessExpress from '@vendia/serverless-express'
import { NextFunction } from 'connect'
import express from 'express'
import { verifyAccessToken } from './repositories/auth_repository'
import * as todo_repository from './repositories/todo_repository'

export const app = express()
app.use(express.json())
app.use((req, res, next) => {
  // CORSエラーを解消
  res.header('Access-Control-Allow-Origin', '*')
  res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
  next()
})

// アクセストークンを検証
const verifyToken = async (req: express.Request, res: express.Response, next: NextFunction) => {
  const authorization = req.headers.authorization
  const accessToken = authorization?.split(' ')[1]
  try {
    if (accessToken != null) {
      const userId = await verifyAccessToken(accessToken)
      res.locals.userId = userId
      next()
    } else {
      throw Error('Unauthorized')
    }
  } catch (e) {
    console.error(e)
    return res.status(401).json({ message: e instanceof Error ? e.message : 'Unauthorized' })
  }
  return
}

// 一覧取得
app.get('/todos', verifyToken, async (req, res) => {
  try {
    const userId = res.locals.userId as string
    const createdAt: string | undefined = req.query.created_at as string | undefined
    const result = await todo_repository.fetchTodos(userId, 20, createdAt)
    res.status(200).json(result)
  } catch (e) {
    console.error(e)
    res.status(400).json({ message: e instanceof Error ? e.message : 'error' })
  }
})

// 作成
app.post('/todos/:todoId', verifyToken, async (req, res) => {
  try {
    const todoText: string | undefined = req.body.todoText as string | undefined
    if (!todoText) {
      throw Error('todoText is empty')
    }
    const userId = res.locals.userId as string
    const todoId = req.params.todoId
    const result = await todo_repository.setTodo(todoId, userId, todoText)
    res.status(200).json(result)
  } catch (e) {
    console.error(e)
    res.status(400).json({ message: e instanceof Error ? e.message : 'error' })
  }
})

// 取得
app.get('/todos/:todoId', verifyToken, async (req, res) => {
  try {
    const userId = res.locals.userId as string
    const todoId = req.params.todoId
    const result = await todo_repository.fetchTodo(todoId)
    if (result.Item != null && result.Item?.userId != userId) {
      throw Error('Invalid userId')
    }
    res.status(200).json(result)
  } catch (e) {
    console.error(e)
    res.status(400).json({ message: e instanceof Error ? e.message : 'error' })
  }
})

// 削除
app.delete('/todos/:todoId', verifyToken, async (req, res) => {
  try {
    const userId = res.locals.userId as string
    const todoId = req.params.todoId
    const result = await todo_repository.deleteTodo(todoId, userId)
    res.status(200).json(result)
  } catch (e) {
    console.error(e)
    res.status(400).json({ message: e instanceof Error ? e.message : 'error' })
  }
})

export const handler = serverlessExpress({ app })

ここまで実装できたら一旦デプロイします。

cdk deploy

[おまけ] テストコード

テストで利用するパッケージを導入します。

npm i --save uuid
npm i -D dotenv @types/uuid supertest @types/supertest

環境変数の読み込みとDynamoDBClientConfigのモックを注入する処理を追加します。

.env.local
DYNAMODB_ENDPOINT=https://dynamodb.ap-northeast-1.amazonaws.com # 実際のサーバーで確認する
DYNAMODB_REGION=ap-northeast-1
DYNAMODB_ACCESS_KEY_ID=xxxx # IAMで作ったユーザーのアクセスキー
DYNAMODB_SECRET_ACCESS_KEY=xxxxxxxx # IAMで作ったユーザーの暗号化キー
test/configure.ts
import * as env from 'dotenv'
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'

env.config({ path: '.env.local' })

// DynamoDBClientを設定
jest.mock('../lambda/repositories/dynamo_db_provider', () => {
  const originalModule = jest.requireActual('../lambda/repositories/dynamo_db_provider')
  return {
    ...originalModule,
    getDocumentClient: jest.fn().mockImplementation(() => {
      return new DynamoDBClient({
        endpoint: process.env.DYNAMODB_ENDPOINT,
        region: process.env.DYNAMODB_REGION,
        credentials: {
          accessKeyId: process.env.DYNAMODB_ACCESS_KEY_ID ?? '',
          secretAccessKey: process.env.DYNAMODB_SECRET_ACCESS_KEY ?? '',
        },
      })
    }),
  }
})
index.tsのテスト

indexのテストコードを実装します。エンドポイントにexpressを使っているので、supertestを用いてテストをします。

test/index.test.ts
import request from 'supertest'
import './configure'
import { app } from '../lambda/index'
import * as auth_repository from '../lambda/repositories/auth_repository'

describe('index', (): void => {
  const spyVerifyAccessToken = jest.spyOn(auth_repository, 'verifyAccessToken')
  const userId = 'test_user_id'

  beforeEach(() => {
    // verifyAccessTokenのモックにテスト用のuserIdを返すよう設定
    spyVerifyAccessToken.mockResolvedValue(userId)
  })

  afterEach(() => {
    // Mockをクリア
    spyVerifyAccessToken.mockClear()
  })

  test('TODOデータの作成と削除が成功すること', async () => {
    const todoId = 'testTodoId'

    // 作成
    {
      const res = await request(app)
        .post(`/todos/${todoId}`)
        .send({ todoText: 'todoText' })
        .set('Authorization', 'Bearer XXX')
      console.log(res.body)
      expect(res.status).toBe(200)
      expect(res.body).not.toBeNull()
    }

    // 削除
    {
      const res = await request(app).delete(`/todos/${todoId}`).set('Authorization', 'Bearer XXX')
      console.log(res.body)
      expect(res.status).toBe(200)
      expect(res.body).not.toBeNull()
    }
  })
})

テストを実行します。

npm run test test/index.test.ts

テストを実行した結果はこちら。

> cdk-todo-app@0.1.0 test
> jest test/index.test.ts

 PASS  test/index.test.ts
  index
    ✓ TODOデータの作成と削除が成功すること (156 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.488 s, estimated 3 s
Ran all test suites matching /test\/index.test.ts/i.

テストが成功すると気持ちいいです👏

todo_repository.ts のテスト

todo_repositoryのテストコードを実装します。

test/repositories/todo_repository.test.ts
import '../configure'
import * as todo_repository from '../../lambda/repositories/todo_repository'
import { v4 } from 'uuid'

describe('todo_repository', (): void => {

  afterAll(() => {
    jest.clearAllMocks()
  })

  test('CRUDテスト', async () => {
    const todoId = v4()
    const userId = 'test_user_id'

    // 作成
    {
      const todoText = 'todo'
      await todo_repository.setTodo(todoId, userId, todoText)
      const result = await todo_repository.fetchTodo(todoId)
      expect(result.Item?.todoId as string).toEqual(todoId)
      expect(result.Item?.todoText as string).toEqual(todoText)
    }

    // 更新
    {
      const todoText = 'todoUpdate'
      await todo_repository.updateTodo(todoId, userId, todoText)
      const result = await todo_repository.fetchTodo(todoId)
      expect(result.Item?.todoText as string).toEqual(todoText)
    }

    // 削除
    {
      await todo_repository.deleteTodo(todoId, userId)
      const result = await todo_repository.fetchTodo(todoId)
      expect(result.Item).toBeUndefined()
    }
  })
})

テストを実行します。

npm run test test/repositories/todo_repository.test.ts

テストを実行した結果はこちら。

> cdk-todo-app@0.1.0 test
> jest test/repositories/todo_repository.test.ts

 PASS  test/repositories/todo_repository.test.ts
  todo_repository
    ✓ CRUDテスト (159 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.838 s, estimated 2 s
Ran all test suites matching /test\/repositories\/todo_repository.test.ts/i.

テストが成功すると気持ちいいです👏

ローカル環境で確認

SAMのローカルサーバーを使えば、Lambdaをローカルサーバー内で確認できます。起動にはesbuildとDockerが必要です。

npm i -D esbuild

ローカルサーバーを起動します。

# 実装したStackをtemplate.ymlへ変換
cdk synth --no-staging > template.yml

# ローカルサーバー起動. 起動にはDockerが必要です
sam local start-api

ただし、ローカル環境だと、NodejsFunctionenvironmentCOGNITO_USER_POOL_IDCOGNITO_USER_POOL_CLIENT_IDが実際のCognitoのIDが付与されず動きませんでした。

ローカルで動作確認する際は、AWSコンソールのCognitoから実際のIDを当ててください。

lib/cdk-todo-app-stack.ts
    const lambdaFunction = new NodejsFunction(this, 'todo-app-lambda', {
      entry: 'lambda/index.ts', //lambda 関数のエントリーポイント
      handler: 'handler', // 実行する関数名
      runtime: Runtime.NODEJS_18_X,
      memorySize: 128,
      environment: {
+        // AWSコンソールから取ってきたものを設定
+        COGNITO_USER_POOL_ID: 'ap-northeast-1_XXXX', 
+        COGNITO_USER_POOL_CLIENT_ID: 'b361xxxxxxxxxxxxxxxxxxxxx',
      },
      timeout: cdk.Duration.seconds(30),
    })

そもそもの私のCDK設定が間違っている可能性もあるので、詳しい方教えていただければ幸いです。

Next.jsでフロントエンドを構築

Next.jsでプロジェクトを作成します。質問内容は全てEnterを押して潜り抜けます。

# プロジェクト作成
npx create-next-app
✔ What is your project named? … todo_app_frontend
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias? … No / Yes

ローカル環境で起動して確認します。

# ローカル環境で起動
cd todo_app_frontend
npm run dev

# 別ターミナルで開く
open http://localhost:3000

Next.jsのページが表示されたらOKです。

AWSへアクセスするために必要な環境変数を.env.localに設定してプロジェクトのルートに設置します。

.env.local
NEXT_PUBLIC_COGNITO_USER_POOL_ID=ap-northeast-1_XXXX
NEXT_PUBLIC_COGNITO_USER_POOL_CLIENT_ID=b361xxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_REST_API_BASE_URL=https://xxx.execute-api.ap-northeast-1.amazonaws.com/v1

COGNITO_USER_POOL_IDCOGNITO_USER_POOL_CLIENT_IDはAWSコンソールから確認できます。

NEXT_PUBLIC_REST_API_BASE_URLはCDKでデプロイした際に確認できます。AWSコンソールからも確認できます。

認証機能

CognitoのAPIを利用するためにaws-amplifyを導入します。

npm install --save aws-amplify

メールアドレスとパスワードでサインアップできるようにします。 流れとして以下の通りです。

  1. メールアドレスとパスワードを使ってサインアップする
  2. メールアドレスに認証コードが届くので、認証コード画面で認証コードをCognitoへ送る
  3. 認証コードの認可後、アクセストークンをローカルストレージに保存する

2.のタイミングでアクセストークンを取得するためにはaws-amplifyHub.listen('auth')を使い認証状態の変化を監視します。

理由として、認証コードが認可された後にアクセストークンを取得できる手段がこれしかなかったためです。Hub以外だとコードが認可された後にサインインを実施しないとトークンが取得できないのでとてもめんどくさいです。

アクセストークンはLambdaのAPIリクエスト時に利用します。

まずは、アクセストークンをローカルストレージに保存するためのラッパーを実装します。

src/app/(repositories)/local_storage_repository.ts
'use client'

export const localStorageKey = {
  accessToken: 'accessToken',
}

export const getValue = (key: keyof typeof localStorageKey) => {
  return window.localStorage.getItem(key)
}

export const setValue = (key: keyof typeof localStorageKey, value: string) => {
  window.localStorage.setItem(key, value)
}

export const removeValue = (key: keyof typeof localStorageKey) => {
  window.localStorage.removeItem(key)
}

認証状態を常に監視する形にするのでシングルトンな状態で監視する人を作ります。また、ついでにメールアドレスをアプリ全体に共有できるようにしたいので、useContextを使います。

src/app/(providers)/auth_user_provider.tsx
'use client'

import React, { useEffect, useState } from 'react'
import { CognitoUser } from 'amazon-cognito-identity-js'
import { Amplify, Hub } from 'aws-amplify'
import * as local_storage_repository from '../(repositories)/local_storage_repository'

// TODO このタイミングで良い?
Amplify.configure({
  Auth: {
    userPoolId: process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID ?? '',
    userPoolWebClientId: process.env.NEXT_PUBLIC_COGNITO_USER_POOL_CLIENT_ID ?? '',
  },
})

type AuthUser = {
  email: string | null
}

const AuthUserContext = React.createContext<
  [AuthUser | null, React.Dispatch<React.SetStateAction<AuthUser | null>>] | undefined
>(undefined)

export const AuthUserProvider = ({ children }: { children: React.ReactNode }) => {
  const [authUser, setAuthUser] = useState<AuthUser | null>(null)

  useEffect(() => {
    Hub.listen('auth', ({ payload }) => {
      const { event } = payload
      console.log('onListenAuthChangeEvent', event, payload.data)

      // サインイン後のデータをセット
      const setSignInData = (data: any) => {
        // メールアドレスをセット
        const email = data.attributes?.email as string | undefined
        console.log('email', email)
        setAuthUser({ email: email ?? null })

        // アクセストークンをローカルストレージに保存
        const cognitoUser: CognitoUser = data as CognitoUser
        const session = cognitoUser.getSignInUserSession()
        const accessToken = session?.getAccessToken().getJwtToken()
        if (!accessToken) {
          console.log('accessToken is empty')
          return
        }
        local_storage_repository.setValue('accessToken', accessToken)
        console.log('saved accessToken')
      }

      // イベントごとに処理
      if (event === 'signUp') {
        // メールアドレスをセット
        const cognitoUser = payload.data?.user as CognitoUser | undefined
        if (cognitoUser) {
          const email = cognitoUser.getUsername()
          console.log('email', email)
          setAuthUser({ email: email })
        }
      } else if (event === 'signIn') {
        setSignInData(payload.data)
      } else if (event === 'autoSignIn') {
        setSignInData(payload.data)
      } else if (event === 'signOut') {
        local_storage_repository.removeValue('accessToken')
      }
    })
  }, [])

  return <AuthUserContext.Provider value={[authUser, setAuthUser]}>{children}</AuthUserContext.Provider>
}

export const useAuthUser = () => {
  const context = React.useContext(AuthUserContext)
  if (context === undefined) {
    throw new Error('useAuthUser must be used within a AuthUserProvider')
  }
  return context
}

アプリのトップにAuthUserProviderをセットします。

src/app/layout.tsx
import './globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { AuthUserProvider } from './(providers)/auth_user_provider'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
+      <body className={inter.className}>
+        <AuthUserProvider>{children}</AuthUserProvider>
+      </body>
    </html>
  )
}

Cognitoのラッパーも実装します。

src/app/(repositories)/auth_repository.ts
import { CognitoUser } from 'amazon-cognito-identity-js'
import { Auth } from 'aws-amplify'

export const signUpWithPassword = async (username: string, email: string, password: string) => {
  const result = await Auth.signUp({
    username: username,
    password: password,
    attributes: {
      email: email,
    },
    autoSignIn: {
      enabled: true,
    },
  })
  return result
}

export const confirmRegistration = async (username: string, code: string) => {
  const result = await Auth.confirmSignUp(username, code)
  return result
}

export const resendConfirmationCode = async (username: string) => {
  const result = await Auth.resendSignUp(username)
  return result
}

export const signInWithPassword = async (username: string, password: string) => {
  const result = await Auth.signIn({
    username: username,
    password: password,
  })
  return result as CognitoUser
}

export const signOut = async () => {
  const result = await Auth.signOut()
  return result
}

サインアップ画面の実装

src/app/sign_up/page.tsx
'use client'

import { useRef } from 'react'
import { signUpWithPassword } from '../(repositories)/auth_repository'
import { useRouter } from 'next/navigation'

const Page = () => {
  const refEmail = useRef<HTMLInputElement>(null)
  const refPassword = useRef<HTMLInputElement>(null)
  const router = useRouter()

  const onSubmit = async () => {
    if (!refEmail.current?.value) {
      console.log('email is Empty')
      return
    }
    if (!refPassword.current?.value) {
      console.log('password is Empty')
      return
    }

    await signUpWithPassword(refEmail.current.value, refEmail.current.value, refPassword.current.value)
    router.push(`/confirm_email_code`)
  }

  return (
    <div className="bg-grey-lighter min-h-screen flex flex-col">
      <div className="container max-w-sm mx-auto flex-1 flex flex-col items-center justify-center px-2">
        <div className="bg-white px-6 py-8 rounded shadow-md text-black w-full">
          <h1 className="mb-8 text-3xl text-center">Sign up</h1>

          <input
            type="text"
            defaultValue=""
            ref={refEmail}
            className="block border border-grey-light w-full p-3 rounded mb-4"
            name="email"
            placeholder="email"
          />

          <input
            type="password"
            defaultValue=""
            ref={refPassword}
            className="block border border-grey-light w-full p-3 rounded mb-4"
            name="password"
            placeholder="password"
          />

          <button
            type="submit"
            onClick={onSubmit}
            className="w-full text-center py-3 rounded bg-blue-500 text-white hover:bg-green-dark focus:outline-none my-1"
          >
            Create Account
          </button>
        </div>
      </div>
    </div>
  )
}

export default Page

サインアップすると認証コード確認画面へ遷移します。

認証コード確認画面の実装

src/app/confirm_email_code/page.tsx
'use client'

import { useRef } from 'react'
import { confirmRegistration, resendConfirmationCode } from '../(repositories)/auth_repository'
import { useRouter } from 'next/navigation'
import { useAuthUser } from '../(providers)/auth_user_provider'

const Page = () => {
  const refCode = useRef<HTMLInputElement>(null)
  const [authUser] = useAuthUser()
  const username = authUser?.email
  const router = useRouter()

  const onSubmit = async () => {
    if (!refCode.current?.value) {
      console.log('code is Empty')
      return
    }
    if (!username) {
      console.log('username is Empty')
      return
    }

    await confirmRegistration(username, refCode.current.value)
    // Hub.listenの通知が来てアクセストークンがローカルに保存されるまで少し待つ(保証はされないが)
    await new Promise((resolve) => setTimeout(resolve, 1500))
    router.push('/todo')
  }

  const onResend = async () => {
    if (!username) {
      console.log('username is Empty')
      return
    }

    await resendConfirmationCode(username)
    alert('Send code')
  }

  return (
    <div className="bg-grey-lighter min-h-screen flex flex-col">
      <div className="container max-w-sm mx-auto flex-1 flex flex-col items-center justify-center px-2">
        <div className="bg-white px-6 py-8 rounded shadow-md text-black w-full">
          <h1 className="mb-8 text-3xl text-center">Confirm Email Code</h1>

          <input
            type="text"
            ref={refCode}
            className="block border border-grey-light w-full p-3 rounded mb-4"
            placeholder="code"
          />

          <button
            type="submit"
            onClick={onSubmit}
            className="w-full text-center py-3 rounded bg-blue-500 text-white hover:bg-green-dark focus:outline-none my-1"
          >
            OK
          </button>
        </div>
        <button className="my-4 text-blue-500 hover:text-blue-700" onClick={onResend}>
          Resend code
        </button>
      </div>
    </div>
  )
}

export default Page

認証コードを入力して送信するとTODO画面の実装へ遷移します。

サインイン画面の実装

アクセストークンの有効期限が切れたら再ログインしなければいけないので、サインイン画面も実装します。

src/app/sign_in/page.tsx
'use client'

import { useRef } from 'react'
import { signInWithPassword } from '../(repositories)/auth_repository'
import { useRouter } from 'next/navigation'

const Page = () => {
  const refUsername = useRef<HTMLInputElement>(null)
  const refPassword = useRef<HTMLInputElement>(null)
  const router = useRouter()

  const onSubmit = async () => {
    if (!refUsername.current?.value) {
      console.log('username is Empty')
      return
    }
    if (!refPassword.current?.value) {
      console.log('password is Empty')
      return
    }

    await signInWithPassword(refUsername.current.value, refPassword.current.value)
    router.push('/todo')
  }

  return (
    <div className="bg-grey-lighter min-h-screen flex flex-col">
      <div className="container max-w-sm mx-auto flex-1 flex flex-col items-center justify-center px-2">
        <div className="bg-white px-6 py-8 rounded shadow-md text-black w-full">
          <h1 className="mb-8 text-3xl text-center">Sign in</h1>

          <input
            type="text"
            defaultValue=""
            ref={refUsername}
            className="block border border-grey-light w-full p-3 rounded mb-4"
            name="username"
            pattern="^[0-9a-zA-Z]+$"
            placeholder="email"
          />

          <input
            type="password"
            defaultValue=""
            ref={refPassword}
            className="block border border-grey-light w-full p-3 rounded mb-4"
            name="password"
            placeholder="password"
          />

          <button
            type="submit"
            onClick={onSubmit}
            className="w-full text-center py-3 rounded bg-blue-500 text-white hover:bg-green-dark focus:outline-none my-1"
          >
            Sign In
          </button>
        </div>
      </div>
    </div>
  )
}

export default Page

TODO機能

データのCRUD処理をします。データをLambda API経由でDynamoDBに保存します。

DynamoDBのプライマリキーをtodoIdにしているので、これをuuidを使って生成します。

uuidを導入します。

npm i --save uuid
npm i -D @types/uuid

次に、APIのラッパーを作ります。

src/app/(repositories)/todo_repository.ts
const baseUrl = process.env.NEXT_PUBLIC_REST_API_BASE_URL

export type QueryResponseType<T, U> = {
  Count: number
  Items: T[]
  LastEvaluatedKey?: U
}

export type GetResponseType<T> = {
  Item?: T
}

export type TodoType = {
  todoId: string
  userId: string
  todoText: string
  createdAt: string
  updatedAt: string
}

export type TodoLastEvaluatedKey = {
  userId: string
  todoId: string
  createdAt: string
}

export const fetchTodoList = async (
  accessToken: string,
  lastEvaluatedKey: TodoLastEvaluatedKey | undefined = undefined,
) => {
  // Pagination用のlastEvaluatedKeyのuserIdは、accessTokenから取得できるので付与しない
  const url =
    `${baseUrl}/todos` +
    (lastEvaluatedKey != null ? `?todo_id=${lastEvaluatedKey.todoId}&created_at=${lastEvaluatedKey.createdAt}` : '')

  const bearerToken = `Bearer ${accessToken}`
  const res = await fetch(url, {
    method: 'GET',
    headers: {
      Authorization: bearerToken,
    },
  })
  const body = await res.json()
  console.log(res.status, body)
  if (res.status >= 200 && res.status < 300) {
    return body as QueryResponseType<TodoType, TodoLastEvaluatedKey>
  } else {
    throw Error(body.message as unknown as string)
  }
}

export const fetchTodo = async (accessToken: string, todoId: string) => {
  const url = `${baseUrl}/todos/${todoId}`
  const bearerToken = `Bearer ${accessToken}`
  const res = await fetch(url, {
    method: 'GET',
    headers: {
      Authorization: bearerToken,
    },
  })
  const body = await res.json()
  console.log(res.status, body)
  if (res.status >= 200 && res.status < 300) {
    return body as GetResponseType<TodoType>
  } else {
    throw Error(body.message as unknown as string)
  }
}

export const postTodo = async (accessToken: string, todoId: string, todoText: string) => {
  const url = `${baseUrl}/todos/${todoId}`
  const bearerToken = `Bearer ${accessToken}`
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      Authorization: bearerToken,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ todoText: todoText }),
  })
  const body = await res.json()
  console.log(res.status, body)
  if (res.status >= 200 && res.status < 300) {
    return res.status
  } else {
    throw Error(body.message as unknown as string)
  }
}

export const deleteTodo = async (accessToken: string, todoId: string) => {
  const url = `${baseUrl}/todos/${todoId}`
  const bearerToken = `Bearer ${accessToken}`
  const res = await fetch(url, {
    method: 'DELETE',
    headers: {
      Authorization: bearerToken,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ todoId: todoId }),
  })
  const body = await res.json()
  console.log(res.status, body)
  if (res.status >= 200 && res.status < 300) {
    return res.status
  } else {
    throw Error(body.message as unknown as string)
  }
}

TODO画面の実装

TODO画面を実装します。

src/app/todo/page.tsx
'use client'

import * as todo_repository from '../(repositories)/todo_repository'
import { TodoType, TodoLastEvaluatedKey } from '../(repositories)/todo_repository'
import * as local_storage_repository from '../(repositories)/local_storage_repository'

import { useEffect, useState } from 'react'
import * as uuid from 'uuid'

const Page = () => {
  const [todoText, setTodoText] = useState<string>('')
  const [todoTypeList, setTodoTypeList] = useState<TodoType[]>([])
  const [todoLastEvaluatedKey, setTodoLastEvaluatedKey] = useState<TodoLastEvaluatedKey | undefined>()
  const [count, setCount] = useState<number>(0)

  useEffect(() => {
    onFetch()
  }, [])

  // 追加
  const onPost = async () => {
    try {
      const accessToken = local_storage_repository.getValue('accessToken')
      if (!accessToken) {
        throw Error('accessToken is empty')
      }
      const todoId = uuid.v4()
      const result = await todo_repository.postTodo(accessToken, todoId, todoText)
      console.log(result)
      // 新しいデータ取得してリストに追加
      if (result === 200) {
        const result = await todo_repository.fetchTodo(accessToken, todoId)
        if (result.Item) {
          setCount(count + 1)
          setTodoTypeList([result.Item, ...todoTypeList])
          setTodoText('')
        }
      }
    } catch (e) {
      alert(e instanceof Error ? e.message : 'Error')
    }
  }

  // 削除
  const onDelete = async (todoId: string) => {
    try {
      const accessToken = local_storage_repository.getValue('accessToken')
      if (!accessToken) {
        throw Error('accessToken is empty')
      }
      const result = await todo_repository.deleteTodo(accessToken, todoId)
      if (result === 200) {
        setCount(Math.max(count - 1, 0))
        setTodoTypeList(todoTypeList.filter((e) => e.todoId != todoId))
      }
    } catch (e) {
      alert(e instanceof Error ? e.message : 'Error')
    }
  }

  // リストを取得
  const onFetch = async () => {
    try {
      const accessToken = local_storage_repository.getValue('accessToken')
      if (!accessToken) {
        throw Error('accessToken is empty')
      }
      const result = await todo_repository.fetchTodoList(accessToken)
      setCount(result.Count)
      setTodoTypeList(result.Items)
      setTodoLastEvaluatedKey(result.LastEvaluatedKey)
    } catch (e) {
      alert(e instanceof Error ? e.message : 'Error')
    }
  }

  // Paginationでリストを取得
  const onFetchMore = async () => {
    try {
      const accessToken = local_storage_repository.getValue('accessToken')
      if (!accessToken) {
        throw Error('accessToken is empty')
      }
      if (!todoLastEvaluatedKey) {
        throw Error('todoLastEvaluatedKey is empty')
      }
      const result = await todo_repository.fetchTodoList(accessToken, todoLastEvaluatedKey)
      setCount(count + result.Count)
      setTodoTypeList([...todoTypeList, ...result.Items])
      setTodoLastEvaluatedKey(result.LastEvaluatedKey)
    } catch (e) {
      alert(e instanceof Error ? e.message : 'Error')
    }
  }

  return (
    <div className="m-4">
      <p>TODO count: {count}</p>
      {/* 入力フォーム */}
      <div>
        <input
          className="block border border-grey-light w-1/3 p-3 rounded mt-2 mb-4 text-black bg-white"
          id="todoId"
          value={todoText}
          onChange={(e) => {
            setTodoText(e.target.value)
          }}
          type="text"
          placeholder="todo"
        />
        <div className="flex">
          <button className="rounded-full py-2 px-4 text-white bg-blue-500 hover:bg-blue-700" onClick={onPost}>
            Save
          </button>
          <button className="rounded-full ml-4 py-2 px-4 text-white bg-blue-500 hover:bg-blue-700" onClick={onFetch}>
            Fetch List
          </button>
        </div>
      </div>
      {/* TODOリスト */}
      <div className="mt-4">
        {todoTypeList.map((e) => {
          return (
            <div className="my-4" key={e.todoId}>
              <div className="flex items-center mb-4">
                <button
                  className="rounded-md mr-2 py-2 px-2 text-white bg-red-500 hover:bg-red-700"
                  onClick={() => {
                    onDelete(e.todoId)
                  }}
                >
                  Delete
                </button>
                <label className="ml-2 text-gray-900 dark:text-gray-300">
                  {e.todoText}, {e.createdAt}
                </label>
              </div>
              <hr className="h-px bg-gray-200 border-0 dark:bg-gray-700" />
            </div>
          )
        })}
      </div>
      {/* Pagination */}
      <button className="rounded-full ml-4 py-2 px-4 text-white bg-blue-500 hover:bg-blue-700" onClick={onFetchMore}>
        Fetch More
      </button>
    </div>
  )
}

export default Page

トップから今まで作った画面へ遷移できるようにしましょう。ついでにサインアウトボタンも設置。

src/app/page.tsx
'use client'

import Link from 'next/link'
import { signOut } from './(repositories)/auth_repository'

export default function Home() {
  return (
    <main className="p-24">
      <Link href="/sign_up" className="underline">
        <p>SignUp Page</p>
      </Link>
      <Link href="/sign_in" className="underline">
        <p>SignIn Page</p>
      </Link>
      <Link href="/todo" className="underline">
        <p>Todo Page</p>
      </Link>
      <button
        className="rounded-full py-2 px-4 my-4 text-white bg-blue-500 hover:bg-blue-700"
        onClick={async () => {
          await signOut()
          alert('サインアウトしました')
        }}
      >
        Sign Out
      </button>
    </main>
  )
}

フロントエンドの実装は以上です。GitHubにプッシュしましょう。

Amplify Hostingにデプロイする

CDKでAmplify Hostingの設定をします。gitHubTokenにGitHubのトークンと、gitHubRepositoryUrlにGitHubのリポジトリURLを設定してください。

GitHubのPersonal Tokenが必要です。GitHubトークンの管理はこちらの記事を参考にしてください。
AWS CDK で NextJS 13 のアプリケーションを Amplify にデプロイする

lib/cdk-todo-app-stack.ts
...

import { CfnApp, CfnBranch } from 'aws-cdk-lib/aws-amplify'
import { BuildSpec } from 'aws-cdk-lib/aws-codebuild'

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

    /**
     * Amplify Hosting
     */
    const gitHubToken = '' // GitHubのPersonal Token (AWS Secret Manager経由で設定推奨)
    const gitHubRepositoryUrl = 'https://github.com/never-inc/todo_app_frontend' // フロントエンドのリポジトリURL

    const amplifyApp = new CfnApp(this, 'todo-app-frontend', {
      name: 'todo-app-frontend',
      oauthToken: gitHubToken,
      repository: gitHubRepositoryUrl,
      enableBranchAutoDeletion: true,
      buildSpec: BuildSpec.fromObjectToYaml({
        version: 1,
        frontend: {
          phases: {
            preBuild: {
              commands: ['npm ci'],
            },
            build: {
              commands: ['npm run build'],
            },
          },
          artifacts: {
            baseDirectory: '.next',
            files: ['**/*'],
          },
          cache: {
            paths: ['node_modules/**/*'],
          },
        },
      }).toBuildSpec(),
      platform: 'WEB_COMPUTE', // SSRをする場合に必要
      customRules: [
        {
          source: '/<*>',
          target: '/index.html',
          status: '404-200',
        },
      ]
    })

    new CfnBranch(this, 'main-branch', {
      appId: amplifyApp.attrAppId,
      branchName: 'main', // ブランチ名
      enableAutoBuild: true,
      enablePerformanceMode: false,
      enablePullRequestPreview: false,
      stage: 'PRODUCTION',
      framework: 'Next.js - SSR',
      environmentVariables: [
        // フロントエンドからCognitoを利用するために必要
        {
          name: 'NEXT_PUBLIC_COGNITO_USER_POOL_ID',
          value: userPool.userPoolId,
        },
        {
          name: 'NEXT_PUBLIC_COGNITO_USER_POOL_CLIENT_ID',
          value: userPoolClient.userPoolClientId,
        },
        // Lambdaが設定されたAPI GatewayへアクセスするためのURL
        {
          name: 'NEXT_PUBLIC_REST_API_BASE_URL',
          value: restApi.url,
        },
      ],
    })
  }
}

Secrets ManagerでGitHubのPersonal Tokenを管理する場合は、以下のように設定します。

スクリーンショット 2024-02-03 10.22.02.png

スクリーンショット 2024-02-03 10.22.39.png

lib/cdk-todo-app-stack.ts
...
import { Secret } from 'aws-cdk-lib/aws-secretsmanager'

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

    // シークレットのARNを設定
    const secret = Secret.fromSecretAttributes(this, 'secret-todo-app', {
      secretCompleteArn: 'arn:aws:secretsmanager:ap-northeast-1:xxx:secret:yyy', 
    })

    const gitHubToken = secret.secretValueFromJson('gitHubToken').unsafeUnwrap()
    ...
}

デプロイしてAmplifyを構築します。

cdk deploy

AWSコンソールでAmplifyを確認します。

スクリーンショット 2023-09-20 19.40.17.png

初回はなぜかビルド実行がされないので、手動で実行します。それ以降は、GitHubのmainブランチにプッシュされると自動的にビルドが走り、新しいアプリとしてデプロイしてくれます。

スクリーンショット 2023-09-20 19.45.08.png

↑の左に表示されているURLをタップすると公開されたアプリをブラウザで確認できます👏

Next.js 14の場合はNode.js 18.17以上にする

Next.js 14の場合、Amplify Hostingの構築イメージをNode.js 18.17以上にしないとビルドに失敗します。

Amplify Hostingからアプリの設定ビルドの設定Build image settings構築イメージpublic.ecr.aws/docker/library/node:18.19に設定します。

28a6beeca8e4-20240203.png

ソースコード

まとめ

Next.js 13とAWS CDK + Amplify Hosting + Cognito + DynamoDB + Lambdaを使ってTODOアプリを作りました。

CDK周りのまとまったドキュメントがなかったので手探りで色々と試して構築しました。

Next.js 13もApp RouterやTailwindなど標準で搭載されており、まぁ覚えることが多いですね笑

間違っていたりもっとこうした方が良いよといったコメントお待ちしております。

24
32
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
32