株式会社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 |
セキュリティ的にLSI
でtodoId
をHASH
、userId
をRANGE
にしても良かったかも。
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
を確認します。
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の設定
メールアドレスとパスワードで認証できるようにします。
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も一緒に設定します。
...
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の設定します。
...
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
を追加
{
"compilerOptions": {
...
+ "esModuleInterop": true,
"typeRoots": ["./node_modules/@types"]
},
"exclude": ["node_modules", "cdk.out"]
}
プロジェクト直下にlambda
ディレクトリとその中にindex.ts
を作成します。index.ts
は後で実装するので空のままで大丈夫です。
Cognitoの実装
Cognitoのアクセストークンから、CognitoのuserIdを取得できるラッパーを作ります。
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テーブルにアクセスするラッパーを作ります。
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))
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
を実装します。
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のモックを注入する処理を追加します。
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で作ったユーザーの暗号化キー
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
を用いてテストをします。
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
のテストコードを実装します。
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
ただし、ローカル環境だと、NodejsFunction
のenvironment
のCOGNITO_USER_POOL_ID
とCOGNITO_USER_POOL_CLIENT_ID
が実際のCognitoのIDが付与されず動きませんでした。
ローカルで動作確認する際は、AWSコンソールのCognitoから実際のIDを当ててください。
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
に設定してプロジェクトのルートに設置します。
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_ID
とCOGNITO_USER_POOL_CLIENT_ID
はAWSコンソールから確認できます。
![](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F183031%2F790e4701-6977-84fd-351d-cef70f9827d0.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=bb9932436d17d35c40e582698a860d9e)
![](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F183031%2Ff7364d5b-c058-1aa5-6608-0a0127d22e90.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=4ccc5f2b6031ef00f7597367351a5ed4)
NEXT_PUBLIC_REST_API_BASE_URL
はCDKでデプロイした際に確認できます。AWSコンソールからも確認できます。
認証機能
CognitoのAPIを利用するためにaws-amplify
を導入します。
npm install --save aws-amplify
メールアドレスとパスワードでサインアップできるようにします。 流れとして以下の通りです。
- メールアドレスとパスワードを使ってサインアップする
- メールアドレスに認証コードが届くので、認証コード画面で認証コードをCognitoへ送る
- 認証コードの認可後、アクセストークンをローカルストレージに保存する
2.のタイミングでアクセストークンを取得するためにはaws-amplify
のHub.listen('auth')
を使い認証状態の変化を監視します。
理由として、認証コードが認可された後にアクセストークンを取得できる手段がこれしかなかったためです。Hub以外だとコードが認可された後にサインインを実施しないとトークンが取得できないのでとてもめんどくさいです。
アクセストークンはLambdaのAPIリクエスト時に利用します。
まずは、アクセストークンをローカルストレージに保存するためのラッパーを実装します。
'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
を使います。
'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
をセットします。
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のラッパーも実装します。
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
}
サインアップ画面の実装
![](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F183031%2F640f1204-26dd-1b56-c16f-5c7038816225.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=1175dded5257097af6cb2751cbe52e50)
'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
サインアップすると認証コード確認画面へ遷移します。
認証コード確認画面の実装
![](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F183031%2F03ffdc18-d3b5-8fff-901d-ee5332ef6d6e.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=dca9b43e05ec1704c67c9ffc339bae34)
'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画面の実装へ遷移します。
サインイン画面の実装
アクセストークンの有効期限が切れたら再ログインしなければいけないので、サインイン画面も実装します。
![](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F183031%2F4b7bf24d-df46-7689-e711-bc8598afbe61.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=e318b7dbf038768c8592955b9391fe80)
'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のラッパーを作ります。
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画面を実装します。
![](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F183031%2F4d1125e4-429e-97e6-702e-abfa4c0711ba.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=2935bbad9e3b0c29a02d1b0af7b42e55)
'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
トップから今まで作った画面へ遷移できるようにしましょう。ついでにサインアウトボタンも設置。
'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 にデプロイする
...
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を管理する場合は、以下のように設定します。
...
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を確認します。
初回はなぜかビルド実行がされないので、手動で実行します。それ以降は、GitHubのmainブランチにプッシュされると自動的にビルドが走り、新しいアプリとしてデプロイしてくれます。
↑の左に表示されている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
に設定します。
ソースコード
- バックエンド
- フロントエンド
まとめ
Next.js 13とAWS CDK + Amplify Hosting + Cognito + DynamoDB + Lambdaを使ってTODOアプリを作りました。
CDK周りのまとまったドキュメントがなかったので手探りで色々と試して構築しました。
Next.js 13もApp RouterやTailwindなど標準で搭載されており、まぁ覚えることが多いですね笑
間違っていたりもっとこうした方が良いよといったコメントお待ちしております。