feature #28919 [DX][WebProfilerBundle] Add Pretty Print functionality for Request Content (SamFleming)

This PR was squashed before being merged into the 4.3-dev branch (closes #28919).

Discussion
----------

[DX][WebProfilerBundle] Add Pretty Print functionality for Request Content

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets |
| License       | MIT
| Doc PR        | n/a ?

## Why?
Quite often when attempting to debug issues with JSON requests sent to a Symfony API, I use the Web Profiler to check the request content. More often than not the request content isn't easily readable (99% of the time it's all stuck on a single line and impossible to read). I always find myself copying + pasting the content into a random online tool to have it "pretty-print" the JSON.

Usually this isn't an issue, but can be annoying when offline. There's also the security issue of sending entire JSON payloads to a third-party server just for formatting 😳. Alternatively, maybe developers  copy+paste into their chosen editors and this PR is all a waste of time — I hope not 😛.

## How?
This PR adds "Pretty-Print" JSON functionality straight into the profiler.

We can use `collector.requestheaders` to detect if the request was JSON and conditionally show the Pretty Print button.

When the button is clicked, we format the JSON from the "Request Content" card.

## What does it look like?
Before:
![without-pretty-print](https://user-images.githubusercontent.com/573318/47180751-36b0ce00-d319-11e8-86ed-eb0d78ebcbe3.png)

After:
![pretty](https://user-images.githubusercontent.com/573318/47180763-3c0e1880-d319-11e8-995d-eba565aad827.png)

Non-JSON Requests (unchanged):
![non-json-request](https://user-images.githubusercontent.com/573318/47181080-03227380-d31a-11e8-8cf2-e8b2e8c1a21d.png)

## Things to consider

- Is `JSON.stringify(JSON.parse(content));` the safest, most efficient way to do this?
- Should the "Pretty Print" button be in-line next to the "Request Content" header? I couldn't find a pattern for this sort of thing elsewhere in the profiler.
- Do people want JSON formatted with 4 spaces, would 2 spaces be preferred? Should this be a configuration option stored in localStorage (such as the light/dark theme configuration)?
- Should this be a toggle? E.g. click to pretty print, then click to undo

## Future Improvements

Depending on how this is received it could be extended to support formatting different request content-types (e.g. XML formatting) — I assume.

## Progress

- [x] Gather feedback and decide where to perform the pretty-print: [server-side, or client-side](https://github.com/symfony/symfony/pull/28919#issuecomment-431508361).
*It was decided server-side would be better.*

Commits
-------

9f85103151 [DX][WebProfilerBundle] Add Pretty Print functionality for Request Content
This commit is contained in:
Fabien Potencier 2019-02-21 10:57:57 +01:00
commit 4e1ad10d91
5 changed files with 91 additions and 6 deletions

View File

@ -41,7 +41,7 @@
/* create the tab navigation for each group of tabs */
for (var i = 0; i < tabGroups.length; i++) {
var tabs = tabGroups[i].querySelectorAll('.tab');
var tabs = tabGroups[i].querySelectorAll(':scope > .tab');
var tabNavigation = document.createElement('ul');
tabNavigation.className = 'tab-navigation';
@ -67,7 +67,7 @@
/* display the active tab and add the 'click' event listeners */
for (i = 0; i < tabGroups.length; i++) {
tabNavigation = tabGroups[i].querySelectorAll('.tab-navigation li');
tabNavigation = tabGroups[i].querySelectorAll(':scope >.tab-navigation li');
for (j = 0; j < tabNavigation.length; j++) {
tabId = tabNavigation[j].getAttribute('data-tab-id');

View File

@ -161,8 +161,27 @@
<p>Request content not available (it was retrieved as a resource).</p>
</div>
{% elseif collector.content %}
<div class="card">
<pre class="break-long-words">{{ collector.content }}</pre>
<div class="sf-tabs">
{% set prettyJson = collector.isJsonRequest ? collector.prettyJson : null %}
{% if prettyJson is not null %}
<div class="tab">
<h3 class="tab-title">Pretty</h3>
<div class="tab-content">
<div class="card" style="max-height: 500px; overflow-y: auto;">
<pre class="break-long-words">{{ prettyJson }}</pre>
</div>
</div>
</div>
{% endif %}
<div class="tab">
<h3 class="tab-title">Raw</h3>
<div class="tab-content">
<div class="card">
<pre class="break-long-words">{{ collector.content }}</pre>
</div>
</div>
</div>
</div>
{% else %}
<div class="empty">

View File

@ -556,7 +556,7 @@
/* create the tab navigation for each group of tabs */
for (var i = 0; i < tabGroups.length; i++) {
var tabs = tabGroups[i].querySelectorAll('.tab');
var tabs = tabGroups[i].querySelectorAll(':scope > .tab');
var tabNavigation = document.createElement('ul');
tabNavigation.className = 'tab-navigation';
@ -582,7 +582,7 @@
/* display the active tab and add the 'click' event listeners */
for (i = 0; i < tabGroups.length; i++) {
tabNavigation = tabGroups[i].querySelectorAll('.tab-navigation li');
tabNavigation = tabGroups[i].querySelectorAll(':scope > .tab-navigation li');
for (j = 0; j < tabNavigation.length; j++) {
tabId = tabNavigation[j].getAttribute('data-tab-id');

View File

@ -252,6 +252,18 @@ class RequestDataCollector extends DataCollector implements EventSubscriberInter
return $this->data['content'];
}
public function isJsonRequest()
{
return 1 === preg_match('{^application/(?:\w+\++)*json$}i', $this->data['request_headers']['content-type']);
}
public function getPrettyJson()
{
$decoded = json_decode($this->getContent());
return JSON_ERROR_NONE === json_last_error() ? json_encode($decoded, JSON_PRETTY_PRINT) : null;
}
public function getContentType()
{
return $this->data['content_type'];

View File

@ -333,4 +333,58 @@ class RequestDataCollectorTest extends TestCase
throw new \InvalidArgumentException(sprintf('Cookie named "%s" is not in response', $name));
}
/**
* @dataProvider provideJsonContentTypes
*/
public function testIsJson($contentType, $expected)
{
$response = $this->createResponse();
$request = $this->createRequest();
$request->headers->set('Content-Type', $contentType);
$c = new RequestDataCollector();
$c->collect($request, $response);
$this->assertSame($expected, $c->isJsonRequest());
}
public function provideJsonContentTypes()
{
return array(
array('text/csv', false),
array('application/json', true),
array('application/JSON', true),
array('application/hal+json', true),
array('application/xml+json', true),
array('application/xml', false),
array('', false),
);
}
/**
* @dataProvider providePrettyJson
*/
public function testGetPrettyJsonValidity($content, $expected)
{
$response = $this->createResponse();
$request = Request::create('/', 'POST', array(), array(), array(), array(), $content);
$c = new RequestDataCollector();
$c->collect($request, $response);
$this->assertSame($expected, $c->getPrettyJson());
}
public function providePrettyJson()
{
return array(
array('null', 'null'),
array('{ "foo": "bar" }', '{
"foo": "bar"
}'),
array('{ "abc" }', null),
array('', null),
);
}
}