API Client Design
A well-designed REST API client centralizes base URL, authentication, error handling, and response parsing. This prevents repetition and makes switching APIs easier.
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'dart:io';
class RestClient {
final String baseUrl;
final String? authToken;
final http.Client _http;
RestClient({required this.baseUrl, this.authToken})
: _http = http.Client();
Map<String, String> get _headers => {
'Content-Type': 'application/json',
'Accept': 'application/json',
if (authToken != null) 'Authorization': 'Bearer $authToken',
};
Uri _uri(String path, [Map<String, String>? params]) =>
Uri.parse('$baseUrl$path').replace(queryParameters: params);
Future<dynamic> get(String path, {Map<String, String>? params}) async {
final res = await _http.get(_uri(path, params), headers: _headers)
.timeout(Duration(seconds: 15));
return _handleResponse(res);
}
Future<dynamic> post(String path, Map<String, dynamic> body) async {
final res = await _http.post(_uri(path), headers: _headers, body: jsonEncode(body))
.timeout(Duration(seconds: 15));
return _handleResponse(res);
}
Future<dynamic> put(String path, Map<String, dynamic> body) async {
final res = await _http.put(_uri(path), headers: _headers, body: jsonEncode(body))
.timeout(Duration(seconds: 15));
return _handleResponse(res);
}
Future<void> delete(String path) async {
final res = await _http.delete(_uri(path), headers: _headers)
.timeout(Duration(seconds: 15));
_handleResponse(res);
}
dynamic _handleResponse(http.Response res) {
if (res.statusCode >= 200 && res.statusCode < 300) {
return res.body.isEmpty ? null : jsonDecode(res.body);
}
throw ApiException(res.statusCode, res.body);
}
void dispose() => _http.close();
}
class ApiException implements Exception {
final int status;
final String body;
ApiException(this.status, this.body);
@override String toString() => 'ApiException($status): $body';
}
Full CRUD Example
class Todo {
final int? id;
final String title;
final bool completed;
Todo({this.id, required this.title, this.completed = false});
factory Todo.fromJson(Map<String, dynamic> j) =>
Todo(id: j['id'], title: j['title'], completed: j['completed'] ?? false);
Map<String, dynamic> toJson() =>
{'title': title, 'completed': completed, 'userId': 1};
@override String toString() => '[$${completed ? 'x' : ' '}] $title (id: $id)';
}
class TodoRepository {
final RestClient _client;
TodoRepository(this._client);
Future<List<Todo>> getAll() async {
var data = await _client.get('/todos', params: {'_limit': '5'});
return (data as List).map((j) => Todo.fromJson(j)).toList();
}
Future<Todo> getById(int id) async {
var data = await _client.get('/todos/$id');
return Todo.fromJson(data);
}
Future<Todo> create(Todo todo) async {
var data = await _client.post('/todos', todo.toJson());
return Todo.fromJson(data);
}
Future<Todo> update(int id, Todo todo) async {
var data = await _client.put('/todos/$id', todo.toJson());
return Todo.fromJson(data);
}
Future<void> delete(int id) => _client.delete('/todos/$id');
}
void main() async {
var client = RestClient(baseUrl: 'https://jsonplaceholder.typicode.com');
var repo = TodoRepository(client);
try {
var todos = await repo.getAll();
todos.forEach(print);
var newTodo = await repo.create(Todo(title: 'Learn Dart REST'));
print('Created: $newTodo');
var updated = await repo.update(newTodo.id!, Todo(title: 'Done!', completed: true));
print('Updated: $updated');
} on ApiException catch (e) {
print('API Error: $e');
} finally {
client.dispose();
}
}
🧠 Quiz
1. Which HTTP method is typically used to update an entire resource?
- A) POST
- B) PATCH (partial) vs PUT (full) ✅
- C) GET
- D) DELETE
2. What does a REST API client's _handleResponse method typically do?
- A) Sends the request
- B) Checks status code and throws on errors, decodes body on success ✅
- C) Adds headers
- D) Serializes the request body
Summary
Build REST API clients by centralizing base URL, headers, timeout, and error handling in a RestClient class. Pair it with a repository class that maps HTTP responses to domain models with fromJson. Handle ApiException for non-2xx status codes. Always call client.dispose() to close the underlying HTTP connection pool.