From 036bac311c21f7ed554481b01d42aace21f7fcf9 Mon Sep 17 00:00:00 2001 From: sasanktumpati Date: Sat, 7 Dec 2024 13:06:15 +0530 Subject: [PATCH] feat: add ability to cancel in-flight HTTP requests and manage client lifecycle --- lib/providers/collection_providers.dart | 70 ++++----- .../details_card/response_pane.dart | 7 - lib/widgets/response_widgets.dart | 4 +- .../lib/services/client_manager.dart | 33 +++++ .../lib/services/http_service.dart | 13 +- .../apidash_core/lib/services/services.dart | 1 + test/widgets/button_send_test.dart | 133 ++++++++++++------ 7 files changed, 169 insertions(+), 92 deletions(-) create mode 100644 packages/apidash_core/lib/services/client_manager.dart diff --git a/lib/providers/collection_providers.dart b/lib/providers/collection_providers.dart index 6cfe05d0..6bcd7742 100644 --- a/lib/providers/collection_providers.dart +++ b/lib/providers/collection_providers.dart @@ -1,7 +1,6 @@ import 'package:apidash_core/apidash_core.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:apidash/consts.dart'; -import 'package:http/http.dart' as http; import 'providers.dart'; import '../models/models.dart'; import '../services/services.dart' show hiveHandler, HiveHandler; @@ -47,7 +46,6 @@ class CollectionStateNotifier final Ref ref; final HiveHandler hiveHandler; final baseResponseModel = const HttpResponseModel(); - final Map _clients = {}; bool hasId(String id) => state?.keys.contains(id) ?? false; @@ -239,21 +237,15 @@ class CollectionStateNotifier Future sendRequest(String id) async { ref.read(codePaneVisibleStateProvider.notifier).state = false; final defaultUriScheme = ref.read( - settingsProvider.select( - (value) => value.defaultUriScheme, - ), + settingsProvider.select((value) => value.defaultUriScheme), ); RequestModel requestModel = state![id]!; - - if (requestModel.httpRequestModel == null) { - return; - } + if (requestModel.httpRequestModel == null) return; HttpRequestModel substitutedHttpRequestModel = getSubstitutedHttpRequestModel(requestModel.httpRequestModel!); - // set current model's isWorking to true and update state var map = {...state!}; map[id] = requestModel.copyWith( isWorking: true, @@ -261,22 +253,27 @@ class CollectionStateNotifier ); state = map; - // Create an HTTP client and store it to allow cancellation - final client = http.Client(); - _clients[id] = client; - (HttpResponse?, Duration?, String?)? responseRec = await request( substitutedHttpRequestModel, defaultUriScheme: defaultUriScheme, - client: client, + requestId: id, ); + late final RequestModel newRequestModel; if (responseRec.$1 == null) { - newRequestModel = requestModel.copyWith( - responseStatus: -1, - message: responseRec.$3, - isWorking: false, - ); + if (responseRec.$3 == 'Request cancelled') { + newRequestModel = requestModel.copyWith( + responseStatus: null, + message: responseRec.$3, + isWorking: false, + ); + } else { + newRequestModel = requestModel.copyWith( + responseStatus: -1, + message: responseRec.$3, + isWorking: false, + ); + } } else { final responseModel = baseResponseModel.fromResponse( response: responseRec.$1!, @@ -307,11 +304,6 @@ class CollectionStateNotifier ref.read(historyMetaStateNotifier.notifier).addHistoryRequest(model); } - // Close the client and remove it from the map - _clients[id]?.close(); - _clients.remove(id); - - // update state with response data map = {...state!}; map[id] = newRequestModel; state = map; @@ -319,22 +311,20 @@ class CollectionStateNotifier ref.read(hasUnsavedChangesProvider.notifier).state = true; } - void cancelRequest(String id) { - if (_clients.containsKey(id)) { - _clients[id]?.close(); - _clients.remove(id); + final httpClientManager = HttpClientManager(); - var currentModel = state![id]!; - var map = {...state!}; - map[id] = currentModel.copyWith( - isWorking: false, - message: 'Request Cancelled', - responseStatus: null, - httpResponseModel: null, - ); - state = map; - ref.read(hasUnsavedChangesProvider.notifier).state = true; - } + void cancelRequest(String id) { + httpClientManager.cancelRequest(id); + var currentModel = state![id]!; + var map = {...state!}; + map[id] = currentModel.copyWith( + isWorking: false, + message: 'Request Cancelled', + responseStatus: null, + httpResponseModel: null, + ); + state = map; + ref.read(hasUnsavedChangesProvider.notifier).state = true; } Future clearData() async { diff --git a/lib/screens/home_page/editor_pane/details_card/response_pane.dart b/lib/screens/home_page/editor_pane/details_card/response_pane.dart index 769d4bc4..e7a51001 100644 --- a/lib/screens/home_page/editor_pane/details_card/response_pane.dart +++ b/lib/screens/home_page/editor_pane/details_card/response_pane.dart @@ -28,13 +28,6 @@ class ResponsePane extends ConsumerWidget { return const NotSentWidget(); } - if (message == "Request Cancelled") { - return ErrorMessage( - message: '$message', - showIssueButton: false, - ); - } - if (responseStatus == -1) { return ErrorMessage(message: '$message. $kUnexpectedRaiseIssue'); } diff --git a/lib/widgets/response_widgets.dart b/lib/widgets/response_widgets.dart index df5e4574..2877c801 100644 --- a/lib/widgets/response_widgets.dart +++ b/lib/widgets/response_widgets.dart @@ -140,9 +140,7 @@ class ResponsePaneHeader extends StatelessWidget { kHSpacer10, Expanded( child: Text( - message == 'Request Cancelled' - ? 'Request Cancelled' - : "$responseStatus: ${message ?? '-'}", + "$responseStatus: ${message ?? '-'}", softWrap: false, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium?.copyWith( diff --git a/packages/apidash_core/lib/services/client_manager.dart b/packages/apidash_core/lib/services/client_manager.dart new file mode 100644 index 00000000..44bf10e1 --- /dev/null +++ b/packages/apidash_core/lib/services/client_manager.dart @@ -0,0 +1,33 @@ +import 'package:http/http.dart' as http; + +class HttpClientManager { + static final HttpClientManager _instance = HttpClientManager._internal(); + final Map _clients = {}; + + factory HttpClientManager() { + return _instance; + } + + HttpClientManager._internal(); + + http.Client createClient(String requestId) { + final client = http.Client(); + _clients[requestId] = client; + return client; + } + + void cancelRequest(String requestId) { + if (_clients.containsKey(requestId)) { + _clients[requestId]?.close(); + _clients.remove(requestId); + } + } + + void closeClient(String requestId) { + cancelRequest(requestId); + } + + bool hasActiveClient(String requestId) { + return _clients.containsKey(requestId); + } +} diff --git a/packages/apidash_core/lib/services/http_service.dart b/packages/apidash_core/lib/services/http_service.dart index d64578de..27d86adf 100644 --- a/packages/apidash_core/lib/services/http_service.dart +++ b/packages/apidash_core/lib/services/http_service.dart @@ -6,14 +6,22 @@ import 'package:seed/seed.dart'; import '../consts.dart'; import '../models/models.dart'; import '../utils/utils.dart'; +import 'client_manager.dart'; typedef HttpResponse = http.Response; Future<(HttpResponse?, Duration?, String?)> request( HttpRequestModel requestModel, { String defaultUriScheme = kDefaultUriScheme, - http.Client? client, + String? requestId, }) async { + final clientManager = HttpClientManager(); + http.Client? client; + + if (requestId != null) { + client = clientManager.createClient(requestId); + } + (Uri?, String?) uriRec = getValidRequestUri( requestModel.url, requestModel.enabledParams, @@ -110,6 +118,9 @@ Future<(HttpResponse?, Duration?, String?)> request( if (shouldCloseClient) { client?.close(); } + if (requestId != null) { + clientManager.closeClient(requestId); + } } } else { return (null, null, uriRec.$2); diff --git a/packages/apidash_core/lib/services/services.dart b/packages/apidash_core/lib/services/services.dart index f6a0ec3b..cdeadd69 100644 --- a/packages/apidash_core/lib/services/services.dart +++ b/packages/apidash_core/lib/services/services.dart @@ -1 +1,2 @@ export 'http_service.dart'; +export 'client_manager.dart'; diff --git a/test/widgets/button_send_test.dart b/test/widgets/button_send_test.dart index f02fddb9..44180cff 100644 --- a/test/widgets/button_send_test.dart +++ b/test/widgets/button_send_test.dart @@ -2,57 +2,108 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:apidash/consts.dart'; import 'package:apidash/widgets/button_send.dart'; - import '../test_consts.dart'; void main() { - testWidgets('Testing for Send Request button', (tester) async { - dynamic changedValue; - await tester.pumpWidget( - MaterialApp( - title: 'Send Request button', - theme: kThemeDataLight, - home: Scaffold( - body: SendButton( - isWorking: false, - onTap: () { - changedValue = 'Send'; + group('SendButton', () { + testWidgets('renders send state correctly when not working', + (tester) async { + bool sendPressed = false; + bool cancelPressed = false; + + await tester.pumpWidget( + MaterialApp( + theme: kThemeDataLight, + home: Scaffold( + body: SendButton( + isWorking: false, + onTap: () => sendPressed = true, + onCancel: () => cancelPressed = true, + ), + ), + ), + ); + + // Verify initial send state + expect(find.byIcon(Icons.send), findsOneWidget); + expect(find.text(kLabelSend), findsOneWidget); + expect(find.byIcon(Icons.cancel), findsNothing); + expect(find.text(kLabelCancel), findsNothing); + + // Tap and verify callback + await tester.tap(find.byType(FilledButton)); + expect(sendPressed, isTrue); + expect(cancelPressed, isFalse); + }); + + testWidgets('renders cancel state correctly when working', (tester) async { + bool sendPressed = false; + bool cancelPressed = false; + + await tester.pumpWidget( + MaterialApp( + theme: kThemeDataLight, + home: Scaffold( + body: SendButton( + isWorking: true, + onTap: () => sendPressed = true, + onCancel: () => cancelPressed = true, + ), + ), + ), + ); + + // Verify initial cancel state + expect(find.byIcon(Icons.send), findsNothing); + expect(find.text(kLabelSend), findsNothing); + expect(find.byIcon(Icons.cancel), findsOneWidget); + expect(find.text(kLabelCancel), findsOneWidget); + + // Tap and verify callback + await tester.tap(find.byType(FilledButton)); + expect(sendPressed, isFalse); + expect(cancelPressed, isTrue); + }); + + testWidgets('updates UI when isWorking changes', (tester) async { + bool isWorking = false; + + await tester.pumpWidget( + MaterialApp( + theme: kThemeDataLight, + home: StatefulBuilder( + builder: (context, setState) { + return Scaffold( + body: SendButton( + isWorking: isWorking, + onTap: () => setState(() => isWorking = true), + onCancel: () => setState(() => isWorking = false), + ), + ); }, ), ), - ), - ); + ); - expect(find.byIcon(Icons.send), findsOneWidget); - expect(find.text(kLabelSend), findsOneWidget); - final button1 = find.byType(FilledButton); - expect(button1, findsOneWidget); + // Initial send state + expect(find.byIcon(Icons.send), findsOneWidget); + expect(find.text(kLabelSend), findsOneWidget); - await tester.tap(button1); - expect(changedValue, 'Send'); - }); + // Tap to start working + await tester.tap(find.byType(FilledButton)); + await tester.pump(); - testWidgets( - 'Testing for Send Request button when RequestModel is viewed and is waiting for response', - (tester) async { - await tester.pumpWidget( - MaterialApp( - title: 'Send Request button', - theme: kThemeDataLight, - home: Scaffold( - body: SendButton( - isWorking: true, - onTap: () {}, - ), - ), - ), - ); + // Verify cancel state + expect(find.byIcon(Icons.cancel), findsOneWidget); + expect(find.text(kLabelCancel), findsOneWidget); - expect(find.byIcon(Icons.send), findsNothing); - expect(find.text(kLabelSending), findsOneWidget); - final button1 = find.byType(FilledButton); - expect(button1, findsOneWidget); + // Tap to cancel + await tester.tap(find.byType(FilledButton)); + await tester.pump(); - expect(tester.widget(button1).enabled, isFalse); + // Verify back to send state + expect(find.byIcon(Icons.send), findsOneWidget); + expect(find.text(kLabelSend), findsOneWidget); + }); }); }