Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -476,17 +476,25 @@ class ActionsColumn extends ColumnData<NetworkRequest>
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',
);
}
Comment thread
muhammadkamel marked this conversation as resolved.
}

class StatusColumn extends ColumnData<NetworkRequest>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,15 @@ class DartIOHttpRequestData extends NetworkRequest {
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);
}),
);
}
Comment thread
muhammadkamel marked this conversation as resolved.
}

Expand Down Expand Up @@ -120,6 +127,8 @@ class DartIOHttpRequestData extends NetworkRequest {
}
notifyListeners();
}
} catch (e, st) {
_log.warning('Failed to fetch full request data: $e', e, st);
} finally {
isFetchingFullData = false;
}
Expand Down Expand Up @@ -308,8 +317,17 @@ class DartIOHttpRequestData extends NetworkRequest {
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;
Expand Down Expand Up @@ -401,7 +419,8 @@ class DartIOHttpRequestData extends NetworkRequest {
}
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;
Expand Down
2 changes: 2 additions & 0 deletions packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ TODO: Remove this section if there are not any updates.

* Fixed exported response status in HAR files so that they parse as integers
instead of strings. [#9900](https://github.com/flutter/devtools/pull/9900)
* Fixed Copy as cURL omitting request headers and body for failed or timed-out
requests. [#9963](https://github.com/flutter/devtools/pull/9963)

## Logging updates

Expand Down
69 changes: 69 additions & 0 deletions packages/devtools_app/test/http/curl_command_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -205,6 +206,74 @@ void main() {
"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 '{}'"));
});
});
}

Expand Down