認証機能付きReactサイトを手軽に作りたかったのですが、都度バックエンドを構築するのが面倒で諦めてました。
しかし、AWS Amplifyを使えば、手軽にバックエンドを構築できるのではと試してみました。
一通り欲しかった機能は試したので、再利用可能なように、ざっくり作り方をメモしておこうと思います。
公開サイトじゃないので、凝ったものではなく、必要最小限の下記機能を盛り込んだものになります。
- ホスティング
- Basic認証
- 認証
- 認証者によるDynamoDBアクセス
- 認証者によるLambdaアクセス
ドキュメント
ドキュメントが古いこともあるので、コマンドのヘルプも参照する。
amplify --help amplify [command] --help
Amplify CLIインストール
Amplify CLIを使って作業するので、まずインストールと初期設定をする。
- AWS IAMでAmplify用のユーザーを作成する
- ユーザーは管理者権限を付与する必要がある
- AWS CLIのプロファイルにAmplify用ユーザーを追加する
- AWS CLIのプロファイル設定は別記事参照
- Amplify CLIインストール
npm install -g @aws-amplify/cli
- Amplify CLI初期設定
amplify configure
プロジェクト作成
- Reactアプリ作成
npx create-react-app my-app --template typescript
- Amplifyプロジェクト作成
- Reactディレクトリ内でコマンドを実行
amplify
ディレクトリが作られ、Amplify関連のファイルは全てこのディレクトリ配下に作成される
amplify init
- 作成済みプロジェクトの設定変更
amplify configure project
- プロジェクト削除
- 依存関係で削除に失敗する時がある。その場合、最終手段としてAmplify Consoleから削除する
amplify delete
補足説明
- プロジェクトには必ず1つアプリが必要
- 1つのプロジェクトに複数のReactサイトを作ることはできない(1プロジェクトにつきホスティングは1つのみのため)
- アプリのルートディレクトリと、プロジェクトのルートディレクトリは同じでなければいけない
ホスティング
- ホスティング追加
- Amplify専用のホスティングと、CloudFrontとS3を使ったホスティングを選択できるが、Amplify専用のホスティングを選択する
- Amplify専用のホスティングの詳細は隠蔽されていて、ユーザーがいじることはできない
amplify hosting add
- 追加したバックエンド(ホスティング)を、実際のAWSサービスにデプロイ
amplify push
- Reactの
build
ディレクトリ以下のサイトコンテンツをホスティングにアップロード- 裏で
amplify push
もやってくれる
- 裏で
amplify publish
- ホスティング先のURLが表示されるので、そのURLからサイトにアクセスできるようになる。
- ホスティングにBasic認証をかける
- Amplify Console
- [アプリの設定]-[アクセスコントロール]-[アクセスの管理]
- Amplify Console
Amplifyのコマンド説明
backend
amplify [category] add
- 各種バックエンド設定をローカルに追加
amplify status
- ローカルのバックエンド設定の状態を表示
amplify push
- ローカルに追加したバックエンド設定を元に、実際のAWSサービスにバックエンドをデプロイする。
- ローカルのバックエンド設定内容は、S3にデータとしてpushされる
- 常にS3のデータと、現在実際にAWSにデプロイされているバックエンドの状態が一致するようになっている
amplify [category] remove
- ローカルに追加した各種バックエンド設定を削除する
- ローカルで削除したバックエンド設定を、実際のAWSサービスにデプロイしたバックエンドからも削除するには
amplify push
する
amplify pull
- ローカルのバックエンド設定を、実際のAWSに構築されたバックエンドの状態で上書き
env
dev・prodなど、バックエンドを環境に分けて構築することができる。
amplify env add
- 新しい環境を追加
amplify env checkout [env_name]
- 環境切り替え
amplify env list
- 環境一覧
amplify env remove [env_name]
- 環境削除
認証
- 認証追加
amplify auth import
で、Cognitoを新規作成せず、既存のCognitoを使うこともできる。既存のCognitoを使う場合でも、環境によってCognitoを変えることができる
amplify auth add
- Reactコード
- ログインユーザーしかサイトアクセスできないようにする
<AmplifyAuthenticator>
だけで使用すると、「Loading...」が出て止まることがあるので、ログイン状態を見て表示出し分けする
import React, { useEffect, useState } from 'react'; import Amplify, { Auth } from 'aws-amplify'; import { AmplifyAuthenticator, AmplifySignOut } from "@aws-amplify/ui-react"; import awsconfig from './aws-exports'; Amplify.configure(awsconfig); function App() { const [username, setUsername] = useState<any>(); useEffect(() => { Auth.currentAuthenticatedUser().then(user => { setUsername(user.username); }).catch(err => { }); }, []); if (!username) { return (<AmplifyAuthenticator />); } return ( <div> <div>{username}</div> <AmplifySignOut /> </div> ); } export default App;
GraphQL
AmplifyはREST APIも使えるが、GraphQLからLambda Functionも呼び出せるので、サーバーへのアクセスはGraphQLに集約する。
GraphQLをハブに「DynamoDB」「Lambda」「S3」等バックエンドにアクセスできる。
- GraphQL追加
- 認証はCognitoを使用する。すると、GraphQLへのアクセスはログインユーザーしか行えなくできる
amplify api add
- スキーマ編集
amplify/backend/api/[api_name]/schema.graphql
を編集する
- スキーマ反映
amplify api gql-compile
補足説明
- ここでいうGraphQLとはAppSyncのこと
- AppSyncとは、GraphQLのクエリを受け取って、DynamoDB、Lambda、S3など、各種AWSサービスを呼び出すゲートウェイサービス
amplify/backend/api/[api_name]/schema.graphql
はテンプレートとなるスキーマamplify api gql-compile
コマンドで、テンプレートから実際のGraphQLスキーマを生成するamplify/backend/api/[api_name]/build/schema.graphql
がテンプレートから生成された実際のGraphQLスキーマ- GraphQLのクエリを書く時は、このスキーマを見ながら書くことになる
DynamoDB
GraphQLのスキーマを書くだけで、GraphQLのクエリで、DynamoDBのテーブルのデータの読み書きができるようになる。(便利!)
- スキーマ編集
amplify/backend/api/[api_name]/schema.graphql
を編集
スキーマ書き方(基本)
シンプル
type Todo @model { id: ID! name: String! description: String }
@model
でDynamoDBにテーブルが作成される- プライマリーキーとして
id
カラムは必須。省略しても自動で付与される- プライマリーソートキーはなし
- 作成日時
createdAt
と更新日時updatedAt
カラムは自動付与される - 誰でも作成・編集・削除ができる
サブスクリプションなし
type Todo @model(subscriptions: null) { id: ID! name: String! description: String }
- サブスクリプションのスキーマを生成しない
スキーマ書き方(所有者)
所有者しかRead・Edit・Deleteできなくする
type Todo @model @auth(rules: [{ allow: owner }]) { id: ID! name: String! description: String }
@auth
をつけると、所有者情報(owner
)カラムが自動付与され、アクセス制御ができるようになるallow
で対象者を指定し、operations
でその対象者しかできない行為を指定するoperations
で指定されなかった行為は他の人もできるようになるoperations
を指定しないと、全ての行為が対象となる。つまり、allow
で指定した人しかできなくなる
所有者情報を持たせるが、全員がRead・Edit・Deleteできるようにする
type Todo @model @auth(rules: [{ allow: owner, operations: [create] }]) { id: ID! name: String! description: String }
- 所有者(owner)に制限されているのは作成(create)のみ。つまり、他のユーザーもRead・Edit・Deleteできる
所有者とAdminグループしかRead・Edit・Deleteできなくする
type Todo @model @auth(rules: [{ allow: owner }]) @auth(rules: [{ allow: groups, groups: ["Admin"]}]) { id: ID! name: String! description: String }
@auth
は複数書くことができるgroups
でグループの権限を設定できる
スキーマ書き方(インデックス)
@key
でDynamoDBのインデックスを作成できる。
- 指定したカラムでの値取得ができるようになる
- 指定したカラムでのソートができるようなる
type Todo @model @auth(rules: [{allow: owner}]) @key(name: "byOwner", fields: ["owner", "createdAt"], queryField: "listTodoByOwner") { id: ID! name: String! description: String owner: String createdAt: AWSDateTime! }
name
はインデックス名fields
配列は、1つ目がパーティションキー、2つ目がソートキー。ソートキーは省略可能queryField
はqueryする時の関数名。省略するとqueryは作成されない
GraphQL(クライアント)
API.graphql({query, variables})
で呼び出す- クエリを直接記述しても構わない
型定義
apiをpushした後、amplify codegen
すると
./API
にTypeScriptのスキーマ型定義が生成される./graphql/[queries|mutations|subscriptions]
にサンプルクエリが生成される- GraphQLのスキーマを編集した時は
amplify codegen
で型定義でクエリを更新するようにする- ただし、スキーマ情報は実際のAppSyncから取得するので、
amplify push
した後に行うこと
- ただし、スキーマ情報は実際のAppSyncから取得するので、
使用例
import { API } from 'aws-amplify'; import { getTodo } from './graphql/queries'; const result: any = await API.graphql({ query: getTodo, variables: { id: '123' } }); console.log(result.data.getTodo);
使用例(クエリを直接記述)
const rawQuery = /* GraphQL */ ` query ($id: ID!) { getTodo(id: $id) { id name } } `; const result: any = await API.graphql({ query: rawQuery, variables: { id: '123' } }); console.log(result.data.getTodo);
使用例(ToDo アプリ)
schema.graphql
type Todo @model @auth(rules: [{ allow: owner }]) @key(name: "byOwner", fields: ["owner", "createdAt"], queryField: "listTodosByOwner") { id: ID! name: String! owner: String! createdAt: AWSDateTime! }
App.tsx
import React, { useEffect, useState } from 'react'; import { Box, Button, Input, Flex, Spacer } from "@chakra-ui/react" import { atom, CallbackInterface, useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil'; import Amplify, { API, Auth } from 'aws-amplify'; import { AmplifyAuthenticator, AmplifySignOut } from "@aws-amplify/ui-react"; import awsconfig from './aws-exports'; import { listTodosByOwner } from './graphql/queries'; import { createTodo, deleteTodo } from './graphql/mutations'; import { Todo, ListTodosByOwnerQuery } from './API'; import { GraphQLResult } from '@aws-amplify/api-graphql'; Amplify.configure(awsconfig); const usernameAtom = atom<string>({ key: `usernameAtom`, default: '' }); const todoListAtom = atom<Array<Todo>>({ key: `todoListAtom`, default: [] }); const loadTodoListAtom: (a: CallbackInterface) => () => void = ({ snapshot, set }) => async () => { const username = await snapshot.getPromise(usernameAtom); if (username) { const result = (await API.graphql({ query: listTodosByOwner, variables: { owner: username, sortDirection: "DESC" } }) as GraphQLResult<ListTodosByOwnerQuery>); const items = result.data?.listTodosByOwner?.items; if (items) { set(todoListAtom, (items as Array<Todo>)); } } }; function App() { const [username, setUsername] = useRecoilState(usernameAtom); const todoList = useRecoilValue(todoListAtom); const loadTodoList = useRecoilCallback(loadTodoListAtom); const [inputText, setInputText] = useState<string>(''); useEffect(() => { Auth.currentAuthenticatedUser().then((user) => { if (username !== user.username) { setUsername(user.username); } loadTodoList(); }).catch(err => { }); }, [username]); if (!username) { return (<AmplifyAuthenticator />); } const todoListDom = todoList.map((v) => { return ( <Flex key={v.id} my="2" border="1px" borderColor="gray.300" p="2" borderRadius="md"> <Box> <Box fontWeight="bold">{v.name}</Box> <Box>{v.createdAt}</Box> </Box> <Spacer /> <Button onClick={async () => { await API.graphql({ query: deleteTodo, variables: { input: { id: v.id } } }); await loadTodoList(); }}>delete</Button> </Flex> ); }); return ( <Box p="4"> <Flex my="2"> <Input mr="2" type="text" value={inputText} onChange={(e) => { setInputText(e.target.value) }} /> <Button onClick={async () => { if (inputText.length === 0) { return; } await API.graphql({ query: createTodo, variables: { input: { owner: username, name: inputText } } }); setInputText(''); await loadTodoList(); }}>add</Button> </Flex> <Box> {todoListDom} </Box> <AmplifySignOut /> </Box> ); } export default App;
Lambda Function
Lambda Functionを追加し、GraphQLから呼び出せるようにする。
- Lambda Function追加
amplify function add
- GraphQLスキーマ編集
amplify/backend/api/[api_name]/schema.graphql
を編集する@function
で指定したLambdaを呼び出せる
type Post{ id: ID! msg: String! comment: [Comment!] @function(name: "[function_name]-${env}") } type Comment{ postID: ID! msg: String! } type Query { getPost(id: ID!): Post @function(name: "[function_name]-${env}") }
- ローカル実行(mock)
--event
でイベントデータを指定する- 環境変数を追加する時は
[env_name]=[env_value] ...
を前に付ける- ローカル実行時に、リージョンでエラーが出る時は
AWS_REGION
環境変数を追加する
- ローカル実行時に、リージョンでエラーが出る時は
amplify mock function [function_name] --event=[event_json_file_path]
AWS_REGION=ap-northeast-1 \ amplify mock function [function_name] --event [event_json_file_path]
Lambda Function 実装
下記ドキュメントから抜粋
eventデータ
- クエリ内容はeventに格納される
- クエリのどこでFunctionが呼び出されたかは、eventから判別する
- ドキュメントのサンプルのように、リゾルバを作成して判別使用することが多い
(例)getPost()からの呼び出し時
{ typeName: "Query", fieldName: "getPost", arguments: { id: "123" }, identity: { username: "...." /* cognito username */ } }
(例)Post.commentからの呼び出し時
{ typeName: "Post", fieldName: "comment", arguments: { }, source: { id: "123", msg: "..." } identity: { username: "...." /* cognito username */ } }
実装例
const posts = [ { id: 1, msg: "POST_01" }, { id: 2, msg: "POST_02" }, { id: 3, msg: "POST_03" } ]; const comments = [ { postID: 1, msg: "COMMENT_01_A" }, { postID: 1, msg: "COMMENT_01_B" }, { postID: 3, msg: "COMMENT_03_C" } ]; const resolvers = { Query: { getPost: queryGetPost }, Post: { comment: postComment } } function queryGetPost(event) { console.log(event); return posts.find(v => v.id === Number.parseInt(event.arguments.id)); } function postComment(event) { console.log(event); return comments.filter(v => v.postID === event.source.id); } exports.handler = async (event) => { const typeHandler = resolvers[event.typeName]; if (typeHandler) { const resolver = typeHandler[event.fieldName]; if (resolver) { return await resolver(event); } } throw new Error("Resolver not found."); };
Lambda Function内からCognitoユーザー情報を取得する
- FunctionにCognitoへのアクセス権限を付与する
- Functionの環境変数
AUTH_[cognito_name]_USERPOOLID
に、cognitoのuserpoolidが格納されるので、それを使ってcognitoにアクセスする
- Functionの環境変数
amplify function update
- 実装
import { CognitoIdentityServiceProvider } from 'aws-sdk'; const cognito = new CognitoIdentityServiceProvider(); const USER_POOL_ID = process.env.AUTH_[cognito_name]_USERPOOLID; exports.handler = async (event) => { const cognitoUser = await cognito.adminGetUser({ UserPoolId: USER_POOL_ID, Username: event.identity.username }).promise(); return JSON.stringify(cognitoUser); };
Lambda Function内からGraphQLを呼び出す
GraphQL(AppSync)のデフォルト認証をCognitoにして、API Keyによる認証を追加し、Lambda FunctionからはAPI Keyを使ってGraphQLを呼び出す。
- GraphQLに認証方法を追加
- API Key認証を追加
amplify api update
- Lambda FunctionにGraphQLへのアクセス権付与
- GraphQLのURLと、API Keyの環境変数が作成され、Lambda Functionに渡される
API_[api_name]_GRAPHQLAPIENDPOINTOUTPUT
API_[api_name]_GRAPHQLAPIKEYOUTPUT
- 環境変数は必ず
amplify push
してAppSyncに設定されたものと一致させてから使用すること
- GraphQLのURLと、API Keyの環境変数が作成され、Lambda Functionに渡される
amplify function update
- GraphQLスキーマ編集
amplify/backend/api/[api_name]/schema.graphql
を編集- デフォルトの認証がCognitoになっているので、そのままではAPI Key認証でアクセスできない
- スキーマに
@aws_api_key
を追加して、API Key認証でもアクセスできるようにする
type Todo @model @auth(rules: [{allow: owner}]) @aws_api_key { id: ID! name: String! owner: String! } type Mutation @aws_api_key{ sample: String @function(name: "[function_name]-${env}") }
- 実装
- API Keyをヘッダーにセットする
- 呼び出し方法は通常のGraphQLと同じ
import axios from 'axios'; const GRAPHQL_EP = process.env.API_AMPLIFYTEST_GRAPHQLAPIENDPOINTOUTPUT; const GRAPHQL_KEY = process.env.API_AMPLIFYTEST_GRAPHQLAPIKEYOUTPUT; const createTodo = /* GraphQL */ ` mutation CreateTodo( $input: CreateTodoInput! $condition: ModelTodoConditionInput ) { createTodo(input: $input, condition: $condition) { id name owner createdAt updatedAt } } `; exports.handler = async (event) => { const variables = { input: { name: "TEST", owner: "owner_test" } }; const graphqlData = await axios({ url: GRAPHQL_EP, method: 'post', headers: { 'x-api-key': GRAPHQL_KEY }, data: { query: createTodo, variables } }); return JSON.stringify(graphqlData.data); };
Lambda Functionに任意の環境変数を渡す
- CloudFormationテンプレートに直接記載する
amplify/backend/function/[function_name]/[function_name]-cloudformation-template.json
- AWS Secrets Managerを使う
感想など
あまり時間をかけずに、使いたい機能だけかいつまんで調べて利用しようとしたのですが、各々の機能が絡んでいて結構手こずりました。
AmplifyやCloudFormationでエラーがでると、自力で解決するしかなく、しかも実際のインフラに起因するエラーなので、ある程度バックエンドの知識がないとお手上げになってしまいますね。
しかし、Amplifyが作るバックエンドはプロダクション利用でも問題ないので安心です。同程度のバックエンドを作るにはかなりのスキルが要求されることを考えると、Amplifyを使うメリットは十分にあるかと思いました。