Fix Copy as cURL omitting headers and body on failed requests (#9963)
Fix Copy as cURL omitting headers and body on failed requests.
Timed-out and cancelled POSTs never complete a response, so the Network
profiler dropped request data from the generated curl command.
---------
Co-authored-by: Sam Rawlins <sam.rawlins@gmail.com>
diff --git a/packages/devtools_app/lib/src/screens/network/network_screen.dart b/packages/devtools_app/lib/src/screens/network/network_screen.dart
index 9721eaa..ef42f64 100644
--- a/packages/devtools_app/lib/src/screens/network/network_screen.dart
+++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart
@@ -476,17 +476,25 @@
MenuItemButton(
child: const Text('Copy as cURL'),
onPressed: () {
- unawaited(
- copyToClipboard(
- CurlCommand.from(data).toString(),
- successMessage: 'Copied the cURL command to the clipboard',
- ),
- );
+ unawaited(_copyAsCurl(data));
},
),
],
);
}
+
+ Future<void> _copyAsCurl(DartIOHttpRequestData data) async {
+ try {
+ await data.getFullRequestData();
+ } catch (_) {
+ // Ignore errors fetching full data so we can still attempt to copy
+ // the partial request data we already have.
+ }
+ await copyToClipboard(
+ CurlCommand.from(data).toString(),
+ successMessage: 'Copied the cURL command to the clipboard',
+ );
+ }
}
class StatusColumn extends ColumnData<NetworkRequest>
diff --git a/packages/devtools_app/lib/src/shared/http/http_request_data.dart b/packages/devtools_app/lib/src/shared/http/http_request_data.dart
index 619db21..0277cbe 100644
--- a/packages/devtools_app/lib/src/shared/http/http_request_data.dart
+++ b/packages/devtools_app/lib/src/shared/http/http_request_data.dart
@@ -42,8 +42,15 @@
this._request, {
bool requestFullDataFromVmService = true,
}) {
- if (requestFullDataFromVmService && _request.isResponseComplete) {
- unawaited(getFullRequestData());
+ if (requestFullDataFromVmService &&
+ (_request.isResponseComplete ||
+ _request.isRequestComplete ||
+ (_request.request?.hasError ?? false))) {
+ unawaited(
+ getFullRequestData().catchError((Object e, StackTrace st) {
+ _log.warning('Failed to fetch full request data: $e', e, st);
+ }),
+ );
}
}
@@ -120,6 +127,8 @@
}
notifyListeners();
}
+ } catch (e, st) {
+ _log.warning('Failed to fetch full request data: $e', e, st);
} finally {
isFetchingFullData = false;
}
@@ -308,8 +317,17 @@
DartIOHttpRequestData._parseCookies(_request.response?.cookies);
/// The request headers for the HTTP request.
- Map<String, dynamic>? get requestHeaders =>
- _hasError ? null : _request.request?.headers;
+ ///
+ /// Returned even when the request failed, so failed / timed-out requests can
+ /// still be replayed (e.g. Copy as cURL). Accessing headers on some error
+ /// profiles throws, so failures fall back to `null`.
+ Map<String, dynamic>? get requestHeaders {
+ try {
+ return _request.request?.headers;
+ } catch (_) {
+ return null;
+ }
+ }
/// The response headers for the HTTP request.
Map<String, dynamic>? get responseHeaders => _request.response?.headers;
@@ -401,7 +419,8 @@
}
final fullRequest = _request as HttpProfileRequest;
try {
- if (!_request.isResponseComplete) return null;
+ // Request body is independent of whether a response arrived. Timed-out
+ // and cancelled POSTs should still expose the body for Copy as cURL.
final acceptedMethods = {'POST', 'PUT', 'PATCH'};
if (!acceptedMethods.contains(_request.method)) return null;
if (_requestBody != null) return _requestBody;
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 10b1377..22f639d 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -62,6 +62,8 @@
## Network profiler updates
+* Fixed Copy as cURL omitting request headers and body for failed or timed-out
+ requests. [#9963](https://github.com/flutter/devtools/pull/9963)
* Added WebSocket support to the Network profiler, including WebSocket
connection details, lifecycle events, frame-level inspection, and connection
timing information. [#9968](https://github.com/flutter/devtools/pull/9968)
diff --git a/packages/devtools_app/test/http/curl_command_test.dart b/packages/devtools_app/test/http/curl_command_test.dart
index 81cda7c..e23825c 100644
--- a/packages/devtools_app/test/http/curl_command_test.dart
+++ b/packages/devtools_app/test/http/curl_command_test.dart
@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+import 'dart:convert';
import 'dart:typed_data';
import 'package:devtools_app/devtools_app.dart';
@@ -205,6 +206,74 @@
"curl --location --request POST 'https://jsonplaceholder.typicode.com/posts' \\\n--data-raw '{\n \"title\": \"foo\", \"body\": \"bar\", \"userId\": 1\n}\n '",
);
});
+
+ test('includes headers and body when response never completes', () {
+ final data = DartIOHttpRequestData(
+ HttpProfileRequest.parse(<String, Object?>{
+ 'id': '7',
+ 'isolateId': 'isolates/0',
+ 'method': 'POST',
+ 'uri': 'https://example.com/api/login',
+ 'events': <Object>[],
+ 'startTime': 0,
+ 'endTime': 1000,
+ 'request': <String, Object?>{
+ 'headers': <String, Object?>{
+ 'content-type': <String>['application/json'],
+ 'accept': <String>['application/json'],
+ 'locale': <String>['en'],
+ },
+ 'contentLength': 42,
+ 'cookies': <Object>[],
+ 'followRedirects': true,
+ 'maxRedirects': 5,
+ 'persistentConnection': false,
+ },
+ 'response': null,
+ 'requestBody': utf8.encode(
+ '{"email":"user@example.com","password":"secret"}',
+ ),
+ })!,
+ requestFullDataFromVmService: false,
+ );
+
+ expect(
+ data.requestBody,
+ '{"email":"user@example.com","password":"secret"}',
+ );
+ expect(
+ CurlCommand.from(data).toString(),
+ "curl --location --request POST 'https://example.com/api/login' \\\n--header 'content-type: application/json' \\\n--header 'accept: application/json' \\\n--header 'locale: en' \\\n--data-raw '{\"email\":\"user@example.com\",\"password\":\"secret\"}'",
+ );
+ });
+
+ test('includes body when request has an error', () {
+ final data = DartIOHttpRequestData(
+ HttpProfileRequest.parse(<String, Object?>{
+ 'id': '8',
+ 'isolateId': 'isolates/0',
+ 'method': 'POST',
+ 'uri': 'https://example.com/api/login',
+ 'events': <Object>[],
+ 'startTime': 0,
+ 'endTime': 1000,
+ 'request': <String, Object?>{
+ 'error': 'Connection timed out',
+ 'contentLength': 2,
+ 'cookies': <Object>[],
+ 'followRedirects': true,
+ 'maxRedirects': 5,
+ 'persistentConnection': false,
+ },
+ 'response': null,
+ 'requestBody': utf8.encode('{}'),
+ })!,
+ requestFullDataFromVmService: false,
+ );
+
+ expect(data.requestBody, '{}');
+ expect(CurlCommand.from(data).toString(), contains("--data-raw '{}'"));
+ });
});
}