feature #22554 [Profiler][Validator] Add a validator panel in profiler (ogizanagi)

This PR was merged into the 3.4 branch.

Discussion
----------

[Profiler][Validator] Add a validator panel in profiler

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

I'm exploring the possibility of having a validator panel in the profiler.
The integration in the form panel is great, but there are a lot of other use-cases where you're likely to call the validator. The idea of this panel is to reference every calls made to the validator (`ValidatorInterface::validate()` at least) along with detailed informations.
Dealing with apis and a mobile app, it's not always easy to get the response body within the app to get what's wrong with the call. So now with this panel, I'm able to get the details without the api response.

In action with Symfony demo (on the admin new post form):

![symfony-demo](https://cloud.githubusercontent.com/assets/2211145/25490828/579a2c96-2b6e-11e7-9574-fb0975a5db83.gif)

![capture d ecran 2017-04-27 a 17 14 24](https://cloud.githubusercontent.com/assets/2211145/25490866/77d76988-2b6e-11e7-83c7-a10613442a5e.png)

On another app, by calling the validator elsewhere:

|No violations|With violations|
|--|--|
|![capture d ecran 2017-04-27 a 17 16 41](https://cloud.githubusercontent.com/assets/2211145/25490861/741886f6-2b6e-11e7-9e18-5948312d0096.png)|![capture d ecran 2017-04-27 a 17 17 32](https://cloud.githubusercontent.com/assets/2211145/25490860/74128daa-2b6e-11e7-979f-0d39741cc172.png)|

What do you think ?

---

Note: the SVG icon used should be changed. If anyone is willing to contribute and provide one, I'll be glad to add it!

Commits
-------

ac5e884f36 [Profiler][Validator] Add a validator panel in profiler
This commit is contained in:
Fabien Potencier 2017-06-19 11:56:06 -07:00
commit 30e817ae07
11 changed files with 551 additions and 3 deletions

View File

@ -81,6 +81,7 @@ class FrameworkExtension extends Extension
private $translationConfigEnabled = false;
private $sessionConfigEnabled = false;
private $annotationsConfigEnabled = false;
private $validatorConfigEnabled = false;
/**
* @var string|null
@ -456,6 +457,10 @@ class FrameworkExtension extends Extension
$loader->load('form_debug.xml');
}
if ($this->validatorConfigEnabled) {
$loader->load('validator_debug.xml');
}
if ($this->translationConfigEnabled) {
$loader->load('translation_debug.xml');
$container->getDefinition('translator.data_collector')->setDecoratedService('translator');
@ -1107,7 +1112,7 @@ class FrameworkExtension extends Extension
*/
private function registerValidationConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader)
{
if (!$this->isConfigEnabled($container, $config)) {
if (!$this->validatorConfigEnabled = $this->isConfigEnabled($container, $config)) {
return;
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<defaults public="false" />
<service id="debug.validator" decorates="validator" decoration-priority="255" class="Symfony\Component\Validator\Validator\TraceableValidator">
<argument type="service" id="debug.validator.inner" />
</service>
<!-- DataCollector -->
<service id="data_collector.validator" class="Symfony\Component\Validator\DataCollector\ValidatorDataCollector">
<argument type="service" id="debug.validator"/>
<tag name="data_collector" template="@WebProfiler/Collector/validator.html.twig" id="validator" priority="320" />
</service>
</services>
</container>

View File

@ -49,7 +49,7 @@
"symfony/stopwatch": "~2.8|~3.0|~4.0",
"symfony/translation": "~3.2|~4.0",
"symfony/templating": "~2.8|~3.0|~4.0",
"symfony/validator": "~3.3|~4.0",
"symfony/validator": "~3.4|~4.0",
"symfony/var-dumper": "~3.3|~4.0",
"symfony/workflow": "~3.3|~4.0",
"symfony/yaml": "~3.2|~4.0",
@ -69,7 +69,7 @@
"symfony/property-info": "<3.3",
"symfony/serializer": "<3.3",
"symfony/translation": "<3.2",
"symfony/validator": "<3.3",
"symfony/validator": "<3.4",
"symfony/workflow": "<3.3"
},
"suggest": {

View File

@ -0,0 +1,98 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block toolbar %}
{% if collector.violationsCount > 0 or collector.calls|length %}
{% set status_color = collector.violationsCount ? 'red' : '' %}
{% set icon %}
{{ include('@WebProfiler/Icon/validator.svg') }}
<span class="sf-toolbar-value">
{{ collector.violationsCount }}
</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Validator calls</b>
<span class="sf-toolbar-status">{{ collector.calls|length }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Number of violations</b>
<span class="sf-toolbar-status {{- collector.violationsCount > 0 ? ' sf-toolbar-status-red' }}">{{ collector.violationsCount }}</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, status: status_color }) }}
{% endif %}
{% endblock %}
{% block menu %}
<span class="label {{- collector.violationsCount ? ' label-status-error' }} {{ collector.calls is empty ? 'disabled' }}">
<span class="icon">{{ include('@WebProfiler/Icon/validator.svg') }}</span>
<strong>Validator</strong>
{% if collector.violationsCount > 0 %}
<span class="count">
<span>{{ collector.violationsCount }}</span>
</span>
{% endif %}
</span>
{% endblock %}
{% block panel %}
<h2>Validator calls</h2>
{% for call in collector.calls %}
<div class="sf-validator sf-reset">
<span class="metadata">In
{% set caller = call.caller %}
{% if caller.line %}
{% set link = caller.file|file_link(caller.line) %}
{% if link %}
<a href="{{ link }}" title="{{ caller.file }}">{{ caller.name }}</a>
{% else %}
<abbr title="{{ caller.file }}">{{ caller.name }}</abbr>
{% endif %}
{% else %}
{{ caller.name }}
{% endif %}
line <a class="text-small sf-toggle" data-toggle-selector="#sf-trace-{{ loop.index0 }}">{{ caller.line }}</a> (<a class="text-small sf-toggle" data-toggle-selector="#sf-context-{{ loop.index0 }}">context</a>):
</span>
<div class="sf-validator-compact hidden" id="sf-trace-{{ loop.index0 }}">
<div class="trace">
{{ caller.file|file_excerpt(caller.line) }}
</div>
</div>
<div class="sf-validator-compact hidden sf-validator-context" id="sf-context-{{ loop.index0 }}">
{{ profiler_dump(call.context, maxDepth=1) }}
</div>
{% if call.violations|length %}
<table>
<thead>
<tr>
<th>Path</th>
<th>Message</th>
<th>Invalid value</th>
<th>Violation</th>
</tr>
</thead>
{% for violation in call.violations %}
<tr>
<td>{{ violation.propertyPath }}</td>
<td>{{ violation.message }}</td>
<td>{{ profiler_dump(violation.seek('invalidValue')) }}</td>
<td>{{ profiler_dump(violation) }}</td>
</tr>
{% endfor %}
</table>
{% else %}
No violations
{% endif %}
</div>
{% else %}
<div class="empty">
<p>No calls to the validator were collected during this request.</p>
</div>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="24" height="24" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve"><path fill="#aaaaaa" d="M19.54,22.5H4.29a2.88,2.88,0,0,1-2.87-2.87V4.37A2.88,2.88,0,0,1,4.29,1.5H18.54a1,1,0,0,1,0,2H4.29a.88.88,0,0,0-.87.87V19.63a.88.88,0,0,0,.87.87H19.54a.88.88,0,0,0,.87-.87V11.29a1,1,0,1,1,2,0v8.33A2.88,2.88,0,0,1,19.54,22.5ZM13,17.29,22.88,6a1.5,1.5,0,1,0-2.26-2L12,14,8,9.11A1.5,1.5,0,0,0,5.65,11l5.1,6.25a1.5,1.5,0,0,0,1.14.55h0A1.5,1.5,0,0,0,13,17.29Z"/></svg>

After

Width:  |  Height:  |  Size: 541 B

View File

@ -880,6 +880,31 @@ table.logs .metadata {
white-space: pre-wrap;
}
{# Validator panel
========================================================================= #}
#collector-content .sf-validator {
margin-bottom: 2em;
}
#collector-content .sf-validator .sf-validator-context,
#collector-content .sf-validator .trace {
border: 1px solid #DDD;
background: #FFF;
padding: 10px;
margin: 0.5em 0;
}
#collector-content .sf-validator .trace {
font-size: 12px;
}
#collector-content .sf-validator .trace li {
margin-bottom: 0;
padding: 0;
}
#collector-content .sf-validator .trace li.selected {
background: rgba(255, 255, 153, 0.5);
}
{# Dump panel
========================================================================= #}
#collector-content .sf-dump {

View File

@ -0,0 +1,105 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Validator\DataCollector;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
use Symfony\Component\Validator\Validator\TraceableValidator;
use Symfony\Component\VarDumper\Caster\Caster;
use Symfony\Component\VarDumper\Caster\ClassStub;
use Symfony\Component\VarDumper\Cloner\Data;
use Symfony\Component\VarDumper\Cloner\VarCloner;
/**
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
*/
class ValidatorDataCollector extends DataCollector implements LateDataCollectorInterface
{
private $validator;
private $cloner;
public function __construct(TraceableValidator $validator)
{
$this->validator = $validator;
$this->data = array(
'calls' => array(),
'violations_count' => 0,
);
}
/**
* {@inheritdoc}
*/
public function collect(Request $request, Response $response, \Exception $exception = null)
{
// Everything is collected once, on kernel terminate.
}
/**
* {@inheritdoc}
*/
public function lateCollect()
{
$collected = $this->validator->getCollectedData();
$this->data['calls'] = $this->cloneVar($collected);
$this->data['violations_count'] += array_reduce($collected, function ($previous, $item) {
return $previous += count($item['violations']);
}, 0);
}
public function getCalls()
{
return $this->data['calls'];
}
public function getViolationsCount()
{
return $this->data['violations_count'];
}
/**
* {@inheritdoc}
*/
public function getName()
{
return 'validator';
}
/**
* {@inheritdoc}
*/
protected function cloneVar($var)
{
if ($var instanceof Data) {
return $var;
}
if (null === $this->cloner) {
$this->cloner = new VarCloner();
$this->cloner->setMaxItems(-1);
$this->cloner->addCasters(array(
FormInterface::class => function (FormInterface $f, array $a) {
return array(
Caster::PREFIX_VIRTUAL.'name' => $f->getName(),
Caster::PREFIX_VIRTUAL.'type_class' => new ClassStub(get_class($f->getConfig()->getType()->getInnerType())),
Caster::PREFIX_VIRTUAL.'data' => $f->getData(),
);
},
));
}
return $this->cloner->cloneVar($var, Caster::EXCLUDE_VERBOSE);
}
}

View File

@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Validator\Tests\DataCollector;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\DataCollector\ValidatorDataCollector;
use Symfony\Component\Validator\Validator\TraceableValidator;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class ValidatorDataCollectorTest extends TestCase
{
public function testCollectsValidatorCalls()
{
$originalValidator = $this->createMock(ValidatorInterface::class);
$validator = new TraceableValidator($originalValidator);
$collector = new ValidatorDataCollector($validator);
$violations = new ConstraintViolationList(array(
$this->createMock(ConstraintViolation::class),
$this->createMock(ConstraintViolation::class),
));
$originalValidator->method('validate')->willReturn($violations);
$validator->validate(new \stdClass());
$collector->lateCollect();
$calls = $collector->getCalls();
$this->assertCount(1, $calls);
$this->assertSame(2, $collector->getViolationsCount());
$call = $calls[0];
$this->assertArrayHasKey('caller', $call);
$this->assertArrayHasKey('context', $call);
$this->assertArrayHasKey('violations', $call);
$this->assertCount(2, $call['violations']);
}
protected function createMock($classname)
{
return $this->getMockBuilder($classname)->disableOriginalConstructor()->getMock();
}
}

View File

@ -0,0 +1,104 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Validator\Tests\Validator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Mapping\MetadataInterface;
use Symfony\Component\Validator\Validator\ContextualValidatorInterface;
use Symfony\Component\Validator\Validator\TraceableValidator;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class TraceableValidatorTest extends TestCase
{
public function testValidate()
{
$originalValidator = $this->createMock(ValidatorInterface::class);
$violations = new ConstraintViolationList(array(
$this->createMock(ConstraintViolation::class),
$this->createMock(ConstraintViolation::class),
));
$originalValidator->expects($this->exactly(2))->method('validate')->willReturn($violations);
$validator = new TraceableValidator($originalValidator);
$object = new \stdClass();
$constraints = array($this->createMock(Constraint::class));
$groups = array('Default', 'Create');
$validator->validate($object, $constraints, $groups);
$line = __LINE__ - 1;
$collectedData = $validator->getCollectedData();
$this->assertCount(1, $collectedData);
$callData = $collectedData[0];
$this->assertSame(iterator_to_array($violations), $callData['violations']);
$this->assertSame(array(
'value' => $object,
'constraints' => $constraints,
'groups' => $groups,
), $callData['context']);
$this->assertEquals(array(
'name' => 'TraceableValidatorTest.php',
'file' => __FILE__,
'line' => $line,
), $callData['caller']);
$validator->validate($object, $constraints, $groups);
$collectedData = $validator->getCollectedData();
$this->assertCount(2, $collectedData);
}
public function testForwardsToOriginalValidator()
{
$originalValidator = $this->createMock(ValidatorInterface::class);
$validator = new TraceableValidator($originalValidator);
$expects = function ($method) use ($originalValidator) { return $originalValidator->expects($this->once())->method($method); };
$expects('getMetadataFor')->willReturn($expected = $this->createMock(MetadataInterface::class));
$this->assertSame($expected, $validator->getMetadataFor('value'), 'returns original validator getMetadataFor() result');
$expects('hasMetadataFor')->willReturn($expected = false);
$this->assertSame($expected, $validator->hasMetadataFor('value'), 'returns original validator hasMetadataFor() result');
$expects('inContext')->willReturn($expected = $this->createMock(ContextualValidatorInterface::class));
$this->assertSame($expected, $validator->inContext($this->createMock(ExecutionContextInterface::class)), 'returns original validator inContext() result');
$expects('startContext')->willReturn($expected = $this->createMock(ContextualValidatorInterface::class));
$this->assertSame($expected, $validator->startContext(), 'returns original validator startContext() result');
$expects('validate')->willReturn($expected = $this->createMock(ConstraintViolationListInterface::class));
$this->assertSame($expected, $validator->validate('value'), 'returns original validator validate() result');
$expects('validateProperty')->willReturn($expected = $this->createMock(ConstraintViolationListInterface::class));
$this->assertSame($expected, $validator->validateProperty(new \stdClass(), 'property'), 'returns original validator validateProperty() result');
$expects('validatePropertyValue')->willReturn($expected = $this->createMock(ConstraintViolationListInterface::class));
$this->assertSame($expected, $validator->validatePropertyValue(new \stdClass(), 'property', 'value'), 'returns original validator validatePropertyValue() result');
}
protected function createMock($classname)
{
return $this->getMockBuilder($classname)->disableOriginalConstructor()->getMock();
}
}

View File

@ -0,0 +1,131 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Validator\Validator;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Collects some data about validator calls.
*
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
*/
class TraceableValidator implements ValidatorInterface
{
private $validator;
private $collectedData = array();
public function __construct(ValidatorInterface $validator)
{
$this->validator = $validator;
}
/**
* @return ConstraintViolationList[]
*/
public function getCollectedData()
{
return $this->collectedData;
}
/**
* {@inheritdoc}
*/
public function getMetadataFor($value)
{
return $this->validator->getMetadataFor($value);
}
/**
* {@inheritdoc}
*/
public function hasMetadataFor($value)
{
return $this->validator->hasMetadataFor($value);
}
/**
* {@inheritdoc}
*/
public function validate($value, $constraints = null, $groups = null)
{
$violations = $this->validator->validate($value, $constraints, $groups);
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 7);
$file = $trace[0]['file'];
$line = $trace[0]['line'];
for ($i = 1; $i < 7; ++$i) {
if (isset($trace[$i]['class'], $trace[$i]['function'])
&& 'validate' === $trace[$i]['function']
&& is_a($trace[$i]['class'], ValidatorInterface::class, true)
) {
$file = $trace[$i]['file'];
$line = $trace[$i]['line'];
while (++$i < 7) {
if (isset($trace[$i]['function'], $trace[$i]['file']) && empty($trace[$i]['class']) && 0 !== strpos($trace[$i]['function'], 'call_user_func')) {
$file = $trace[$i]['file'];
$line = $trace[$i]['line'];
break;
}
}
break;
}
}
$name = str_replace('\\', '/', $file);
$name = substr($name, strrpos($name, '/') + 1);
$this->collectedData[] = array(
'caller' => compact('name', 'file', 'line'),
'context' => compact('value', 'constraints', 'groups'),
'violations' => iterator_to_array($violations),
);
return $violations;
}
/**
* {@inheritdoc}
*/
public function validateProperty($object, $propertyName, $groups = null)
{
return $this->validator->validateProperty($object, $propertyName, $groups);
}
/**
* {@inheritdoc}
*/
public function validatePropertyValue($objectOrClass, $propertyName, $value, $groups = null)
{
return $this->validator->validatePropertyValue($objectOrClass, $propertyName, $value, $groups);
}
/**
* {@inheritdoc}
*/
public function startContext()
{
return $this->validator->startContext();
}
/**
* {@inheritdoc}
*/
public function inContext(ExecutionContextInterface $context)
{
return $this->validator->inContext($context);
}
}

View File

@ -22,6 +22,8 @@
},
"require-dev": {
"symfony/http-foundation": "~2.8|~3.0|~4.0",
"symfony/http-kernel": "~2.8|~3.0|~4.0.0",
"symfony/var-dumper": "~3.3|~4.0.0",
"symfony/intl": "^2.8.18|^3.2.5|~4.0",
"symfony/yaml": "~3.3|~4.0",
"symfony/config": "~2.8|~3.0|~4.0",