⚒️

【Rails / Flutter】Rails API をバックエンド、 Flutter をフロントエンドとする構成を作る Part3

2024/06/30に公開
2

前回、前々回に引き続き、 Rails をバックエンドに、 Flutter をフロントエンドにした構成を作ってみたいと思います。
インフラ構成に Terraform を使い、 API 通信には GraphQL を採用し、これまで単体の勉強しかしたことがなかったものを組み合わせてみることにしました。

さっそく、始めましょう!

1. Rails 側でユーザー認証の下準備

gem のインストール

まず、必要な gem をインストールします。 Gemfileに以下の行を追加します。

Gemfile
gem 'jwt', '~> 2.8.0'
gem 'bcrypt', '~> 3.1'

ジェムをインストールします。

docker-compose run web bundle install

スキーマ定義の変更

User を扱えるように、スキーマ定義に追加していきます。

db/Schemafile.rb
# 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

モデルの設定

habit.rb
class Habit < ApplicationRecord
  belongs_to :user # 追加

  validates :name, presence: true
  validates :description, presence: true
end
user.rb
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ファイルを作成し、以下の内容を追加します。

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ファイルを以下のように編集します。

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 ファイルを作成し、以下の内容を追加します。

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ファイルを以下のように編集します。

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 での確認の際にコピペできて便利なので書いてあります)

create_habit.rb
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 ファイルを以下のように編集します。

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 を作っていきます。

jwt_service.rb
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についてはこのあたりの記事で詳細に解説されています。
https://zenn.dev/mikakane/articles/tutorial_for_jwt
https://qiita.com/knaot0/items/8427918564400968bd2b

4. ユーザー登録の確認用クエリを作成

app/graphql/types/query_type.rb に validate_user を追加します。

query_type.rb
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 に格納することで、ログインの永続化を実現します。

authentication_screen.dart
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'),
            ),
          ],
        ),
      ),
    );
  }
}

ログインの自動化とユーザー情報の保持

アプリ起動時、状態によって遷移する画面を出し分ける処理を書いていきます。

  • ユーザー登録済みの場合は アプリトップ画面
  • 未登録、未ログイン、認証期限切れの場合は ユーザー認証画面
main.dart
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 を渡しています。

create_habit_screen.dart
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

NedwardNedward

この記事を参考にさせていただいて勉強している初学者です。importしているmodel/user.dartの箇所は何をしている部分なのでしょうか?

恐らく、final userの箇所で定義しているUserという部分に関係していそうだなというのはわかるのですが、どういう言葉で検索をかけて調べていいかが分からず直接書いた方にお聞きしようと考えた次第です。もしよろしければ教えていただければと思います。よろしくお願いいたします。

岩﨑 弘幸岩﨑 弘幸

こんにちは!
質問ありがとうございます!

User モデルの記述がこの中にありませんでしたね...失礼しました。
以下のようなファイルで、クラス定義を model ディレクトリ内に配置している形です。

model/user.dart
// Userクラスの定義
class User {
  final String id;
  final String username;

  User({required this.id, required this.username});
}

あとはご推察の通り、用意した User クラスを使って user を生成する流れですね。

main.dart
final user = User(
  id: result.data!['validateUser']['id'],
  username: result.data!['validateUser']['username'],
);