【Rails / Flutter】Rails API をバックエンド、 Flutter をフロントエンドとする構成を作る Part3
前回、前々回に引き続き、 Rails をバックエンドに、 Flutter をフロントエンドにした構成を作ってみたいと思います。
インフラ構成に Terraform を使い、 API 通信には GraphQL を採用し、これまで単体の勉強しかしたことがなかったものを組み合わせてみることにしました。
さっそく、始めましょう!
1. Rails 側でユーザー認証の下準備
gem のインストール
まず、必要な gem をインストールします。 Gemfile
に以下の行を追加します。
gem 'jwt', '~> 2.8.0'
gem 'bcrypt', '~> 3.1'
ジェムをインストールします。
docker-compose run web bundle install
スキーマ定義の変更
User を扱えるように、スキーマ定義に追加していきます。
# frozen_string_literal: true
create_table "habits", charset: "utf8mb4", collation: "utf8mb4_bin", force: :cascade do |t|
t.bigint :user_id, null: false # 追加
t.string :name, null: false, default: ""
t.string :description, default: ""
t.datetime :created_at, null: false, precision: 6
t.datetime :updated_at, null: false, precision: 6
t.index :user_id, name: "index_habits_on_user_id"
t.index :updated_at, name: "index_habits_on_updated_at"
end
add_foreign_key "habits", "users" # 追加
create_table "users", charset: "utf8mb4", collation: "utf8mb4_bin", force: :cascade do |t|
t.string :username, null: false, default: ""
t.string :password
t.string :password_digest
t.datetime :created_at, null: false, precision: 6
t.datetime :updated_at, null: false, precision: 6
end
ridgepole apply を使ってデータベースのマイグレーションをします。
docker-compose run web ridgepole -c config/database.yml --apply -f db/Schemafile.rb
モデルの設定
class Habit < ApplicationRecord
belongs_to :user # 追加
validates :name, presence: true
validates :description, presence: true
end
class User < ApplicationRecord
has_secure_password
has_many :habits, dependent: :destroy
validates :username, presence: true
end
2. GraphQL ミューテーションの作成
CreateUser ミューテーションの作成
次に、ユーザーを作成するための GraphQL ミューテーションを作成します。app/graphql/mutations/create_user.rb
ファイルを作成し、以下の内容を追加します。
module Mutations
class CreateUser < BaseMutation
argument :params, InputTypes::User, required: true
field :token, String, null: true
field :user, Types::UserType, null: true
field :errors, [String], null: false
def resolve(params:)
user = User.new(username: params[:username], password: params[:password])
if user.save
token = JwtService.encode(user_id: user.id)
{ user: user, token: token, errors: [] }
else
{ user: nil, token: nil, errors: user.errors.full_messages }
end
end
end
end
次に、app/graphql/types/mutation_type.rb
ファイルを以下のように編集します。
module Types
class MutationType < Types::BaseObject
field :create_user, mutation: Mutations::CreateUser
end
end
LoginUser ミューテーションの作成
次に、ユーザーをログインさせるための GraphQL ミューテーションを作成します。app/graphql/mutations/login_user.rb
ファイルを作成し、以下の内容を追加します。
module Mutations
class LoginUser < BaseMutation
argument :params, InputTypes::User, required: true
field :token, String, null: true
field :user, Types::UserType, null: true
field :errors, [String], null: false
def resolve(params:)
user = User.find_by(username: params[:username])
if user&.authenticate(params[:password])
token = JwtService.encode(user_id: user.id)
{ user: user, token: token, errors: [] }
else
{ user: nil, token: nil, errors: ['Invalid credentials'] }
end
end
end
end
次に、app/graphql/types/mutation_type.rb
ファイルを以下のように編集します。
module Types
class MutationType < Types::BaseObject
field :create_user, mutation: Mutations::CreateUser
field :login_user, mutation: Mutations::LoginUser
end
end
CreateHabit ミューテーションの作成
次に、ログインしたユーザーがHabitを作成できるようにするための GraphQL ミューテーションを作成します。app/graphql/mutations/create_habit.rb
ファイルを作成し、以下の内容を追加します。
(コメントアウトしている部分は必要ありませんが、 GraphiQL や curl での確認の際にコピペできて便利なので書いてあります)
module Mutations
class CreateHabit < BaseMutation
argument :params, InputTypes::Habit, required: true
field :habit, Types::HabitType, null: false
field :errors, [String], null: true
# mutation {
# createHabit(input: {params: {name: "New Habit", description: "Description of new habit", userId: 1}}) {
# habit {
# id
# name
# description
# }
# errors
# }
# }
# curl -X POST 'http://localhost:3000/graphql' \
# -H 'Content-Type: application/json' \
# -d '{
# "query": "mutation CreateHabit($input: HabitAttributes!) { createHabit(input: {params: $input}) { habit { id name description } errors } }",
# "variables": {
# "input": {
# "name": "New Habit",
# "description": "Description of new habit",
# "userId": 1
# }
# }
# }'
def resolve(params:)
habit = Habit.new(name: params[:name], description: params[:description], user_id: params[:user_id])
if habit.save
{ habit: habit }
else
{ errors: habit.errors.full_messages }
end
end
end
end
次に、 app/graphql/types/mutation_type.rb
ファイルを以下のように編集します。
module Types
class MutationType < Types::BaseObject
field :create_user, mutation: Mutations::CreateUser
field :login_user, mutation: Mutations::LoginUser
field :create_habit, mutation: Mutations::CreateHabit
end
end
3. JWT の設定
create, login ミューテーションで指定した JwtService を作っていきます。
require 'jwt'
class JwtService
SECRET_KEY = Rails.application.secrets.secret_key_base
def self.encode(payload, exp = 2.weeks.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET_KEY)
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY, true, algorithm: 'HS256')
HashWithIndifferentAccess.new(decoded[0])
rescue JWT::DecodeError => e
Rails.logger.error "JWT Decode Error: #{e.message}"
nil
end
end
これによりJWTトークンを生成し認証に使えるようになりました。
JWTについてはこのあたりの記事で詳細に解説されています。
4. ユーザー登録の確認用クエリを作成
app/graphql/types/query_type.rb
に validate_user を追加します。
field :validate_user, UserType, null: true do
description "Validate user based on the JWT token"
end
def validate_user
# リクエストヘッダからトークンを取得し、ユーザーIDを取得してユーザーを検索
token = context[:request].headers['Authorization']&.split(' ')&.last
return unless token
decoded_token = JwtService.decode(token)
return unless decoded_token
User.find_by(id: decoded_token[:user_id])
end
この validate_user にユーザー登録時に発行した JWT トークンを渡せば、ユーザーが返ってくるようにしています。
5. Flutter アプリの更新
認証機能の追加
Flutter アプリに認証機能を追加し、ユーザーが登録・ログインできるようにします。
GraphQL ミューテーションを使用して、ユーザー登録とログインを行います。
- GraphQLパッケージのインストール
graphql_flutter: ^5.1.2
flutter_secure_storage: ^9.2.2 # データの永続化
flutter_riverpod: ^2.5.1 # 状態管理
- Flutterでのユーザー登録とログイン
ユーザー登録とログインを扱うページを作ります。
ユーザー登録後 _saveToken 内で Rails から受け取ったJWTトークンを FlutterSecureStorage に格納することで、ログインの永続化を実現します。
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'home_screen.dart';
import 'model/user.dart';
class AuthenticationScreen extends ConsumerStatefulWidget {
const AuthenticationScreen({super.key});
AuthenticationScreenState createState() => AuthenticationScreenState();
}
class AuthenticationScreenState extends ConsumerState<AuthenticationScreen> {
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final storage = const FlutterSecureStorage(); // インスタンスの生成
bool isLogin = true; // ユーザーがログイン画面か登録画面かを切り替えるためのフラグ
// JWTトークンをセキュアストレージに保存
Future<void> _saveToken(String token) async {
await storage.write(key: 'jwt_token', value: token);
}
void _authenticate() {
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Username and password cannot be empty")),
);
return;
}
authenticateUser(_usernameController.text, _passwordController.text, isLogin);
}
Future<void> authenticateUser(String username, String password, bool isLogin) async {
final HttpLink httpLink = HttpLink(
'http://localhost:3000/graphql',
);
final String? token = await storage.read(key: 'jwt_token');
print(token);
final AuthLink authLink = AuthLink(
getToken: () => 'Bearer $token',
);
final Link link = authLink.concat(httpLink);
final GraphQLClient client = GraphQLClient(
link: link,
cache: GraphQLCache(),
);
final String authMutation = isLogin
? r'''
mutation LoginUser($username: String!, $password: String!) {
loginUser(input: { params: { username: $username, password: $password }}) {
token
user {
id
username
}
errors
}
}
'''
: r'''
mutation CreateUser($username: String!, $password: String!) {
createUser(input: { params: { username: $username, password: $password }}) {
token
user {
id
username
}
errors
}
}
''';
final MutationOptions options = MutationOptions(
document: gql(authMutation),
variables: <String, dynamic>{
'username': username,
'password': password,
},
);
final QueryResult result = await client.mutate(options);
if (result.hasException) {
print('Authentication Error: ${result.exception.toString()}');
} else if (result.data == null) {
print('No data returned from the server.');
} else {
final token = result.data!['loginUser']?['token'] ?? result.data!['createUser']?['token'];
if (token != null) {
print(token);
await _saveToken(token); // トークンをセキュアストレージに保存
final user = User(
id: result.data!['loginUser']?['user']['id'] ?? result.data!['createUser']?['user']['id'],
username: result.data!['loginUser']?['user']['username'] ?? result.data!['createUser']?['user']['username'],
);
ref.read(userProvider.notifier).state = user;
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const HomeScreen()));
} else {
print('Token is null.');
}
}
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(isLogin ? 'Login' : 'Register'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
TextField(
controller: _usernameController,
decoration: const InputDecoration(labelText: 'Username'),
),
const SizedBox(height: 10),
TextField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _authenticate,
child: Text(isLogin ? 'Login' : 'Register'),
),
TextButton(
onPressed: () {
setState(() {
isLogin = !isLogin;
});
},
child: Text(isLogin ? 'Need an account? Register' : 'Already have an account? Login'),
),
],
),
),
);
}
}
ログインの自動化とユーザー情報の保持
アプリ起動時、状態によって遷移する画面を出し分ける処理を書いていきます。
- ユーザー登録済みの場合は アプリトップ画面 へ
- 未登録、未ログイン、認証期限切れの場合は ユーザー認証画面 へ
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'home_screen.dart';
import 'authentication_screen.dart';
import 'model/user.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final HttpLink httpLink = HttpLink(
'http://localhost:3000/graphql',
);
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'jwt_token');
final Link link = token != null ? AuthLink(getToken: () => 'Bearer $token').concat(httpLink) : httpLink;
ValueNotifier<GraphQLClient> client = ValueNotifier<GraphQLClient>(
GraphQLClient(
link: link,
cache: GraphQLCache(store: InMemoryStore()),
),
);
runApp(ProviderScope(child: MyApp(client: client)));
}
class MyApp extends StatelessWidget {
final ValueNotifier<GraphQLClient> client;
const MyApp({required this.client, super.key});
Widget build(BuildContext context) {
return GraphQLProvider(
client: client,
child: MaterialApp(
title: 'Habit Tracker',
home: Consumer(
builder: (context, ref, child) {
return FutureBuilder(
future: _checkAuthToken(ref, client),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError || !snapshot.hasData) {
return const AuthenticationScreen();
} else {
return const HomeScreen();
}
},
);
},
),
),
);
}
Future<bool> _checkAuthToken(WidgetRef ref, ValueNotifier<GraphQLClient> client) async {
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'jwt_token');
if (token == null) return false;
final QueryOptions options = QueryOptions(
document: gql('''
query ValidateUser {
validateUser {
id
username
}
}
'''),
);
final QueryResult result = await client.value.query(options);
if (result.hasException || result.data == null) {
return false;
}
final user = User(
id: result.data!['validateUser']['id'],
username: result.data!['validateUser']['username'],
);
ref.read(userProvider.notifier).state = user;
return true;
}
}
アプリ起動時に FlutterSecureStorage から JWTトークンを取り出し、 validate_user クエリでトークンが有効かを確認。
有効であれば HomeScreen へ遷移し、有効でなければ AuthenticationScreen に遷移させ、認証をしてもらうような流れにしています。
また、 validate_user で受け取ったユーザー情報は userProvider で永続化し、どこからでもアクセスできるようにしています。
Habit 作成機能の追加
認証されたユーザーが Habit を作成できるようにします。
GraphQL ミューテーションを使用して、 Habit を作成します。
- Habit作成 (CreateHabit ミューテーション)
ref.watch で userProvider からユーザーを取得し、 CreateHabit ミューテーションに ID を渡しています。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'home_screen.dart';
import 'model/user.dart';
class CreateHabitScreen extends ConsumerWidget {
final TextEditingController nameController = TextEditingController();
final TextEditingController descriptionController = TextEditingController();
CreateHabitScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
String createHabitMutation = r"""
mutation CreateHabit($name: String!, $description: String!, $userId: ID!) {
createHabit(input: {params: {name: $name, description: $description, userId: $userId}}) {
habit {
id
name
description
}
errors
}
}
""";
return Scaffold(
appBar: AppBar(
title: const Text('新しい習慣を作成'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: '名前'),
),
TextField(
controller: descriptionController,
decoration: const InputDecoration(labelText: '説明'),
),
ElevatedButton(
onPressed: () {
final String name = nameController.text;
final String description = descriptionController.text;
GraphQLProvider.of(context).value.mutate(
MutationOptions(
document: gql(createHabitMutation),
variables: {
'name': name,
'description': description,
'userId': user!.id,
},
onCompleted: (dynamic resultData) {
print(resultData);
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const HomeScreen()));
},
),
);
},
child: const Text('作成'),
)
],
),
),
);
}
}
6. 動作確認とまとめ
動作確認
フォームから登録でき、一覧に表示されることが確認できました!
GraphiQL からも同様のデータが存在することが確認できます。
まとめと次のステップ
これで最低限の機能が完成しました!
今回、時間の関係で断念した機能もあったりするので機能の改善と追加を続けていこうと思います。
他にできると良さそうなこと
- ユーザーのプロフィール画像など、画像ファイルの登録
- GraphQL で画像を扱う方法がひと癖あり、今回挑戦したのですがうまく動かせなかったので再チャレンジしたい
- 多要素認証
- Flutter のデザイン調整
- 今回は Rails との疎通が目的だったので最低限の見た目にしていますが綺麗に整えたい
Discussion
この記事を参考にさせていただいて勉強している初学者です。importしているmodel/user.dartの箇所は何をしている部分なのでしょうか?
恐らく、final userの箇所で定義しているUserという部分に関係していそうだなというのはわかるのですが、どういう言葉で検索をかけて調べていいかが分からず直接書いた方にお聞きしようと考えた次第です。もしよろしければ教えていただければと思います。よろしくお願いいたします。
こんにちは!
質問ありがとうございます!
User モデルの記述がこの中にありませんでしたね...失礼しました。
以下のようなファイルで、クラス定義を model ディレクトリ内に配置している形です。
あとはご推察の通り、用意した User クラスを使って user を生成する流れですね。