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 @@ -76,7 +76,13 @@ class NetworkRequestInspector extends StatelessWidget {
),
if (data is DartIOHttpRequestData) ...[
(
tab: _buildTab(tabName: NetworkRequestInspector._headersTabTitle),
tab: _buildTab(
tabName: NetworkRequestInspector._headersTabTitle,
trailing: HttpViewTrailingCopyButton(
data,
HttpRequestHeadersView.copyText,
),
),
tabView: HttpRequestHeadersView(data),
),
if (data.requestBody != null)
Expand Down Expand Up @@ -113,7 +119,13 @@ class NetworkRequestInspector extends StatelessWidget {
),
if (data.hasCookies)
(
tab: _buildTab(tabName: NetworkRequestInspector._cookiesTabTitle),
tab: _buildTab(
tabName: NetworkRequestInspector._cookiesTabTitle,
trailing: HttpViewTrailingCopyButton(
data,
HttpRequestCookiesView.copyText,
),
),
tabView: HttpRequestCookiesView(data),
),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,45 @@ ExpansionTile _buildTile(
class HttpRequestHeadersView extends StatelessWidget {
const HttpRequestHeadersView(this.data, {super.key});

static const _generalTitle = 'General';
static const _responseHeadersTitle = 'Response Headers';
static const _requestHeadersTitle = 'Request Headers';

@visibleForTesting
static const generalKey = Key('General');
static const generalKey = Key(_generalTitle);
@visibleForTesting
static const requestHeadersKey = Key('Request Headers');
static const requestHeadersKey = Key(_requestHeadersTitle);
@visibleForTesting
static const responseHeadersKey = Key('Response Headers');
static const responseHeadersKey = Key(_responseHeadersTitle);

final DartIOHttpRequestData data;

/// The contents of the Headers tab as plain text, for copying.
///
/// Each non-empty section is listed under its title, in the same order as
/// the tab displays them, separated by a blank line. Values are formatted as
/// they would appear on the wire (see [headersToText]), so multi-value
/// headers are not wrapped in brackets as they are in the tab.
static String copyText(DartIOHttpRequestData data) {
final sections = [
(title: _generalTitle, headers: data.general, format: generalInfoToText),
(
title: _responseHeadersTitle,
headers: data.responseHeaders,
format: headersToText,
),
(
title: _requestHeadersTitle,
headers: data.requestHeaders,
format: headersToText,
),
];
return [
for (final (:title, :headers, :format) in sections)
if (headers != null && headers.isNotEmpty) '$title\n${format(headers)}',
].join('\n\n');
}

@override
Widget build(BuildContext context) {
final general = data.general;
Expand All @@ -73,19 +103,19 @@ class HttpRequestHeadersView extends StatelessWidget {
child: ListView(
children: [
_buildHeadersTile(
'General',
_generalTitle,
general,
constraints,
key: generalKey,
),
_buildHeadersTile(
'Response Headers',
_responseHeadersTitle,
responseHeaders,
constraints,
key: responseHeadersKey,
),
_buildHeadersTile(
'Request Headers',
_requestHeadersTitle,
requestHeaders,
constraints,
key: requestHeadersKey,
Expand Down Expand Up @@ -413,25 +443,30 @@ class ImageResponseView extends StatelessWidget {
Widget build(BuildContext context) {
final encodedResponse = data.encodedResponse!;
final img = image.decodeImage(encodedResponse)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTile('Image Preview', [
Padding(
padding: const EdgeInsets.all(denseSpacing),
child: Image.memory(encodedResponse),
),
]),
_buildTile('Metadata', [
_buildRow(context, 'Format', data.type),
_buildRow(
context,
'Size',
prettyPrintBytes(encodedResponse.lengthInBytes, includeUnit: true)!,
),
_buildRow(context, 'Dimensions', '${img.width} x ${img.height}'),
]),
],
return SelectionArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTile('Image Preview', [
Padding(
padding: const EdgeInsets.all(denseSpacing),
child: Image.memory(encodedResponse),
),
]),
_buildTile('Metadata', [
_buildRow(context, 'Format', data.type),
_buildRow(
context,
'Size',
prettyPrintBytes(
encodedResponse.lengthInBytes,
includeUnit: true,
)!,
),
_buildRow(context, 'Dimensions', '${img.width} x ${img.height}'),
]),
],
),
);
}

Expand Down Expand Up @@ -463,13 +498,36 @@ bool _isJsonDecodable(String source) {
class HttpRequestCookiesView extends StatelessWidget {
const HttpRequestCookiesView(this.data, {super.key});

static const _requestCookiesTitle = 'Request Cookies';
static const _responseCookiesTitle = 'Response Cookies';

@visibleForTesting
static const requestCookiesKey = Key('Request Cookies');
static const requestCookiesKey = Key(_requestCookiesTitle);
@visibleForTesting
static const responseCookiesKey = Key('Response Cookies');
static const responseCookiesKey = Key(_responseCookiesTitle);

final DartIOHttpRequestData data;

/// The contents of the Cookies tab as plain text, for copying.
///
/// Response cookies are listed as raw `Set-Cookie` values, one per line, and
/// request cookies as a `Cookie` header value. Sections are in the same
/// order as the tab displays them, separated by a blank line.
static String copyText(DartIOHttpRequestData data) {
final responseCookies = data.responseCookies;
final requestCookies = data.requestCookies;
final responseCookiesText = responseCookiesToText(
responseCookies,
data.responseHeaders,
);
return [
if (responseCookies.isNotEmpty)
'$_responseCookiesTitle\n$responseCookiesText',
if (requestCookies.isNotEmpty)
'$_requestCookiesTitle\n${requestCookiesToText(requestCookies)}',
].join('\n\n');
}

DataRow _buildRow(int index, Cookie cookie, {bool requestCookies = false}) {
return DataRow.byIndex(
index: index,
Expand Down Expand Up @@ -568,30 +626,32 @@ class HttpRequestCookiesView extends StatelessWidget {
final responseCookies = data.responseCookies;
return LayoutBuilder(
builder: (context, constraints) {
return ListView(
children: [
if (responseCookies.isNotEmpty)
_buildCookiesTable(
context,
'Response Cookies',
responseCookies,
constraints,
responseCookiesKey,
),
// Add padding between the cookie tables if displaying both
// response and request cookies.
if (responseCookies.isNotEmpty && requestCookies.isNotEmpty)
const Padding(padding: EdgeInsets.only(bottom: 24.0)),
if (requestCookies.isNotEmpty)
_buildCookiesTable(
context,
'Request Cookies',
requestCookies,
constraints,
requestCookiesKey,
requestCookies: true,
),
],
return SelectionArea(
child: ListView(
children: [
if (responseCookies.isNotEmpty)
_buildCookiesTable(
context,
_responseCookiesTitle,
responseCookies,
constraints,
responseCookiesKey,
),
// Add padding between the cookie tables if displaying both
// response and request cookies.
if (responseCookies.isNotEmpty && requestCookies.isNotEmpty)
const Padding(padding: EdgeInsets.only(bottom: 24.0)),
if (requestCookies.isNotEmpty)
_buildCookiesTable(
context,
_requestCookiesTitle,
requestCookies,
constraints,
requestCookiesKey,
requestCookies: true,
),
],
),
);
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,38 @@

import 'dart:convert';

import '../../../shared/http/http.dart';

const _setCookieHeader = 'set-cookie';
const _cookieHeader = 'cookie';

/// Returns [headers] as `key: value` lines, as they would appear on the wire.
///
/// Header values decoded from JSON are a `List<dynamic>` rather than a
/// `List<String>`, so any [Iterable] is treated as a multi-value header:
/// - `set-cookie` gets one line per value, since cookie dates
/// (`Expires=...`) contain commas.
/// - `cookie` values are joined with `; `.
/// - all other values are joined with `, `.
Iterable<String> _headerLines(Map<String, Object?> headers) sync* {
for (final MapEntry(:key, :value) in headers.entries) {
if (value is! Iterable) {
yield '$key: $value';
continue;
}
switch (key.toLowerCase()) {
case _setCookieHeader:
for (final cookie in value) {
yield '$key: $cookie';
}
case _cookieHeader:
yield '$key: ${value.join('; ')}';
default:
yield '$key: ${value.join(', ')}';
}
}
}

/// Calculates the size of the headers in bytes.
///
/// Takes a map of headers [headers], where keys are header names and values
Expand All @@ -13,15 +45,9 @@ int calculateHeadersSize(Map<String, Object?>? headers) {
if (headers == null) return -1;

// Combine headers into a single string with CRLF endings
String headersString = headers.entries.map((entry) {
final key = entry.key;
var value = entry.value;
// If the value is a List, join it with a comma
if (value is List<String>) {
value = value.join(', ');
}
return '$key: $value\r\n';
}).join();
String headersString = _headerLines(
headers,
).map((line) => '$line\r\n').join();

// Add final CRLF to indicate end of headers
headersString += '\r\n';
Expand All @@ -30,6 +56,52 @@ int calculateHeadersSize(Map<String, Object?>? headers) {
return utf8.encode(headersString).length;
}

/// Formats [headers] as `key: value` lines, as they would appear on the wire,
/// suitable for copying.
///
/// Returns an empty string if [headers] is null.
String headersToText(Map<String, Object?>? headers) =>
headers == null ? '' : _headerLines(headers).join('\n');

/// Formats [info] as plain `key: value` lines, the way the General section of
/// the Headers tab displays them.
///
/// Unlike [headersToText], values are not treated as multi-value headers, since
/// the General section holds request details like `redirects` rather than HTTP
/// headers.
String generalInfoToText(Map<String, Object?> info) =>
info.entries.map((entry) => '${entry.key}: ${entry.value}').join('\n');

String _cookieNameAndValue(Cookie cookie) => '${cookie.name}=${cookie.value}';

/// Formats request [cookies] the way they are sent in a `Cookie` header:
/// `name=value; name2=value2`.
String requestCookiesToText(List<Cookie> cookies) =>
cookies.map(_cookieNameAndValue).join('; ');

/// Formats response cookies as one `Set-Cookie` value per line.
///
/// Prefers the raw `set-cookie` values in [responseHeaders], since parsing a
/// [Cookie] drops attributes like `Max-Age` and `SameSite`. Falls back to
/// `name=value` from [cookies] when the header is not available.
String responseCookiesToText(
List<Cookie> cookies,
Map<String, Object?>? responseHeaders,
) {
final rawCookies = responseHeaders?.entries
.where((entry) => entry.key.toLowerCase() == _setCookieHeader)
.expand(
(entry) =>
entry.value is Iterable ? entry.value as Iterable : [entry.value],
)
Comment on lines +93 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[CONCERN] Checking and casting to Iterable instead of List is safer and more idiomatic. This avoids potential runtime TypeErrors if entry.value is a list of a more specific type (like List<Object?> or List<String>) which might fail when cast directly to List<dynamic>.

Suggested change
.expand(
(entry) => entry.value is List ? entry.value as List : [entry.value],
)
.expand(
(entry) => entry.value is Iterable
? entry.value as Iterable
: [entry.value],
)
References
  1. Key Principles: Readability, Maintainability, Consistency, and Code Reuse & DRY. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same change here, done.

.nonNulls
.toList();
if (rawCookies != null && rawCookies.isNotEmpty) {
return rawCookies.join('\n');
}
return cookies.map(_cookieNameAndValue).join('\n');
}

// Output Formats:
// - 512 → "512 B"
// - 2000 → "2.0 kB"
Expand Down
7 changes: 6 additions & 1 deletion packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ TODO: Remove this section if there are not any updates.

## Network profiler updates

TODO: Remove this section if there are not any updates.
* Added copy buttons to the Headers and Cookies tabs of the request
inspector, and made cookie values and image metadata selectable.
[#10052](https://github.com/flutter/devtools/pull/10052)
* Fixed the `headersSize` reported in HAR exports for requests with
multi-value headers.
[#10052](https://github.com/flutter/devtools/pull/10052)

## Logging updates

Expand Down
Loading
Loading