# Flutter Frontend: Complete API Integration Guide ## Objective Integrate your Flutter app with the Codex CQRS-based ASP.NET Core API using type-safe Dart clients generated from OpenAPI specification. --- ## Prerequisites - Flutter SDK 3.0+ installed - Dart 3.0+ - Git access to backend repository - VS Code with Flutter/Dart extensions or Android Studio - Basic understanding of REST APIs and Dart --- ## Architecture Overview The Codex API uses **CQRS pattern** with OpenHarbor.CQRS framework: - **Queries** (Read): `POST /api/query/{queryName}` or `GET /api/query/{queryName}` - **Commands** (Write): `POST /api/command/{commandName}` - **Dynamic Queries** (Paginated): `POST /api/dynamicquery/{itemType}` All requests use JSON payloads (even for empty requests). --- ## Step 1: Access OpenAPI Specification ### Get the Spec File ```bash # Option A: Clone backend repository git clone cd backend # The spec is located at: docs/openapi.json # Option B: Download from running API curl http://localhost:5246/swagger/v1/swagger.json -o openapi.json ``` ### Review Documentation Before coding, read: - `backend/docs/ARCHITECTURE.md` - Understand CQRS patterns - `backend/docs/CHANGELOG.md` - Check for breaking changes - `backend/docs/openapi.json` - Your API contract --- ## Step 2: Generate Dart API Client ### Install OpenAPI Generator ```bash # Install via Homebrew (macOS) brew install openapi-generator # Or via npm npm install -g @openapitools/openapi-generator-cli # Or use Docker docker pull openapitools/openapi-generator-cli ``` ### Generate Dart Client ```bash # Navigate to your Flutter project cd /path/to/your/flutter-app # Create API directory mkdir -p lib/api # Generate Dart client openapi-generator-cli generate \ -i ../backend/docs/openapi.json \ -g dart \ -o lib/api/generated \ --additional-properties=pubName=codex_api_client,pubLibrary=codex_api_client # If using Docker: docker run --rm \ -v ${PWD}:/local openapitools/openapi-generator-cli generate \ -i /local/../backend/docs/openapi.json \ -g dart \ -o /local/lib/api/generated \ --additional-properties=pubName=codex_api_client,pubLibrary=codex_api_client ``` This generates: - `lib/api/generated/lib/api.dart` - API client - `lib/api/generated/lib/model/` - DTOs and models - `lib/api/generated/doc/` - API documentation --- ## Step 3: Configure Flutter Project ### Update pubspec.yaml Add required dependencies: ```yaml name: your_flutter_app description: Your Flutter app description environment: sdk: '>=3.0.0 <4.0.0' dependencies: flutter: sdk: flutter # HTTP client http: ^1.1.0 # JSON serialization json_annotation: ^4.8.1 # State management (choose one) flutter_riverpod: ^2.4.9 # Recommended # provider: ^6.1.1 # bloc: ^8.1.3 # Secure storage for tokens flutter_secure_storage: ^9.0.0 # Environment configuration flutter_dotenv: ^5.1.0 dev_dependencies: flutter_test: sdk: flutter build_runner: ^2.4.7 json_serializable: ^6.7.1 flutter_lints: ^3.0.1 mockito: ^5.4.4 flutter: uses-material-design: true # Add environment files assets: - .env - .env.development - .env.production ``` ### Run pub get ```bash flutter pub get cd lib/api/generated flutter pub get cd ../../.. ``` --- ## Step 4: Setup Environment Configuration ### Create Environment Files **`.env.development`:** ```env API_BASE_URL=http://localhost:5246 API_TIMEOUT=30000 ``` **`.env.production`:** ```env API_BASE_URL=https://api.yourapp.com API_TIMEOUT=30000 ``` **`.env` (default/local):** ```env API_BASE_URL=http://localhost:5246 API_TIMEOUT=30000 ``` ### Create Config Class **`lib/config/api_config.dart`:** ```dart import 'package:flutter_dotenv/flutter_dotenv.dart'; class ApiConfig { static String get baseUrl => dotenv.env['API_BASE_URL'] ?? 'http://localhost:5246'; static int get timeout => int.parse(dotenv.env['API_TIMEOUT'] ?? '30000'); static const String apiVersion = 'v1'; } ``` ### Initialize in main.dart **`lib/main.dart`:** ```dart import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); // Load environment variables await dotenv.load(fileName: ".env"); runApp( const ProviderScope( child: MyApp(), ), ); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Codex App', theme: ThemeData( primarySwatch: Colors.blue, useMaterial3: true, ), home: const HomePage(), ); } } ``` --- ## Step 5: Create API Client Service ### Create HTTP Client with Interceptors **`lib/services/api_client.dart`:** ```dart import 'package:http/http.dart' as http; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../config/api_config.dart'; class ApiClient extends http.BaseClient { final http.Client _client = http.Client(); final FlutterSecureStorage _storage = const FlutterSecureStorage(); @override Future send(http.BaseRequest request) async { // Add base URL if not already present if (!request.url.toString().startsWith('http')) { request = _updateRequestUrl(request); } // Add authentication token final token = await _storage.read(key: 'auth_token'); if (token != null && token.isNotEmpty) { request.headers['Authorization'] = 'Bearer $token'; } // Add default headers request.headers['Content-Type'] = 'application/json'; request.headers['Accept'] = 'application/json'; print('REQUEST ${request.method} ${request.url}'); try { final response = await _client.send(request); print('RESPONSE ${response.statusCode} ${request.url}'); return response; } catch (e) { print('ERROR Request failed: $e'); rethrow; } } http.BaseRequest _updateRequestUrl(http.BaseRequest request) { final newUri = Uri.parse('${ApiConfig.baseUrl}${request.url.path}'); if (request is http.Request) { final newRequest = http.Request(request.method, newUri) ..headers.addAll(request.headers) ..body = request.body; return newRequest; } throw UnsupportedError('Unsupported request type'); } Future setAuthToken(String token) async { await _storage.write(key: 'auth_token', value: token); } Future clearAuthToken() async { await _storage.delete(key: 'auth_token'); } Future getAuthToken() async { return await _storage.read(key: 'auth_token'); } } ``` --- ## Step 6: Create Service Layer (Repository Pattern) ### Health Query Service Example **`lib/services/health_service.dart`:** ```dart import 'package:codex_api_client/api.dart'; import '../models/api_result.dart'; import 'api_client.dart'; class HealthService { final ApiClient _apiClient; late final DefaultApi _api; HealthService(this._apiClient) { _api = DefaultApi(_apiClient, ApiConfig.baseUrl); } /// Check if API is healthy Future> checkHealth() async { try { // Call POST /api/query/health final response = await _api.apiQueryHealthPost( healthQuery: HealthQuery(), // Empty query object ); return ApiResult.success(response ?? false); } on ApiException catch (e) { return ApiResult.failure( message: e.message ?? 'Health check failed', statusCode: e.code, ); } catch (e) { return ApiResult.failure( message: 'Network error: $e', ); } } } ``` ### Create ApiResult Model **`lib/models/api_result.dart`:** ```dart class ApiResult { final T? data; final String? errorMessage; final int? statusCode; final Map>? validationErrors; bool get isSuccess => errorMessage == null; bool get isFailure => !isSuccess; ApiResult.success(this.data) : errorMessage = null, statusCode = null, validationErrors = null; ApiResult.failure({ required String message, this.statusCode, this.validationErrors, }) : data = null, errorMessage = message; /// Handle the result with callbacks R when({ required R Function(T data) success, required R Function(String message) failure, }) { if (isSuccess && data != null) { return success(data as T); } else { return failure(errorMessage ?? 'Unknown error'); } } } ``` --- ## Step 7: Integrate with Riverpod (State Management) ### Create Providers **`lib/providers/api_providers.dart`:** ```dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../services/api_client.dart'; import '../services/health_service.dart'; // API Client Provider final apiClientProvider = Provider((ref) { return ApiClient(); }); // Health Service Provider final healthServiceProvider = Provider((ref) { final apiClient = ref.watch(apiClientProvider); return HealthService(apiClient); }); // Health Check Provider (auto-fetches) final healthCheckProvider = FutureProvider((ref) async { final healthService = ref.watch(healthServiceProvider); final result = await healthService.checkHealth(); return result.when( success: (isHealthy) => isHealthy, failure: (_) => false, ); }); ``` ### Use in UI **`lib/screens/home_screen.dart`:** ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/api_providers.dart'; class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final healthCheck = ref.watch(healthCheckProvider); return Scaffold( appBar: AppBar( title: const Text('Codex App'), actions: [ // API Health Indicator healthCheck.when( data: (isHealthy) => Icon( isHealthy ? Icons.check_circle : Icons.error, color: isHealthy ? Colors.green : Colors.red, ), loading: () => const CircularProgressIndicator(), error: (_, __) => const Icon(Icons.error, color: Colors.red), ), const SizedBox(width: 16), ], ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ healthCheck.when( data: (isHealthy) => Text( isHealthy ? 'API Connected' : 'API Disconnected', style: Theme.of(context).textTheme.headlineSmall, ), loading: () => const CircularProgressIndicator(), error: (error, _) => Text('Error: $error'), ), ], ), ), ); } } ``` --- ## Step 8: Handle CQRS Commands and Queries ### Example: User Command Service **`lib/services/user_service.dart`:** ```dart import 'package:codex_api_client/api.dart'; import '../models/api_result.dart'; import 'api_client.dart'; class UserService { final ApiClient _apiClient; late final DefaultApi _api; UserService(this._apiClient) { _api = DefaultApi(_apiClient, ApiConfig.baseUrl); } /// Create a new user (Command) Future> createUser({ required String username, required String email, required String password, }) async { try { await _api.apiCommandCreateuserPost( createUserCommand: CreateUserCommand( username: username, email: email, password: password, ), ); return ApiResult.success(null); } on ApiException catch (e) { return _handleApiException(e); } catch (e) { return ApiResult.failure(message: 'Network error: $e'); } } /// Get paginated users (Dynamic Query) Future>> getUsers({ int page = 1, int pageSize = 20, String? searchTerm, }) async { try { final filters = searchTerm != null && searchTerm.isNotEmpty ? [ DynamicQueryFilter( path: 'username', type: FilterType.contains, value: searchTerm, ), ] : []; final response = await _api.apiDynamicqueryUserPost( dynamicQuery: DynamicQuery( page: page, pageSize: pageSize, filters: filters, sorts: [ DynamicQuerySort( path: 'createdAt', descending: true, ), ], ), ); return ApiResult.success(response); } on ApiException catch (e) { return _handleApiException(e); } catch (e) { return ApiResult.failure(message: 'Network error: $e'); } } ApiResult _handleApiException(ApiException e) { switch (e.code) { case 400: // Validation errors from FluentValidation return ApiResult.failure( message: 'Validation failed', statusCode: 400, validationErrors: _parseValidationErrors(e.message), ); case 401: return ApiResult.failure( message: 'Unauthorized. Please log in.', statusCode: 401, ); case 403: return ApiResult.failure( message: 'Permission denied', statusCode: 403, ); case 404: return ApiResult.failure( message: 'Resource not found', statusCode: 404, ); case 500: return ApiResult.failure( message: 'Server error. Please try again.', statusCode: 500, ); default: return ApiResult.failure( message: e.message ?? 'Unknown error', statusCode: e.code, ); } } Map>? _parseValidationErrors(String? message) { // Parse validation errors from response // Format depends on backend's error response structure return null; } } ``` --- ## Step 9: Testing ### Unit Tests **`test/services/health_service_test.dart`:** ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:mockito/annotations.dart'; import 'package:your_app/services/health_service.dart'; import 'package:your_app/services/api_client.dart'; @GenerateMocks([ApiClient]) import 'health_service_test.mocks.dart'; void main() { late HealthService healthService; late MockApiClient mockApiClient; setUp(() { mockApiClient = MockApiClient(); healthService = HealthService(mockApiClient); }); group('HealthService', () { test('checkHealth returns true when API is healthy', () async { // Arrange when(mockApiClient.send(any)).thenAnswer( (_) async => http.StreamedResponse( Stream.value(utf8.encode('true')), 200, ), ); // Act final result = await healthService.checkHealth(); // Assert expect(result.isSuccess, true); expect(result.data, true); }); test('checkHealth returns failure on error', () async { // Arrange when(mockApiClient.send(any)).thenThrow(Exception('Network error')); // Act final result = await healthService.checkHealth(); // Assert expect(result.isFailure, true); expect(result.errorMessage, contains('Network error')); }); }); } ``` ### Integration Tests **`integration_test/api_integration_test.dart`:** ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:your_app/services/api_client.dart'; import 'package:your_app/services/health_service.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('API Integration Tests', () { late ApiClient apiClient; late HealthService healthService; setUp(() { apiClient = ApiClient(); healthService = HealthService(apiClient); }); testWidgets('Health check endpoint works', (tester) async { // Ensure backend is running on localhost:5246 final result = await healthService.checkHealth(); expect(result.isSuccess, true); expect(result.data, true); }); }); } ``` ### Run Tests ```bash # Unit tests flutter test # Integration tests (requires backend running) flutter test integration_test/ ``` --- ## Step 10: Error Handling & User Feedback ### Create Error Display Widget **`lib/widgets/error_snackbar.dart`:** ```dart import 'package:flutter/material.dart'; import '../models/api_result.dart'; class ErrorSnackbar { static void show(BuildContext context, ApiResult result) { if (result.isFailure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(result.errorMessage ?? 'An error occurred'), backgroundColor: Colors.red, action: SnackBarAction( label: 'Dismiss', textColor: Colors.white, onPressed: () { ScaffoldMessenger.of(context).hideCurrentSnackBar(); }, ), ), ); } } static void showValidationErrors( BuildContext context, Map>? errors, ) { if (errors == null || errors.isEmpty) return; final errorMessages = errors.entries .map((e) => '${e.key}: ${e.value.join(', ')}') .join('\n'); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(errorMessages), backgroundColor: Colors.orange, duration: const Duration(seconds: 5), ), ); } } ``` --- ## Step 11: Monitoring API Changes ### Create Update Script **`scripts/update_api_client.sh`:** ```bash #!/bin/bash echo "Updating API client from backend..." # Pull latest backend changes cd ../backend git pull origin main # Regenerate Dart client cd ../flutter-app openapi-generator-cli generate \ -i ../backend/docs/openapi.json \ -g dart \ -o lib/api/generated \ --additional-properties=pubName=codex_api_client,pubLibrary=codex_api_client echo "API client updated!" echo "" echo "Next steps:" echo "1. Check backend/docs/CHANGELOG.md for breaking changes" echo "2. Run: flutter pub get" echo "3. Run: flutter test" echo "4. Fix any compilation errors" ``` Make executable: ```bash chmod +x scripts/update_api_client.sh ``` --- ## Step 12: CI/CD Integration ### GitHub Actions Workflow **`.github/workflows/flutter_ci.yml`:** ```yaml name: Flutter CI on: push: branches: [main, develop] pull_request: branches: [main, develop] jobs: test: runs-on: ubuntu-latest steps: - name: Checkout Flutter app uses: actions/checkout@v3 - name: Checkout backend uses: actions/checkout@v3 with: repository: your-org/backend path: backend - name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: '3.16.0' channel: 'stable' - name: Install OpenAPI Generator run: npm install -g @openapitools/openapi-generator-cli - name: Check for API changes run: | if ! diff -q lib/api/openapi.json backend/docs/openapi.json; then echo "API changes detected!" ./scripts/update_api_client.sh fi - name: Get dependencies run: flutter pub get - name: Analyze code run: flutter analyze - name: Run tests run: flutter test - name: Build app run: flutter build apk --debug ``` --- ## Complete Checklist for Flutter Team ### Initial Setup - [ ] Clone backend repository or access `docs/openapi.json` - [ ] Review `docs/CHANGELOG.md` for breaking changes - [ ] Review `docs/ARCHITECTURE.md` for CQRS patterns - [ ] Install OpenAPI Generator CLI - [ ] Generate Dart API client - [ ] Update `pubspec.yaml` with dependencies - [ ] Run `flutter pub get` - [ ] Create environment configuration files - [ ] Setup API client with interceptors ### Service Layer - [ ] Create `ApiClient` with authentication - [ ] Create `ApiResult` model for error handling - [ ] Create service classes (Health, User, etc.) - [ ] Setup Riverpod providers - [ ] Implement error handling ### Testing - [ ] Write unit tests for services - [ ] Write widget tests for UI - [ ] Setup integration tests - [ ] Test authentication flow - [ ] Test error scenarios (401, 400, 500) ### UI Integration - [ ] Create error display widgets - [ ] Implement loading states - [ ] Add API health indicator - [ ] Handle validation errors in forms ### DevOps - [ ] Create API update script - [ ] Setup CI/CD pipeline - [ ] Configure environment variables - [ ] Document team workflow --- ## Common Issues & Solutions ### Issue: "Failed to load .env file" **Solution:** Ensure `.env` file exists and is listed in `pubspec.yaml` assets. ### Issue: "401 Unauthorized on all requests" **Solution:** Check that `ApiClient` is properly adding Bearer token to headers. ### Issue: "Connection refused to localhost" **Solution:** - On Android emulator: use `http://10.0.2.2:5246` - On iOS simulator: use `http://localhost:5246` - Update `API_BASE_URL` in `.env` accordingly ### Issue: "Type errors after regenerating client" **Solution:** Breaking changes occurred. Review `backend/docs/CHANGELOG.md` and update service layer. ### Issue: "OpenAPI generator fails" **Solution:** Ensure `openapi.json` is valid JSON. Validate with: `cat openapi.json | python3 -m json.tool` --- ## Android Network Configuration **`android/app/src/main/AndroidManifest.xml`:** ```xml ``` --- ## iOS Network Configuration **`ios/Runner/Info.plist`:** ```xml NSAppTransportSecurity NSAllowsArbitraryLoads ``` --- ## Support & Resources - **OpenAPI Spec:** `backend/docs/openapi.json` - **Breaking Changes:** `backend/docs/CHANGELOG.md` - **Architecture:** `backend/docs/ARCHITECTURE.md` - **Backend API (Dev):** `http://localhost:5246` - **Swagger UI (Dev):** `http://localhost:5246/swagger` --- **Last Updated:** 2025-01-26 **Backend API Version:** v1 **OpenAPI Version:** 3.0.1 **Recommended Dart Client Generator:** openapi-generator-cli with `dart` generator