mirror of
https://github.com/foss42/apidash.git
synced 2025-06-23 08:09:07 +08:00
feat: add ability to cancel in-flight HTTP requests and manage client lifecycle
This commit is contained in:
@ -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;
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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(
|
||||
|
33
packages/apidash_core/lib/services/client_manager.dart
Normal file
33
packages/apidash_core/lib/services/client_manager.dart
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -1 +1,2 @@
|
||||
export 'http_service.dart';
|
||||
export 'client_manager.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;
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Reference in New Issue
Block a user