8000 feature #43931 [HttpClient][WebProfilerBundle] Add button to copy a r… · symfony/symfony@194e274 · GitHub
[go: up one dir, main page]

Skip to content

Commit 194e274

Browse files
committed
feature #43931 [HttpClient][WebProfilerBundle] Add button to copy a request as a cURL command (Deuchnord)
This PR was squashed before being merged into the 6.1 branch. Discussion ---------- [HttpClient][WebProfilerBundle] Add button to copy a request as a cURL command | Q | A | ------------- | --- | Branch? | 6.1 | Bug fix? | no | New feature? | yes <!-- please update src/**/CHANGELOG.md files --> | Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files --> | Tickets | #33311 <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead --> | License | MIT | Doc PR | Adding a button to each request in the HttpClient section to copy it as a `curl` command that can be then pasted either in the terminal (on Linux and Mac) or in an application that can parse it (like Insomnia). Work in progress: - [x] Make a first PoC of the feature - [x] Generate the `curl` command - [x] Added some UX considerations to the button - [x] Update the tests <!-- Replace this notice by a short README for your feature/bugfix. This will help people understand your PR and can be used as a start for the documentation. Additionally (see https://symfony.com/releases): - Always add tests and ensure they pass. - Never break backward compatibility (see https://symfony.com/bc). - Bug fixes must be submitted against the lowest maintained branch where they apply (lowest branches are regularly merged to upper ones so they get the fixes too.) - Features and deprecations must be submitted against branch 5.x. - Changelog entry should follow https://symfony.com/doc/current/contributing/code/conventions.html#writing-a-changelog-entry --> Commits ------- 67c9146 [HttpClient][WebProfilerBundle] Add button to copy a request as a cURL command
2 parents 3c2c068 + 67c9146 commit 194e274

File tree

4 files changed

+157
-6
lines changed

4 files changed

+157
-6
lines changed

src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/http_client.html.twig

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@
9494
Profile
9595
</th>
9696
{% endif %}
97+
{% if trace.curlCommand is not null %}
98+
<th>
99+
<button class="btn btn-sm hidden label" title="Copy as cURL command" data-clipboard-text="{{ trace.curlCommand }}">cURL</button>
100+
</th>
101+
{% endif %}
97102
</tr>
98103
</thead>
99104
<tbody>
@@ -110,7 +115,7 @@
110115
{{ trace.http_code }}
111116
</span>
112117
</th>
113-
<td>
118+
<td{% if trace.curlCommand %} colspan="2"{% endif %}>
114119
{{ profiler_dump(trace.info, maxDepth=1) }}
115120
</td>
116121
{% if profiler_token and profiler_link %}

src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/base_js.html.twig

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,29 @@ if (typeof Sfjs === 'undefined' || typeof Sfjs.loadToolbar === 'undefined') {
3939
}
4040
4141
if (navigator.clipboard) {
42-
document.querySelectorAll('[data-clipboard-text]').forEach(function(element) {
43-
removeClass(element, 'hidden');
44-
element.addEventListener('click', function() {
45-
navigator.clipboard.writeText(element.getAttribute('data-clipboard-text'));
46-
})
42+
document.addEventListener('readystatechange', () => {
43+
if (document.readyState !== 'complete') {
44+
return;
45+
}
46+
47+
document.querySelectorAll('[data-clipboard-text]').forEach(function (element) {
48+
removeClass(element, 'hidden');
49+
element.addEventListener('click', function () {
50+
navigator.clipboard.writeText(element.getAttribute('data-clipboard-text'));
51+
52+
if (element.classList.contains("label")) {
53+
let oldContent = element.textContent;
54+
55+
element.textContent = "✅ Copied!";
56+
element.classList.add("status-success");
57+
58+
setTimeout(() => {
59+
element.textContent = oldContent;
60+
element.classList.remove("status-success");
61+
}, 7000);
62+
}
63+
});
64+
});
4765
});
4866
}
4967

src/Symfony/Component/HttpClient/DataCollector/HttpClientDataCollector.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,61 @@ private function collectOnClient(TraceableHttpClient $client): array
163163
unset($traces[$i]['info']); // break PHP reference used by TraceableHttpClient
164164
$traces[$i]['info'] = $this->cloneVar($info);
165165
$traces[$i]['options'] = $this->cloneVar($trace['options']);
166+
$traces[$i]['curlCommand'] = $this->getCurlCommand($trace);
166167
}
167168

168169
return [$errorCount, $traces];
169170
}
171+
172+
private function getCurlCommand(array $trace): ?string
173+
{
174+
$debug = explode("\n", $trace['info']['debug']);
175+
$url = $trace['url'];
176+
$command = ['curl', '--compressed'];
177+
178+
$dataArg = [];
179+
180+
if ($json = $trace['options']['json'] ?? null) {
181+
$dataArg[] = '--data '.escapeshellarg(json_encode($json, \JSON_PRETTY_PRINT));
182+
} elseif ($body = $trace['options']['body'] ?? null) {
183+
if (\is_string($body)) {
184+
$dataArg[] = '--data '.escapeshellarg($body);
185+
} elseif (\is_array($body)) {
186+
foreach ($body as $key => $value) {
187+
$dataArg[] = '--data '.escapeshellarg("$key=$value");
188+
}
189+
} else {
190+
return null;
191+
}
192+
}
193+
194+
$dataArg = empty($dataArg) ? null : implode(' ', $dataArg);
195+
196+
foreach ($debug as $line) {
197+
$line = substr($line, 0, -1);
198+
199+
if (str_starts_with('< ', $line)) {
200+
// End of the request, beginning of the response. Stop parsing.
201+
break;
202+
}
203+
204+
if ('' === $line || preg_match('/^[*<]|(Host: )/', $line)) {
205+
continue;
206+
}
207+
208+
if (preg_match('/^> ([A-Z]+)/', $line, $match)) {
209+
$command[] = sprintf('--request %s', $match[1]);
210+
$command[] = sprintf('--url %s', escapeshellarg($url));
211+
continue;
212+
}
213+
214+
$command[] = '--header '.escapeshellarg($line);
215+
}
216+
217+
if (null !== $dataArg) {
218+
$command[] = $dataArg;
219+
}
220+< 10000 div class="diff-text-inner">
221+
return implode(" \\\n ", $command);
222+
}
170223
}

src/Symfony/Component/HttpClient/Tests/DataCollector/HttpClientDataCollectorTest.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,81 @@ public function testItIsEmptyAfterReset()
166166
$this->assertEquals(0, $sut->getRequestCount());
167167
}
168168

169+
/**
170+
* @requires extension openssl
171+
*/
172+
public function testItGeneratesCurlCommandsAsExpected()
173+
{
174+
$sut = new HttpClientDataCollector();
175+
$sut->registerClient('http_client', $this->httpClientThatHasTracedRequests([
176+
[
177+
'method' => 'GET',
178+
'url' => 'https://symfony.com/releases.json',
179+
],
180+
]));
181+
$sut->collect(new Request(), new Response());
182+
$collectedData = $sut->getClients();
183+
self::assertCount(1, $collectedData['http_client']['traces']);
184+
$curlCommand = $collectedData['http_client']['traces'][0]['curlCommand'];
185+
self::assertEquals("curl \\
186+
--compressed \\
187+
--request GET \\
188+
--url 'https://symfony.com/releases.json' \\
189+
--header 'Accept: */*' \\
190+
--header 'Accept-Encoding: gzip' \\
191+
--header 'User-Agent: Symfony HttpClient/Native'", $curlCommand
192+
);
193+
}
194+
195+
/**
196+
* @requires extension openssl
197+
*/
198+
public function testItDoesNotFollowRedirectionsWhenGeneratingCurlCommands()
199+
{
200+
$sut = new HttpClientDataCollector();
201+
$sut->registerClient('http_client', $this->httpClientThatHasTracedRequests([
202+
[
203+
'method' => 'GET',
204+
'url' => 'http://symfony.com/releases.json',
205+
],
206+
]));
207+
$sut->collect(new Request(), new Response());
208+
$collectedData = $sut->getClients();
209+
self::assertCount(1, $collectedData['http_client']['traces']);
210+
$curlCommand = $collectedData['http_client']['traces'][0]['curlCommand'];
211+
self::assertEquals("curl \\
212+
--compressed \\
213+
--request GET \\
214+
--url 'http://symfony.com/releases.json' \\
215+
--header 'Accept: */*' \\
216+
--header 'Accept-Encoding: gzip' \\
217+
--header 'User-Agent: Symfony HttpClient/Native'", $curlCommand
218+
);
219+
}
220+
221+
/**
222+
* @requires extension openssl
223+
*/
224+
public function testItDoesNotGeneratesCurlCommandsForUnsupportedBodyType()
225+
{
226+
$sut = new HttpClientDataCollector();
227+
$sut->registerClient('http_client', $this->httpClientThatHasTracedRequests([
228+
[
229+
'method' => 'GET',
230+
'url' => 'https://symfony.com/releases.json',
231+
'options' => [
232+
'body' => static fn (int $size): string => '',
233+
],
234+
],
235+
]));
236+
$sut->collect(new Request(), new Response());
237+
$collectedData = $sut->getClients();
238+
self::assertCount(1, $collectedData['http_client']['traces']);
239+
$curlCommand = $collectedData['http_client']['traces'][0]['curlCommand'];
240+
self::assertNull($curlCommand
241+
);
242+
}
243+
169244
private function httpClientThatHasTracedRequests($tracedRequests): TraceableHttpClient
170245
{
171246
$httpClient = new TraceableHttpClient(new NativeHttpClient());

0 commit comments

Comments
 (0)
0