feat: add ability to cancel in-flight HTTP requests and manage client lifecycle

This commit is contained in:
sasanktumpati
2024-12-07 13:06:15 +05:30
parent 596cf05576
commit 036bac311c
7 changed files with 169 additions and 92 deletions

View File

@ -1,7 +1,6 @@
import 'package:apidash_core/apidash_core.dart'; import 'package:apidash_core/apidash_core.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:apidash/consts.dart'; import 'package:apidash/consts.dart';
import 'package:http/http.dart' as http;
import 'providers.dart'; import 'providers.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../services/services.dart' show hiveHandler, HiveHandler; import '../services/services.dart' show hiveHandler, HiveHandler;
@ -47,7 +46,6 @@ class CollectionStateNotifier
final Ref ref; final Ref ref;
final HiveHandler hiveHandler; final HiveHandler hiveHandler;
final baseResponseModel = const HttpResponseModel(); final baseResponseModel = const HttpResponseModel();
final Map<String, http.Client> _clients = {};
bool hasId(String id) => state?.keys.contains(id) ?? false; bool hasId(String id) => state?.keys.contains(id) ?? false;
@ -239,21 +237,15 @@ class CollectionStateNotifier
Future<void> sendRequest(String id) async { Future<void> sendRequest(String id) async {
ref.read(codePaneVisibleStateProvider.notifier).state = false; ref.read(codePaneVisibleStateProvider.notifier).state = false;
final defaultUriScheme = ref.read( final defaultUriScheme = ref.read(
settingsProvider.select( settingsProvider.select((value) => value.defaultUriScheme),
(value) => value.defaultUriScheme,
),
); );
RequestModel requestModel = state![id]!; RequestModel requestModel = state![id]!;
if (requestModel.httpRequestModel == null) return;
if (requestModel.httpRequestModel == null) {
return;
}
HttpRequestModel substitutedHttpRequestModel = HttpRequestModel substitutedHttpRequestModel =
getSubstitutedHttpRequestModel(requestModel.httpRequestModel!); getSubstitutedHttpRequestModel(requestModel.httpRequestModel!);
// set current model's isWorking to true and update state
var map = {...state!}; var map = {...state!};
map[id] = requestModel.copyWith( map[id] = requestModel.copyWith(
isWorking: true, isWorking: true,
@ -261,22 +253,27 @@ class CollectionStateNotifier
); );
state = map; 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( (HttpResponse?, Duration?, String?)? responseRec = await request(
substitutedHttpRequestModel, substitutedHttpRequestModel,
defaultUriScheme: defaultUriScheme, defaultUriScheme: defaultUriScheme,
client: client, requestId: id,
); );
late final RequestModel newRequestModel; late final RequestModel newRequestModel;
if (responseRec.$1 == null) { if (responseRec.$1 == null) {
newRequestModel = requestModel.copyWith( if (responseRec.$3 == 'Request cancelled') {
responseStatus: -1, newRequestModel = requestModel.copyWith(
message: responseRec.$3, responseStatus: null,
isWorking: false, message: responseRec.$3,
); isWorking: false,
);
} else {
newRequestModel = requestModel.copyWith(
responseStatus: -1,
message: responseRec.$3,
isWorking: false,
);
}
} else { } else {
final responseModel = baseResponseModel.fromResponse( final responseModel = baseResponseModel.fromResponse(
response: responseRec.$1!, response: responseRec.$1!,
@ -307,11 +304,6 @@ class CollectionStateNotifier
ref.read(historyMetaStateNotifier.notifier).addHistoryRequest(model); 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 = {...state!};
map[id] = newRequestModel; map[id] = newRequestModel;
state = map; state = map;
@ -319,22 +311,20 @@ class CollectionStateNotifier
ref.read(hasUnsavedChangesProvider.notifier).state = true; ref.read(hasUnsavedChangesProvider.notifier).state = true;
} }
void cancelRequest(String id) { final httpClientManager = HttpClientManager();
if (_clients.containsKey(id)) {
_clients[id]?.close();
_clients.remove(id);
var currentModel = state![id]!; void cancelRequest(String id) {
var map = {...state!}; httpClientManager.cancelRequest(id);
map[id] = currentModel.copyWith( var currentModel = state![id]!;
isWorking: false, var map = {...state!};
message: 'Request Cancelled', map[id] = currentModel.copyWith(
responseStatus: null, isWorking: false,
httpResponseModel: null, message: 'Request Cancelled',
); responseStatus: null,
state = map; httpResponseModel: null,
ref.read(hasUnsavedChangesProvider.notifier).state = true; );
} state = map;
ref.read(hasUnsavedChangesProvider.notifier).state = true;
} }
Future<void> clearData() async { Future<void> clearData() async {

View File

@ -28,13 +28,6 @@ class ResponsePane extends ConsumerWidget {
return const NotSentWidget(); return const NotSentWidget();
} }
if (message == "Request Cancelled") {
return ErrorMessage(
message: '$message',
showIssueButton: false,
);
}
if (responseStatus == -1) { if (responseStatus == -1) {
return ErrorMessage(message: '$message. $kUnexpectedRaiseIssue'); return ErrorMessage(message: '$message. $kUnexpectedRaiseIssue');
} }

View File

@ -140,9 +140,7 @@ class ResponsePaneHeader extends StatelessWidget {
kHSpacer10, kHSpacer10,
Expanded( Expanded(
child: Text( child: Text(
message == 'Request Cancelled' "$responseStatus: ${message ?? '-'}",
? 'Request Cancelled'
: "$responseStatus: ${message ?? '-'}",
softWrap: false, softWrap: false,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(

View File

@ -0,0 +1,33 @@
import 'package:http/http.dart' as http;
class HttpClientManager {
static final HttpClientManager _instance = HttpClientManager._internal();
final Map<String, http.Client> _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);
}
}

View File

@ -6,14 +6,22 @@ import 'package:seed/seed.dart';
import '../consts.dart'; import '../consts.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../utils/utils.dart'; import '../utils/utils.dart';
import 'client_manager.dart';
typedef HttpResponse = http.Response; typedef HttpResponse = http.Response;
Future<(HttpResponse?, Duration?, String?)> request( Future<(HttpResponse?, Duration?, String?)> request(
HttpRequestModel requestModel, { HttpRequestModel requestModel, {
String defaultUriScheme = kDefaultUriScheme, String defaultUriScheme = kDefaultUriScheme,
http.Client? client, String? requestId,
}) async { }) async {
final clientManager = HttpClientManager();
http.Client? client;
if (requestId != null) {
client = clientManager.createClient(requestId);
}
(Uri?, String?) uriRec = getValidRequestUri( (Uri?, String?) uriRec = getValidRequestUri(
requestModel.url, requestModel.url,
requestModel.enabledParams, requestModel.enabledParams,
@ -110,6 +118,9 @@ Future<(HttpResponse?, Duration?, String?)> request(
if (shouldCloseClient) { if (shouldCloseClient) {
client?.close(); client?.close();
} }
if (requestId != null) {
clientManager.closeClient(requestId);
}
} }
} else { } else {
return (null, null, uriRec.$2); return (null, null, uriRec.$2);

View File

@ -1 +1,2 @@
export 'http_service.dart'; export 'http_service.dart';
export 'client_manager.dart';

View File

@ -2,57 +2,108 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:apidash/consts.dart'; import 'package:apidash/consts.dart';
import 'package:apidash/widgets/button_send.dart'; import 'package:apidash/widgets/button_send.dart';
import '../test_consts.dart'; import '../test_consts.dart';
void main() { void main() {
testWidgets('Testing for Send Request button', (tester) async { group('SendButton', () {
dynamic changedValue; testWidgets('renders send state correctly when not working',
await tester.pumpWidget( (tester) async {
MaterialApp( bool sendPressed = false;
title: 'Send Request button', bool cancelPressed = false;
theme: kThemeDataLight,
home: Scaffold( await tester.pumpWidget(
body: SendButton( MaterialApp(
isWorking: false, theme: kThemeDataLight,
onTap: () { home: Scaffold(
changedValue = 'Send'; 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); // Initial send state
expect(find.text(kLabelSend), findsOneWidget); expect(find.byIcon(Icons.send), findsOneWidget);
final button1 = find.byType(FilledButton); expect(find.text(kLabelSend), findsOneWidget);
expect(button1, findsOneWidget);
await tester.tap(button1); // Tap to start working
expect(changedValue, 'Send'); await tester.tap(find.byType(FilledButton));
}); await tester.pump();
testWidgets( // Verify cancel state
'Testing for Send Request button when RequestModel is viewed and is waiting for response', expect(find.byIcon(Icons.cancel), findsOneWidget);
(tester) async { expect(find.text(kLabelCancel), findsOneWidget);
await tester.pumpWidget(
MaterialApp(
title: 'Send Request button',
theme: kThemeDataLight,
home: Scaffold(
body: SendButton(
isWorking: true,
onTap: () {},
),
),
),
);
expect(find.byIcon(Icons.send), findsNothing); // Tap to cancel
expect(find.text(kLabelSending), findsOneWidget); await tester.tap(find.byType(FilledButton));
final button1 = find.byType(FilledButton); await tester.pump();
expect(button1, findsOneWidget);
expect(tester.widget<FilledButton>(button1).enabled, isFalse); // Verify back to send state
expect(find.byIcon(Icons.send), findsOneWidget);
expect(find.text(kLabelSend), findsOneWidget);
});
}); });
} }