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: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 {
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
|
@ -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(
|
||||||
|
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 '../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);
|
||||||
|
@ -1 +1,2 @@
|
|||||||
export 'http_service.dart';
|
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: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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user