Client Development
Setting Up the Hypermodern Client
The Hypermodern client provides a unified interface for communicating with servers across multiple protocols. Whether you're building a mobile app, web application, or desktop client, the API remains consistent.
Basic Client Setup
import 'package:hypermodern/hypermodern.dart';
// Automatic protocol selection based on URL
final client = HypermodernClient('ws://localhost:8082');
// Or specify protocol explicitly
final httpClient = HypermodernClient.http('http://localhost:8080');
final wsClient = HypermodernClient.websocket('ws://localhost:8082');
final tcpClient = HypermodernClient.tcp('localhost:8081');
Client Configuration
Configure client behavior with detailed options:
final client = HypermodernClient(
'ws://localhost:8082',
config: ClientConfig(
// Connection settings
connectionTimeout: Duration(seconds: 10),
requestTimeout: Duration(seconds: 30),
// Retry configuration
maxRetries: 3,
retryDelay: Duration(seconds: 1),
retryBackoffMultiplier: 2.0,
// Reconnection (WebSocket/TCP)
autoReconnect: true,
reconnectAttempts: 5,
reconnectDelay: Duration(seconds: 2),
// Performance tuning
binaryMode: true,
compressionEnabled: true,
keepAlive: true,
// Security
validateCertificates: true,
customHeaders: {
'User-Agent': 'MyApp/1.0.0',
'X-API-Version': '2.0',
},
),
);
Authentication Setup
Configure authentication for all requests:
// JWT Authentication
client.setAuthToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');
// Custom authentication
client.setAuthProvider(CustomAuthProvider());
// Per-request authentication
final response = await client.request<User>(
'get_user',
request,
auth: BearerAuth('custom-token'),
);
Making Requests Across Protocols
The beauty of Hypermodern is that request patterns remain identical regardless of the underlying protocol.
Basic Request Pattern
import 'generated/models.dart';
import 'generated/requests.dart';
Future<void> basicRequests() async {
await client.connect();
try {
// Create a user
final createRequest = CreateUserRequest(
username: 'alice',
email: 'alice@example.com',
password: 'secure123',
);
final newUser = await client.request<User>('create_user', createRequest);
print('Created user: ${newUser.username} (ID: ${newUser.id})');
// Get the user back
final getRequest = GetUserRequest(id: newUser.id);
final fetchedUser = await client.request<User>('get_user', getRequest);
print('Fetched user: ${fetchedUser.username}');
// Update the user
final updateRequest = UpdateUserRequest(
id: newUser.id,
username: 'alice_updated',
);
final updatedUser = await client.request<User>('update_user', updateRequest);
print('Updated user: ${updatedUser.username}');
} finally {
await client.disconnect();
}
}
Batch Requests
Execute multiple requests efficiently:
Future<void> batchRequests() async {
// Parallel execution
final futures = [
client.request<User>('get_user', GetUserRequest(id: 1)),
client.request<User>('get_user', GetUserRequest(id: 2)),
client.request<User>('get_user', GetUserRequest(id: 3)),
];
final users = await Future.wait(futures);
print('Fetched ${users.length} users in parallel');
// Sequential with error handling
final userIds = [1, 2, 3, 4, 5];
final fetchedUsers = <User>[];
for (final id in userIds) {
try {
final user = await client.request<User>('get_user', GetUserRequest(id: id));
fetchedUsers.add(user);
} on NotFoundException {
print('User $id not found, skipping');
}
}
print('Successfully fetched ${fetchedUsers.length} users');
}
Request Cancellation
Cancel long-running requests:
Future<void> cancellableRequests() async {
final cancellationToken = CancellationToken();
// Start a long-running request
final future = client.request<SearchResult>(
'search_users',
SearchUsersRequest(query: 'complex search'),
cancellationToken: cancellationToken,
);
// Cancel after 5 seconds
Timer(Duration(seconds: 5), () {
cancellationToken.cancel();
});
try {
final result = await future;
print('Search completed: ${result.totalCount} results');
} on CancellationException {
print('Search was cancelled');
}
}
Handling Real-time Streams
Streaming is where Hypermodern really shines, providing real-time communication with the same ease as regular requests.
Basic Streaming
Future<void> basicStreaming() async {
await client.connect();
// Start streaming user updates
final request = WatchUsersRequest(userIds: [1, 2, 3]);
await for (final update in client.stream<UserUpdate>('watch_users', request)) {
switch (update.type) {
case UpdateType.created:
print('User created: ${update.user.username}');
break;
case UpdateType.updated:
print('User updated: ${update.user.username}');
break;
case UpdateType.deleted:
print('User deleted: ${update.userId}');
break;
}
}
}
Stream Management
Control stream lifecycle and handle errors:
Future<void> managedStreaming() async {
StreamSubscription<UserUpdate>? subscription;
try {
final stream = client.stream<UserUpdate>(
'watch_users',
WatchUsersRequest(userIds: [1, 2, 3]),
);
subscription = stream.listen(
(update) {
print('Received update: ${update.type}');
},
onError: (error) {
print('Stream error: $error');
// Implement retry logic
},
onDone: () {
print('Stream completed');
},
);
// Let it run for 30 seconds
await Future.delayed(Duration(seconds: 30));
} finally {
await subscription?.cancel();
}
}
Bidirectional Streaming
Handle two-way communication:
Future<void> bidirectionalChat() async {
final chatRoom = 'general';
// Set up incoming message stream
final incomingMessages = client.stream<ChatMessage>(
'chat_messages',
JoinChatRequest(roomId: chatRoom),
);
// Listen for incoming messages
final subscription = incomingMessages.listen((message) {
print('${message.username}: ${message.content}');
});
// Send messages
final messageController = client.getStreamController<ChatMessage>('chat_messages');
// Send a message
messageController.add(ChatMessage(
roomId: chatRoom,
content: 'Hello everyone!',
username: 'alice',
));
// Send more messages based on user input
stdin.transform(utf8.decoder).transform(LineSplitter()).listen((line) {
if (line.isNotEmpty) {
messageController.add(ChatMessage(
roomId: chatRoom,
content: line,
username: 'alice',
));
}
});
// Cleanup
await Future.delayed(Duration(minutes: 5));
await subscription.cancel();
await messageController.close();
}
Stream Filtering and Transformation
Process streams with Dart's powerful stream operations:
Future<void> streamProcessing() async {
final userUpdates = client.stream<UserUpdate>('watch_users', WatchUsersRequest());
// Filter only user creations
final newUsers = userUpdates
.where((update) => update.type == UpdateType.created)
.map((update) => update.user);
// Buffer updates and process in batches
final batchedUpdates = userUpdates
.bufferTime(Duration(seconds: 5))
.where((batch) => batch.isNotEmpty);
await for (final batch in batchedUpdates) {
print('Processing batch of ${batch.length} updates');
await processBatch(batch);
}
}
Future<void> processBatch(List<UserUpdate> updates) async {
// Process multiple updates efficiently
final creations = updates.where((u) => u.type == UpdateType.created).length;
final updates_count = updates.where((u) => u.type == UpdateType.updated).length;
final deletions = updates.where((u) => u.type == UpdateType.deleted).length;
print('Batch summary: $creations created, $updates_count updated, $deletions deleted');
}
Connection Management and Lifecycle
Proper connection management ensures reliable communication and optimal resource usage.
Connection Lifecycle
class ClientManager {
late HypermodernClient _client;
StreamSubscription? _connectionSubscription;
Future<void> initialize() async {
_client = HypermodernClient(
'ws://localhost:8082',
config: ClientConfig(
autoReconnect: true,
reconnectAttempts: 5,
reconnectDelay: Duration(seconds: 2),
),
);
// Monitor connection state
_connectionSubscription = _client.connectionState.listen((state) {
switch (state) {
case ConnectionState.connecting:
print('🔄 Connecting to server...');
break;
case ConnectionState.connected:
print('✅ Connected to server');
_onConnected();
break;
case ConnectionState.disconnected:
print('❌ Disconnected from server');
_onDisconnected();
break;
case ConnectionState.reconnecting:
print('🔄 Reconnecting to server...');
break;
case ConnectionState.failed:
print('💥 Connection failed');
_onConnectionFailed();
break;
}
});
await _client.connect();
}
void _onConnected() {
// Resume any paused operations
// Sync local state with server
// Start real-time subscriptions
}
void _onDisconnected() {
// Pause operations that require connectivity
// Cache operations for later retry
}
void _onConnectionFailed() {
// Implement fallback strategies
// Switch to offline mode
// Notify user of connectivity issues
}
Future<void> dispose() async {
await _connectionSubscription?.cancel();
await _client.disconnect();
}
}
Connection Pooling (HTTP)
For HTTP clients, manage connection pools efficiently:
class HttpClientPool {
final Map<String, HypermodernClient> _clients = {};
HypermodernClient getClient(String baseUrl) {
return _clients.putIfAbsent(baseUrl, () {
return HypermodernClient.http(
baseUrl,
config: ClientConfig(
connectionPoolSize: 10,
keepAlive: true,
keepAliveTimeout: Duration(seconds: 30),
maxRequestsPerConnection: 100,
),
);
});
}
Future<void> closeAll() async {
for (final client in _clients.values) {
await client.disconnect();
}
_clients.clear();
}
}
Heartbeat and Keep-Alive
Maintain persistent connections:
class HeartbeatManager {
final HypermodernClient client;
Timer? _heartbeatTimer;
HeartbeatManager(this.client);
void startHeartbeat() {
_heartbeatTimer = Timer.periodic(Duration(seconds: 30), (timer) async {
try {
await client.request<PingResponse>('ping', PingRequest());
} catch (e) {
print('Heartbeat failed: $e');
// Connection might be dead, trigger reconnection
await client.reconnect();
}
});
}
void stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
}
Error Handling Strategies
Robust error handling is crucial for production applications.
Error Types and Handling
Future<User?> safeGetUser(int userId) async {
try {
final request = GetUserRequest(id: userId);
return await client.request<User>('get_user', request);
} on NotFoundException catch (e) {
// User doesn't exist - this might be expected
print('User $userId not found');
return null;
} on ValidationException catch (e) {
// Invalid request data
print('Invalid request: ${e.message}');
print('Field errors: ${e.fieldErrors}');
throw UserInputException('Please check your input');
} on UnauthorizedException catch (e) {
// Authentication required
print('Authentication required');
await _handleAuthenticationRequired();
throw AuthenticationRequiredException();
} on RateLimitException catch (e) {
// Rate limit exceeded
print('Rate limit exceeded, retry after ${e.retryAfter} seconds');
await Future.delayed(Duration(seconds: e.retryAfter));
return await safeGetUser(userId); // Retry
} on NetworkException catch (e) {
// Network connectivity issues
print('Network error: ${e.message}');
if (await _isOnline()) {
// Retry with exponential backoff
return await _retryWithBackoff(() => safeGetUser(userId));
} else {
throw OfflineException('No internet connection');
}
} on TimeoutException catch (e) {
// Request timed out
print('Request timed out after ${e.timeout}');
throw TimeoutException('Server is taking too long to respond');
} on ServerException catch (e) {
// Server-side error
print('Server error: ${e.message} (${e.statusCode})');
if (e.statusCode >= 500) {
// Server error, might be temporary
return await _retryWithBackoff(() => safeGetUser(userId));
} else {
// Client error, don't retry
throw ServerException('Server rejected the request');
}
} catch (e) {
// Unexpected error
print('Unexpected error: $e');
throw UnexpectedException('Something went wrong');
}
}
Retry Strategies
Implement sophisticated retry logic:
class RetryManager {
static Future<T> retryWithExponentialBackoff<T>(
Future<T> Function() operation, {
int maxAttempts = 3,
Duration initialDelay = const Duration(seconds: 1),
double backoffMultiplier = 2.0,
Duration maxDelay = const Duration(seconds: 30),
bool Function(Exception)? shouldRetry,
}) async {
int attempt = 0;
Duration delay = initialDelay;
while (attempt < maxAttempts) {
try {
return await operation();
} catch (e) {
attempt++;
if (attempt >= maxAttempts) {
rethrow;
}
if (shouldRetry != null && e is Exception && !shouldRetry(e)) {
rethrow;
}
print('Attempt $attempt failed: $e. Retrying in ${delay.inSeconds}s...');
await Future.delayed(delay);
delay = Duration(
milliseconds: (delay.inMilliseconds * backoffMultiplier).round(),
);
if (delay > maxDelay) {
delay = maxDelay;
}
}
}
throw StateError('This should never be reached');
}
static bool shouldRetryException(Exception e) {
return e is NetworkException ||
e is TimeoutException ||
(e is ServerException && e.statusCode >= 500) ||
e is RateLimitException;
}
}
// Usage
final user = await RetryManager.retryWithExponentialBackoff(
() => client.request<User>('get_user', GetUserRequest(id: 123)),
shouldRetry: RetryManager.shouldRetryException,
);
Circuit Breaker Pattern
Prevent cascading failures:
class CircuitBreaker {
final int failureThreshold;
final Duration timeout;
final Duration resetTimeout;
int _failureCount = 0;
DateTime? _lastFailureTime;
CircuitState _state = CircuitState.closed;
CircuitBreaker({
this.failureThreshold = 5,
this.timeout = const Duration(seconds: 30),
this.resetTimeout = const Duration(minutes: 1),
});
Future<T> execute<T>(Future<T> Function() operation) async {
if (_state == CircuitState.open) {
if (_shouldAttemptReset()) {
_state = CircuitState.halfOpen;
} else {
throw CircuitBreakerOpenException('Circuit breaker is open');
}
}
try {
final result = await operation();
_onSuccess();
return result;
} catch (e) {
_onFailure();
rethrow;
}
}
void _onSuccess() {
_failureCount = 0;
_state = CircuitState.closed;
}
void _onFailure() {
_failureCount++;
_lastFailureTime = DateTime.now();
if (_failureCount >= failureThreshold) {
_state = CircuitState.open;
}
}
bool _shouldAttemptReset() {
return _lastFailureTime != null &&
DateTime.now().difference(_lastFailureTime!) > resetTimeout;
}
}
enum CircuitState { closed, open, halfOpen }
Performance Optimization
Optimize client performance for different scenarios.
Request Batching
Batch multiple requests for efficiency:
class RequestBatcher {
final HypermodernClient client;
final Duration batchWindow;
final int maxBatchSize;
final Map<String, List<BatchedRequest>> _pendingRequests = {};
final Map<String, Timer> _batchTimers = {};
RequestBatcher(
this.client, {
this.batchWindow = const Duration(milliseconds: 100),
this.maxBatchSize = 10,
});
Future<T> request<T>(String endpoint, dynamic request) async {
final completer = Completer<T>();
final batchedRequest = BatchedRequest<T>(request, completer);
_pendingRequests.putIfAbsent(endpoint, () => []).add(batchedRequest);
// Start batch timer if not already running
if (!_batchTimers.containsKey(endpoint)) {
_batchTimers[endpoint] = Timer(batchWindow, () => _executeBatch(endpoint));
}
// Execute immediately if batch is full
if (_pendingRequests[endpoint]!.length >= maxBatchSize) {
_batchTimers[endpoint]?.cancel();
_executeBatch(endpoint);
}
return completer.future;
}
Future<void> _executeBatch(String endpoint) async {
final requests = _pendingRequests.remove(endpoint) ?? [];
_batchTimers.remove(endpoint);
if (requests.isEmpty) return;
try {
// Execute batch request
final batchRequest = BatchRequest(
endpoint: endpoint,
requests: requests.map((r) => r.request).toList(),
);
final batchResponse = await client.request<BatchResponse>(
'batch_execute',
batchRequest,
);
// Distribute responses
for (int i = 0; i < requests.length; i++) {
if (i < batchResponse.responses.length) {
requests[i].completer.complete(batchResponse.responses[i]);
} else {
requests[i].completer.completeError(
Exception('Missing response for batched request'),
);
}
}
} catch (e) {
// Fail all requests in batch
for (final request in requests) {
request.completer.completeError(e);
}
}
}
}
Caching Strategies
Implement intelligent caching:
class CachedClient {
final HypermodernClient client;
final Map<String, CacheEntry> _cache = {};
final Duration defaultTtl;
CachedClient(this.client, {this.defaultTtl = const Duration(minutes: 5)});
Future<T> request<T>(
String endpoint,
dynamic request, {
Duration? ttl,
bool forceRefresh = false,
}) async {
final cacheKey = _generateCacheKey(endpoint, request);
final entry = _cache[cacheKey];
// Return cached result if valid
if (!forceRefresh && entry != null && !entry.isExpired) {
return entry.data as T;
}
// Fetch fresh data
final response = await client.request<T>(endpoint, request);
// Cache the response
_cache[cacheKey] = CacheEntry(
data: response,
expiresAt: DateTime.now().add(ttl ?? defaultTtl),
);
return response;
}
String _generateCacheKey(String endpoint, dynamic request) {
// Generate deterministic cache key
return '$endpoint:${request.hashCode}';
}
void clearCache() {
_cache.clear();
}
void evictExpired() {
_cache.removeWhere((key, entry) => entry.isExpired);
}
}
class CacheEntry {
final dynamic data;
final DateTime expiresAt;
CacheEntry({required this.data, required this.expiresAt});
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
Generated Client Services
Hypermodern automatically generates type-safe client services from your schema definitions, providing a clean, intuitive API for interacting with your server endpoints.
Client Factory Pattern
The generated client factory provides organized access to all your API endpoints:
import 'package:hypermodern/hypermodern.dart';
import 'generated/client/client_factory.dart';
// Initialize the client
final client = HypermodernClient('https://your-api.example.com');
// Create the service factory
final api = ClientFactory(client);
// Now you have organized access to all services:
// - api.user (UserService)
// - api.post (PostService)
// - api.comment (CommentService)
// - api.auth (AuthService)
// - api.notification (NotificationService)
Authentication Service Example
Complete user authentication flows:
Future<void> authenticationExample() async {
try {
// 1. User login
final loginRequest = LoginRequest(
email: 'user@example.com',
password: 'secure_password'
);
final loginResult = await api.auth.login(loginRequest);
print('Login successful: ${loginResult.token}');
// 2. Set authentication token for future requests
client.setAuthToken(loginResult.token);
// 3. Refresh token when needed
final refreshResult = await api.auth.refreshToken(RefreshTokenRequest(
refreshToken: loginResult.refreshToken
));
print('Token refreshed: ${refreshResult.token}');
// 4. Logout
await api.auth.logout();
print('Logout successful');
} catch (e) {
print('Authentication error: $e');
}
}
Post Management Example
Full CRUD operations with proper typing:
Future<void> postManagementExample() async {
try {
// Create a new post
final newPost = CreatePostRequest(
title: 'My First Blog Post',
content: 'This is the content of my first blog post...',
tags: ['technology', 'programming', 'dart'],
status: PostStatus.draft,
);
// Create post
final createdPost = await api.post.createPost(newPost);
print('Post created: ${createdPost.title}');
// Get post by ID
final fetchedPost = await api.post.getPost(createdPost.id);
print('Fetched post: ${fetchedPost.title}');
// Update post
final updateRequest = UpdatePostRequest(
id: createdPost.id,
title: 'My Updated Blog Post',
status: PostStatus.published,
);
final updatedPost = await api.post.updatePost(updateRequest);
print('Post updated: ${updatedPost.title}');
// List all posts
final posts = await api.post.listPosts(ListPostsRequest(
limit: 10,
status: PostStatus.published,
));
print('Found ${posts.length} published posts');
} catch (e) {
print('Post management error: $e');
}
}
User Management Example
Profile and account management:
Future<void> userManagementExample() async {
try {
// Get user profile
final profile = await api.user.getUserProfile();
print('User profile: ${profile.username}');
// Update user profile
final updateRequest = UpdateUserProfileRequest(
displayName: 'New Display Name',
bio: 'Software developer passionate about Dart',
preferences: UserPreferences(
theme: 'dark',
notifications: true,
language: 'en',
),
);
final updatedProfile = await api.user.updateUserProfile(updateRequest);
print('Profile updated: ${updatedProfile.displayName}');
// Get user's posts
final userPosts = await api.user.getUserPosts(profile.id);
print('User has ${userPosts.length} posts');
// Follow another user
await api.user.followUser(FollowUserRequest(
userId: 'other-user-id'
));
print('Now following user');
// Get followers
final followers = await api.user.getFollowers(profile.id);
print('User has ${followers.length} followers');
} catch (e) {
print('User management error: $e');
}
}
Comment Management Example
Nested resource operations:
Future<void> commentManagementExample() async {
try {
// Add a comment to a post
final newComment = CreateCommentRequest(
postId: 'post-123',
content: 'Great post! Thanks for sharing.',
parentCommentId: null, // Top-level comment
);
final createdComment = await api.comment.createComment(newComment);
print('Comment created: ${createdComment.id}');
// Reply to the comment
final reply = CreateCommentRequest(
postId: 'post-123',
content: 'I agree with your comment!',
parentCommentId: createdComment.id, // Reply to the comment
);
final createdReply = await api.comment.createComment(reply);
print('Reply created: ${createdReply.id}');
// Get all comments for a post
final comments = await api.comment.getPostComments('post-123');
print('Post has ${comments.length} comments');
// Update a comment
final updateRequest = UpdateCommentRequest(
id: createdComment.id,
content: 'Updated comment content',
);
final updatedComment = await api.comment.updateComment(updateRequest);
print('Comment updated: ${updatedComment.content}');
// Delete a comment
await api.comment.deleteComment(createdComment.id);
print('Comment deleted');
} catch (e) {
print('Comment management error: $e');
}
}
Error Handling with Generated Services
The generated services maintain the same error handling patterns:
Future<void> errorHandlingExample() async {
try {
final post = await api.post.getPost('non-existent-id');
} on NotFoundException catch (e) {
print('Post not found: ${e.message}');
} on UnauthorizedException catch (e) {
print('Access denied: ${e.message}');
// Redirect to login
} on ValidationException catch (e) {
print('Invalid input: ${e.fieldErrors}');
// Show field-specific errors to user
} on NetworkException catch (e) {
print('Network error: ${e.message}');
// Show offline message or retry
}
}
Benefits of Generated Services
- Type Safety: Full compile-time type checking for all requests and responses
- Organized API: Logical grouping of endpoints by service (user, post, comment, auth, etc.)
- Consistent Patterns: Same error handling and request patterns across all services
- Auto-completion: IDE support with full method and parameter suggestions
- Documentation: Generated services include endpoint documentation
- Maintainable: Services regenerate automatically when schema changes
What's Next
You now have comprehensive knowledge of client development with Hypermodern, including the powerful generated client services that provide type-safe, organized access to your API endpoints. In the next chapter, we'll shift focus to server development, exploring how to implement robust server applications that can handle multiple protocols, process requests efficiently, and provide real-time streaming capabilities.
No Comments