diff --git a/packages/devtools_app/lib/src/http/curl_command.dart b/packages/devtools_app/lib/src/http/curl_command.dart new file mode 100644 index 00000000000..1f29fd49dac --- /dev/null +++ b/packages/devtools_app/lib/src/http/curl_command.dart @@ -0,0 +1,126 @@ +// Copyright 2022 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../primitives/utils.dart'; +import 'http_request_data.dart'; + +class CurlCommand { + /// [CurlCommand] provides the ability to create a cURL command string + /// based on the passed [DartIOHttpRequestData]. + /// + /// When [followRedirects] is false, the `--location` option is omitted from cURL. + /// When [multiline] is false, the command will be forced to be in a single line. + factory CurlCommand.from( + DartIOHttpRequestData data, { + bool followRedirects = true, + bool multiline = true, + }) { + return CurlCommand._( + commandParts: [ + 'curl', + if (followRedirects) '--location', + '--request', + data.method, + _escapeString(data.uri), + ..._headers(data, multiline: multiline), + ..._body(data, multiline: multiline), + ], + ); + } + + CurlCommand._({ + required this.commandParts, + }); + + static const _lineBreak = '\\\n'; + + final List commandParts; + + /// Returns the cURL command as a string. + @override + String toString() { + return _buildCommandString(commandParts); + } + + static List _headers( + DartIOHttpRequestData data, { + required bool multiline, + }) { + final parts = []; + final headers = data.requestHeaders; + + if (headers != null && headers.isNotEmpty) { + for (final header in headers.entries) { + final headerKey = header.key.toLowerCase(); + final headerValue = _unwrapHeaderValue(header.value); + + if (headerValue == null) continue; + + parts.addAll([ + if (multiline) _lineBreak, + '--header', + _escapeString('$headerKey: $headerValue') + ]); + } + } + + return parts; + } + + static List _body( + DartIOHttpRequestData data, { + required bool multiline, + }) { + final requestBody = data.requestBody; + if (requestBody == null) return []; + + return [ + if (multiline) _lineBreak, + '--data-raw', + _escapeString(requestBody), + ]; + } + + /// Escapes an arbitrary string by wrapping it inside single quotes. + /// + /// Enclosing characters in single quotes preserves the literal value of each + /// character in the string. Single quotes can't occur within, which is why it + /// is necessary to replace all occurences of the character ' with '\''. + /// + /// See: https://www.gnu.org/software/bash/manual/html_node/Quoting.html + static String _escapeString(String text) { + final content = text.replaceAll("'", "'\\''"); + + return "'$content'"; + } + + static String? _unwrapHeaderValue(dynamic value) { + if (value is String) { + return value; + } else if (value is List) { + return value.safeFirst as String?; + } + + return null; + } + + /// Given a list of [commandParts], build the cURL command string. + static String _buildCommandString(List commandParts) { + String commandString = ''; + + for (int index = 0; index < commandParts.length; index++) { + final previousPart = commandParts.safeGet(index - 1); + + // Only insert a space when this is not the first element AND the previous + // part is not a line break. + if (index != 0 && previousPart != _lineBreak) { + commandString += ' '; + } + + commandString += commandParts[index]; + } + + return commandString; + } +} diff --git a/packages/devtools_app/lib/src/http/http_request_data.dart b/packages/devtools_app/lib/src/http/http_request_data.dart index 6b8d3ec950a..1e87a65d8be 100644 --- a/packages/devtools_app/lib/src/http/http_request_data.dart +++ b/packages/devtools_app/lib/src/http/http_request_data.dart @@ -293,6 +293,7 @@ class DartIOHttpRequestData extends NetworkRequest { final acceptedMethods = {'POST', 'PUT', 'PATCH'}; if (!acceptedMethods.contains(_request.method)) return null; if (_requestBody != null) return _requestBody; + if (fullRequest.requestBody == null) return null; _requestBody = utf8.decode(fullRequest.requestBody!); return _requestBody; } on FormatException { 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 5fec452359f..870a14033f0 100644 --- a/packages/devtools_app/lib/src/screens/network/network_screen.dart +++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart @@ -8,6 +8,8 @@ import 'package:provider/provider.dart'; import '../../analytics/analytics.dart' as ga; import '../../analytics/constants.dart' as analytics_constants; +import '../../http/curl_command.dart'; +import '../../http/http_request_data.dart'; import '../../primitives/auto_dispose_mixin.dart'; import '../../primitives/utils.dart'; import '../../shared/common_widgets.dart'; @@ -331,6 +333,7 @@ class NetworkRequestsTable extends StatelessWidget { static TypeColumn typeColumn = TypeColumn(); static DurationColumn durationColumn = DurationColumn(); static TimestampColumn timestampColumn = TimestampColumn(); + static ActionsColumn actionsColumn = ActionsColumn(); final NetworkController networkController; final List requests; @@ -348,6 +351,7 @@ class NetworkRequestsTable extends StatelessWidget { typeColumn, durationColumn, timestampColumn, + actionsColumn ], data: requests, keyFactory: (NetworkRequest? data) => ValueKey(data), @@ -408,6 +412,78 @@ class MethodColumn extends ColumnData { } } +class ActionsColumn extends ColumnData + implements ColumnRenderer { + ActionsColumn() + : super( + '', + fixedWidthPx: scaleByFontFactor(32), + alignment: ColumnAlignment.right, + ); + + static const _actionSplashRadius = 16.0; + + @override + bool get supportsSorting => false; + + @override + bool get includeHeader => false; + + @override + dynamic getValue(NetworkRequest dataObject) { + return ''; + } + + List _buildOptions(BuildContext context, NetworkRequest data) { + return [ + if (data is DartIOHttpRequestData) ...[ + PopupMenuItem( + child: const Text('Copy as URL'), + onTap: () { + copyToClipboard( + data.uri, + 'Copied the URL to the clipboard', + context, + ); + }, + ), + PopupMenuItem( + child: const Text('Copy as cURL'), + onTap: () { + copyToClipboard( + CurlCommand.from(data).toString(), + 'Copied the cURL command to the clipboard', + context, + ); + }, + ) + ] + ]; + } + + @override + Widget build( + BuildContext context, + NetworkRequest data, { + bool isRowSelected = false, + VoidCallback? onPressed, + }) { + final options = _buildOptions(context, data); + + // Only show the actions button when there are options and the row is + // currently selected. + if (options.isEmpty || !isRowSelected) return const SizedBox.shrink(); + + return PopupMenuButton( + icon: const Icon(Icons.more_vert), + padding: const EdgeInsets.symmetric(horizontal: densePadding), + splashRadius: _actionSplashRadius, + tooltip: '', + itemBuilder: (context) => options, + ); + } +} + class StatusColumn extends ColumnData implements ColumnRenderer { StatusColumn() diff --git a/packages/devtools_app/lib/src/shared/table.dart b/packages/devtools_app/lib/src/shared/table.dart index efd9062110a..1405cd22782 100644 --- a/packages/devtools_app/lib/src/shared/table.dart +++ b/packages/devtools_app/lib/src/shared/table.dart @@ -1327,37 +1327,41 @@ class _TableRowState extends State> overflow: TextOverflow.ellipsis, ); - content = InkWell( - canRequestFocus: false, - onTap: () => _handleSortChange( - column, - secondarySortColumn: widget.secondarySortColumn, - ), - child: Row( - mainAxisAlignment: _mainAxisAlignmentFor(column), - children: [ - if (isSortColumn) - Icon( - widget.sortDirection == SortDirection.ascending - ? Icons.expand_less - : Icons.expand_more, - size: defaultIconSize, - ), - if (isSortColumn) const SizedBox(width: densePadding), - // TODO: This Flexible wrapper was added to get the - // network_profiler_test.dart tests to pass. - Flexible( - child: column.titleTooltip != null - ? DevToolsTooltip( - message: column.titleTooltip, - padding: const EdgeInsets.all(denseSpacing), - child: title, - ) - : title, + final headerContent = Row( + mainAxisAlignment: _mainAxisAlignmentFor(column), + children: [ + if (isSortColumn) + Icon( + widget.sortDirection == SortDirection.ascending + ? Icons.expand_less + : Icons.expand_more, + size: defaultIconSize, ), - ], - ), + if (isSortColumn) const SizedBox(width: densePadding), + // TODO: This Flexible wrapper was added to get the + // network_profiler_test.dart tests to pass. + Flexible( + child: column.titleTooltip != null + ? DevToolsTooltip( + message: column.titleTooltip, + padding: const EdgeInsets.all(denseSpacing), + child: title, + ) + : title, + ), + ], ); + + content = column.includeHeader + ? InkWell( + canRequestFocus: false, + onTap: () => _handleSortChange( + column, + secondarySortColumn: widget.secondarySortColumn, + ), + child: headerContent, + ) + : headerContent; } else { final padding = column.getNodeIndentPx(node); assert(padding >= 0); diff --git a/packages/devtools_app/lib/src/shared/table_data.dart b/packages/devtools_app/lib/src/shared/table_data.dart index d855e54a8b4..c0ae99e2f0f 100644 --- a/packages/devtools_app/lib/src/shared/table_data.dart +++ b/packages/devtools_app/lib/src/shared/table_data.dart @@ -43,6 +43,8 @@ abstract class ColumnData { bool get numeric => false; + bool get includeHeader => true; + bool get supportsSorting => numeric; int compare(T a, T b) { diff --git a/packages/devtools_app/test/http/curl_command_test.dart b/packages/devtools_app/test/http/curl_command_test.dart new file mode 100644 index 00000000000..7560d230698 --- /dev/null +++ b/packages/devtools_app/test/http/curl_command_test.dart @@ -0,0 +1,284 @@ +// Copyright 2022 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data'; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/http/curl_command.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vm_service/vm_service.dart'; + +import '../test_data/network.dart'; + +void main() { + group('NetworkCurlCommand', () { + test('parses simple GET request', () { + final command = CurlCommand.from( + _testDartIOHttpRequestData( + method: 'GET', + uri: Uri.parse('https://www.example.com'), + ), + ); + + expect( + command.toString(), + "curl --location --request GET 'https://www.example.com'", + ); + }); + + test('parses PUT request', () { + final command = CurlCommand.from( + _testDartIOHttpRequestData( + method: 'PUT', + uri: Uri.parse('https://www.example.com'), + headers: {}, + ), + ); + + expect( + command.toString(), + "curl --location --request PUT 'https://www.example.com'", + ); + }); + + test('parses simple GET request with headers', () { + final command = CurlCommand.from( + _testDartIOHttpRequestData( + method: 'GET', + uri: Uri.parse('https://www.example.com'), + headers: { + 'accept-language': ['en-GB,de-DE'], + 'user-agent': ['SomeUserAgent/5.0 (Macintosh; Intel Mac OS X)'] + }, + ), + ); + + expect( + command.toString(), + "curl --location --request GET 'https://www.example.com' \\\n--header 'accept-language: en-GB,de-DE' \\\n--header 'user-agent: SomeUserAgent/5.0 (Macintosh; Intel Mac OS X)'", + ); + }); + + test('parses POST with body', () { + final command = CurlCommand.from( + _testDartIOHttpRequestData( + method: 'POST', + uri: Uri.parse('https://www.example.com'), + headers: { + 'accept-language': ['en-GB,de-DE'], + 'user-agent': ['SomeUserAgent/5.0 (Macintosh; Intel Mac OS X)'] + }, + requestBody: Uint8List.fromList( + 'It\'s a request body!\nHopefully this works.'.codeUnits, + ), + ), + ); + + expect( + command.toString(), + "curl --location --request POST 'https://www.example.com' \\\n--header 'accept-language: en-GB,de-DE' \\\n--header 'user-agent: SomeUserAgent/5.0 (Macintosh; Intel Mac OS X)' \\\n--data-raw 'It'\\''s a request body!\nHopefully this works.'", + ); + }); + + test('parses null body', () { + final command = CurlCommand.from( + _testDartIOHttpRequestData( + method: 'POST', + uri: Uri.parse('https://www.example.com'), + headers: {}, + // Ignore this warning to make the `null` value used more apparent + // ignore: avoid_redundant_argument_values + requestBody: null, + ), + ); + + expect( + command.toString(), + "curl --location --request POST 'https://www.example.com'", + ); + }); + + test('parses empty body', () { + final command = CurlCommand.from( + _testDartIOHttpRequestData( + method: 'POST', + uri: Uri.parse('https://www.example.com'), + headers: {}, + requestBody: Uint8List(0), + ), + ); + + expect( + command.toString(), + "curl --location --request POST 'https://www.example.com' \\\n--data-raw ''", + ); + }); + + test('escapes \' character in url', () { + final command = CurlCommand.from( + _testDartIOHttpRequestData( + method: 'GET', + uri: Uri.parse('https://www.example.com/search?q=\'test\''), + headers: { + 'accept-language': ['en-GB,de-DE'], + 'user-agent': ['SomeUserAgent/5.0 (Macintosh; Intel Mac OS X)'] + }, + ), + ); + + expect( + command.toString(), + "curl --location --request GET 'https://www.example.com/search?q='\\''test'\\''' \\\n--header 'accept-language: en-GB,de-DE' \\\n--header 'user-agent: SomeUserAgent/5.0 (Macintosh; Intel Mac OS X)'", + ); + }); + + test('escapes \' character in headers', () { + final command = CurlCommand.from( + _testDartIOHttpRequestData( + method: 'GET', + uri: Uri.parse('https://www.example.com'), + headers: { + 'accept-language': ['en-GB,de-DE'], + 'authorization': ['Bearer \'this is a\' test'] + }, + ), + ); + + expect( + command.toString(), + "curl --location --request GET 'https://www.example.com' \\\n--header 'accept-language: en-GB,de-DE' \\\n--header 'authorization: Bearer '\\''this is a'\\'' test'", + ); + }); + + test('no line breaks when "multiline" is false', () { + final command = CurlCommand.from( + _testDartIOHttpRequestData( + method: 'POST', + uri: Uri.parse('https://www.example.com'), + headers: { + 'accept-language': ['en-GB,de-DE'], + 'authorization': ['Bearer \'this is a\' test'] + }, + requestBody: Uint8List(0), + ), + multiline: false, + ); + + expect( + command.toString(), + "curl --location --request POST 'https://www.example.com' --header 'accept-language: en-GB,de-DE' --header 'authorization: Bearer '\\''this is a'\\'' test' --data-raw ''", + ); + }); + + test('no --location when followRedirects is false', () { + final command = CurlCommand.from( + _testDartIOHttpRequestData( + method: 'GET', + uri: Uri.parse('https://www.example.com'), + headers: {}, + ), + multiline: false, + followRedirects: false, + ); + + expect( + command.toString(), + "curl --request GET 'https://www.example.com'", + ); + }); + + test('parses GET request from test_data', () { + final command = CurlCommand.from(httpGet); + + expect( + command.toString(), + "curl --location --request GET \'https://jsonplaceholder.typicode.com/albums/1\' \\\n--header 'content-length: 0'", + ); + }); + + test('parses POST request from test_data', () { + final command = CurlCommand.from(httpPost); + + expect( + command.toString(), + "curl --location --request POST \'https://jsonplaceholder.typicode.com/posts\' \\\n--data-raw ' {\n title: '\\''foo'\\'',\n body: '\\''bar'\\'',\n userId: 1,\n }\n '", + ); + }); + }); +} + +class _TestDartIOHttpRequestData extends DartIOHttpRequestData { + _TestDartIOHttpRequestData( + int timelineMicrosBase, + this._request, + ) : super(timelineMicrosBase, _request); + + final HttpProfileRequest _request; + + @override + String? get requestBody { + final body = super.requestBody; + if (body != null) { + return body; + } + + if (_request.requestBody != null) { + return String.fromCharCodes(_request.requestBody!); + } + + return null; + } + + @override + Future getFullRequestData() async { + // Do nothing + } +} + +DartIOHttpRequestData _testDartIOHttpRequestData({ + required String method, + required Uri uri, + Uint8List? requestBody, + Map? headers, + List? cookies, +}) { + return _TestDartIOHttpRequestData( + 0, + HttpProfileRequest( + id: 0, + isolateId: '0', + method: method, + uri: uri, + requestBody: requestBody, + responseBody: null, + startTime: 0, + endTime: 0, + response: HttpProfileResponseData( + compressionState: '', + connectionInfo: {}, + contentLength: 0, + cookies: [], + headers: {}, + isRedirect: false, + persistentConnection: false, + reasonPhrase: '', + redirects: [], + startTime: 0, + statusCode: 200, + endTime: 0, + ), + request: HttpProfileRequestData.buildSuccessfulRequest( + headers: headers ?? {}, + connectionInfo: {}, + contentLength: requestBody?.length ?? 0, + cookies: cookies ?? [], + followRedirects: false, + maxRedirects: 0, + method: method, + persistentConnection: false, + events: [], + ), + ), + ); +}