Chopper
Dart/Flutter向けのHTTPクライアント生成ライブラリ。source_genを利用してRetrofitライクなAPI定義からHTTPクライアントを自動生成。コンバーター、インターセプター対応により柔軟なカスタマイズが可能。time-savingなcode generationを提供。
GitHub概要
lejard-h/chopper
Chopper is an http client generator using source_gen and inspired from Retrofit.
トピックス
スター履歴
ライブラリ
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('ユーザーを削除しました')),
);
}
}
}