Chopper

Dart/Flutter向けのHTTPクライアント生成ライブラリ。source_genを利用してRetrofitライクなAPI定義からHTTPクライアントを自動生成。コンバーター、インターセプター対応により柔軟なカスタマイズが可能。time-savingなcode generationを提供。

HTTPクライアントDartFlutterアノテーションコード生成

GitHub概要

lejard-h/chopper

Chopper is an http client generator using source_gen and inspired from Retrofit.

スター737
ウォッチ8
フォーク130
作成日:2018年4月7日
言語:Dart
ライセンス:Other

トピックス

なし

スター履歴

lejard-h/chopper Star History
データ取得日時: 2025/10/22 09:55

ライブラリ

Chopper

概要

ChopperはDartおよびFlutter向けの「Retrofit風HTTPクライアントジェネレーター」として開発された、アノテーションベースのコード生成を活用するHTTPクライアントライブラリです。Retrofit(Java/Android)にインスパイアされ、source_genとbuild_runnerを利用してリフレクションを避けながら型安全なHTTPクライアントを自動生成。DartのHTTPパッケージをベースに構築され、宣言的なAPI定義、包括的なリクエスト/レスポンス変換、インターセプター機能により、FlutterアプリでのREST API統合を大幅に簡素化し、保守性の高いネットワーク層を実現します。

詳細

Chopper 2025年版はFlutterエコシステムにおけるモダンなHTTP通信の標準的選択肢として確固たる地位を築いています。Retrofitのアノテーションアプローチを踏襲しつつ、Dartの制約に最適化された設計により、リフレクションレスでの高性能動作を実現。@ChopperApiクラスでの宣言的API定義、@GET/@POST等のHTTPメソッドアノテーション、@Path/@Query/@Body等のパラメータアノテーションにより直感的で読みやすいAPI層を構築可能。JsonConverter、FormUrlEncodedConverter等の豊富なコンバーター、HttpLoggingInterceptor等のインターセプター、Authenticatorによる認証機能を標準提供し、企業レベルのAPI統合要件にも対応します。

主な特徴

  • アノテーション駆動開発: Retrofit風の宣言的API定義による高い可読性
  • 自動コード生成: source_genによる型安全なHTTPクライアント実装生成
  • 豊富なコンバーター: JSON、Form URL Encoded等の多様な形式サポート
  • インターセプター機能: ログ記録、認証、リクエスト変更の柔軟な処理
  • DartのHTTPベース: 標準HTTPパッケージによる安定した通信基盤
  • ビルド時最適化: リフレクション不使用による高いランタイムパフォーマンス

メリット・デメリット

メリット

  • Retrofit経験者にとって親しみやすい学習コストの低さ
  • アノテーションベースによる宣言的で保守性の高いAPI定義
  • コード生成による型安全性とコンパイル時エラー検出
  • 豊富なインターセプターとコンバーターによる高い拡張性
  • DartのHTTPパッケージベースによる軽量性と安定性
  • Flutterのビルドプロセスとの優れた統合性

デメリット

  • 初期セットアップでbuild_runnerとchopper_generatorの設定が必要
  • コード生成プロセスにより初回ビルド時間が増加
  • Dioベースの高機能HTTPクライアント(Retrofit.dart)と比較して機能制限
  • 複雑なレスポンス変換処理では手動実装が必要
  • リアルタイム通信(WebSocket)等の非HTTP通信は別途対応
  • 大規模プロジェクトでのコード生成ファイル管理の複雑化

参考ページ

書き方の例

インストールと基本セットアップ

# pubspec.yaml
dependencies:
  chopper: ^8.0.2
  json_annotation: ^4.9.0

dev_dependencies:
  chopper_generator: ^8.0.1
  build_runner: ^2.4.9
  json_serializable: ^6.8.0

# Flutterプロジェクトでの確認
flutter pub get
flutter pub run build_runner build

基本的なAPIサービス定義

// api_service.dart
import 'package:chopper/chopper.dart';
import 'package:json_annotation/json_annotation.dart';

// 生成されるファイルを指定
part 'api_service.chopper.dart';
part 'api_service.g.dart';

// データモデル
@JsonSerializable()
class User {
  final int id;
  final String name;
  final String email;
  final DateTime createdAt;

  User({
    required this.id,
    required this.name,
    required this.email,
    required this.createdAt,
  });

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

@JsonSerializable()
class CreateUserRequest {
  final String name;
  final String email;

  CreateUserRequest({required this.name, required this.email});

  factory CreateUserRequest.fromJson(Map<String, dynamic> json) => 
    _$CreateUserRequestFromJson(json);
  Map<String, dynamic> toJson() => _$CreateUserRequestToJson(this);
}

// APIサービス定義
@ChopperApi(baseUrl: '/api/v1')
abstract class ApiService extends ChopperService {
  // ファクトリーコンストラクタ(生成されるクラスを返す)
  static ApiService create([ChopperClient? client]) => _$ApiService(client);

  // 基本的なGETリクエスト
  @Get(path: '/users')
  Future<Response<List<User>>> getUsers();

  // パスパラメータ付きGETリクエスト
  @Get(path: '/users/{id}')
  Future<Response<User>> getUserById(@Path('id') int userId);

  // クエリパラメータ付きGETリクエスト
  @Get(path: '/users')
  Future<Response<List<User>>> getUsersWithPagination(
    @Query('page') int page,
    @Query('limit') int limit,
    @Query('sort') String? sort,
  );

  // POSTリクエスト(JSONボディ)
  @Post(path: '/users')
  Future<Response<User>> createUser(@Body() CreateUserRequest request);

  // PUTリクエスト(更新)
  @Put(path: '/users/{id}')
  Future<Response<User>> updateUser(
    @Path('id') int userId,
    @Body() CreateUserRequest request,
  );

  // DELETEリクエスト
  @Delete(path: '/users/{id}')
  Future<Response<void>> deleteUser(@Path('id') int userId);

  // カスタムヘッダー付きリクエスト
  @Get(path: '/users/profile')
  @FactoryConverter(request: FormUrlEncodedConverter.requestFactory)
  Future<Response<User>> getCurrentUser(
    @Header('Authorization') String token,
  );

  // フォームデータでのPOST
  @Post(path: '/auth/login')
  @FactoryConverter(request: FormUrlEncodedConverter.requestFactory)
  Future<Response<Map<String, dynamic>>> login(
    @Field('username') String username,
    @Field('password') String password,
  );
}

// コード生成実行後、以下のコマンドを実行
// flutter pub run build_runner build

ChopperClientの設定と使用方法

// chopper_client.dart
import 'package:chopper/chopper.dart';
import 'package:dio/dio.dart';
import 'package:logging/logging.dart';
import 'api_service.dart';

class ApiManager {
  late ChopperClient _chopperClient;
  late ApiService _apiService;

  ApiManager() {
    _initializeClient();
  }

  void _initializeClient() {
    _chopperClient = ChopperClient(
      // ベースURL設定
      baseUrl: Uri.parse('https://api.example.com'),
      
      // 使用するサービスを登録
      services: [
        ApiService.create(),
      ],
      
      // JSON変換設定
      converter: JsonConverter(),
      
      // エラーハンドリング変換
      errorConverter: JsonConverter(),
      
      // インターセプター設定
      interceptors: [
        // ログ記録インターセプター
        HttpLoggingInterceptor(),
        
        // カスタム認証インターセプター
        _AuthInterceptor(),
        
        // カール形式ログ出力(デバッグ用)
        CurlInterceptor(),
      ],
      
      // リクエスト・レスポンス変換
      converter: const JsonConverter(),
    );

    _apiService = _chopperClient.getService<ApiService>();
  }

  ApiService get apiService => _apiService;

  void dispose() {
    _chopperClient.dispose();
  }
}

// カスタム認証インターセプター
class _AuthInterceptor implements RequestInterceptor, ResponseInterceptor {
  String? _accessToken;

  void setToken(String token) {
    _accessToken = token;
  }

  @override
  FutureOr<Request> onRequest(Request request) async {
    if (_accessToken != null) {
      return request.copyWith(
        headers: {
          ...request.headers,
          'Authorization': 'Bearer $_accessToken',
        },
      );
    }
    return request;
  }

  @override
  FutureOr<Response> onResponse(Response response) async {
    if (response.statusCode == 401) {
      // トークン期限切れ処理
      print('認証エラー: トークンを更新してください');
      await _refreshToken();
    }
    return response;
  }

  Future<void> _refreshToken() async {
    // トークン更新ロジック
    print('トークン更新処理を実行');
  }
}

// 使用例
void main() async {
  final apiManager = ApiManager();
  final apiService = apiManager.apiService;

  try {
    // ユーザー一覧取得
    final usersResponse = await apiService.getUsers();
    if (usersResponse.isSuccessful) {
      final users = usersResponse.body;
      print('ユーザー一覧: ${users?.length}件');
      users?.forEach((user) => print('${user.name} (${user.email})'));
    }

    // 特定ユーザー取得
    final userResponse = await apiService.getUserById(1);
    if (userResponse.isSuccessful) {
      final user = userResponse.body;
      print('ユーザー詳細: ${user?.name}');
    }

    // ページネーション付きユーザー取得
    final paginatedResponse = await apiService.getUsersWithPagination(
      1, // page
      10, // limit
      'created_at', // sort
    );

    // 新規ユーザー作成
    final createRequest = CreateUserRequest(
      name: '田中太郎',
      email: '[email protected]',
    );

    final createResponse = await apiService.createUser(createRequest);
    if (createResponse.isSuccessful) {
      final createdUser = createResponse.body;
      print('ユーザー作成完了: ID=${createdUser?.id}');
    }

  } catch (e) {
    print('APIエラー: $e');
  } finally {
    apiManager.dispose();
  }
}

高度な設定とカスタマイズ(認証、エラーハンドリング等)

// advanced_chopper_setup.dart
import 'package:chopper/chopper.dart';
import 'dart:convert';
import 'dart:io';

// カスタムエラーレスポンス
class ApiError {
  final String message;
  final int code;
  final Map<String, dynamic>? details;

  ApiError({
    required this.message,
    required this.code,
    this.details,
  });

  factory ApiError.fromJson(Map<String, dynamic> json) {
    return ApiError(
      message: json['message'] ?? 'Unknown error',
      code: json['code'] ?? 0,
      details: json['details'],
    );
  }
}

// カスタムエラーコンバーター
class ErrorConverter implements Converter {
  const ErrorConverter();

  @override
  Request convertRequest(Request request) => request;

  @override
  Response<BodyType> convertResponse<BodyType, InnerType>(Response response) {
    if (response.statusCode >= 400) {
      try {
        final jsonData = json.decode(response.body);
        final error = ApiError.fromJson(jsonData);
        throw ChopperHttpException(response, error.message);
      } catch (e) {
        throw ChopperHttpException(response, 'API Error: ${response.statusCode}');
      }
    }
    return response;
  }
}

// 自動リトライインターセプター
class RetryInterceptor implements RequestInterceptor, ResponseInterceptor {
  final int maxRetries;
  final Duration initialDelay;
  final List<int> retryStatusCodes;

  const RetryInterceptor({
    this.maxRetries = 3,
    this.initialDelay = const Duration(seconds: 1),
    this.retryStatusCodes = const [500, 502, 503, 504],
  });

  @override
  FutureOr<Request> onRequest(Request request) {
    return request.copyWith(
      headers: {
        ...request.headers,
        'X-Retry-Count': '0',
      },
    );
  }

  @override
  FutureOr<Response> onResponse(Response response) async {
    if (!retryStatusCodes.contains(response.statusCode)) {
      return response;
    }

    final retryCountHeader = response.base.request?.headers['X-Retry-Count'];
    final currentRetryCount = int.tryParse(retryCountHeader ?? '0') ?? 0;

    if (currentRetryCount >= maxRetries) {
      return response;
    }

    // 指数バックオフでリトライ
    await Future.delayed(initialDelay * (currentRetryCount + 1));

    print('Retrying request (${currentRetryCount + 1}/$maxRetries): ${response.base.request?.url}');

    // 新しいリクエストでリトライカウントを更新
    final retryRequest = response.base.request?.copyWith(
      headers: {
        ...response.base.request?.headers ?? {},
        'X-Retry-Count': '${currentRetryCount + 1}',
      },
    );

    if (retryRequest != null) {
      return response.base.request?.client?.send(retryRequest) ?? response;
    }

    return response;
  }
}

// タイムアウト設定インターセプター
class TimeoutInterceptor implements RequestInterceptor {
  final Duration timeout;

  const TimeoutInterceptor({this.timeout = const Duration(seconds: 30)});

  @override
  FutureOr<Request> onRequest(Request request) {
    return request.copyWith(
      headers: {
        ...request.headers,
        'X-Request-Timeout': timeout.inMilliseconds.toString(),
      },
    );
  }
}

// 高度なChopperClient設定
class AdvancedApiManager {
  late ChopperClient _chopperClient;
  late ApiService _apiService;

  AdvancedApiManager() {
    _initializeAdvancedClient();
  }

  void _initializeAdvancedClient() {
    _chopperClient = ChopperClient(
      baseUrl: Uri.parse('https://api.example.com'),
      
      services: [
        ApiService.create(),
      ],
      
      // 複数コンバーター設定
      converter: JsonConverter(),
      errorConverter: ErrorConverter(),
      
      interceptors: [
        // タイムアウト設定
        TimeoutInterceptor(timeout: Duration(seconds: 60)),
        
        // 認証インターセプター
        AuthInterceptor(),
        
        // リトライインターセプター
        RetryInterceptor(
          maxRetries: 5,
          initialDelay: Duration(seconds: 2),
          retryStatusCodes: [429, 500, 502, 503, 504],
        ),
        
        // レスポンス圧縮
        (Request request) {
          return request.copyWith(
            headers: {
              ...request.headers,
              'Accept-Encoding': 'gzip, deflate',
            },
          );
        },
        
        // ロギング(プロダクションでは条件付き)
        if (isDebugMode()) HttpLoggingInterceptor(),
      ],
    );

    _apiService = _chopperClient.getService<ApiService>();
  }

  ApiService get apiService => _apiService;

  bool isDebugMode() {
    bool inDebugMode = false;
    assert(inDebugMode = true);
    return inDebugMode;
  }

  void dispose() {
    _chopperClient.dispose();
  }
}

// 認証管理クラス
class AuthInterceptor implements RequestInterceptor, ResponseInterceptor {
  String? _accessToken;
  String? _refreshToken;
  DateTime? _tokenExpiry;

  void setTokens(String accessToken, String refreshToken, DateTime expiry) {
    _accessToken = accessToken;
    _refreshToken = refreshToken;
    _tokenExpiry = expiry;
  }

  void clearTokens() {
    _accessToken = null;
    _refreshToken = null;
    _tokenExpiry = null;
  }

  @override
  FutureOr<Request> onRequest(Request request) async {
    // トークンの有効期限チェック
    if (_accessToken != null && _tokenExpiry != null) {
      if (DateTime.now().isAfter(_tokenExpiry!.subtract(Duration(minutes: 5)))) {
        await _refreshAccessToken();
      }
    }

    if (_accessToken != null) {
      return request.copyWith(
        headers: {
          ...request.headers,
          'Authorization': 'Bearer $_accessToken',
          'Content-Type': 'application/json',
          'Accept': 'application/json',
        },
      );
    }

    return request;
  }

  @override
  FutureOr<Response> onResponse(Response response) async {
    if (response.statusCode == 401) {
      // アクセストークン期限切れ
      if (await _refreshAccessToken()) {
        // リトライ
        final retryRequest = response.base.request?.copyWith(
          headers: {
            ...response.base.request?.headers ?? {},
            'Authorization': 'Bearer $_accessToken',
          },
        );
        
        if (retryRequest != null) {
          return response.base.request?.client?.send(retryRequest) ?? response;
        }
      } else {
        // リフレッシュ失敗 - ログアウト処理
        clearTokens();
        throw UnauthorizedException('認証に失敗しました。再ログインしてください。');
      }
    }

    return response;
  }

  Future<bool> _refreshAccessToken() async {
    if (_refreshToken == null) return false;

    try {
      // リフレッシュトークンでアクセストークンを更新
      final response = await ChopperClient(
        baseUrl: Uri.parse('https://api.example.com'),
        converter: JsonConverter(),
      ).send(Request(
        'POST',
        Uri.parse('/auth/refresh'),
        Uri.parse('https://api.example.com'),
        headers: {'Content-Type': 'application/json'},
        body: json.encode({'refresh_token': _refreshToken}),
      ));

      if (response.statusCode == 200) {
        final data = json.decode(response.body);
        _accessToken = data['access_token'];
        _tokenExpiry = DateTime.now().add(Duration(seconds: data['expires_in']));
        return true;
      }
    } catch (e) {
      print('Token refresh error: $e');
    }

    return false;
  }
}

// カスタム例外クラス
class UnauthorizedException implements Exception {
  final String message;
  UnauthorizedException(this.message);
  
  @override
  String toString() => 'UnauthorizedException: $message';
}

class ChopperHttpException implements Exception {
  final Response response;
  final String message;

  ChopperHttpException(this.response, this.message);

  @override
  String toString() => 'ChopperHttpException: $message (${response.statusCode})';
}

エラーハンドリングと実践的な使用例

// error_handling_example.dart
import 'package:chopper/chopper.dart';
import 'dart:io';

// 統合エラーハンドリングクラス
class ApiResponseHandler {
  static Future<T?> handleResponse<T>(
    Future<Response<T>> responseFuture, {
    String? context,
    bool showUserError = true,
  }) async {
    try {
      final response = await responseFuture;
      
      if (response.isSuccessful) {
        return response.body;
      } else {
        await _handleErrorResponse(response, context, showUserError);
        return null;
      }
    } on SocketException {
      _showError('ネットワークエラー: インターネット接続を確認してください', showUserError);
      return null;
    } on TimeoutException {
      _showError('タイムアウト: しばらく待ってから再試行してください', showUserError);
      return null;
    } on ChopperHttpException catch (e) {
      _showError('API Error: ${e.message}', showUserError);
      return null;
    } catch (e) {
      _showError('予期しないエラーが発生しました: $e', showUserError);
      return null;
    }
  }

  static Future<void> _handleErrorResponse(
    Response response,
    String? context,
    bool showUserError,
  ) async {
    String errorMessage = 'Unknown error';
    
    switch (response.statusCode) {
      case 400:
        errorMessage = 'リクエストが正しくありません';
        break;
      case 401:
        errorMessage = '認証が必要です';
        // 自動ログアウト処理などを実行
        await _handleUnauthorized();
        break;
      case 403:
        errorMessage = 'アクセス権限がありません';
        break;
      case 404:
        errorMessage = '要求されたリソースが見つかりません';
        break;
      case 422:
        // バリデーションエラーの詳細解析
        errorMessage = await _parseValidationErrors(response);
        break;
      case 429:
        errorMessage = 'リクエストが多すぎます。しばらく待ってから再試行してください';
        break;
      case 500:
        errorMessage = 'サーバーエラーが発生しました';
        break;
      case 502:
      case 503:
      case 504:
        errorMessage = 'サーバーが一時的に利用できません';
        break;
      default:
        errorMessage = 'エラーが発生しました (${response.statusCode})';
    }

    if (context != null) {
      errorMessage = '$context: $errorMessage';
    }

    _showError(errorMessage, showUserError);
  }

  static Future<String> _parseValidationErrors(Response response) async {
    try {
      final errorData = json.decode(response.body);
      if (errorData['errors'] != null) {
        final errors = errorData['errors'] as Map<String, dynamic>;
        final errorMessages = errors.values
            .expand((error) => error is List ? error : [error])
            .join(', ');
        return 'バリデーションエラー: $errorMessages';
      }
    } catch (e) {
      print('Error parsing validation errors: $e');
    }
    return 'バリデーションエラーが発生しました';
  }

  static Future<void> _handleUnauthorized() async {
    // トークンクリア、ログアウト処理
    print('認証エラー: ログアウト処理を実行');
    // ログイン画面への遷移などを実行
  }

  static void _showError(String message, bool show) {
    if (show) {
      print('Error: $message');
      // 実際のアプリでは Toast、Snackbar、Dialog等で表示
    }
  }
}

// 実践的な使用例
class UserRepository {
  final ApiService _apiService;

  UserRepository(this._apiService);

  Future<List<User>?> getUsers({int page = 1, int limit = 20}) async {
    return ApiResponseHandler.handleResponse(
      _apiService.getUsersWithPagination(page, limit, 'created_at'),
      context: 'ユーザー一覧の取得',
    );
  }

  Future<User?> getUserById(int userId) async {
    return ApiResponseHandler.handleResponse(
      _apiService.getUserById(userId),
      context: 'ユーザー詳細の取得',
    );
  }

  Future<User?> createUser(String name, String email) async {
    final request = CreateUserRequest(name: name, email: email);
    return ApiResponseHandler.handleResponse(
      _apiService.createUser(request),
      context: 'ユーザーの作成',
    );
  }

  Future<User?> updateUser(int userId, String name, String email) async {
    final request = CreateUserRequest(name: name, email: email);
    return ApiResponseHandler.handleResponse(
      _apiService.updateUser(userId, request),
      context: 'ユーザーの更新',
    );
  }

  Future<bool> deleteUser(int userId) async {
    final result = await ApiResponseHandler.handleResponse(
      _apiService.deleteUser(userId),
      context: 'ユーザーの削除',
    );
    return result != null;
  }

  // ページネーション対応の全件取得
  Future<List<User>> getAllUsers({int batchSize = 50}) async {
    final allUsers = <User>[];
    int currentPage = 1;
    
    while (true) {
      final users = await getUsers(page: currentPage, limit: batchSize);
      
      if (users == null || users.isEmpty) {
        break;
      }
      
      allUsers.addAll(users);
      
      if (users.length < batchSize) {
        // 最後のページに到達
        break;
      }
      
      currentPage++;
      
      // API負荷軽減のための待機
      await Future.delayed(Duration(milliseconds: 100));
    }
    
    return allUsers;
  }
}

// Flutter Widgetでの使用例
class UserListScreen extends StatefulWidget {
  @override
  _UserListScreenState createState() => _UserListScreenState();
}

class _UserListScreenState extends State<UserListScreen> {
  late UserRepository _userRepository;
  List<User> _users = [];
  bool _isLoading = false;
  String? _error;

  @override
  void initState() {
    super.initState();
    final apiManager = ApiManager();
    _userRepository = UserRepository(apiManager.apiService);
    _loadUsers();
  }

  Future<void> _loadUsers() async {
    setState(() {
      _isLoading = true;
      _error = null;
    });

    try {
      final users = await _userRepository.getUsers();
      setState(() {
        _users = users ?? [];
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _error = e.toString();
        _isLoading = false;
      });
    }
  }

  Future<void> _createUser() async {
    final user = await _userRepository.createUser('新規ユーザー', '[email protected]');
    if (user != null) {
      setState(() {
        _users.add(user);
      });
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('ユーザーを作成しました')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('ユーザー一覧')),
      body: _buildBody(),
      floatingActionButton: FloatingActionButton(
        onPressed: _createUser,
        child: Icon(Icons.add),
      ),
    );
  }

  Widget _buildBody() {
    if (_isLoading) {
      return Center(child: CircularProgressIndicator());
    }

    if (_error != null) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('エラー: $_error'),
            ElevatedButton(
              onPressed: _loadUsers,
              child: Text('再試行'),
            ),
          ],
        ),
      );
    }

    return RefreshIndicator(
      onRefresh: _loadUsers,
      child: ListView.builder(
        itemCount: _users.length,
        itemBuilder: (context, index) {
          final user = _users[index];
          return ListTile(
            title: Text(user.name),
            subtitle: Text(user.email),
            trailing: IconButton(
              icon: Icon(Icons.delete),
              onPressed: () => _deleteUser(user.id),
            ),
          );
        },
      ),
    );
  }

  Future<void> _deleteUser(int userId) async {
    final success = await _userRepository.deleteUser(userId);
    if (success) {
      setState(() {
        _users.removeWhere((user) => user.id == userId);
      });
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('ユーザーを削除しました')),
      );
    }
  }
}