From c9e79e028f01b318165b3602f288e5159a3b2fa1 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Tue, 2 Jun 2026 10:24:37 -0400 Subject: [PATCH] Split oott_api.dart into per-domain modules Break the 372-line frontend API client into focused files under lib/utils/api/: error mapping (api_error.dart), Dio setup behind a buildDio factory (dio_config.dart), and device/scanner/notification endpoints as extensions in part files. Collapse the five near-identical scanner-status methods via a generic _getModel helper and de-duplicate the pagination logic via a shared _paginate helper. The BackendAPI singleton and dioErrorToUserMessage remain importable from oott_api.dart unchanged, so no call sites are affected. Co-Authored-By: Claude Opus 4.8 --- TODO.md | 2 +- frontend/lib/utils/api/api_error.dart | 40 ++ frontend/lib/utils/api/dio_config.dart | 89 +++++ frontend/lib/utils/api/oott_api_devices.dart | 108 ++++++ .../lib/utils/api/oott_api_notifications.dart | 39 ++ frontend/lib/utils/api/oott_api_scanners.dart | 40 ++ frontend/lib/utils/oott_api.dart | 352 ++---------------- 7 files changed, 353 insertions(+), 317 deletions(-) create mode 100644 frontend/lib/utils/api/api_error.dart create mode 100644 frontend/lib/utils/api/dio_config.dart create mode 100644 frontend/lib/utils/api/oott_api_devices.dart create mode 100644 frontend/lib/utils/api/oott_api_notifications.dart create mode 100644 frontend/lib/utils/api/oott_api_scanners.dart diff --git a/TODO.md b/TODO.md index c30a5b3..a98a9de 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,7 @@ ## Frontend - [ ] Can we add front-end tests? -- [ ] Break down oott_api.dart in modules +- [x] Break down oott_api.dart in modules ## Improve engine diff --git a/frontend/lib/utils/api/api_error.dart b/frontend/lib/utils/api/api_error.dart new file mode 100644 index 0000000..b57450b --- /dev/null +++ b/frontend/lib/utils/api/api_error.dart @@ -0,0 +1,40 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +String dioErrorToUserMessage(Object error) { + if (error is TypeError || error is FormatException) { + debugPrint('Backend response shape error: $error'); + return 'Unexpected response shape from backend.'; + } + if (error is! DioException) { + debugPrint('Non-Dio error from backend call: $error'); + return 'Unexpected error.'; + } + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return 'Backend did not respond in time.'; + case DioExceptionType.connectionError: + return 'Cannot reach backend. Check the base URL and your network.'; + case DioExceptionType.badCertificate: + return 'Backend TLS certificate could not be verified.'; + case DioExceptionType.cancel: + return 'Request canceled.'; + case DioExceptionType.badResponse: + final status = error.response?.statusCode; + if (status == 401 || status == 403) { + return 'Authentication failed. Check your API key in Settings.'; + } + if (status == 404) { + return 'Not found.'; + } + if (status != null && status >= 500 && status < 600) { + return 'Backend error (status $status). Please try again.'; + } + return 'Unexpected response from backend (status ${status ?? 'unknown'}).'; + case DioExceptionType.unknown: + debugPrint('Unknown Dio error: ${error.message}'); + return 'Unexpected error contacting backend.'; + } +} diff --git a/frontend/lib/utils/api/dio_config.dart b/frontend/lib/utils/api/dio_config.dart new file mode 100644 index 0000000..26f2029 --- /dev/null +++ b/frontend/lib/utils/api/dio_config.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:dio_smart_retry/dio_smart_retry.dart'; +import 'package:flutter/foundation.dart'; + +import '../backend_reachability.dart'; + +const _connectTimeout = Duration(seconds: 5); +const _receiveTimeout = Duration(seconds: 15); +const _sendTimeout = Duration(seconds: 15); + +const _retryableStatuses = {408, 429, 500, 502, 503, 504}; + +/// Builds a [Dio] client configured for the OOTT backend: base options, +/// authentication header, retry/reachability interceptors and, in debug +/// builds, request logging. +Dio buildDio({ + required String baseUrl, + required String apiKey, + bool withRetry = true, + bool withReachability = true, +}) { + final headers = { + HttpHeaders.contentTypeHeader: 'application/json', + }; + if (apiKey.isNotEmpty) { + headers[HttpHeaders.authorizationHeader] = 'Bearer $apiKey'; + } + + final dio = Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: _connectTimeout, + receiveTimeout: _receiveTimeout, + sendTimeout: _sendTimeout, + headers: headers, + ), + ); + + if (withRetry) { + dio.interceptors.add(_buildRetryInterceptor(dio)); + } + if (withReachability) { + dio.interceptors.add(_buildReachabilityInterceptor()); + } + if (kDebugMode) { + dio.interceptors.add(_buildLogInterceptor()); + } + return dio; +} + +RetryInterceptor _buildRetryInterceptor(Dio dio) => RetryInterceptor( + dio: dio, + retries: 2, + retryDelays: const [Duration(seconds: 1), Duration(seconds: 3)], + logPrint: kDebugMode ? (msg) => debugPrint(msg.toString()) : null, + retryEvaluator: (error, attempt) { + if (error.requestOptions.method != 'GET') return false; + if (error.type == DioExceptionType.cancel) return false; + if (error.error is FormatException) return false; + if (error.type == DioExceptionType.badResponse) { + final status = error.response?.statusCode; + return status != null && _retryableStatuses.contains(status); + } + return true; + }, +); + +InterceptorsWrapper _buildReachabilityInterceptor() => InterceptorsWrapper( + onResponse: (response, handler) { + BackendReachability.instance.recordSuccess(); + handler.next(response); + }, + onError: (error, handler) { + BackendReachability.instance.recordFailure(error); + handler.next(error); + }, +); + +LogInterceptor _buildLogInterceptor() => LogInterceptor( + request: true, + requestHeader: false, + requestBody: false, + responseHeader: false, + responseBody: false, + error: true, + logPrint: (obj) => debugPrint(obj.toString()), +); diff --git a/frontend/lib/utils/api/oott_api_devices.dart b/frontend/lib/utils/api/oott_api_devices.dart new file mode 100644 index 0000000..3475973 --- /dev/null +++ b/frontend/lib/utils/api/oott_api_devices.dart @@ -0,0 +1,108 @@ +part of '../oott_api.dart'; + +/// Device endpoints: lookup, listing, registration, updates and event history. +extension DeviceApi on BackendAPI { + Future getDevice(String macAddress) => + _getModel('/devices/$macAddress', Device.fromJson); + + Future<({List items, bool hasNextPage})> listDevices({ + bool? isRegistered, + String? owner, + DeviceType? deviceType, + String? sortBy, + bool? sortAscending, + int page = 0, + int perPage = 10, + CancelToken? cancelToken, + }) async { + final params = {}; + if (isRegistered != null) params['is_registered'] = isRegistered; + if (owner != null && owner.isNotEmpty) params['owner'] = owner; + if (deviceType != null) { + params['device_type'] = deviceType == DeviceType.unknown + ? '' + : deviceType.apiName; + } + if (sortBy != null) params['sort_by'] = sortBy; + if (sortAscending != null) { + params['sort_order'] = sortAscending ? 'asc' : 'desc'; + } + params['page_offset'] = page * perPage; + params['page_limit'] = perPage + 1; + + final response = await _dio.get( + '/devices', + queryParameters: params, + cancelToken: cancelToken, + ); + + return _paginate( + response.data as List, + (item) => Device.fromJson(item as Map), + perPage, + ); + } + + Future registerDevice( + String macAddress, + String owner, + String deviceType, { + String? name, + }) async { + await _dio.put( + '/devices', + data: { + 'mac_address': macAddress, + 'owner': owner, + 'device_type': deviceType, + 'name': ?name, + }, + ); + } + + Future updateDevice( + String macAddress, + String owner, + String deviceType, + String vendor, { + String? name, + }) async { + await _dio.put( + '/devices/$macAddress', + data: { + 'owner': owner, + 'device_type': deviceType, + 'vendor': vendor, + 'name': name, + }, + ); + } + + Future forgetDevice(String macAddress) async { + await _dio.delete('/devices/$macAddress'); + } + + Future> getDeviceEvents( + String macAddress, { + DateTime? createdFrom, + }) async { + final queryParams = {}; + if (createdFrom != null) { + queryParams['created_from'] = createdFrom.toUtc().toIso8601String(); + } + final response = await _dio.get( + '/devices/$macAddress/events', + queryParameters: queryParams.isEmpty ? null : queryParams, + ); + return (response.data as List) + .map((item) => DeviceEvent.fromJson(item as Map)) + .toList(); + } + + Future getDeviceSummary({CancelToken? cancelToken}) => + _getModel( + '/devices/summary', + DeviceSummary.fromJson, + cancelToken: cancelToken, + ); +} diff --git a/frontend/lib/utils/api/oott_api_notifications.dart b/frontend/lib/utils/api/oott_api_notifications.dart new file mode 100644 index 0000000..09f5b77 --- /dev/null +++ b/frontend/lib/utils/api/oott_api_notifications.dart @@ -0,0 +1,39 @@ +part of '../oott_api.dart'; + +/// Notification endpoints: listing and read/new state transitions. +extension NotificationApi on BackendAPI { + Future markNotificationAsRead(int id) async { + await _dio.get('/notifications/$id'); + } + + Future markNotificationAsNew(int id) async { + await _dio.post('/notifications/$id/mark_as_new'); + } + + Future markAllNotificationsAsRead() async { + await _dio.post('/notifications/mark_all_as_old'); + } + + Future<({List items, bool hasNextPage})> listNotifications( + bool? isNew, { + int page = 0, + int perPage = 5, + CancelToken? cancelToken, + }) async { + final response = await _dio.get( + '/notifications', + queryParameters: { + 'is_new': isNew ?? '', + 'page_offset': page * perPage, + 'page_limit': perPage + 1, + }, + cancelToken: cancelToken, + ); + + return _paginate( + response.data as List, + (item) => Notification.fromJson(item), + perPage, + ); + } +} diff --git a/frontend/lib/utils/api/oott_api_scanners.dart b/frontend/lib/utils/api/oott_api_scanners.dart new file mode 100644 index 0000000..7c13461 --- /dev/null +++ b/frontend/lib/utils/api/oott_api_scanners.dart @@ -0,0 +1,40 @@ +part of '../oott_api.dart'; + +/// Per-scanner status endpoints. Every scanner exposes the same +/// `/_scanner/status` shape, so each getter just decodes its model. +extension ScannerApi on BackendAPI { + Future getArpScannerStatus({CancelToken? cancelToken}) => + _getModel( + '/arp_scanner/status', + ArpScannerStatus.fromJson, + cancelToken: cancelToken, + ); + + Future getMdnsScannerStatus({CancelToken? cancelToken}) => + _getModel( + '/mdns_scanner/status', + MdnsScannerStatus.fromJson, + cancelToken: cancelToken, + ); + + Future getSsdpScannerStatus({CancelToken? cancelToken}) => + _getModel( + '/ssdp_scanner/status', + SsdpScannerStatus.fromJson, + cancelToken: cancelToken, + ); + + Future getDhcpScannerStatus({CancelToken? cancelToken}) => + _getModel( + '/dhcp_scanner/status', + DhcpScannerStatus.fromJson, + cancelToken: cancelToken, + ); + + Future getSnmpScannerStatus({CancelToken? cancelToken}) => + _getModel( + '/snmp_scanner/status', + SnmpScannerStatus.fromJson, + cancelToken: cancelToken, + ); +} diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index 26b536c..f2a6539 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -1,11 +1,12 @@ -import 'dart:io'; +library; import 'package:dio/dio.dart'; -import 'package:dio_smart_retry/dio_smart_retry.dart'; import 'package:encrypter/encrypter/xor.dart'; -import 'package:flutter/foundation.dart'; -import 'package:frontend/utils/backend_reachability.dart'; -import 'package:frontend/utils/pref_utils.dart'; + +import 'api/api_error.dart'; +import 'api/dio_config.dart'; +import 'backend_reachability.dart'; +import 'pref_utils.dart'; import '../model/arp_scanner_status.dart'; import '../model/dhcp_scanner_status.dart'; import '../model/mdns_scanner_status.dart'; @@ -17,87 +18,11 @@ import '../model/device_summary.dart'; import '../model/device_type.dart'; import '../model/notification.dart'; -const _connectTimeout = Duration(seconds: 5); -const _receiveTimeout = Duration(seconds: 15); -const _sendTimeout = Duration(seconds: 15); +export 'api/api_error.dart'; -const _retryableStatuses = {408, 429, 500, 502, 503, 504}; - -RetryInterceptor _buildRetryInterceptor(Dio dio) => RetryInterceptor( - dio: dio, - retries: 2, - retryDelays: const [Duration(seconds: 1), Duration(seconds: 3)], - logPrint: kDebugMode ? (msg) => debugPrint(msg.toString()) : null, - retryEvaluator: (error, attempt) { - if (error.requestOptions.method != 'GET') return false; - if (error.type == DioExceptionType.cancel) return false; - if (error.error is FormatException) return false; - if (error.type == DioExceptionType.badResponse) { - final status = error.response?.statusCode; - return status != null && _retryableStatuses.contains(status); - } - return true; - }, -); - -InterceptorsWrapper _buildReachabilityInterceptor() => InterceptorsWrapper( - onResponse: (response, handler) { - BackendReachability.instance.recordSuccess(); - handler.next(response); - }, - onError: (error, handler) { - BackendReachability.instance.recordFailure(error); - handler.next(error); - }, -); - -LogInterceptor _buildLogInterceptor() => LogInterceptor( - request: true, - requestHeader: false, - requestBody: false, - responseHeader: false, - responseBody: false, - error: true, - logPrint: (obj) => debugPrint(obj.toString()), -); - -String dioErrorToUserMessage(Object error) { - if (error is TypeError || error is FormatException) { - debugPrint('Backend response shape error: $error'); - return 'Unexpected response shape from backend.'; - } - if (error is! DioException) { - debugPrint('Non-Dio error from backend call: $error'); - return 'Unexpected error.'; - } - switch (error.type) { - case DioExceptionType.connectionTimeout: - case DioExceptionType.sendTimeout: - case DioExceptionType.receiveTimeout: - return 'Backend did not respond in time.'; - case DioExceptionType.connectionError: - return 'Cannot reach backend. Check the base URL and your network.'; - case DioExceptionType.badCertificate: - return 'Backend TLS certificate could not be verified.'; - case DioExceptionType.cancel: - return 'Request canceled.'; - case DioExceptionType.badResponse: - final status = error.response?.statusCode; - if (status == 401 || status == 403) { - return 'Authentication failed. Check your API key in Settings.'; - } - if (status == 404) { - return 'Not found.'; - } - if (status != null && status >= 500 && status < 600) { - return 'Backend error (status $status). Please try again.'; - } - return 'Unexpected response from backend (status ${status ?? 'unknown'}).'; - case DioExceptionType.unknown: - debugPrint('Unknown Dio error: ${error.message}'); - return 'Unexpected error contacting backend.'; - } -} +part 'api/oott_api_devices.dart'; +part 'api/oott_api_scanners.dart'; +part 'api/oott_api_notifications.dart'; class BackendAPI { static final BackendAPI _instance = BackendAPI._internal(); @@ -107,59 +32,30 @@ class BackendAPI { reconfigureFromPrefs(); } + late String _baseUrl; + late String _apiKey; + late Dio _dio; + void reconfigureFromPrefs() { _baseUrl = PrefUtil.getValue("base_url", "http://localhost:3000/api") as String; _apiKey = XOR().xorDecode(PrefUtil.getValue("api_key", "") as String); - final headers = { - HttpHeaders.contentTypeHeader: 'application/json', - }; - if (_apiKey.isNotEmpty) { - headers[HttpHeaders.authorizationHeader] = 'Bearer $_apiKey'; - } - - _dio = Dio( - BaseOptions( - baseUrl: _baseUrl, - connectTimeout: _connectTimeout, - receiveTimeout: _receiveTimeout, - sendTimeout: _sendTimeout, - headers: headers, - ), - ); - _dio.interceptors.add(_buildRetryInterceptor(_dio)); - _dio.interceptors.add(_buildReachabilityInterceptor()); - if (kDebugMode) { - _dio.interceptors.add(_buildLogInterceptor()); - } + _dio = buildDio(baseUrl: _baseUrl, apiKey: _apiKey); BackendReachability.instance.setProber(() => _dio.get('/test')); } // Returns null if the test was successful, and a String with a message about the issue if not static Future test(String baseUrl, String apiKey) async { - final headers = { - HttpHeaders.contentTypeHeader: 'application/json', - }; - if (apiKey.isNotEmpty) { - headers[HttpHeaders.authorizationHeader] = 'Bearer $apiKey'; - } - - Dio dio = Dio( - BaseOptions( - baseUrl: baseUrl, - connectTimeout: _connectTimeout, - receiveTimeout: _receiveTimeout, - sendTimeout: _sendTimeout, - headers: headers, - ), + final dio = buildDio( + baseUrl: baseUrl, + apiKey: apiKey, + withRetry: false, + withReachability: false, ); - if (kDebugMode) { - dio.interceptors.add(_buildLogInterceptor()); - } try { - Response response = await dio.get('/test'); + final response = await dio.get('/test'); return response.data == 'OOTT_API_OK' ? null : "URL successfully called but didn't return the expected value. Check your URL and make sure it points to your OOTT backend base URL."; @@ -168,201 +64,25 @@ class BackendAPI { } } - late String _baseUrl; - late String _apiKey; - late Dio _dio; - - Future markNotificationAsRead(int id) async { - await _dio.get('/notifications/$id'); - } - - Future markNotificationAsNew(int id) async { - await _dio.post('/notifications/$id/mark_as_new'); - } - - Future markAllNotificationsAsRead() async { - await _dio.post('/notifications/mark_all_as_old'); - } - - Future getDevice(String macAddress) async { - final response = await _dio.get('/devices/$macAddress'); - return Device.fromJson(response.data as Map); - } - - Future<({List items, bool hasNextPage})> listDevices({ - bool? isRegistered, - String? owner, - DeviceType? deviceType, - String? sortBy, - bool? sortAscending, - int page = 0, - int perPage = 10, + /// Fetches [path] and decodes the JSON object response with [fromJson]. + Future _getModel( + String path, + T Function(Map) fromJson, { CancelToken? cancelToken, }) async { - final params = {}; - if (isRegistered != null) params['is_registered'] = isRegistered; - if (owner != null && owner.isNotEmpty) params['owner'] = owner; - if (deviceType != null) { - params['device_type'] = deviceType == DeviceType.unknown - ? '' - : deviceType.apiName; - } - if (sortBy != null) params['sort_by'] = sortBy; - if (sortAscending != null) { - params['sort_order'] = sortAscending ? 'asc' : 'desc'; - } - params['page_offset'] = page * perPage; - params['page_limit'] = perPage + 1; - - final response = await _dio.get( - '/devices', - queryParameters: params, - cancelToken: cancelToken, - ); - - final results = (response.data as List) - .map((item) => Device.fromJson(item as Map)) - .toList(); - final hasNextPage = results.length > perPage; - return ( - items: hasNextPage ? results.take(perPage).toList() : results, - hasNextPage: hasNextPage, - ); + final response = await _dio.get(path, cancelToken: cancelToken); + return fromJson(response.data as Map); } - Future registerDevice( - String macAddress, - String owner, - String deviceType, { - String? name, - }) async { - await _dio.put( - '/devices', - data: { - 'mac_address': macAddress, - 'owner': owner, - 'device_type': deviceType, - 'name': ?name, - }, - ); - } - - Future updateDevice( - String macAddress, - String owner, - String deviceType, - String vendor, { - String? name, - }) async { - await _dio.put( - '/devices/$macAddress', - data: { - 'owner': owner, - 'device_type': deviceType, - 'vendor': vendor, - 'name': name, - }, - ); - } - - Future forgetDevice(String macAddress) async { - await _dio.delete('/devices/$macAddress'); - } - - Future> getDeviceEvents( - String macAddress, { - DateTime? createdFrom, - }) async { - final queryParams = {}; - if (createdFrom != null) { - queryParams['created_from'] = createdFrom.toUtc().toIso8601String(); - } - final response = await _dio.get( - '/devices/$macAddress/events', - queryParameters: queryParams.isEmpty ? null : queryParams, - ); - return (response.data as List) - .map((item) => DeviceEvent.fromJson(item as Map)) - .toList(); - } - - Future getDeviceSummary({CancelToken? cancelToken}) async { - final response = await _dio.get( - '/devices/summary', - cancelToken: cancelToken, - ); - return DeviceSummary.fromJson(response.data as Map); - } - - Future getArpScannerStatus({ - CancelToken? cancelToken, - }) async { - final response = await _dio.get( - '/arp_scanner/status', - cancelToken: cancelToken, - ); - return ArpScannerStatus.fromJson(response.data as Map); - } - - Future getMdnsScannerStatus({ - CancelToken? cancelToken, - }) async { - final response = await _dio.get( - '/mdns_scanner/status', - cancelToken: cancelToken, - ); - return MdnsScannerStatus.fromJson(response.data as Map); - } - - Future getSsdpScannerStatus({ - CancelToken? cancelToken, - }) async { - final response = await _dio.get( - '/ssdp_scanner/status', - cancelToken: cancelToken, - ); - return SsdpScannerStatus.fromJson(response.data as Map); - } - - Future getDhcpScannerStatus({ - CancelToken? cancelToken, - }) async { - final response = await _dio.get( - '/dhcp_scanner/status', - cancelToken: cancelToken, - ); - return DhcpScannerStatus.fromJson(response.data as Map); - } - - Future getSnmpScannerStatus({ - CancelToken? cancelToken, - }) async { - final response = await _dio.get( - '/snmp_scanner/status', - cancelToken: cancelToken, - ); - return SnmpScannerStatus.fromJson(response.data as Map); - } - - Future<({List items, bool hasNextPage})> listNotifications( - bool? isNew, { - int page = 0, - int perPage = 5, - CancelToken? cancelToken, - }) async { - final response = await _dio.get( - '/notifications', - queryParameters: { - 'is_new': isNew ?? '', - 'page_offset': page * perPage, - 'page_limit': perPage + 1, - }, - cancelToken: cancelToken, - ); - - final results = (response.data as List) - .map((item) => Notification.fromJson(item)) - .toList(); + /// Splits a "fetch one extra item" page into its items and a [hasNextPage] + /// flag. Callers request `perPage + 1` items so a full extra item signals + /// that another page exists. + ({List items, bool hasNextPage}) _paginate( + List data, + T Function(dynamic) fromItem, + int perPage, + ) { + final results = data.map(fromItem).toList(); final hasNextPage = results.length > perPage; return ( items: hasNextPage ? results.take(perPage).toList() : results,