[Flutter]Dioで統一したインターフェースでAPIクライアントを作ってみる

完全自分用の学習備忘録。

詳細な解説等はコード内でコメントアウトしております。

以下の記事を参考に実装していきます。

[Flutter でスマートに非同期処理のエラーハンドリングをする方法 - Qiita](https://qiita.com/marienplatz/items/642180febbc47b73cd7e)

### 使用するライブラリ・パッケージ

記事の通り、`Result型` を使用します。

- pubspec.yaml
    
    ```dart
    dependencies:
      flutter:
        sdk: flutter
      dio: ^5.7.0
      freezed:
      freezed_annotation:
    ```
    

### APIリクエスト処理の設計と実装

 各APIエンドポイントのURLを定義します。

（エンドポイントはプロジェクトに合わせて自由に書き換えてください）

- lib/api/service/api_url.dart
    
    ```dart
    class ApiUrl {
    
      /// 今回はローカルサーバーのみ
      static const String baseUrl = 'http://localhost:8080';
    
      /// 認証
      static const String signUp = '/auth/sign_up';
      static const String signIn = '/auth/login';
    
      /// ToDo
      static const String fetchToDos = '/todos';
      static const String addToDo = '/todos';
      static const String editToDo = '/todos';
      static const String deleteToDo = '/todos';
    }
    ```
    

HTTP Method をenumで管理します。

- lib/api/service/http_method.dart
    
    ```dart
    enum HttpMethod {
      GET,
      POST,
      PUT,
      DELETE;
    
      String get name {
        switch (this) {
          case HttpMethod.GET:
            return 'GET';
          case HttpMethod.POST:
            return 'POST';
          case HttpMethod.PUT:
            return 'PUT';
          case HttpMethod.DELETE:
            return 'DELETE';
        }
      }
    }
    ```
    

freezed を使って Result 型を作ります。

- lib/api/service/result.dart
    
    作成後、以下のコマンドを実行します。
    
    エラー（Error）が発生した場合は、throwをしてプログラムを終了させるため、Result型にはException（例外）のみをResultでラップするようにしています。
    
    `dart run build_runner watch --delete-conflicting-outputs`
    
    ```dart
    import 'package:freezed_annotation/freezed_annotation.dart';
    import 'package:spotify_app/api/service/api_service.dart';
    import 'package:spotify_app/model/custom_exception.dart';
    
    part 'result.freezed.dart';
    
    @freezed
    abstract class Result<T> with _$Result<T> {
      const factory Result.success(T value) = Success<T>;
      const factory Result.exception(CustomException exception) = ResultException<T>;
    }
    ```
    

APIリクエストの共通部分を定義する抽象クラスを作成します。

- lib/api/service/common_http_router.dart
    
    ```dart
    import 'package:dio/dio.dart';
    import 'package:spotify_app/api/service/api_url.dart';
    import 'package:spotify_app/config/config.dart';
    import 'package:spotify_app/config/storage.dart';
    import 'http_method.dart';
    
    // APIリクエストの共通部分を定義する抽象クラス
    // 各APIリクエストの詳細なパスやパラメータ、ボディなどはサブクラスで実装
    abstract class CommonHttpRouter {
    
      // 基本URL
      String baseUrl = ApiUrl.baseUrl;
    
      // 各APIエンドポイントのパス
      String get path;
    
      // GET、POST、PUT、DELETE
      HttpMethod get method;
    
      // ヘッダー
      Future<Map<String, String>> get headers async =>
          {
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'Authorization': 'Bearer ${ await SecureStorage().load(
                Config.secureStorageJwtTokenKey)}', // 端末のローカルに保存したJWT認証トークンをヘッダーに追加
          };
    
      // パスパラメーター
      List<String>? get pathParameters => null;
    
      // クエリパラメーター
      Map<String, dynamic>? get queryParameters => null;
    
      // ボディ
      Object? body() => null;
    
      // パスパラメーターとクエリパラメーターを組み合わせる
      String get combinedPathAndQueryParameters {
        String combinedPath = path;
        if (pathParameters != null) {
          combinedPath += '/${pathParameters!.join('/')}';
        }
        if (queryParameters != null) {
          combinedPath += '?${queryParameters!.entries
              .map((entry) => '${entry.key}=${entry.value}')
              .join('&')}';
        }
        return combinedPath;
      }
    
      // HTTP通信をするためのDioインスタンスを生成
      Future<Dio> get dio async {
        final headers = await this.headers;
        return Dio(
          BaseOptions(
            baseUrl: baseUrl,
            headers: headers,
            method: method.name,
            responseType: ResponseType.json,
            connectTimeout: Config.apiDuration,
          ),
        );
      }
    }
    ```
    

APIリクエスト処理を定義していきます。

`abstract class UseCase<I, O>` を作成します。

- lib/api/service/api_service.dart
    
    ```dart
    import 'package:dio/dio.dart';
    import 'package:flutter/cupertino.dart';
    import 'package:spotify_app/api/service/result.dart';
    import 'package:spotify_app/model/custom_error.dart';
    import 'package:spotify_app/model/custom_exception.dart';
    import 'common_http_router.dart';
    import 'http_method.dart';
    
    /*
    abstract class UseCase<I, O>
    I → Input リクエストするデータの型を指定する CommonHttpRouter
    O → Output レスポンスの型を指定する Map<String, dynamic>?
     */
    
    class ApiService extends UseCase<CommonHttpRouter, Map<String, dynamic>?> {
    
      // UseCaseクラス内のFuture<O> execute(I request);の「O, I」に型を指定
      // excute()をoverrideをして具体的なAPIリクエスト処理を定義し、
      // リクエスト成功時 → Map<String, dynamic>?, 失敗時 → CustomExceptionまたはCustomErrorをthrowするようにする
      // ViewModel側でUseCaseをcall()し、Result型を返す
      @override
      Future<Map<String, dynamic>?> execute(CommonHttpRouter request) async {
        final dio = await request.dio;
    
        Response? response;
        try {
          if (request.method == HttpMethod.GET) {
            response = await dio.request(
              request.combinedPathAndQueryParameters
            );
          } else {
            response = await dio.request(
              request.combinedPathAndQueryParameters,
              data: request.body(),
            );
          }
    
          print(response.data);
          print(response.statusCode);
          print(response.statusMessage);
    
          if (response == null) {
            throw CustomError(999, 'レスポンスがありません。');
          }
    
          if (response.statusCode! >= 200 && response.statusCode! < 300) {
            return response.data['data'];
          } else {
          throw CustomError(response.statusCode ?? 999, response.statusMessage ?? '不明なエラーが発生しました。');
          }
        } on DioException catch (exception) {
          throw CustomException(exception.response?.statusCode ?? 999, exception.message ?? '不明なエラーが発生しました。');
        } on Exception catch (exception) {
          throw CustomException(999, exception.toString());
        }
      }
    }
    
    /*
    abstract class UseCase<I, O>
    I → Input リクエストするデータの型を指定する CommonHttpRouter
    O → Output レスポンスの型を指定する Map<String, dynamic>?
     */
    
    abstract class UseCase<I, O> {
      Future<Result<O>> call(I request) async {
        try {
          final data = await execute(request); // ここでAPIリクエストを実行
          return Result.success(data);
        } on CustomException catch (exception) {
          return Result.exception(exception);
        } on CustomError catch (error) {
          // エラーの場合はプログラムを終了させるためError用のResult型は定義していない
          debugPrint('statusCode: ${error.statsCode.toString()}');
          debugPrint('message: ${error.message}');
          throw error;
        }
      }
    
      Future<O> execute(I request);
    }
    ```
    

### 使用例

実際にどのように使用するのか実装していきます。

まず、リクエストする情報を組み立てていきます。例として、

- サインアップリクエスト
- ToDoタスクの新規作成リクエスト
- ToDoタスクの更新リクエスト

を組み立てていきます。

**認証（サインアップ / サインイン）**

- lib/api/requests/sign_up_request.dart
    
    ```dart
    import 'package:spotify_app/api/service/api_url.dart';
    import 'package:spotify_app/api/service/common_http_router.dart';
    import 'package:spotify_app/model/auth_model.dart';
    import '../../../service/http_method.dart';
    
    class SignUpRequest extends CommonHttpRouter {
      final AuthModel model;
    
      SignUpRequest(this.model);
    
      @override
      String get path => ApiUrl.signUp;
    
      @override
      HttpMethod get method => HttpMethod.POST;
    
      @override
      Object? body() => AuthModel.toJson(model);
    }
    ```
    
- lib/api/requests/sign_in_request.dart
    
    ```dart
    import 'package:spotify_app/api/service/api_url.dart';
    import 'package:spotify_app/api/service/common_http_router.dart';
    import 'package:spotify_app/model/auth_model.dart';
    import '../../../service/http_method.dart';
    
    class SignInRequest extends CommonHttpRouter {
      final AuthModel model;
    
      SignInRequest(this.model);
    
      @override
      String get path => ApiUrl.signIn;
    
      @override
      HttpMethod get method => HttpMethod.POST;
    
      @override
      Object? body() => AuthModel.toJson(model);
    }
    ```
    

**Todo（新規作成 / 更新 ）**

- lib/api/requests/add_to_do_request.dart
    
    ```dart
    import 'dart:convert';
    
    import 'package:spotify_app/api/service/api_url.dart';
    import 'package:spotify_app/api/service/common_http_router.dart';
    import '../../../service/http_method.dart';
    
    class AddToDoRequest extends CommonHttpRouter {
      final String title ;
    
      AddToDoRequest(this.title);
    
      @override
      String get path => ApiUrl.addToDo;
    
      @override
      HttpMethod get method => HttpMethod.POST;
    
      @override
      Object? body() => {'title': title};
    }
    ```
    
- lib/api/requests/update_to_do_request.dart
    
    ```dart
    import 'package:spotify_app/api/service/api_url.dart';
    import 'package:spotify_app/api/service/common_http_router.dart';
    import 'package:spotify_app/model/to_do_model.dart';
    import '../../../service/http_method.dart';
    
    class UpdateToDoRequest extends CommonHttpRouter {
      final ToDoModel model;
    
      UpdateToDoRequest(this.model);
    
      @override
      String get path => ApiUrl.editToDo;
    
      @override
      HttpMethod get method => HttpMethod.PUT;
    
      @override
      List<String>? get pathParameters => [model.id.toString()];
    
      @override
      Object? body() => ToDoModel.toJson(model);
    }
    ```
    

APIリクエストの呼び出しもとを実装していきます。

- main.dart
    
    ```dart
    import 'package:flutter/material.dart';
    import 'package:spotify_app/api/requests/post/auth/sign_in_request.dart';
    import 'package:spotify_app/api/requests/post/auth/sign_up_request.dart';
    import 'package:spotify_app/api/requests/post/todo/add_to_do_request.dart';
    import 'package:spotify_app/api/requests/post/todo/update_to_do_request.dart';
    import 'package:spotify_app/api/service/api_service.dart';
    import 'package:spotify_app/api/service/common_http_router.dart';
    import 'package:spotify_app/model/custom_exception.dart';
    import 'package:spotify_app/model/to_do_model.dart';
    
    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
    
      // APIサービスのインスタンス
      final ApiService apiService = ApiService();
    
      // 認証ロジック
      Future<void> handleAuthRequest(CommonHttpRouter request) async {
        try {
          final result = await apiService(request);
          result.when(
            success: (Map<String, dynamic>? json) async {
              final authModel = AuthModel.fromJson(json!);
              await SecureStorage().save(Config.secureStorageJwtTokenKey, authModel.jwtToken ?? '');
              print("Authentication successful: ${authModel.jwtToken}");
            },
            exception: (CustomException error) {
              print("Authentication failed: ${error.message}");
            },
          );
        } catch (e) {
          print("Unexpected error: $e");
        }
      }
    
      Future<void> signUp(String username, String email, String password) async {
        final authModel = AuthModel(username: username, email: email, password: password);
        final signUpRequest = SignUpRequest(authModel);
        await handleAuthRequest(signUpRequest);
      }
    
      Future<void> signIn(String email, String password) async {
        final authModel = AuthModel(email: email, password: password);
        final signInRequest = SignInRequest(authModel);
        await handleAuthRequest(signInRequest);
      }
    
      // ToDoロジック
      List<ToDoModel> todos = [];
    
      Future<void> fetchToDos() async {
        try {
          final fetchToDosRequest = FetchToDosRequest();
          final result = await apiService(fetchToDosRequest);
          result.when(
            success: (Map<String, dynamic>? json) {
              todos = (json!['todos'] as List<dynamic>)
                  .map((todoJson) => ToDoModel.fromJson(todoJson))
                  .toList();
              print("Fetched ToDos: $todos");
            },
            exception: (CustomException error) {
              print("Failed to fetch ToDos: ${error.message}");
            },
          );
        } catch (e) {
          print("Unexpected error: $e");
        }
      }
    
      Future<void> addTodo(String title) async {
        try {
          final addToDoRequest = AddToDoRequest(title);
          final result = await apiService(addToDoRequest);
          result.when(
            success: (Map<String, dynamic>? json) {
              final newToDo = ToDoModel.fromJson(json!);
              todos.add(newToDo);
              print("Added ToDo: $newToDo");
            },
            exception: (CustomException error) {
              print("Failed to add ToDo: ${error.message}");
            },
          );
        } catch (e) {
          print("Unexpected error: $e");
        }
      }
    
      Future<void> removeTodo(int id) async {
        try {
          final deleteToDoRequest = DeleteToDoRequest(id);
          final result = await apiService(deleteToDoRequest);
          result.when(
            success: (_) {
              todos.removeWhere((todo) => todo.id == id);
              print("Removed ToDo with ID: $id");
            },
            exception: (CustomException error) {
              print("Failed to remove ToDo: ${error.message}");
            },
          );
        } catch (e) {
          print("Unexpected error: $e");
        }
      }
    
      Future<void> updateTodo(int id, String newTitle, bool isCompleted) async {
        try {
          final toDoModel = ToDoModel(id: id, title: newTitle, isCompleted: isCompleted);
          final updateToDoRequest = UpdateToDoRequest(toDoModel);
          final result = await apiService(updateToDoRequest);
          result.when(
            success: (_) {
              todos = todos.map((todo) {
                return todo.id == id ? todo.copyWith(title: newTitle, isCompleted: isCompleted) : todo;
              }).toList();
              print("Updated ToDo with ID: $id");
            },
            exception: (CustomException error) {
              print("Failed to update ToDo: ${error.message}");
            },
          );
        } catch (e) {
          print("Unexpected error: $e");
        }
      }
    
      // サンプル実行
      await signUp("testuser", "test@example.com", "password123");
      await signIn("test@example.com", "password123");
      await fetchToDos();
      await addTodo("New Task");
      await updateTodo(1, "Updated Task", true);
      await removeTodo(1);
    }
    
    ```