🌐

【Flutter】dio + freezed でAPIレスポンスをResult<T>で受け取る

14 min read 3

想定読者

  • FlutterでのAPIレスポンスのハンドリングにResult<T> を使いたい
  • swiftの Union や kotlinの sealed classのようなものをFlutterで扱いたい

はじめに

普段Androidを書いているときに retrofit から受け取ったAPIレスポンスを sealed class を利用し、以下のように変換しています。

sealed class Result<T> {
  data class Success<T>(val body: T?) : Result<T>()
  data class Failure<T>(val type ErrorType) : Result<T>()

  companion object {
    fun create<T>(retrofitResponse: retrofit2.Response<T>): Result<T> {
      return when {
        response.isSuccessful -> Success(response.body())
        else -> Failure(ErrorType.create(response))
      }
    }
  }
}

Result に変換することでViewModel層などでは、retrofit に依存せず、when文 を利用し、レスポンスをさばくことが可能になります。

fun request(keyword: String) {
  viewModelScope.launch {
    when (val result = repository.fetchSomething(keyword)) {
      is Success -> result.body?.let { _elements.value = it }
      is Failure -> _errorType.value = result.type
    }
  }
}

最近Flutterを書いていて、上記と同じようなことをしたいと考え、以下のように実装してみました。

至らない点もあると思うので、ぜひコメント等でご指摘いただけたら嬉しいです。

dio

HTTP Clientライブラリです。

Flutterの公式では http というライブラリを推していますが
個人的にはAndroidで OkHttp を利用していたこともあり、dio の書き方がそれに似ている感触を得たので、dioを利用しています。

また、Githubのstar数が異常に多く、日本語の記事は少ないものの、英語での記事は大量にあったため安心して選定しました。

公式のリファレンスにもありますが、Dioクラスはabstract classなので、以下のようにアプリ独自のクラスを作ります。

class MyDio with DioMixin implements Dio{
  // ...
}

(公式 Extends より引用)

簡単なリクエストは以下のようにかけます

Response response;
Dio dio = new Dio();
response = await dio.get("/test?id=12&name=wendu");
print(response.data.toString());
// Optionally the request above could also be done as
response = await dio.get("/test", queryParameters: {"id": 12, "name": "wendu"});
print(response.data.toString());

(公式 Examples より引用)

freezed

Dartではsealed classのようなものがありません。
ただ、同様のものを生成してくれるライブラリとして freezedがあります。

詳細は割愛します。(公式を読んでいただけたらわかると思うので。)


abstract class Union<T> with _$Union<T> {
  const factory Union(T value) = Data<T>;
  const factory Union.loading() = Loading<T>;
  const factory Union.error([String message]) = ErrorDetails<T>;
}

(公式 The syntax より引用)

実装

Resultクラスは以下のように設定しております。
(簡略化のため、importなどは省略しております。)

result.dart


abstract class Result<T> with _$Result<T> {
  const factory Result.success(T value) = Success<T>;
  const factory Result.failure(Error error) = Failure<T>;
}

以下の記事を参考に作成しました。
Handling Network Calls and Exceptions in Flutter

余談ですが、Errorクラスは以下のようにつくっています。


abstract class Error with _$Error {
  const factory Error.requestCancelled() = _RequestCancelled;

  const factory Error.unauthorisedRequest() = _UnauthorisedRequest;

  const factory Error.requestError({ ApiError apiError }) = _RequestError;

  const factory Error.serviceUnavailable() = _ServiceUnavailable;

  const factory Error.sendTimeout() = _SendTimeout;

  const factory Error.noInternetConnection() = _NoInternetConnection;

  const factory Error.unexpectedError() = _UnexpectedError;

  const Error._();

  static Error getApiError(error) {
    if (error is Exception) {
      try {
        Error _error;
        if (error is DioError) {
          switch (error.type) {
            case DioErrorType.CONNECT_TIMEOUT:
            case DioErrorType.SEND_TIMEOUT:
            case DioErrorType.RECEIVE_TIMEOUT:
              _error = Error.sendTimeout();
              break;
            case DioErrorType.CANCEL:
              _error = Error.requestCancelled();
              break;
            case DioErrorType.RESPONSE:
              final statusCode = error.response.statusCode;
              if (400 <= statusCode && statusCode < 500) {
                _error = Error.requestError(apiError: ApiError.fromJson(error.response.data));
              } else if (500 <= statusCode) {
                _error = Error.serviceUnavailable();
              }
              break;
            default:
              _error = Error.unexpectedError();
          }
        } else if (error is SocketException) {
          _error = Error.noInternetConnection();
        } else {
          _error = Error.unexpectedError();
        }
        return _error;
      } catch (_) {
        return Error.unexpectedError();
      }
    } else {
      return Error.unexpectedError();
    }
  }

  String get errorMessage => this.when(
      requestCancelled: () => "キャンセルされました",
      unauthorisedRequest: () => "認証エラーです",
      requestError: (apiError error) => error.message,
      serviceUnavailable: () => "しばらく時間をおいてから再度お試しください",
      sendTimeout: () => "通信環境の良いところで再度お試しください",
      noInternetConnection: () => "通信環境の良いところで再度お試しください",
      unexpectedError: () => "不明なエラーが発生しました"
  );
}

次に、AppDioというクラスを作成し、ここでDioの設定を色々と行っています。

app_dio.dart

class AppDio with DioMixin implements Dio {
  factory AppDio() {
    if (_instance == null) {
      final dio = AppDio._();
      dio.httpClientAdapter = DefaultHttpClientAdapter();
      dio.options = BaseOptions(...);
      dio.interceptors
        ..add(LogInterceptor(responseBody: true));
      _instance = dio;
    }
    return _instance;
  }

  static AppDio _instance;
  AppDio._();
}

APiClient, Dioなどを利用し、DataSourceImplなどから以下のようにリクエストを送ります。

class SomethingDataSourceImpl with SomethingDataSourceMixin {
  SomethingDataSourceImpl({  Dio dio }) : _dio = dio;

  final Dio _dio;

  
  Future<Result<Something>> fetchSomething() async {
    try {
      return await _dio.get<Map<String, dynamic>>("something").then((response) => 
        Result.success(Something.fromJson(json)));
    } catch(error) {
      return Result.failure(error);
    }
  }
}

上記のようにクラスやメソッドを用意することにより、SomethingDataSourceImpl からデータを受け取ったクラスでは、
レスポンスを以下のように処理することができます。

final Result<Something> result = await _somethingRepository.fetchSomething();
result.when(
  success: (Something something) {
    ...
  },
  failure: (Error error) {
    ...
  }
);

result. とうったら when() が予測に出てくると思います。

これによって、kotlinsealed class のように満たしたかった HTTP Client のレスポンスnの型に依存せず、when文を利用し、success, failure を処理することができました。

おまけ

dioのレスポンスをハンドルするクラスとして ApiClient というクラスを作成しました。
処理の内容はAppDioに書いても問題ないかもしれません。

api_client.dart

class ApiClient {
  factory ApiClient() {
    if (_instance == null) {
      _instance = ApiClient._();
    }
    return _instance;
  }
  static ApiClient _instance;
  ApiClient._();

  Future<Result<T>> sendRequest<T>({
     Future<Response<Map<String, dynamic>>> request,
     T Function(Map<String, dynamic>) jsonDecodeCallback
  }) async {
    try {
      return await request.then((value) => Result.success(jsonDecodeCallback(value.data)));
    } catch (error) {
      return Result.failure(Error.getApiError(error));
    }
  }
}

ここで鍵になるのは、引数でrequestjsonDecodeCallbackを受け取っていることです。

request は、dioを利用したリクエストそのもので、dioにはget(), post(), put() 等、様々なリクエストがありますが、それらのレスポンスの型が Future<Response<T>> となっていることを利用し、ここで集約します。Future<Response<T>> における <T> は then で流れてくる Response クラスのインスタンスが保持している data プロパティの型です。

これを Map<String> 十することで、Response クラスのインスタンスから json を受け取っています。

そして、受け取った json を本来ほしい型へ変換するために jsonDecodeCallback を用意しています。

T Function(Map<String, dynamic>) jsonDecodeCallback とすることで、Map<String, dynamic> を引数として T を返す Function を引数とすることができます。

これをやろうと思った背景は、上記の SomethingDataSourceImplのように、DataSource層に毎回 try-catch、エラーハンドリングを書くことが煩わしかったため、作成しました。

try {
  return await _dio.get().then((response) => Result.success(Something.fromJson(response)));
} catch(error) {
  return Result.failure(Error.getApiError(error));
}

SomethingDataSourceImpl は以下のように書き換わります。

class SomethingDataSourceImpl with SomethingDataSourceMixin {
  SomethingDataSourceImpl({  Dio dio }) : _dio = dio;

  final Dio _dio;

  
  Future<Result<Something>> fetchSomething() async => ApiClient().sendRequest(
    request: _dio.get<Map<String, dynamic>>("something"),
    jsonDecodeCallback: (json) => Something.fromJson(json)
  );
}

スッキリしたように感じないでしょうか?
感じなかったら別にやる必要はないと思います。

終わりに

拙い文章でしたので、もし何かdiofreezedの使い方、Result<T>型のつくり方、ミスの指摘やフィードバック、質問等がありましたら、ぜひ @muttsu_623 までご連絡ください。

Discussion

初めまして、こちらの記事興味深く拝読させていただきました!
参考にエラーハンドリングを作っているところです。

そこで質問なのですが、
Error.getApiError

const factory Error.requestError({ ApiError apiError }) = _RequestError;

にてApiError と言うクラスが出てくるのですが、こちらはどの様な定義になっていますでしょうか?

ご回答よろしくお願いいたします!

コメントくださりありがとうございます😊

わかりにくくてすみません💦

ApiError はAPIを叩いたときのエラーレスポンスをハンドリングするクラスになっております。

例えばGitHubのREST APIを利用した場合、エラーが以下のようなレスポンスで返ってきます。

HTTP/1.1 400 Bad Request
Content-Length: 35

{"message":"Problems parsing JSON"}

GitHub Docs クライアントエラー

これをさばくために、ApiError クラスを以下のように作成します。

import 'package:json_annotation/json_annotation.dart';

part 'api_error.g.dart';

class ApiError {
  ApiError(this.message);
  factory ApiErorr.fromJson(Map<String, dynamic> json) =>
      _$ApiErrorFromJsonFromJson(json);

  @JsonKey(name: 'message')
  final String message;
}

※importしている json_annotation はJSONからクラスを作成してくれる [json_serializable](https://pub.dev/packages/json_serializable) を利用したものになります。

これにより、 ApiError.fromJson(error.response.data) から ApiError クラスのインスタンスが生成できるようになります!

これにより、以下の error.message でレスポンスの "Problems parsing JSON" がエラーメッセージとして利用できるようになります。

  String get errorMessage => this.when(
      requestCancelled: () => "キャンセルされました",
      unauthorisedRequest: () => "認証エラーです",
      requestError: (ApiError error) => error.message,
      serviceUnavailable: () => "しばらく時間をおいてから再度お試しください",
      sendTimeout: () => "通信環境の良いところで再度お試しください",
      noInternetConnection: () => "通信環境の良いところで再度お試しください",
      unexpectedError: () => "不明なエラーが発生しました"
  );

ご丁寧な返信ありがとうございます!
非常に良く分かりました!

ログインするとコメントできます