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: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<String, http.Client> _clients = {};
bool hasId(String id) => state?.keys.contains(id) ?? false;
@ -239,21 +237,15 @@ class CollectionStateNotifier
Future<void> 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) {
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,11 +311,10 @@ 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();
void cancelRequest(String id) {
httpClientManager.cancelRequest(id);
var currentModel = state![id]!;
var map = {...state!};
map[id] = currentModel.copyWith(
@ -335,7 +326,6 @@ class CollectionStateNotifier
state = map;
ref.read(hasUnsavedChangesProvider.notifier).state = true;
}
}
Future<void> clearData() async {
ref.read(clearDataStateProvider.notifier).state = true;

View File

@ -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');
}

View File

@ -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(

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 '../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);

View File

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