Chopper

HTTP client generation library for Dart/Flutter. Automatically generates HTTP client from Retrofit-like API definitions using source_gen. Enables flexible customization through converter and interceptor support. Provides time-saving code generation.

HTTP ClientDartFlutterAnnotationCode Generation

GitHub Overview

lejard-h/chopper

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

Stars737
Watchers8
Forks130
Created:April 7, 2018
Language:Dart
License:Other

Topics

None

Star History

lejard-h/chopper Star History
Data as of: 10/22/2025, 09:55 AM

Library

Chopper

Overview

Chopper is a "Retrofit-style HTTP client generator" developed for Dart and Flutter, utilizing annotation-based code generation to create HTTP client libraries. Inspired by Retrofit (Java/Android), it leverages source_gen and build_runner to automatically generate type-safe HTTP clients while avoiding reflection. Built on top of Dart's HTTP package, it significantly simplifies REST API integration in Flutter apps through declarative API definitions, comprehensive request/response conversion, and interceptor functionality, enabling maintainable network layers.

Details

Chopper 2025 edition has established itself as a standard choice for modern HTTP communication in the Flutter ecosystem. Following Retrofit's annotation approach while optimized for Dart's constraints, it achieves high-performance operation without reflection. Features declarative API definition with @ChopperApi classes, HTTP method annotations like @GET/@POST, and parameter annotations like @Path/@Query/@Body for intuitive and readable API layers. Provides rich converters like JsonConverter and FormUrlEncodedConverter, interceptors like HttpLoggingInterceptor, and authentication features through Authenticator, meeting enterprise-level API integration requirements.

Key Features

  • Annotation-Driven Development: High readability through Retrofit-style declarative API definitions
  • Automatic Code Generation: Type-safe HTTP client implementation generation via source_gen
  • Rich Converters: Support for diverse formats including JSON and Form URL Encoded
  • Interceptor Functionality: Flexible handling of logging, authentication, and request modification
  • Dart HTTP Foundation: Stable communication infrastructure based on standard HTTP package
  • Build-Time Optimization: High runtime performance through reflection-free operation

Pros and Cons

Pros

  • Low learning curve for developers familiar with Retrofit
  • Declarative and maintainable API definitions through annotation-based approach
  • Type safety and compile-time error detection through code generation
  • High extensibility through rich interceptors and converters
  • Lightweight and stable operation based on Dart's HTTP package
  • Excellent integration with Flutter's build process

Cons

  • Initial setup requires build_runner and chopper_generator configuration
  • Increased initial build time due to code generation process
  • Feature limitations compared to Dio-based high-feature HTTP clients (Retrofit.dart)
  • Manual implementation required for complex response transformation processing
  • Separate handling needed for non-HTTP communication like real-time (WebSocket)
  • Complex code generation file management in large-scale projects

Reference Pages

Code Examples

Installation and Basic Setup

# 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

# Verification in Flutter project
flutter pub get
flutter pub run build_runner build

Basic API Service Definition

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

// Specify generated files
part 'api_service.chopper.dart';
part 'api_service.g.dart';

// Data model
@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 service definition
@ChopperApi(baseUrl: '/api/v1')
abstract class ApiService extends ChopperService {
  // Factory constructor (returns generated class)
  static ApiService create([ChopperClient? client]) => _$ApiService(client);

  // Basic GET request
  @Get(path: '/users')
  Future<Response<List<User>>> getUsers();

  // GET request with path parameters
  @Get(path: '/users/{id}')
  Future<Response<User>> getUserById(@Path('id') int userId);

  // GET request with query parameters
  @Get(path: '/users')
  Future<Response<List<User>>> getUsersWithPagination(
    @Query('page') int page,
    @Query('limit') int limit,
    @Query('sort') String? sort,
  );

  // POST request (JSON body)
  @Post(path: '/users')
  Future<Response<User>> createUser(@Body() CreateUserRequest request);

  // PUT request (update)
  @Put(path: '/users/{id}')
  Future<Response<User>> updateUser(
    @Path('id') int userId,
    @Body() CreateUserRequest request,
  );

  // DELETE request
  @Delete(path: '/users/{id}')
  Future<Response<void>> deleteUser(@Path('id') int userId);

  // Request with custom headers
  @Get(path: '/users/profile')
  @FactoryConverter(request: FormUrlEncodedConverter.requestFactory)
  Future<Response<User>> getCurrentUser(
    @Header('Authorization') String token,
  );

  // POST with form data
  @Post(path: '/auth/login')
  @FactoryConverter(request: FormUrlEncodedConverter.requestFactory)
  Future<Response<Map<String, dynamic>>> login(
    @Field('username') String username,
    @Field('password') String password,
  );
}

// After code generation, run the following command:
// flutter pub run build_runner build

ChopperClient Configuration and Usage

// 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(
      // Base URL configuration
      baseUrl: Uri.parse('https://api.example.com'),
      
      // Register services to use
      services: [
        ApiService.create(),
      ],
      
      // JSON conversion configuration
      converter: JsonConverter(),
      
      // Error handling conversion
      errorConverter: JsonConverter(),
      
      // Interceptor configuration
      interceptors: [
        // Logging interceptor
        HttpLoggingInterceptor(),
        
        // Custom authentication interceptor
        _AuthInterceptor(),
        
        // Curl format log output (for debugging)
        CurlInterceptor(),
      ],
      
      // Request/Response conversion
      converter: const JsonConverter(),
    );

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

  ApiService get apiService => _apiService;

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

// Custom authentication interceptor
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) {
      // Token expiration handling
      print('Authentication error: Please update token');
      await _refreshToken();
    }
    return response;
  }

  Future<void> _refreshToken() async {
    // Token refresh logic
    print('Executing token refresh process');
  }
}

// Usage example
void main() async {
  final apiManager = ApiManager();
  final apiService = apiManager.apiService;

  try {
    // Get user list
    final usersResponse = await apiService.getUsers();
    if (usersResponse.isSuccessful) {
      final users = usersResponse.body;
      print('User list: ${users?.length} items');
      users?.forEach((user) => print('${user.name} (${user.email})'));
    }

    // Get specific user
    final userResponse = await apiService.getUserById(1);
    if (userResponse.isSuccessful) {
      final user = userResponse.body;
      print('User details: ${user?.name}');
    }

    // Get users with pagination
    final paginatedResponse = await apiService.getUsersWithPagination(
      1, // page
      10, // limit
      'created_at', // sort
    );

    // Create new user
    final createRequest = CreateUserRequest(
      name: 'John Doe',
      email: '[email protected]',
    );

    final createResponse = await apiService.createUser(createRequest);
    if (createResponse.isSuccessful) {
      final createdUser = createResponse.body;
      print('User creation completed: ID=${createdUser?.id}');
    }

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

Advanced Configuration and Customization (Authentication, Error Handling, etc.)

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

// Custom error response
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'],
    );
  }
}

// Custom error converter
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;
  }
}

// Automatic retry interceptor
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;
    }

    // Exponential backoff retry
    await Future.delayed(initialDelay * (currentRetryCount + 1));

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

    // Update retry count for new request
    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;
  }
}

// Timeout configuration interceptor
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(),
      },
    );
  }
}

// Advanced ChopperClient configuration
class AdvancedApiManager {
  late ChopperClient _chopperClient;
  late ApiService _apiService;

  AdvancedApiManager() {
    _initializeAdvancedClient();
  }

  void _initializeAdvancedClient() {
    _chopperClient = ChopperClient(
      baseUrl: Uri.parse('https://api.example.com'),
      
      services: [
        ApiService.create(),
      ],
      
      // Multiple converter configuration
      converter: JsonConverter(),
      errorConverter: ErrorConverter(),
      
      interceptors: [
        // Timeout configuration
        TimeoutInterceptor(timeout: Duration(seconds: 60)),
        
        // Authentication interceptor
        AuthInterceptor(),
        
        // Retry interceptor
        RetryInterceptor(
          maxRetries: 5,
          initialDelay: Duration(seconds: 2),
          retryStatusCodes: [429, 500, 502, 503, 504],
        ),
        
        // Response compression
        (Request request) {
          return request.copyWith(
            headers: {
              ...request.headers,
              'Accept-Encoding': 'gzip, deflate',
            },
          );
        },
        
        // Logging (conditional in production)
        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();
  }
}

// Authentication management class
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 {
    // Token expiration check
    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) {
      // Access token expired
      if (await _refreshAccessToken()) {
        // Retry
        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 {
        // Refresh failed - logout process
        clearTokens();
        throw UnauthorizedException('Authentication failed. Please log in again.');
      }
    }

    return response;
  }

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

    try {
      // Update access token with refresh token
      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;
  }
}

// Custom exception classes
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 and Practical Usage Examples

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

// Integrated error handling class
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('Network error: Please check your internet connection', showUserError);
      return null;
    } on TimeoutException {
      _showError('Timeout: Please wait a moment and try again', showUserError);
      return null;
    } on ChopperHttpException catch (e) {
      _showError('API Error: ${e.message}', showUserError);
      return null;
    } catch (e) {
      _showError('An unexpected error occurred: $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 = 'Bad request';
        break;
      case 401:
        errorMessage = 'Authentication required';
        // Execute automatic logout process, etc.
        await _handleUnauthorized();
        break;
      case 403:
        errorMessage = 'Access denied';
        break;
      case 404:
        errorMessage = 'Requested resource not found';
        break;
      case 422:
        // Detailed validation error analysis
        errorMessage = await _parseValidationErrors(response);
        break;
      case 429:
        errorMessage = 'Too many requests. Please wait and try again';
        break;
      case 500:
        errorMessage = 'Server error occurred';
        break;
      case 502:
      case 503:
      case 504:
        errorMessage = 'Server temporarily unavailable';
        break;
      default:
        errorMessage = 'Error occurred (${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 'Validation error: $errorMessages';
      }
    } catch (e) {
      print('Error parsing validation errors: $e');
    }
    return 'Validation error occurred';
  }

  static Future<void> _handleUnauthorized() async {
    // Token clearing, logout process
    print('Authentication error: Executing logout process');
    // Execute navigation to login screen, etc.
  }

  static void _showError(String message, bool show) {
    if (show) {
      print('Error: $message');
      // In actual apps, display with Toast, Snackbar, Dialog, etc.
    }
  }
}

// Practical usage example
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: 'Getting user list',
    );
  }

  Future<User?> getUserById(int userId) async {
    return ApiResponseHandler.handleResponse(
      _apiService.getUserById(userId),
      context: 'Getting user details',
    );
  }

  Future<User?> createUser(String name, String email) async {
    final request = CreateUserRequest(name: name, email: email);
    return ApiResponseHandler.handleResponse(
      _apiService.createUser(request),
      context: 'Creating user',
    );
  }

  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: 'Updating user',
    );
  }

  Future<bool> deleteUser(int userId) async {
    final result = await ApiResponseHandler.handleResponse(
      _apiService.deleteUser(userId),
      context: 'Deleting user',
    );
    return result != null;
  }

  // Pagination-aware full retrieval
  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) {
        // Reached last page
        break;
      }
      
      currentPage++;
      
      // Wait to reduce API load
      await Future.delayed(Duration(milliseconds: 100));
    }
    
    return allUsers;
  }
}

// Usage example in 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('New User', '[email protected]');
    if (user != null) {
      setState(() {
        _users.add(user);
      });
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('User created')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('User List')),
      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: $_error'),
            ElevatedButton(
              onPressed: _loadUsers,
              child: Text('Retry'),
            ),
          ],
        ),
      );
    }

    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('User deleted')),
      );
    }
  }
}