はじめに
明けましておめでとうございます! yu-Matsuです!
今年も頑張ってアウトプットしていきたいと思いますので、よろしくお願いします。
今回は、React + CloudFront + APIGateway でWebアプリを作成、ホスティングする方法をおさらいする機会があったため、備忘録として記事にしたいと思います。
色々詰め込んでいるのでかなり長い記事になっています。
出来るだけ細かく章分けしているつもりですので、「もう知ってるよ」という内容がある場合は必要な情報だけ拾っていってもらえればと思います!
今回作成するWebアプリケーションのイメージ
今回作成するもののイメージは以下のようになります。
- CloudFront + S3でWebアプリケーションをホスティング
- Cognitoで認証機能を設ける
-
API Gateway + Lambdaでフロントエンドから実行するAPIを実装
- DynamoDBからデータを取得するAPI
- Bedrockとやり取りするAPI
- Cognitoをオーソライザーとして設定し、認証を通していないユーザーはAPIを実行できないようにする
フロントエンドのホスティングといえば、カジュアルに扱えるものとしてAWS Amplilfyを利用する場合が多いと思います。しかし、プロジェクトの制約等でAmplifyがNGの場合も結構あり、そういった場合にCloudFront + S3構成が選択肢に挙がります。
この記事が、CloudFront + S3構成でホスティングが必要になった際のサンプル、マニュアルになれば幸いです!
作成してみよう
1. Reactプロジェクトの作成
最初にReactのプロジェクトを作成しましょう!
React開発ツールとして以前は「create-react-app」が利用されていたのですが、今では非推奨になっています。 今回は、代替ツールとしてよく利用されている「Vite」を利用します。
まずは、作業用のディレクトリを作成します。「frontend」というディレクトリを作成して、移動します。
% mkdir frontend && cd frontend
次に、以下のコマンドを実行し、Vite + Rreact のプロジェクト作成を開始します。
% npm create vite@latest
すると、以下のようにプロジェクト作成が始まります。
以下のメッセージに関しては「y」で大丈夫です。
「Project name」は、お好きなプロジェクト名を入力します。こちらがプロジェクトのルートディレクトリ名になります。
フレームワークの選択は「React」を選択。
言語に関しては、今回は簡単のために「JavaScript」を選択。(本来はTypeScriptを選ぶことが多いかと思います。)
一通り設定が終わると、ViteによるReactプロジェクトが作成されます。
作業ディレクトリ(今回だとfrontendディレクトリ)に以下のようなディレクトリが作成されていれば、無事完了です!
早速動かしてみましょう。
以下のコマンド群を実行します。
% npm install
% npm run dev
すると、以下のようにローカルサーバーが起動しますので、「Local:〜」 に表示されているURLをブラウザで開きます。(例だとhttp://localhost:5173)
以下のような画面が開かれたらOKです!
2. 一旦CloudFront + S3でホスティングしてみる
先ほどはローカルサーバーでアプリケーションが動くことを確認したので、今度はCloudFront + S3でホスティングしてみたいと思います。
2.1. S3バケットの作成
WebアプリケーションのオリジンとなるS3バケットを作成します。
S3のコンソールの「バケットを作成」から新規バケットを作成します。
バケット名は完全に一意である必要があるため、注意が必要です。
その他の設定はそのままで作成で大丈夫です。
2.2. CloudFrontディストリビューションの作成
次に、CloudFrontディストリビューションを作成していきます。
以降の作業は東京リージョンで実施します。
CloudFrontのコンソールを開き、「ディストリビューションを作成」から新規ディストリビューションを作成します。
作成画面で、まず「オリジン」として先ほど作成したS3バケットを選択します。オリジン名はそのままでも大丈夫です。
「オリジンアクセス」は「Origin access control settings」を選択し、「Create new OAC」から新規でOrigin access controlを作成します。
OACの作成モーダルが開くので、特に何も変えずに「Create」します。
ディストリビューション作成画面に戻るので、「Origin access control」で作成したOACを選択します。「S3バケットポリシーを更新する必要があります」というメッセージが出ますが、後で対応するので一旦無視します。
「デフォルトのキャッシュビヘイビア」、「関数の関連付け」、「ウェブアプリケーションファイアウォール (WAF) 」の設定は特に変更なし、「設定」欄の「デフォルトルートオブジェクト」に「index.html」と入力して、「ディストリビューションの作成」を押下します。
ディストリビューションの作成が完了したら、以下のようなメッセージが表示されます。
ここで改めてバケットポリシーの更新を促されるので、「ポリシーをコピー」からPolicy statementをコピーします。
オリジンとして設定したS3のバケットポリシーを更新します。バケット詳細画面を開き、「アクセス許可」タブの「バケットポリシー」から更新します。
先ほどコピーしたポリシーステートメントを貼り付けて、保存します。
これでCloudFrontディストリビューションの作成は完了です!
2.3. WebアプリケーションをS3にアップロード
CloudFrontの準備は出来たので、WebアプリケーションをオリジンのS3バケットにアップロードします。
以下のコマンドを実行して、Reactプロジェクトをビルドしましょう
npm run build
以下のような感じになれば、ビルド成功です!
プロジェクト直下に「dist」というディレクトリが出来ていることも確認しておきます。
ビルドが完了したら、distディレクトリの中身をS3バケットにアップロードします。
これで、ホスティングの一連の流れが完了です!
2.4. 動作確認
では、動作確認をしてみましょう。
CloudFront詳細画面の「ディストリビューションドメイン名」をコピーして、ブラウザで開いてみます。
このような感じでローカルサーバーを建てた際と同じようなページが開かれたら成功です!
3. Cognitoでの認証機構を追加
これでWebアプリケーション(単なるウェルカムページですが...)のホスティングができました。次は認証機構を追加したいと思います。
3.1. Cognitoユーザープールの作成
まずは、Cognitoのユーザープールを作成します。
Cognitoのコンソールのユーザープール一覧から、「ユーザープールを作成」を押下します。
すると、アプケーションの設定画面が開きますので、「アプリケーションを定義」で「シングルページアプリケーション(SPA)」を選択、「アプリケーションに名前を付ける」には適当な名前を入力します。
「オプションの設定」では、サインイン属性を選択できます。今回は「メールアドレス」のみ選択します。
「サインアップのための必須属性」ではサインイン属性として選んだ属性のうち、サインアップ時に必ずユーザーに求める属性を選択します。今回は「email」を選択します。
注目ポイント: サインイン/サインアップの属性に関する設定は慎重に!
サインイン/サインアップの属性の設定は、ユーザープールの作成後は変更することが出来ません。もし変更が必要な場合は再作成する必要があるため、慎重に設定を行いましょう。
「リターンURL」はサインイン後に戻るコールバック先のページのURLになります。今回は1ページのみなので、CloudFrontのディストリビューションドメインを指定します。
ここまで入力したら、ページ下部の「作成」を押下してユーザープールを作成します!
3.2. フロントエンドで認証
ユーザープール作成が完了したら、Quick Setupガイドなるものが表示されます。今はこんなのあるんですね!!
今回はReactでアプリケーションを作成しているので、「React」を選択します。すると、Cognitoでログイン機能を実装するためのサンプルコードを提示してくれます。
手順①の設定は一旦触らずに、手順②から進めます。
oidc-client-tsとreact-oidc-contextというライブラリが必要になるので、手順にあるコマンドを実行します。
npm install oidc-client-ts react-oidc-context --save
注目ポイント: Cognitoから提供されるマネージドサインイン画面
Reactから利用できるCognitoの認証UIといえば、「ui-react」が代表的なライブラリですが、Cognitoのアップデートにより上記の新しいUIが提供されています。こちらは後述する認証用Contextも提供してくれているので、個人的には使い勝手が良さそうに感じます。
インストールが完了したら、手順③にあるサンプルコードを現在のプロジェクトに反映していきます。サンプルに「index.js」とありますが、今回のプロジェクトだと対応するファイルは「main.jsx」になります。
サンプルコードを反映させたmain.jsxが以下になります。
Appコンポーネントを 「AuthProvider」 でラップするイメージです。
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { AuthProvider } from "react-oidc-context";
import './index.css'
import App from './App.jsx'
const cognitoAuthConfig = {
authority: "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_ユーザープールID",
client_id: "アプリケーションクライアントID",
redirect_uri: "http://localhost:5173",
response_type: "code",
scope: "phone openid email",
};
createRoot(document.getElementById('root')).render(
<StrictMode>
<AuthProvider {...cognitoAuthConfig}>
<App />
</AuthProvider>
</StrictMode>
)
次は、手順④のサンプルコードです。こちらは今回のプロジェクトだと「App.jsx」に対応しています。
「useAuth」からCognitoの認証情報を取得することができます。main.jsxで追加したAuthProviderはContextであるため、Appコンポーネント配下のコンポーネントは全てuseAuthで認証情報を取得できます。
今回は試しにサインインしたユーザーのメールアドレス、IDトークン、アクセストークン、Refreshトークンを表示してみます。
import { useAuth } from "react-oidc-context";
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const auth = useAuth();
if (auth.isAuthenticated) {
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div>
<pre> Hello: {auth.user?.profile.email} </pre>
<pre> ID Token: {auth.user?.id_token} </pre>
<pre> Access Token: {auth.user?.access_token} </pre>
<pre> Refresh Token: {auth.user?.refresh_token} </pre>
<button onClick={() => auth.removeUser()}>Sign out</button>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
return (
<div>
<button onClick={() => auth.signinRedirect()}>Sign in</button>
</div>
);
}
export default App
それでは、フロントエンドのコードの修正が完了したので、2.3.と同じ手順でコードをビルドし、S3バケットにアップロードしましょう。
3.3. 動作確認
S3バケットにアップロード後、直ぐにWebアプリケーションにアクセスしても、キャッシュが残っていて変更が反映されていない場合があります。
なので、CloudFrontの「キャッシュ削除」からキャッシュを削除しましょう。
キャッシュ削除タブを開き、削除ボタンを押下すると、以下のようなオブジェクトパスを入力する画面に移ります。ここに「/*」と入力し、「キャッシュ削除の作成」を押下してキャッシュを削除します。
削除完了したら、動作確認です!
2.4.と同様に、ブラウザからCloudFrontのディストリビューションドメインにアクセスします。
「Sign in」ボタンだけのページが表示されたら成功です。
Sing inボタンを押下すると、Cognitoから提供されているサインイン画面が開きます。
まだユーザーがないので、今回は「Create an account」を押下します。
サインアップ画面に遷移するので、必要な情報を入力していきます。
パスワードを入力する際に、ちゃんとバリデーションもかかっています!素晴らしい!
必要な情報を入力し「Sign up」ボタンを押下すると、確認コードが指定したメールアドレスに送信されます。
届いた確認コードを入力し、「Confirm account」を押下すると、アプリケーションが開きます!
ほとんどぼかしていますが、各種トークンが取得でき、ページに表示できていることが分かります!
4. APIの実装
実際のWebアプリケーションは、ページ上から何らかのAPIを実行できることがほとんどかと思います。(データベースから情報を取ってきたり、何かフォームに入力して送信したり...)
このステップではAPI Gateway、Lambdaを利用して、アプリケーションから実行できるAPIを実装します。
4.1. APIの処理を担うLambdaの作成
APIを実装する前に、APIの処理を担うLambdaを先に作成します。
4.1.1. データベースからデータを取得する処理の実装
まずは、DynamoDBからデータを取得してくるLambdaです。
DynamoDBのテーブルは以下のようなものを作成します。(作成手順は省略します)
- テーブル名:react-frontend
- パーティションキー: id(文字列)
Lambdaは以下のようなものを作成します。(こちらも作成手順は省略します)
- Lambda名:get_sentence
- ライタイム:Node.js 22x
- アーキテクチャ: x86_64
- 実行ロール: 「基本的なLambdaアクセス権限で新しいロールを作成」
作成後、Lambdaの詳細画面の「コード」タブから以下のようなコードを入力します。
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
const ddb = new DynamoDBClient();
export const handler = async (event) => {
const input = {
TableName: "react-frontend",
Key: {
"id": { S: "1" },
}
};
const command = new GetItemCommand(input);
const data = await ddb.send(command);
const response = {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
},
statusCode: 200,
body: JSON.stringify('Welcome to React World!'),
};
return response;
};
注目ポイント: Lambdaのreturn値について
内容としては、DynamoDBのAWS SDKを利用して先ほど作成したテーブルからデータを取得するだけの単純なものになりますが、APIの処理を実現するLambdaの場合は、returnする内容に以下のようなheadersを含めることが重要です!
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
また、このLambdaはDynamoDBを参照するため、Lambda作成時に自動的に作成されたIAMロールのポリシーを以下のように修正しておきます。
"Statement": [
{
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:ap-northeast-1:アカウントID:*"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:ap-northeast-1:アカウントID:log-group:/aws/lambda/get_sentence:*"
]
},
{
"Sid": "Statement1",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem"
],
"Resource": [
"*"
]
}
]
4.1.2. 生成AIモデルとやり取りする処理の実装
もう一つ、Bedrockを利用して生成AIモデルとやり取りするLambdaを実装します。
Lambda実装の前にBedrockのコンソールにて以下のモデルを有効化しておいてください。
- Claude 3.5 Sonnet
Bedrockのモデル有効化が終わったら、get_sentenceと同様に以下のようなLambdaを作成します。
- Lambda名:post_bedrock
- ライタイム:Node.js 22x
- アーキテクチャ: x86_64
- 実行ロール: 「基本的なLambdaアクセス権限で新しいロールを作成」
Lambda作成後、「コード」タブから以下のようなコードを入力します。
import {
BedrockRuntimeClient,
InvokeModelCommand,
} from "@aws-sdk/client-bedrock-runtime";
const MODEL_ID = "anthropic.claude-3-5-sonnet-20240620-v1:0";
export const handler = async (event) => {
const body = JSON.parse(event.body);
const client = new BedrockRuntimeClient();
const payload = {
anthropic_version: "bedrock-2023-05-31",
max_tokens: 1000,
messages: [{ role: "user", content: [{ type: "text", text: body["text"]}] }],
};
const apiResponse = await client.send(
new InvokeModelCommand({
contentType: "application/json",
body: JSON.stringify(payload),
modelId: MODEL_ID,
}),
);
const decodedResponseBody = new TextDecoder().decode(apiResponse.body);
const responseBody = JSON.parse(decodedResponseBody);
const responses = responseBody.content;
const response = {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Headers" : "Content-Type",
"Access-Control-Allow-Origin": "*"
},
statusCode: 200,
body: JSON.stringify(responses[0]["text"]),
};
return response;
};
こちらも、Lambdaに渡された「event」に含まれる内容をBedrockのモデルへの入力とし、そのレスポンスを返すだけの単純な処理です。get_sentenceと同様に、returnの内容にheadersを含めることがキモになります。
このLambdaはBedrockにアクセスするため、ポリシーを以下のように修正してください。
"Statement": [
{
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:ap-northeast-1:アカウントID:*"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:ap-northeast-1:アカウントID:log-group:/aws/lambda/post_bedrock:*"
]
},
{
"Sid": "Statement1",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel"
],
"Resource": [
"*"
]
}
]
また、Bedrockからのレスポンスを得るまでに少し時間がかかるため、「設定」タブからLambdaのタイムアウト時間を1分に変更しておきます。
4.2. API Gatewayの作成
裏の処理が実装できましたので、APIを実装していきます!
4.2.1. データベースからデータを取得するAPIの実装
まずはデータベースからデータを取得するAPIの実装から。
API Gatewayのコンソールから「APIの作成」を押下し、作成画面を開きます。
今回はREST APIを作成するので、「REST API」の「構築」を押下します。
REST APIの作成画面に遷移します。「API名」にAPI名を入力し(記事では例として「frontend-api」)、「エンドポイントタイプ」は「リージョン」で「APIを作成」を押下し、APIを作成します。
APIの作成が完了したら、以下のようなリソースの画面に遷移しますので、「リソースの作成」を押下します。
リソース作成画面で、リソースパスに「/」、リソース名には「sentence」と入力します。こちらがAPI実行時のパス名になります。また、「CORS(クロスオリジンリソース共有)」に忘れずにチェックをつけて、「リソースを作成」します。
注目ポイント: CORSにチェックを忘れずに!
このCORSにチェックをつけることがポイントになります。これを忘れると、API実行時にCORSエラーが発生してしまいます。
CORSの解説をしようとすると、それだけで一つの記事になってしまうので、詳しく知りたい方は以下の記事をご参考ください。
リソースの作成ができたら、「メソッドを作成」しましょう。
メソッド作成画面では、以下のように設定します。
- ①メソッドタイプは「GET」を選択します
- ②統合タイプは裏の処理をLambdaで実装しているので「Lambda関数」を選択します
- ③「Lambdaプロキシ統合」にチェックをつけます
- ④Lambda関数は先ほど作成した「get_sentence」を選択します
以上でGETメソッドの作成は完了です!
作成したメソッドの動作確認をしてみましょう。
メソッド詳細の「テスト」タブを開きます。
「テストメソッド」が開くので、今回は特に何も入力せずに「テスト」を押下してテスト実行します。
以下のように、データベースに登録した値である「Welcome to React World!」が帰ってくれば成功です!
これで一つAPIが作成できました!
次はBedrockとやり取りするAPIを作っていきます!
4.2.2. 生成AIモデルとやり取りするAPIの実装
リソース画面にて新しく「リソースを作成」を開きます。
リソースパスに「/」、リソース名には「message」と入力します。CORSにチェックを入れるのを忘れずに!
メソッドの作成に移ります。今回はメソッドタイプに「POST」を選択します。統合タイプは同様に「Lambda関数」を選択し、プロキシ統合にもチェック。Lambda関数には「post_bedrock」を選択し、メソッドを作成します。
同様にテストしてみましょう。
今回はBedrockへの入力をリクエストパラメータとしてAPIに渡すため、「リクエスト本文」に以下のようなパラメータを設定し、テスト実行します。
{
"text": "こんにちは!"
}
以下のようにBedrockからのレスポンスが返ってきたら成功です!
4.3. APIのデプロイ
リソースの作成が一通り完了したので、APIをステージにデプロイしましょう。
リソース画面の右上の「APIをデプロイ」を押下します。
デプロイモーダルが開くので、「ステージ」に「新しいステージ」、「ステージ名」に「api」と入力し、「デプロイ」します。
デプロイが完了したら、ステージ画面に遷移します。「URLを呼び出す」に表示されているURLにリソースパスを付けてアクセスすると、APIを実行出来るようになります。
注目ポイント: APIのデプロイは忘れずに!
コンソール上でAPIを作成/変更した際は、必ずこのデプロイを忘れないようにしましょう。デプロイしないと編集した内容が反映されません。(慣れないうちはデプロイを忘れて「あれ?」となることが多いです...)
これでAPIの作成が一通り完了したのですが、せっかくアプリケーションにCognitoで認証をかけるので、APIもCognitoでサインインしたユーザーのみ実行出来るようにしたいと思います!
4.4. オーソライザーの設定
APIの認証は、左メニューの「オーソライザー」から設定できます。
「オーソライザー」を開くと一覧画面が開くので、「オーソライザーを作成」していきます。
オーソライザー作成画面で、以下の設定を行い、作成します。
- ①オーソライザー名は「cognito-authorizer」と入力
- ②オーソライザーのタイプは「Cognito」を選択
- ③Cognitoユーザープールは3.1.で作成したものを選択
- ④トークンのソースは「Authorization」と入力
オーソライザーの作成が完了しました!
それではオーソライザーをAPIに設定していきましょう。
リソースパス「/sentence」のGETメソッドにおける「メソッドリクエスト」を編集します。
「メソッドリクエストの設定」欄の「認可」で先ほど作成したオーソライザーを選択して、その他設定は触らずに保存します。これで、「GET /sentence」はCongito認証を通した場合のみ実行出来るようになりました。
動作確認として、curlコマンドを使って認証情報を含めずにAPIを実行してみましょう。
curl -X GET https://APIのID.execute-api.ap-northeast-1.amazonaws.com/api/sentence
実行してみると、以下のように「Unauthorized」が返ってくるので、認証されていないアクセスは弾かれていることがわかります。
% curl -X GET https://APIのID.execute-api.ap-northeast-1.amazonaws.com/api/sentence
> {"message":"Unauthorized"}
次に、認証情報を含めてAPIを実行してみます。
認証情報として、Cognito認証が通った後に発行されるIDトークンをリクエストヘッダー(Authorizationヘッダー)に含めることになるので、まずはIDトークンを取得します。以下のコマンドを実行してください。(ユーザーは3.3.で作成したものを利用します。)
aws cognito-idp admin-initiate-auth --user-pool-id CognitoユーザープールID --client-id アプリクライアントID --auth-flow ADMIN_NO_SRP_AUTH --auth-parameters USERNAME=ユーザ名,PASSWORD=パスワード
実行すると以下のような結果が返ってくるので、その中のIDトークンを利用します。
{
"ChallengeParameters": {},
"AuthenticationResult": {
"AccessToken": "xxxxxx",
"ExpiresIn": 3600,
"TokenType": "Bearer",
"RefreshToken": "xxxxxx",
"IdToken": "xxxxxx"
}
}
IDトークンをヘッダーに含めた場合のAPI実行コマンドは以下のようになりますので、実行してみます。
curl -X GET -H "Authorization: Bearer IDトークン" \
https://APIのID.execute-api.ap-northeast-1.amazonaws.com/api/sentence
認証を通しているので、APIの実行に成功しました!
% curl -X GET -H "Authorization: Bearer IDトークン" \
https://APIのID.execute-api.ap-northeast-1.amazonaws.com/api/sentence
> "Welcome to React World!"
注目ポイント: OPTIONメソッドにオーソライザーをつけるべき?
OPTIONメソッドにオーソライザーと付けていませんが、忘れているわけではありません。OPTIONメソッドにオーソライザーをつけると、認証を通していても401エラーになってしまいます。
詳しくは省略しますが、OPTIONメソッドはブラウザが実際のリクエスト(今回はGET)を行う前に自動的に行うプリフライトリクエストであり、Authorizationヘッダーが含まれないからです。認証情報がないリクエストなのに、オーソライザーが設定されているので401エラーになってしまう、というわけです。
「/sentence」のオーソライザー設定が完了しましたので、「/message」の設定も同様に実施します。(手順は省略)
設定/デプロイが完了したら、以下のcurlコマンドを実行して動作確認しましょう。
% curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer IDトークン" -d '{"text": "こんにちは!"}' \
https://APIのID.execute-api.ap-northeast-1.amazonaws.com/api/message
> "こんにちは!お元気ですか?何かお手伝いできることはありますか?ご質問やお話したいトピックがあれば、お気軽におっしゃってください。"
こちらも問題なさそうですね!
以上で、APIのオーソライザー設定が完了です!
5. フロントエンドからAPIを実行できるようにする
それではいよいよ作成したAPIをフロントエンドに組み込んでいきましょう!
5.1. フロントエンドの構成の修正
組み込みにあたり、ついでにフロントエンドの構成も以下のように変更してみます。
react-project/
├── src/
│ ├── assets/
│ │ └── react.svg
│ ├── components/
│ │ ├── css/
│ │ │ └── header.css
│ │ ├── ProtectRoute.jsx
│ │ ├── Header.jsx
│ │ └── Layout.jsx
│ ├── pages/
│ │ ├── HomePage.jsx
│ │ └── Login.jsx
│ ├── services/
│ │ └── api.js
│ ├── utils/
│ │ └── AuthToken.js
│ ├── App.css
│ ├── App.jsx
│ ├── index.css
│ └── main.jsx
├── .env
└── .env.production
-
components: ページ間で共通のコンポーネントをここに作成していきます
- ProtectRoute.jsx: 認証情報が有効かどうかをチェックするコンポーネント
- Header.jsx: ページ上部に表示するヘッダーのコンポーネント
- Layout.jsx: ヘッダーなどをページ間で共通して表示するコンポーネントを配置
-
pages: ページコンポーネント
- HomePage.jsx: ホームページ
- Signin.jsx: サインインページ
-
services:
- api.js: APIの呼び出し処理をまとめている
-
utils:
- AuthToken.js: Cognitoの認証情報をセッションストレージから取得する関数
- .env、.env.production: 環境変数ファイル。詳細は後述
今回の改修でページが複数になりルーティングが必要になるので、react-router-domのインストールしておきます。
npm install react-router-dom
5.2. 簡単なコンポーネントの解説(ホームページを除く)
main.jsxは以下のように変更しています。
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { AuthProvider } from "react-oidc-context";
import './index.css'
import App from './App.jsx'
export const cognitoAuthConfig = {
authority: import.meta.env.VITE_COGNITO_AUTHORITY,
client_id: import.meta.env.VITE_COGNITO_CLIENT_ID,
redirect_uri: import.meta.env.VITE_COGNITO_REDIRECT_URI,
response_type: "code",
scope: "phone openid email",
loadUserInfo: true,
};
createRoot(document.getElementById('root')).render(
<StrictMode>
<AuthProvider {...cognitoAuthConfig}>
<App />
</AuthProvider>
</StrictMode>,
)
cognitoAuthConfigのパラメータにloadUserInfoを追加しています。こちらを有効にすることで、認証情報がブラウザのセッションストレージに保持されます。
また、authority、client_id、redirect_uriは環境変数に切り出すことにしました。環境変数のファイルは以下になります。
# Viteを利用している場合、変数名の先頭に「VITE_」をつける必要がある
VITE_API_ENDPOINT="https://APIのID.execute-api.ap-northeast-1.amazonaws.com/api"
VITE_COGNITO_AUTHORITY="https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_CognitoのユーザープールID"
VITE_COGNITO_CLIENT_ID="CognitoのアプリケーションクライアントID"
#開発中はローカル実行する想定
VITE_COGNITO_REDIRECT_URI="http://localhost:5173/home"
# Viteを利用している場合、変数名の先頭に「VITE_」をつける必要がある
VITE_API_ENDPOINT="https://APIのID.execute-api.ap-northeast-1.amazonaws.com/api"
VITE_COGNITO_AUTHORITY="https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_CognitoのユーザープールID"
VITE_COGNITO_CLIENT_ID="CognitoのアプリケーションクライアントID"
VITE_COGNITO_REDIRECT_URI="https://CloudFrontのディストリビューションドメイン/home"
redirect_uriを3.2.の時点から変更して「/home」にリダイレクトするようにしています。
また、ローカルで動作確認しながら開発できるように、環境変数を「.env」と「.env.production」に分けています。
-
.env
- 環境変数の定義ファイル。npm run dev などでローカル実行する際に読み込まれる
-
.env.production
- こちらはビルドする際に読み込まれる。(ビルド時に.envよりも優先されるイメージ)
App.jsxのコードは以下のように変更しています。
サインインページ、ホームページ、ページ間共通のパーツはコンポーネント化しているので、App.jsxではそれらのルーティングの定義がメインとなっています。
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Signin } from './pages/Signin';
import { HomePage } from './pages/HomePage';
import { Layout } from './components/Layout';
import './App.css'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/signin" element={<Signin />} />
<Route
path="/*"
element={
<Layout>
<Routes>
<Route path="/home" element={
<ProtectedRoute>
<HomePage />
</ProtectedRoute>
} />
</Routes>
</Layout>
}
/>
<Route path="/" element={<Navigate to="/home" replace />} />
</Routes>
</BrowserRouter>
);
}
サインインページのコードは以下になります。
useAuthを利用して認証情報を取得し、サインイン済みであればホームページに遷移、未サインインであればSign inボタンが表示されます。Sign inボタンを押下すると、auth.signinRedirectが呼び出され、Cognitoから提供されているサインイン画面にリダイレクトされます。
import { Navigate } from 'react-router-dom';
import { useAuth } from "react-oidc-context";
export function Signin() {
const auth = useAuth();
if (auth.isLoading) return <div>Loading...</div>;
if (auth.isAuthenticated) {
return <Navigate to="/home" replace />;
}
return (
<div>
<h1>ログインページ</h1>
<button onClick={() => auth.signinRedirect()}>Sign in</button>
</div>
);
}
ProjectedRoute.jsxは、ページを開く際にユーザーの認証が有効かどうかをチェックし、有効でなければサインページに遷移するようなコンポーネントになっています。
import { Navigate } from 'react-router-dom';
import { useAuth } from "react-oidc-context";
export function ProtectedRoute({ children }) {
const auth = useAuth();
if (auth.isLoading) {
return <div>Loading...</div>;
}
if (!auth.isAuthenticated) {
return <Navigate to="/signin" replace />;
}
return children;
}
Header.jsxはページ間で共通するコンポーネントとなっており、画面上部に表示されるヘッダーを実装しています。アプリケーションのタイトル表示だけだと味気ないので、サインアウトボタンも追加しています。
import { Navigate } from 'react-router-dom';
import { useAuth } from "react-oidc-context";
import "./css/header.css";
export function Header() {
const auth = useAuth();
const signOutButton = () => {
auth.removeUser();
return <Navigate to="/login" replace />;
};
return (
<div className="header-area">
<h2 className="header-text">Reactの勉強</h2>
<button className="header-button" onClick={signOutButton}>
ログアウト
</button>
</div>
)
}
// header.css(別ファイル)
.header-area {
background-color:gainsboro;
height: 50px;
display: flex;
justify-content: space-between;
}
.header-text {
font-size: 40px;
margin: 0 auto;
margin-inline-start: 20px;
font-weight: bold;
font-family: "Mochiy Pop One";
width: 400px;
}
.header-button {
transform: scale(0.8);
}
Layout.jsxは、上記のヘッダーのような画面をまたいで表示されるコンポーネントを配置するためのコンポーネントになります。
import { Header } from "./Header";
export function Layout({ children }) {
return (
<>
<Header />
{children}
</>
);
}
App.jsx内で以下のように利用します。もしホームページ以外の画面を追加する場合は、Routeコンポーネントを追加していく感じになります。
<Layout>
<Routes>
<Route path="/home" element={
<ProtectedRoute>
<HomePage />
</ProtectedRoute>
} />
{/* <Route path="/xxxx" element={
<ProtectedRoute>
<xxxxx />
</ProtectedRoute>
} /> */}
{/* .... */}
</Routes>
</Layout>
ホームページ周りを除いたコンポーネントの簡単な解説は以上です。
5.3. ホームページからAPIを実行できるようにしてみる
コンポーネントからAPIを実行出来るようにするための、APIの呼び出し処理をapi.jsにまとめています。
import { getAuthToken } from '../utils/AuthToken';
/*
* データベースからデータを取得するAPIを実行
* @return {String} 取得したデータ
*/
export const getSentence = async () => {
// セッションストレージから認証情報を取得
const auth = getAuthToken()
// 認証情報をヘッダーに含め、APIを実行
const res = await fetch(
`${import.meta.env.VITE_API_ENDPOINT}/sentence`,
{
method: 'GET',
headers: {
'Authorization': 'Bearer '+ auth.token,
'Content-Type': 'application/json',
}
});
const data = await res.json()
return data
}
/*
* Bedrockとやり取りするAPIを実行
* @param {String} ユーザーの入力
* @return {String} Bedrockのレスポンス
*/
export const postMessage = async (text) => {
// セッションストレージから認証情報を取得
const auth = getAuthToken()
// 認証情報をヘッダーに含め、APIを実行
const res = await fetch(
`${import.meta.env.VITE_API_ENDPOINT}/message`,
{
method: 'POST',
headers: {
'Authorization': 'Bearer '+ auth.token,
'Content-Type': 'application/json',
},
body: JSON.stringify({
'text': text
})
});
const data = await res.json()
return data
}
- getSentence: 4.2.1.で実装した、データベースからデータを取得するAPIを実行
-
postMessage: 4.2.2.で実装した、Bedrockとやり取りするAPIを実行
リクエストする際に、後述するgetAuthTokenを利用して認証情報を取得し、ヘッダーに含めています。
AuthToken.jsで、ブラウザのセッションストレージに格納されているユーザーの認証情報を取得して返すgetAuthTokenを実装しています。今回は利用していませんが、トークンと合わせて有効期限も返します。
import { cognitoAuthConfig } from "../main.jsx";
/*
* セッションストレージからユーザーの認証情報を取得する
* @return {token: String, expiredAt: String} ユーザーの認証情報
*/
export function getAuthToken() {
// セッションストレージから
const oidcData = sessionStorage.getItem(
`oidc.user:${cognitoAuthConfig.authority}:${cognitoAuthConfig.client_id}`,
);
if (!oidcData) {
return null;
}
const authInfo = JSON.parse(oidcData);
return {
token: authInfo.id_token, // アクセストークン
expiredAt: authInfo.expires_at, // 有効期限
};
};
上記を踏まえて、API呼び出しを出来るようにしたホームページのコードが以下になります。
import { getSentence, postMessage } from '../services/api';
import { useAuth } from "react-oidc-context";
import { Navigate } from 'react-router-dom';
import { useState, useEffect } from 'react'
export function HomePage() {
const auth = useAuth();
const [sentence, setSentence] = useState("");
const [text, setText] = useState("");
const [messages, setMessages] = useState([]);
// ページが読み込まれたタイミングでgetSentenceを実行する
useEffect(() => {
getSentence().then(sentence => setSentence(sentence))
},[]);
if (auth.isLoading) return <div>Loading...</div>;
// 認証が切れていたらサインインページにリダイレクトする
if (!auth.isAuthenticated) {
return <Navigate to="/signin" replace />;
}
// useAuthで取得した情報からユーザー名(メールアドレス)を取り出す
const userName = auth.user?.profile.email
// postMessageを実行する
// Bedrockとのやりとりに置いて、ユーザーの入力とBedrockからのレスポンスをリアルタイムに画面に表示するように、状態を更新する
const post = async () => {
setMessages(prevResults => [...prevResults, {actor: "user", content: text}])
const response = await postMessage(text);
setMessages(prevResults => [...prevResults, {actor: "ai", content: response}])
}
return (
<>
<div>
<h2>ようこそ {userName} さん</h2>
<p>{sentence}</p>
{messages.map((item) => {
return (
<div>
{ item.actor == "user" ? "あなた" : "AI"} : {item.content}
</div>
);
})}
</div>
<textarea
cols={100}
rows={5}
placeholder="何か入力"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<div>
<button onClick={post}>
POST
</button>
</div>
</>
);
}
api.jsで定義したAPIを然るべきタイミング実行し、その結果を表示しています。
- ページ読み込み時にuseEffectによりgetSentenceを実行。その結果をページ上に表示している
- テキストフィールドにユーザーが何かしら入力し、POSTボタンを押下したタイミングで、postMessageが実行される。ユーザーとBedrockのやりとりを状態で管理しており、POSTボタン押下時にユーザーの入力が、API実行完了時にBedrockからのレスポンスが表示されるようになっている。
これで、APIを実行できるフロントエンドの実装が完了です!
5.4. 動作確認
それでは動作確認をしてみましょう!
まずは実装したフロントエンドのコードをビルドします。
npm run build
前述の通り、ビルドの際は環境変数として**.env.production**が読み取られるので、特にコードとしては意識する必要はありませんが、ローカル実行しつつ開発していた場合は、Cognitoの「マネージドログインページの設定」のコールバックURLを戻すことを忘れないようにしましょう!
ビルドが完了したら、成果物を2.3.の手順と同様にS3バケットにアップロードし、3.3.と同様にCloudFrontのキャッシュを削除しましょう。これで準備完了です!
CloudFrontのディストリビューションドメインにブラウザからアクセスすると、以下のようなサインインページが表示されます。
「Sign in」ボタンを押下すると、Cognitoのマネージドサインインページが開きますので、サインインします。(3.3.の時に作成したユーザーでサインインしてください。)
サインインに成功すると、ホームページが開きます!
下画像赤枠の部分に「Welcome to React World!」と表示されているので、getSentenceが問題なく実行できていることがわかります。
次は、Bedrockとやり取りしてみましょう。
テキストエリアに「2025年をエンジニアとして良い年にするために、どのように過ごしたら良いですか?」と入力し、「POST」ボタンを押下します
下画像のようにBedrockからレスポンスが返ってきたら成功です!
無難な回答をしてくれているような気がします!
かなり長くなりましたが、これでReactによるWebアプリケーションの実装と、そのホスティングが完了しました!
Next Action
ここまで簡単なWebアプリケーションのホスティングまで出来ましたが、より良いものにしていくためのNext Actionとして以下のような対応が挙げられます。
WAFでのアクセス制御
今回はURLさえ知っていれば誰でも利用できるアプリケーションの想定ですが、アクセスを制限する場合もあるかと思います。その際に、CloudFrontやAPIGatewayにWAF:ウェブアプリケーションファイアウォールを設定してアクセスを制御します。
また、開発中は開発者のみアクセスできるようにWAFで制限をかけ、商用リリース時にWAFを外す、といったような使い方も出来ます。
WAFの設定はAWSコンソール上から簡単に出来ますので、以下の公式ドキュメントを参考にしてみてください。
-
WAFの利用方法:
https://docs.aws.amazon.com/waf/latest/developerguide/web-acl.html -
CloudFrontにWAFを適用する方法:
https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/WAF-one-click.html
独自ドメインの取得
現状ではCloudFrontのディストリビューション作成時に自動生成されるURLでアプリケーションにアクセスできるようになっていますが、もちろん独自ドメインを取得し、それをURLとして利用することも可能です。
AWSのサービスであるRoute53と**AWS Certificate Manager (ACM)**を利用します。
- Route53: AWSが提供するドメインネームサービス
- ACM: AWSが提供するSSL/TLS証明書の発行、管理、および自動化を行うフルマネージドサービス
諸々の設定方法に関しては本記事では省略しますが、以下の記事が参考になります。
フロントエンドのスタイリング
本記事ではフロントエンドの見た目を整えるスタイリングをほとんどしていません。本来はcssを利用してスタイリングを行ったり、Material UIのようなUIコンポーネントライブラリを利用することが多いです。以下のような様々な手法がありますので、自分の実現したい画面デザインに合ったものを選択しましょう。
- スタイリング
- UIフレームワーク
CI/CD(フロントエンド/インフラ)の整備
本記事では、CloudFrontなどのリソースをAWSのコンソールから手動で作成していましたが、これらはコード化(IaC: Infrastructure as Code)して管理することができます。IaCツールの一つであるTerraformの例だと、
- 作成したいAWSリソースの定義のコードをTerraformで作成
- Terraformのデプロイコマンドを実行することにより、リソースがAWSにデプロイされる
のような流れでリソースを管理、作成できるというワケです。
また、上記のIaCと組み合わせて、コードをGitHub等のリポジトリにpushやマージした際に、自動的に上記のようなデプロイコマンドやビルド/テストコマンドを実行させる、といったようなCI/CDの整備も是非やっておきたいところです。
- IaCのツール
- CI/CD
最後に
今回は久しぶりにフロントエンドを触る機会がありましたので、AWS上でWebアプリケーションを展開する方法についてのおさらいしてみました。
APIGatewayのCORS周りなどのハマりポイントなど、初見だとハマるポイントも色々解説出来たかと思いますので、これからWebアプリケーションを作成してみようと考えている方の参考になると幸いです。
Webホスティングの方法はCloudFront+S3以外にも、冒頭でさらっと触れたAWS AmplifyやApp Runnerなど色々ありますので、色々比較してみて実装したいWebアプリケーションにあったものを選択するのが良いかと思います。
本記事はこれで以上になります。ご高覧ありがとうございました!
Appendix
Congnitoのマネージドサインインページのデザイン変更
今回利用しているCognitoのマネージドサインインページですが、実はデザインを変更することが出来ます。
Cognitoユーザープールのアプリケーションクライアントページの「ログインページ」を開くと、「マネージドログインのスタイル」がありますので、ここから編集画面を開きます。
プレビュー画面の右上の「ブランディングデザイナーを起動」から色々いじることが出来ます。
また、「コンポーネント」タブを開くと、コンポーネント単位でもデザインの編集ができるようです。
例えば、「ページの背景」だと、サインインページの背景画像をお好みのものに変えることが出来ます。下画像では、実際に背景を変更しています。
最終的に、背景画像とボタンのスタイルを変更、あとReactのロゴを表示するようにしてみました!
編集が完了したら、画像では見切れていますが、「変更を保存」することで、反映されます。
実際にアプリケーションにアクセスしてみると、変更が反映されていることが確認できました!