diff --git a/packages/devtools_app/lib/src/screens/network/constants.dart b/packages/devtools_app/lib/src/screens/network/constants.dart new file mode 100644 index 00000000000..3004ce1002c --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/constants.dart @@ -0,0 +1,81 @@ +// Copyright 2024 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. + +enum NetworkEventKeys { + log, + version, + creator, + name, + creatorVersion, + pages, + startedDateTime, + id, + title, + pageTimings, + onContentLoad, + onLoad, + entries, + pageref, + time, + request, + method, + url, + httpVersion, + cookies, + headers, + queryString, + postData, + mimeType, + text, + headersSize, + bodySize, + response, + status, + statusText, + content, + size, + redirectURL, + cache, + timings, + blocked, + dns, + connect, + send, + wait, + receive, + ssl, + serverIPAddress, + connection, + comment, + value, +} + +class NetworkEventDefaults { + static const logVersion = '1.2'; + static const creatorName = 'devtools'; + static const onContentLoad = -1; + static const onLoad = -1; + static const httpVersion = 'HTTP/1.1'; + static const responseHttpVersion = 'http/2.0'; + static const blocked = -1; + static const dns = -1; + static const connect = -1; + static const send = 1; + static const receive = 1; + static const ssl = -1; +} + +class NetworkEventCustomFieldKeys { + static const isolateId = '_isolateId'; + static const id = '_id'; + static const startTime = '_startTime'; + static const events = '_events'; +} + +enum NetworkEventCustomFieldRemappedKeys { + isolateId, + id, + startTime, + events, +} diff --git a/packages/devtools_app/lib/src/screens/network/har_builder.dart b/packages/devtools_app/lib/src/screens/network/har_builder.dart new file mode 100644 index 00000000000..2b06386f0ec --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/har_builder.dart @@ -0,0 +1,42 @@ +// Copyright 2024 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 '../../shared/http/http_request_data.dart'; +import '../../shared/utils.dart'; +import 'constants.dart'; +import 'har_data_entry.dart'; + +/// Builds a HAR (HTTP Archive) object from a list of HTTP requests. +/// +/// The HAR format is a JSON-based format used for logging a web browser's +/// interaction with a site. It is useful for performance analysis and +/// debugging. This function constructs the HAR object based on the 1.2 +/// specification. +/// +/// For more details on the HAR format, see the [HAR 1.2 Specification](https://github.com/ahmadnassri/har-spec/blob/master/versions/1.2.md). +/// +/// Parameters: +/// - [httpRequests]: A list of [DartIOHttpRequestData] data. +/// +/// Returns: +/// - A Map representing the HAR object. +Map buildHar(List httpRequests) { + // Build the creator + final creator = { + NetworkEventKeys.name.name: NetworkEventDefaults.creatorName, + NetworkEventKeys.creatorVersion.name: devToolsVersion, + }; + + // Build the entries + final entries = httpRequests.map((e) => HarDataEntry.toJson(e)).toList(); + + // Assemble the final HAR object + return { + NetworkEventKeys.log.name: { + NetworkEventKeys.version.name: NetworkEventDefaults.logVersion, + NetworkEventKeys.creator.name: creator, + NetworkEventKeys.entries.name: entries, + }, + }; +} diff --git a/packages/devtools_app/lib/src/screens/network/har_data_entry.dart b/packages/devtools_app/lib/src/screens/network/har_data_entry.dart new file mode 100644 index 00000000000..5b587972fd8 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/har_data_entry.dart @@ -0,0 +1,295 @@ +// Copyright 2024 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:convert'; + +import '../../shared/http/http_request_data.dart'; +import '../../shared/primitives/utils.dart'; +import 'constants.dart'; + +class HarDataEntry { + HarDataEntry(this.request); + + /// Creates an instance of [HarDataEntry] from a JSON object. + /// + /// This factory constructor expects the [json] parameter to be a Map + /// representing a single HAR entry. + factory HarDataEntry.fromJson(Map json) { + _convertHeaders(json); + + final modifiedRequestData = + _remapCustomFieldKeys(json); + + // Retrieving url, method from requestData + modifiedRequestData['uri'] = + (modifiedRequestData['request'] as Map)['url']; + modifiedRequestData['method'] = + (modifiedRequestData['request'] as Map)['method']; + + // Adding missing keys which are mandatory for parsing + (modifiedRequestData['response'] as Map)['redirects'] = []; + Object? requestPostData; + Object? responseContent; + if (modifiedRequestData['response'] != null && + (modifiedRequestData['response'] as Map)['content'] != + null) { + responseContent = + (modifiedRequestData['response'] as Map)['content']; + } + + if (modifiedRequestData['request'] != null && + (modifiedRequestData['request'] as Map)['postData'] != + null) { + requestPostData = + (modifiedRequestData['response'] as Map)['content']; + } + + return HarDataEntry( + DartIOHttpRequestData.fromJson( + modifiedRequestData, + requestPostData as Map, + responseContent as Map, + ), + ); + } + + final DartIOHttpRequestData request; + + /// Converts the instance to a JSON object. + /// + /// This method returns a Map representing a single HAR entry, suitable for + /// serialization. + static Map toJson(DartIOHttpRequestData e) { + // Implement the logic to convert DartIOHttpRequestData to HAR entry format + final requestCookies = e.requestCookies.map((cookie) { + return { + NetworkEventKeys.name.name: cookie.name, + NetworkEventKeys.value.name: cookie.value, + 'path': cookie.path, + 'domain': cookie.domain, + 'expires': cookie.expires?.toUtc().toIso8601String(), + 'httpOnly': cookie.httpOnly, + 'secure': cookie.secure, + }; + }).toList(); + + final requestHeaders = e.requestHeaders?.entries.map((header) { + var value = header.value; + if (value is List) { + value = value.first; + } + return { + NetworkEventKeys.name.name: header.key, + NetworkEventKeys.value.name: value, + }; + }).toList(); + + final queryString = Uri.parse(e.uri).queryParameters.entries.map((param) { + return { + NetworkEventKeys.name.name: param.key, + NetworkEventKeys.value.name: param.value, + }; + }).toList(); + + final responseCookies = e.responseCookies.map((cookie) { + return { + NetworkEventKeys.name.name: cookie.name, + NetworkEventKeys.value.name: cookie.value, + 'path': cookie.path, + 'domain': cookie.domain, + 'expires': cookie.expires?.toUtc().toIso8601String(), + 'httpOnly': cookie.httpOnly, + 'secure': cookie.secure, + }; + }).toList(); + + final responseHeaders = e.responseHeaders?.entries.map((header) { + var value = header.value; + if (value is List) { + value = value.first; + } + return { + NetworkEventKeys.name.name: header.key, + NetworkEventKeys.value.name: value, + }; + }).toList(); + + return { + NetworkEventKeys.startedDateTime.name: + e.startTimestamp.toUtc().toIso8601String(), + NetworkEventKeys.time.name: e.duration?.inMilliseconds, + // Request + NetworkEventKeys.request.name: { + NetworkEventKeys.method.name: e.method.toUpperCase(), + NetworkEventKeys.url.name: e.uri.toString(), + NetworkEventKeys.httpVersion.name: NetworkEventDefaults.httpVersion, + NetworkEventKeys.cookies.name: requestCookies, + NetworkEventKeys.headers.name: requestHeaders, + NetworkEventKeys.queryString.name: queryString, + NetworkEventKeys.postData.name: { + NetworkEventKeys.mimeType.name: e.contentType, + NetworkEventKeys.text.name: e.requestBody, + }, + NetworkEventKeys.headersSize.name: + _calculateHeadersSize(e.requestHeaders), + NetworkEventKeys.bodySize.name: _calculateBodySize(e.requestBody), + }, + // Response + NetworkEventKeys.response.name: { + NetworkEventKeys.status.name: e.status, + NetworkEventKeys.statusText.name: '', + NetworkEventKeys.httpVersion.name: + NetworkEventDefaults.responseHttpVersion, + NetworkEventKeys.cookies.name: responseCookies, + NetworkEventKeys.headers.name: responseHeaders, + NetworkEventKeys.content.name: { + NetworkEventKeys.size.name: e.responseBody?.length, + NetworkEventKeys.mimeType.name: e.type, + NetworkEventKeys.text.name: e.responseBody, + }, + NetworkEventKeys.redirectURL.name: '', + NetworkEventKeys.headersSize.name: + _calculateHeadersSize(e.responseHeaders), + NetworkEventKeys.bodySize.name: _calculateBodySize(e.responseBody), + }, + // Cache + NetworkEventKeys.cache.name: {}, + NetworkEventKeys.timings.name: { + NetworkEventKeys.blocked.name: NetworkEventDefaults.blocked, + NetworkEventKeys.dns.name: NetworkEventDefaults.dns, + NetworkEventKeys.connect.name: NetworkEventDefaults.connect, + NetworkEventKeys.send.name: NetworkEventDefaults.send, + NetworkEventKeys.wait.name: e.duration?.inMilliseconds ?? 0, + NetworkEventKeys.receive.name: NetworkEventDefaults.receive, + NetworkEventKeys.ssl.name: NetworkEventDefaults.ssl, + }, + NetworkEventKeys.connection.name: e.hashCode.toString(), + NetworkEventKeys.comment.name: '', + + // Custom fields + // har spec requires underscore to be added for custom fields, hence removing them + NetworkEventCustomFieldKeys.isolateId: '', + NetworkEventCustomFieldKeys.id: e.id, + NetworkEventCustomFieldKeys.startTime: + e.startTimestamp.microsecondsSinceEpoch, + NetworkEventCustomFieldKeys.events: [], + }; + } + + /// Converts the HAR data entry back to [DartIOHttpRequestData]. + DartIOHttpRequestData toDartIOHttpRequest() { + return request; + } + + static Map _convertHeadersListToMap( + List serializedHeaders, + ) { + final transformedHeaders = {}; + + for (final header in serializedHeaders) { + if (header is Map) { + final key = header[NetworkEventKeys.name.name] as String?; + final value = header[NetworkEventKeys.value.name]; + + if (key != null) { + if (transformedHeaders.containsKey(key)) { + if (transformedHeaders[key] is List) { + (transformedHeaders[key] as List).add(value); + } else { + transformedHeaders[key] = [transformedHeaders[key], value]; + } + } else { + transformedHeaders[key] = value; + } + } + } + } + + return transformedHeaders; + } + + // Convert list of headers to map + static void _convertHeaders(Map requestData) { + // Request Headers + if (requestData['request'] != null && + (requestData['request'] as Map)['headers'] != null) { + if ((requestData['request'] as Map)['headers'] is List) { + (requestData['request'] as Map)['headers'] = + _convertHeadersListToMap( + ((requestData['request'] as Map)['headers']) + as List, + ); + } + } + + // Response Headers + if (requestData['response'] != null && + (requestData['response'] as Map)['headers'] != null) { + if ((requestData['response'] as Map)['headers'] + is List) { + (requestData['response'] as Map)['headers'] = + _convertHeadersListToMap( + ((requestData['response'] as Map)['headers']) + as List, + ); + } + } + } + + // Removing underscores from custom fields + static Map _remapCustomFieldKeys( + Map originalMap, + ) { + final replacementMap = { + NetworkEventCustomFieldKeys.isolateId: + NetworkEventCustomFieldRemappedKeys.isolateId.name, + NetworkEventCustomFieldKeys.id: + NetworkEventCustomFieldRemappedKeys.id.name, + NetworkEventCustomFieldKeys.startTime: + NetworkEventCustomFieldRemappedKeys.startTime.name, + NetworkEventCustomFieldKeys.events: + NetworkEventCustomFieldRemappedKeys.events.name, + }; + + final convertedMap = {}; + + originalMap.forEach((key, value) { + if (replacementMap.containsKey(key)) { + convertedMap[replacementMap[key]!] = value; + } else { + convertedMap[key] = value; + } + }); + + return convertedMap; + } +} + +int _calculateHeadersSize(Map? 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) { + value = value.join(', '); + } + return '$key: $value\r\n'; + }).join(); + + // Add final CRLF to indicate end of headers + headersString += '\r\n'; + + // Calculate the byte length of the headers string + return utf8.encode(headersString).length; +} + +int _calculateBodySize(String? requestBody) { + if (requestBody.isNullOrEmpty) { + return 0; + } + return utf8.encode(requestBody!).length; +} diff --git a/packages/devtools_app/lib/src/screens/network/har_network_data.dart b/packages/devtools_app/lib/src/screens/network/har_network_data.dart new file mode 100644 index 00000000000..579cf8de939 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/har_network_data.dart @@ -0,0 +1,59 @@ +// Copyright 2024 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 'package:devtools_shared/devtools_shared.dart'; + +import '../../shared/http/http_request_data.dart'; +import 'har_builder.dart'; +import 'har_data_entry.dart'; + +/// A class that represents network data in the HTTP Archive (HAR) format. +/// +/// This class implements the [Serializable] interface, allowing instances to +/// be serialized to and from JSON. +class HarNetworkData with Serializable { + /// Creates an instance of [HarNetworkData] with a list of [DartIOHttpRequestData] requests. + /// + /// The [requests] parameter should contain the list of [DartIOHttpRequestData] request data. + HarNetworkData(this.requests); + + /// Creates an instance of [HarNetworkData] from a JSON object. + /// + /// This factory constructor expects the [json] parameter to be a Map + /// representing the HAR data, with a structure containing a 'log' key, + /// which in turn contains an 'entries' key. Each entry in the 'entries' + /// list should be a Map representing an HTTP request. + /// + /// ```dart + /// final harData = HarNetworkData.fromJson(json); + /// ``` + factory HarNetworkData.fromJson(Map json) { + final entries = + ((json['log'] as Map)['entries'] as List) + .map( + (entryJson) => + HarDataEntry.fromJson(entryJson as Map) + .toDartIOHttpRequest(), + ) + .toList(); + + return HarNetworkData(entries); + } + + /// The list of [DartIOHttpRequestData] request data. + final List requests; + + /// Converts the instance to a JSON object. + /// + /// This method returns a Map representing the HAR data, suitable for + /// serialization. + /// + /// ```dart + /// final json = harData.toJson(); + /// ``` + @override + Map toJson() { + return buildHar(requests); + } +} diff --git a/packages/devtools_app/lib/src/screens/network/network_controller.dart b/packages/devtools_app/lib/src/screens/network/network_controller.dart index cf3c1dda2cc..dc76ad14cc1 100644 --- a/packages/devtools_app/lib/src/screens/network/network_controller.dart +++ b/packages/devtools_app/lib/src/screens/network/network_controller.dart @@ -3,11 +3,13 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; import 'package:vm_service/vm_service.dart'; +import '../../shared/config_specific/import_export/import_export.dart'; import '../../shared/config_specific/logger/allowed_error.dart'; import '../../shared/globals.dart'; import '../../shared/http/http_request_data.dart'; @@ -16,6 +18,7 @@ import '../../shared/primitives/utils.dart'; import '../../shared/ui/filter.dart'; import '../../shared/ui/search.dart'; import '../../shared/utils.dart'; +import 'har_network_data.dart'; import 'network_model.dart'; import 'network_screen.dart'; import 'network_service.dart'; @@ -56,6 +59,29 @@ class NetworkController extends DisposableController ); subscribeToFilterChanges(); } + List? _httpRequests; + + String? exportAsHarFile() { + _httpRequests = + filteredData.value.whereType().toList(); + + if (_httpRequests.isNullOrEmpty) { + debugPrint('No valid request data to export'); + return ''; + } + + try { + // Build the HAR object + final har = HarNetworkData(_httpRequests!); + return ExportController().downloadFile( + json.encode(har.toJson()), + type: ExportFileType.har, + ); + } catch (e) { + debugPrint('Exception in export $e'); + } + return null; + } static const methodFilterId = 'network-method-filter'; 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 7464e7a26e4..12e0d3a97af 100644 --- a/packages/devtools_app/lib/src/screens/network/network_screen.dart +++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart @@ -234,6 +234,14 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> onPressed: widget.controller.clear, ), const SizedBox(width: defaultSpacing), + DownloadButton( + minScreenWidthForTextBeforeScaling: + _NetworkProfilerControls._includeTextWidth, + onPressed: widget.controller.exportAsHarFile, + gaScreen: gac.network, + gaSelection: gac.NetworkEvent.downloadAsHar.name, + ), + const SizedBox(width: defaultSpacing), const Expanded(child: SizedBox()), // TODO(kenz): fix focus issue when state is refreshed SearchField( diff --git a/packages/devtools_app/lib/src/shared/analytics/constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants.dart index f3830805d7b..7b7b1fbd144 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants.dart @@ -12,6 +12,7 @@ part 'constants/_debugger_constants.dart'; part 'constants/_deep_links_constants.dart'; part 'constants/_extension_constants.dart'; part 'constants/_memory_constants.dart'; +part 'constants/_network_constants.dart'; part 'constants/_logging_constants.dart'; part 'constants/_performance_constants.dart'; part 'constants/_vs_code_sidebar_constants.dart'; diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_network_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_network_constants.dart new file mode 100644 index 00000000000..5ac4bbfa2cd --- /dev/null +++ b/packages/devtools_app/lib/src/shared/analytics/constants/_network_constants.dart @@ -0,0 +1,9 @@ +// Copyright 2024 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. + +part of '../constants.dart'; + +enum NetworkEvent { + downloadAsHar, +} diff --git a/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart b/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart index d90d0bfe26c..52ec2759746 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart @@ -113,7 +113,8 @@ enum ExportFileType { json, csv, yaml, - data; + data, + har; @override String toString() => name; 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 1196793b4d7..38afd862874 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 @@ -46,6 +46,19 @@ class DartIOHttpRequestData extends NetworkRequest { } } + factory DartIOHttpRequestData.fromJson( + Map modifiedRequestData, + Map requestPostData, + Map responseContent, + ) { + return DartIOHttpRequestData( + HttpProfileRequestRef.parse(modifiedRequestData)!, + requestFullDataFromVmService: false, + ) + .._responseBody = responseContent['text'].toString() + .._requestBody = requestPostData['text'].toString(); + } + static const _connectionInfoKey = 'connectionInfo'; static const _contentTypeKey = 'content-type'; static const _localPortKey = 'localPort'; diff --git a/packages/devtools_app/test/network/har_network_test.dart b/packages/devtools_app/test/network/har_network_test.dart new file mode 100644 index 00000000000..e616784db1c --- /dev/null +++ b/packages/devtools_app/test/network/har_network_test.dart @@ -0,0 +1,57 @@ +// Copyright 2024 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:convert'; +import 'dart:io'; + +import 'package:devtools_app/src/screens/network/har_network_data.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final file = File('test/network/sample_requests.json'); + final fileContent = file.readAsStringSync(); + final jsonData = jsonDecode(fileContent); + + group('HarNetworkData', () { + test('toJson serializes correctly', () { + // Parse the HAR data + final harData = HarNetworkData.fromJson(jsonData); + + // Serialize the HAR data back to JSON + final json = harData.toJson(); + + // Verify the serialization + expect(json['log'], isNotNull); + final log = json['log'] as Map?; + expect(log?['version'], '1.2'); + expect((log?['creator'] as Map)['name'], 'devtools'); + + final entries = log?['entries'] as List?; + expect(entries?.length, 2); + + final entry = entries?[0] as Map?; + expect(entry?['startedDateTime'], '2024-07-11T13:19:35.156Z'); + expect((entry?['request'] as Map)['method'], 'GET'); + expect( + (entry?['request'] as Map)['url'], + 'https://jsonplaceholder.typicode.com/albums/1', + ); + expect( + (entry?['request'] as Map)['httpVersion'], + 'HTTP/1.1', + ); + expect((entry?['request'] as Map)['cookies'], isEmpty); + + expect(entry?['cache'], isEmpty); + expect((entry?['timings'] as Map)['blocked'], -1); + expect((entry?['timings'] as Map)['dns'], -1); + expect((entry?['timings'] as Map)['connect'], -1); + expect((entry?['timings'] as Map)['send'], 1); + expect((entry?['timings'] as Map)['receive'], 1); + expect((entry?['timings'] as Map)['ssl'], -1); + expect(entry?['comment'], ''); + }); + }); +} diff --git a/packages/devtools_app/test/network/sample_requests.json b/packages/devtools_app/test/network/sample_requests.json new file mode 100644 index 00000000000..fc828bccf1c --- /dev/null +++ b/packages/devtools_app/test/network/sample_requests.json @@ -0,0 +1,228 @@ +{ + "log": { + "version": "1.2", + "creator": { + "name": "devtools", + "creatorVersion": "2.38.0-dev.0" + }, + "entries": [ + { + "startedDateTime": "2024-07-11T13:19:35.156Z", + "time": 313, + "request": { + "method": "GET", + "url": "https://jsonplaceholder.typicode.com/albums/1", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": { "name": "x-ratelimit-reset", + "value": "1709091028"}, + "queryString": [], + "postData": { + "mimeType": "[application/json; charset=utf-8]", + "text": null + }, + "headersSize": 120, + "bodySize": 0 + }, + "response": { + "status": "200", + "statusText": "", + "httpVersion": "http/2.0", + "cookies": [], + "headers": [{"": ""}], + "content": { + "size": 0, + "mimeType": "json", + "text": "" + }, + "redirectURL": "", + "headersSize": 1164, + "bodySize": 0 + }, + "cache": {}, + "timings": { + "blocked": -1, + "dns": -1, + "connect": -1, + "send": 1, + "wait": 313, + "receive": 1, + "ssl": -1 + }, + "connection": "525659655", + "comment": "", + "_isolateId": "", + "_id": "-2285651906968511108", + "_startTime": 1720703975156000, + "_events": [] + }, + { + "startedDateTime": "2024-07-11T13:19:37.155Z", + "time": 182, + "request": { + "method": "GET", + "url": "https://jsonplaceholder.typicode.com/albums/1", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "user-agent", + "value": "Dart/3.4 (dart:io)" + }, + { + "name": "accept-encoding", + "value": "gzip" + }, + { + "name": "content-length", + "value": "0" + }, + { + "name": "host", + "value": "jsonplaceholder.typicode.com" + } + ], + "queryString": [], + "postData": { + "mimeType": "[application/json; charset=utf-8]", + "text": null + }, + "headersSize": 120, + "bodySize": 0 + }, + "response": { + "status": "200", + "statusText": "", + "httpVersion": "http/2.0", + "cookies": [], + "headers": [ + { + "name": "x-ratelimit-reset", + "value": "1709091028" + }, + { + "name": "x-ratelimit-limit", + "value": "1000" + }, + { + "name": "date", + "value": "Thu, 11 Jul 2024 13:19:37 GMT" + }, + { + "name": "transfer-encoding", + "value": "chunked" + }, + { + "name": "vary", + "value": "Origin, Accept-Encoding" + }, + { + "name": "content-encoding", + "value": "gzip" + }, + { + "name": "x-ratelimit-remaining", + "value": "999" + }, + { + "name": "pragma", + "value": "no-cache" + }, + { + "name": "server", + "value": "cloudflare" + }, + { + "name": "reporting-endpoints", + "value": "heroku-nel=https://nel.heroku.com/reports?ts=1709090976&sid=e11707d5-02a7-43ef-b45e-2cf4d2036f7d&s=YpsV0iwpAFY4lRLb4CpTkx%2B929Gjbe5%2BZ7X1RzUnIzs%3D" + }, + { + "name": "cf-ray", + "value": "8a1916135be93c5e-BOM" + }, + { + "name": "etag", + "value": "W/\"40-74G1+b66MteeTYAz6G+NybtDGFA\"" + }, + { + "name": "connection", + "value": "keep-alive" + }, + { + "name": "cache-control", + "value": "max-age=43200" + }, + { + "name": "age", + "value": "3993" + }, + { + "name": "report-to", + "value": "{\"group\":\"heroku-nel\",\"max_age\":3600,\"endpoints\":[{\"url\":\"https://nel.heroku.com/reports?ts=1709090976&sid=e11707d5-02a7-43ef-b45e-2cf4d2036f7d&s=YpsV0iwpAFY4lRLb4CpTkx%2B929Gjbe5%2BZ7X1RzUnIzs%3D\"}]}" + }, + { + "name": "cf-cache-status", + "value": "HIT" + }, + { + "name": "content-type", + "value": "application/json; charset=utf-8" + }, + { + "name": "access-control-allow-credentials", + "value": "true" + }, + { + "name": "x-powered-by", + "value": "Express" + }, + { + "name": "alt-svc", + "value": "h3=\":443\"; ma=86400" + }, + { + "name": "nel", + "value": "{\"report_to\":\"heroku-nel\",\"max_age\":3600,\"success_fraction\":0.005,\"failure_fraction\":0.05,\"response_headers\":[\"Via\"]}" + }, + { + "name": "via", + "value": "1.1 vegur" + }, + { + "name": "x-content-type-options", + "value": "nosniff" + }, + { + "name": "expires", + "value": "-1" + } + ], + "content": { + "size": 0, + "mimeType": "json", + "text": "" + }, + "redirectURL": "", + "headersSize": 1164, + "bodySize": 0 + }, + "cache": {}, + "timings": { + "blocked": -1, + "dns": -1, + "connect": -1, + "send": 1, + "wait": 182, + "receive": 1, + "ssl": -1 + }, + "connection": "249624739", + "comment": "", + "_isolateId": "", + "_id": "-2285651906968511106", + "_startTime": 1720703977155000, + "_events": [] + } + ] + } +} \ No newline at end of file