Working with REST APIs
1. Understanding REST Principles
| Principle | Description | Example |
|---|---|---|
| Statelessness | Each request from client to server must contain all information needed to understand and process the request. | GET /api/users/123 includes all necessary data to retrieve user details. |
| Client-Server Architecture | Separation of concerns between client and server, allowing them to evolve independently. | Mobile app (client) communicates with cloud backend (server) via HTTP. |
| Uniform Interface | Consistent and standardized way to interact with resources using standard HTTP methods. | GET, POST, PUT, DELETE used for CRUD operations. |
| Resource-Based | Everything is treated as a resource identified by a URI. | /api/orders/456 represents an order resource. |
| Representation | Resources can be represented in various formats like JSON, XML. | Accept: application/json header specifies format. |
Example: REST API Endpoints
GET /api/users # Retrieve list of users
POST /api/users # Create a new user
GET /api/users/123 # Retrieve specific user
PUT /api/users/123 # Update specific user
DELETE /api/users/123 # Delete specific user
Note: REST is an architectural style, not a protocol or standard. It relies on HTTP for communication.
2. Implementing Repository Pattern
Example: Basic Repository Implementation
// Repository interface
abstract class UserRepository {
Future<User> fetchUser(int id);
Future<List<User>> fetchUsers();
Future<User> saveUser(User user);
}
// Concrete implementation
class UserRepositoryImpl implements UserRepository {
final HttpClient client;
UserRepositoryImpl(this.client);
@override
Future<User> fetchUser(int id) async {
final response = await client.get('/users/$id');
return User.fromJson(response.data);
}
@override
Future<List<User>> fetchUsers() async {
final response = await client.get('/users');
return (response.data as List)
.map((e) => User.fromJson(e))
.toList();
}
@override
Future<User> saveUser(User user) async {
final response = await client.post('/users', data: user.toJson());
return User.fromJson(response.data);
}
}
| Component | Description | Usage |
|---|---|---|
| Repository Interface | Defines contract for data access operations | Enforces consistency across implementations |
| Concrete Implementation | Actual implementation using HTTP client | Handles network calls and data mapping |
| Dependency Injection | Inject repository into use cases or widgets | Allows easy mocking and testing |
Note: The repository pattern abstracts data source logic, making it easier to switch between local and remote data sources.
Repository vs Direct API Calls
- Repository: Provides a clean abstraction layer, supports mocking, and centralizes data logic
- Direct API: Simpler but tightly couples UI to network logic
3. Creating API Service Classes
| Property | Description | Example |
|---|---|---|
| Base URL | Root endpoint for API calls | https://api.example.com/v1 |
| HTTP Client | Instance used to make requests | http.Client() |
| Headers | Authentication and content-type headers | {'Authorization': 'Bearer token', 'Content-Type': 'application/json'} |
Example: Basic API Service Class
import 'dart:convert';
import 'package:http/http.dart' as http;
class ApiService {
static const String baseUrl = 'https://api.example.com/v1';
final http.Client client = http.Client();
Future<http.Response> fetchUserData(int userId) async {
final response = await client.get(
Uri.parse('$baseUrl/users/$userId'),
headers: {'Authorization': 'Bearer token'},
);
return response;
}
Future<http.Response> postUserData(Map<String, dynamic> data) async {
final response = await client.post(
Uri.parse('$baseUrl/users'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(data),
);
return response;
}
}
Note: Always dispose of
http.Client() in production to prevent resource leaks.
Example: Using Dio for API Service
import 'package:dio/dio.dart';
class DioApiService {
final Dio dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com/v1',
headers: {'Authorization': 'Bearer token'},
));
Future<Response> fetchUser(int userId) async {
return await dio.get('/users/$userId');
}
Future<Response> createUser(Map<String, dynamic> data) async {
return await dio.post('/users', data: data);
}
}
4. Handling API Errors
| Error Type | Description | Handling Strategy | Code Example |
|---|---|---|---|
| NetworkError | Occurs when the device cannot connect to the internet or the server is unreachable. | Display a user-friendly message and provide retry options. | |
| TimeoutException | Occurs when the request exceeds the specified timeout duration. | Inform the user and allow for a retry or fallback action. | |
| HttpError (4xx/5xx) | Server returns an HTTP error status code indicating client or server issues. | Parse the error code and provide context-specific messages to the user. | |
Example: Comprehensive Error Handling
Future<dynamic> fetchData() async {
try {
final response = await http.get(
Uri.parse('https://api.example.com/data'),
timeout: Duration(seconds: 10),
);
if (response.statusCode == 200) {
return json.decode(response.body);
} else if (response.statusCode == 401) {
throw Exception('Unauthorized: Please log in again');
} else if (response.statusCode == 404) {
throw Exception('Resource not found');
} else {
throw Exception('HTTP Error: ${response.statusCode}');
}
} on SocketException {
throw Exception('No internet connection');
} on TimeoutException {
throw Exception('Request timed out');
} catch (e) {
throw Exception('An unexpected error occurred: $e');
}
}
5. Implementing Request Retry Logic
| Property | Description | Default Value | Notes |
|---|---|---|---|
| maxRetries | Maximum number of retry attempts for failed requests | 3 |
Set to 0 to disable retries |
| retryDelay | Initial delay (in milliseconds) before retrying a request | 1000 |
Exponential backoff is applied by default |
| retryableStatusCodes | List of HTTP status codes that trigger a retry | [429, 500, 503] |
Customize based on your API's retry policy |
Example: Basic Retry Logic with Dio
import 'package:dio/dio.dart';
final dio = Dio();
// Add retry interceptor
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
// Add custom logic if needed
return handler.next(options);
},
onResponse: (response, handler) {
return handler.next(response);
},
onError: (error, handler) async {
if (error.response?.statusCode == 503 && error.retryCount < 3) {
await Future.delayed(Duration(milliseconds: 1000 * (error.retryCount + 1)));
return handler.retry();
}
return handler.next(error);
},
));
// Use dio to make requests
final response = await dio.get('https://api.example.com/data');
Note: The
retryCount property is added by Dio's retry interceptor and can be used to track how many times a request has been retried.
Warning: Be cautious with retry logic to avoid infinite loops or excessive load on the server. Always implement a maximum retry limit.
6. Using Interceptors
| Interceptor Type | Description | Usage |
|---|---|---|
| Dio Interceptor | Allows intercepting requests and responses for logging, authentication, or transformation. | final dio = Dio();\ndio.interceptors.add(InterceptorsWrapper(\n onRequest: (options, handler) {\n // Modify request\n return handler.next(options);\n },\n onResponse: (response, handler) {\n // Modify response\n return handler.next(response);\n },\n)); |
Example: Adding Authentication Token
final dio = Dio();\ndio.interceptors.add(InterceptorsWrapper(\n onRequest: (options, handler) {\n options.headers['Authorization'] = 'Bearer $token';\n return handler.next(options);\n },\n));
7. Caching API Responses
| Property | Description | Default |
|---|---|---|
| cacheDuration | Duration to cache API responses in seconds | 300 |
| maxCacheSize | Maximum number of cached responses to store | 100 |
| cacheKey | Unique identifier for caching a specific API endpoint | null |
Example: Using http package with caching
import 'package:http/http.dart' as http;
import 'dart:convert';
class CachedApiService {
static final Map<String, dynamic> _cache = {};
static final Map<String, DateTime> _cacheTimestamps = {};
static Future<dynamic> getCachedResponse(
String url, {
int cacheDuration = 300,
}) async {
final cached = _cache[url];
final timestamp = _cacheTimestamps[url];
if (cached != null && timestamp != null) {
final now = DateTime.now();
if (now.difference(timestamp).inSeconds < cacheDuration) {
return cached;
}
}
final response = await http.get(Uri.parse(url));
final data = json.decode(response.body);
_cache[url] = data;
_cacheTimestamps[url] = DateTime.now();
return data;
}
}
Example: Using shared_preferences for persistent caching
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
class PersistentCacheService {
static Future<dynamic> getCachedData(String key) async {
final prefs = await SharedPreferences.getInstance();
final cached = prefs.getString(key);
if (cached != null) {
try {
return json.decode(cached);
} catch (e) {
return null;
}
}
return null;
}
static Future<void> setCachedData(String key, dynamic data) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(key, json.encode(data));
}
}
Note: Caching strategies should consider data freshness and user experience trade-offs. For real-time data, shorter cache durations or no caching may be preferred.
8. Implementing Pagination
Example: Fetching paginated data
class ApiService {
static const String baseUrl = 'https://api.example.com';
Future<List<Post>> fetchPosts(int page, int limit) async {
final response = await http.get(
Uri.parse('$baseUrl/posts?page=$page&limit=$limit'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return (data as List).map((e) => Post.fromJson(e)).toList();
} else {
throw Exception('Failed to load posts');
}
}
}
| Property | Description | Usage |
|---|---|---|
| page | Current page number | Used in API request to fetch specific page of data |
| limit | Number of items per page | Controls how many items are returned in each API response |
| total | Total number of items available | Used to calculate total pages and enable pagination controls |
Workflow: Implementing Pagination in Flutter
- Create a service class to handle API calls with page and limit parameters
- Fetch initial data (e.g., page 1, limit 20)
- Implement scroll listener or button to load next page
- Append new data to existing list when loading more pages
- Update UI with new page data and disable loading indicator
Comparison: Pagination Approaches
- Offset-based: Uses offset parameter (e.g., page=2, limit=10) - Simple but can be inefficient with large datasets
- Cursor-based: Uses cursor/token (e.g., last ID from previous page) - More efficient for large datasets
- Keyset-based: Uses keyset (e.g., timestamp or ID) - Good for ordered data
Note: When implementing pagination, always handle loading states and potential errors gracefully to provide a smooth user experience.
9. Using Query Parameters
| Parameter | Type | Description | Example |
|---|---|---|---|
| query | Map<String, String> |
Map of query parameters to be appended to the URL | {'page': '1', 'limit': '20'} |
Example: Making a GET request with query parameters
import 'package:http/http.dart' as http;
Future<http.Response> fetchPosts() async {
final response = await http
.get(Uri.parse('https://jsonplaceholder.typicode.com/posts'),
queryParameters: {'userId': '1', 'page': '1'});
return response;
}
Note: Query parameters are automatically URL-encoded by the
http package.
10. Handling Rate Limiting
| Property | Description | Values |
|---|---|---|
| http.Response | Response object from HTTP requests | statusCode, headers |
| X-RateLimit-Limit | Maximum number of requests allowed in a window | 1000 |
| X-RateLimit-Remaining | Number of requests remaining in the current window | 999 |
| X-RateLimit-Reset | Timestamp when the rate limit window resets | 1678886400 |
Example: Checking Rate Limits
import 'package:http/http.dart' as http;
Future<void> fetchWithRateLimitCheck() async {
final response = await http.get(Uri.parse('https://api.example.com/data'));
final rateLimit = response.headers['x-ratelimit-remaining'];
if (rateLimit != null && int.parse(rateLimit) <= 5) {
print("Warning: Low API calls remaining");
}
}
Example: Implementing Retry Logic
import 'package:http/http.dart' as http;
Future<http.Response> fetchWithRetry(String url, int maxRetries) async {
for (int i = 0; i <= maxRetries; i++) {
try {
final response = await http.get(Uri.parse(url));
if (response.statusCode == 429) {
// Handle rate limit
final resetTime = int.parse(response.headers['x-ratelimit-reset']!);
final delay = resetTime - DateTime.now().millisecondsSinceEpoch ~/ 1000;
await Future.delayed(Duration(seconds: delay));
continue;
}
return response;
} catch (e) {
if (i == maxRetries) rethrow;
}
}
throw Exception("Max retries exceeded");
}
Note: Implementing exponential backoff can improve resilience when handling rate limits.