Merge pull request #210 from jhallbachner/validation2

Added Support for Validation Component (refactored)
This commit is contained in:
William Durand 2013-08-13 03:28:09 -07:00
commit 96b40b8a8c
14 changed files with 552 additions and 9 deletions

View File

@ -14,6 +14,7 @@ namespace Nelmio\ApiDocBundle\Extractor;
use Doctrine\Common\Annotations\Reader; use Doctrine\Common\Annotations\Reader;
use Nelmio\ApiDocBundle\Annotation\ApiDoc; use Nelmio\ApiDocBundle\Annotation\ApiDoc;
use Nelmio\ApiDocBundle\Parser\ParserInterface; use Nelmio\ApiDocBundle\Parser\ParserInterface;
use Nelmio\ApiDocBundle\Parser\PostParserInterface;
use Symfony\Component\Routing\Route; use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\ContainerInterface;
@ -268,13 +269,24 @@ class ApiDocExtractor
$normalizedInput = $this->normalizeClassParameter($input); $normalizedInput = $this->normalizeClassParameter($input);
$supportedParsers = array();
$parameters = array();
foreach ($this->parsers as $parser) { foreach ($this->parsers as $parser) {
if ($parser->supports($normalizedInput)) { if ($parser->supports($normalizedInput)) {
$parameters = $parser->parse($normalizedInput); $supportedParsers[] = $parser;
break; $parameters = $this->mergeParameters($parameters, $parser->parse($normalizedInput));
} }
} }
foreach($supportedParsers as $parser) {
if($parser instanceof PostParserInterface) {
$mp = $parser->postParse($normalizedInput, $parameters);
$parameters = $this->mergeParameters($parameters, $mp);
}
}
$parameters = $this->clearClasses($parameters);
if ('PUT' === $method) { if ('PUT' === $method) {
// All parameters are optional with PUT (update) // All parameters are optional with PUT (update)
array_walk($parameters, function($val, $key) use (&$data) { array_walk($parameters, function($val, $key) use (&$data) {
@ -294,9 +306,9 @@ class ApiDocExtractor
foreach ($this->parsers as $parser) { foreach ($this->parsers as $parser) {
if ($parser->supports($normalizedOutput)) { if ($parser->supports($normalizedOutput)) {
$response = $parser->parse($normalizedOutput); $response = $parser->parse($normalizedOutput);
break;
} }
} }
$response = $this->clearClasses($response);
$annotation->setResponse($response); $annotation->setResponse($response);
} }
@ -374,6 +386,57 @@ class ApiDocExtractor
return array_merge($defaults, $input); return array_merge($defaults, $input);
} }
/**
* Merges two parameter arrays together. This logic allows documentation to be built
* from multiple parser passes, with later passes extending previous passes:
* - Boolean values are true if any parser returns true.
* - Requirement parameters are concatenated.
* - Other string values are overridden by later parsers when present.
* - Array parameters are recursively merged.
*
* @param array $p1 The pre-existing parameters array.
* @param array $p2 The newly-returned parameters array.
* @return array The resulting, merged array.
*/
protected function mergeParameters($p1, $p2)
{
$params = $p1;
foreach($p2 as $propname => $propvalue) {
if(!isset($p1[$propname])) {
$params[$propname] = $propvalue;
} else {
$v1 = $p1[$propname];
foreach($propvalue as $name => $value) {
if(is_array($value)) {
if(isset($v1[$name]) && is_array($v1[$name])) {
$v1[$name] = $this->mergeParameters($v1[$name], $value);
} else {
$v1[$name] = $value;
}
} elseif(!is_null($value)) {
if(in_array($name, array('required', 'readonly'))) {
$v1[$name] = $v1[$name] || $value;
} elseif(in_array($name, array('requirement'))) {
if(isset($v1[$name])) {
$v1[$name] .= ', ' . $value;
} else {
$v1[$name] = $value;
}
} else {
$v1[$name] = $value;
}
}
}
$params[$propname] = $v1;
}
}
return $params;
}
/** /**
* Parses annotations for a given method, and adds new information to the given ApiDoc * Parses annotations for a given method, and adds new information to the given ApiDoc
* annotation. Useful to extract information from the FOSRestBundle annotations. * annotation. Useful to extract information from the FOSRestBundle annotations.
@ -389,4 +452,21 @@ class ApiDocExtractor
$handler->handle($annotation, $annots, $route, $method); $handler->handle($annotation, $annots, $route, $method);
} }
} }
/**
* Clears the temporary 'class' parameter from the parameters array before it is returned.
*
* @param array $array The source array.
* @return array The cleared array.
*/
protected function clearClasses($array)
{
if(is_array($array)) {
unset($array['class']);
foreach($array as $name => $item) {
$array[$name] = $this->clearClasses($item);
}
}
return $array;
}
} }

View File

@ -69,10 +69,11 @@ abstract class AbstractFormatter implements FormatterInterface
$newName = $this->getNewName($name, $info, $parentName); $newName = $this->getNewName($name, $info, $parentName);
$newParams[$newName] = array( $newParams[$newName] = array(
'description' => $info['description'],
'dataType' => $info['dataType'], 'dataType' => $info['dataType'],
'readonly' => $info['readonly'], 'readonly' => $info['readonly'],
'required' => $info['required'], 'required' => $info['required'],
'description' => array_key_exists('description', $info) ? $info['description'] : null,
'format' => array_key_exists('format', $info) ? $info['format'] : null,
'sinceVersion' => array_key_exists('sinceVersion', $info) ? $info['sinceVersion'] : null, 'sinceVersion' => array_key_exists('sinceVersion', $info) ? $info['sinceVersion'] : null,
'untilVersion' => array_key_exists('untilVersion', $info) ? $info['untilVersion'] : null, 'untilVersion' => array_key_exists('untilVersion', $info) ? $info['untilVersion'] : null,
); );

View File

@ -7,6 +7,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder;
use Nelmio\ApiDocBundle\DependencyInjection\RegisterJmsParserPass; use Nelmio\ApiDocBundle\DependencyInjection\RegisterJmsParserPass;
use Nelmio\ApiDocBundle\DependencyInjection\RegisterExtractorParsersPass; use Nelmio\ApiDocBundle\DependencyInjection\RegisterExtractorParsersPass;
use Nelmio\ApiDocBundle\DependencyInjection\ExtractorHandlerCompilerPass; use Nelmio\ApiDocBundle\DependencyInjection\ExtractorHandlerCompilerPass;
use Nelmio\ApiDocBundle\DependencyInjection\ParserHandlerCompilerPass;
class NelmioApiDocBundle extends Bundle class NelmioApiDocBundle extends Bundle
{ {

View File

@ -24,6 +24,11 @@ class FormTypeParser implements ParserInterface
*/ */
protected $formFactory; protected $formFactory;
/**
* @var \Symfony\Component\Form\FormRegistry
*/
protected $formRegistry;
/** /**
* @var array * @var array
*/ */
@ -129,6 +134,8 @@ class FormTypeParser implements ParserInterface
'description' => $config->getAttribute('description'), 'description' => $config->getAttribute('description'),
'readonly' => $config->getDisabled(), 'readonly' => $config->getDisabled(),
); );
} else {
$parameters[$name]['class'] = $type;
} }
continue; continue;

View File

@ -126,6 +126,10 @@ class JmsMetadataParser implements ParserInterface
'untilVersion' => $item->untilVersion, 'untilVersion' => $item->untilVersion,
); );
if(!is_null($dataType['class'])) {
$params[$name]['class'] = $dataType['class'];
}
// if class already parsed, continue, to avoid infinite recursion // if class already parsed, continue, to avoid infinite recursion
if (in_array($dataType['class'], $visited)) { if (in_array($dataType['class'], $visited)) {
continue; continue;

View File

@ -33,6 +33,8 @@ interface ParserInterface
* - readonly boolean * - readonly boolean
* - children (optional) array of nested property names mapped to arrays * - children (optional) array of nested property names mapped to arrays
* in the format described here * in the format described here
* - class (optional) the fully-qualified class name of the item, if
* it is represented by an object
* *
* @param string $item The string type of input to parse. * @param string $item The string type of input to parse.
* @return array * @return array

View File

@ -0,0 +1,38 @@
<?php
/*
* This file is part of the NelmioApiDocBundle.
*
* (c) Nelmio <hello@nelm.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Parser;
/**
* This is the interface parsers must implement in order to register a second parsing pass after the initial structure
* is populated..
*/
interface PostParserInterface
{
/**
* Reparses an object for additional documentation details after it has already been parsed once, to allow
* parsers to extend information initially documented by other parsers.
*
* Returns an array of class property metadata where each item is a key (the property name) and
* an array of data with the following keys:
* - dataType string
* - required boolean
* - description string
* - readonly boolean
* - children (optional) array of nested property names mapped to arrays
* in the format described here
*
* @param string $item The string type of input to parse.
* @param array $parameters The previously-parsed parameters array.
* @return array
*/
public function postParse(array $item, array $parameters);
}

178
Parser/ValidationParser.php Normal file
View File

@ -0,0 +1,178 @@
<?php
/*
* This file is part of the NelmioApiDocBundle.
*
* (c) Nelmio <hello@nelm.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Parser;
use Symfony\Component\Validator\MetadataFactoryInterface;
use Symfony\Component\Validator\Constraint;
/**
* Uses the Symfony Validation component to extract information about API objects.
*/
class ValidationParser implements ParserInterface, PostParserInterface
{
/**
* @var \Symfony\Component\Validator\MetadataFactoryInterface
*/
protected $factory;
/**
* Requires a validation MetadataFactory.
*
* @param MetadataFactoryInterface $factory
*/
public function __construct(MetadataFactoryInterface $factory)
{
$this->factory = $factory;
}
/**
* {@inheritdoc}
*/
public function supports(array $input)
{
$className = $input['class'];
return $this->factory->hasMetadataFor($className);
}
/**
* {@inheritdoc}
*/
public function parse(array $input)
{
$params = array();
$className = $input['class'];
$classdata = $this->factory->getMetadataFor($className);
$properties = $classdata->getConstrainedProperties();
foreach($properties as $property) {
$vparams = array();
$pds = $classdata->getPropertyMetadata($property);
foreach($pds as $propdata) {
$constraints = $propdata->getConstraints();
foreach($constraints as $constraint) {
$vparams = $this->parseConstraint($constraint, $vparams);
}
}
if(isset($vparams['format'])) {
$vparams['format'] = join(', ', $vparams['format']);
}
foreach(array('dataType', 'readonly', 'required') as $reqprop) {
if(!isset($vparams[$reqprop])) {
$vparams[$reqprop] = null;
}
}
$params[$property] = $vparams;
}
return $params;
}
/**
* {@inheritDoc}
*/
public function postParse(array $input, array $parameters)
{
foreach($parameters as $param => $data) {
if(isset($data['class']) && isset($data['children'])) {
$input = array('class' => $data['class']);
$parameters[$param]['children'] = array_merge(
$parameters[$param]['children'], $this->postParse($input, $parameters[$param]['children'])
);
$parameters[$param]['children'] = array_merge(
$parameters[$param]['children'], $this->parse($input, $parameters[$param]['children'])
);
}
}
return $parameters;
}
/**
* Create a valid documentation parameter based on an individual validation Constraint.
* Currently supports:
* - NotBlank/NotNull
* - Type
* - Email
* - Url
* - Ip
* - Length (min and max)
* - Choice (single and multiple, min and max)
* - Regex (match and non-match)
*
* @param Constraint $constraint The constraint metadata object.
* @param array $vparams The existing validation parameters.
* @return mixed The parsed list of validation parameters.
*/
protected function parseConstraint(Constraint $constraint, $vparams)
{
$class = substr(get_class($constraint), strlen('Symfony\\Component\\Validator\\Constraints\\'));
switch($class) {
case 'NotBlank':
case 'NotNull':
$vparams['required'] = true;
break;
case 'Type':
$vparams['dataType'] = $constraint->type;
break;
case 'Email':
$vparams['format'][] = '{email address}';
break;
case 'Url':
$vparams['format'][] = '{url}';
break;
case 'Ip':
$vparams['format'][] = '{ip address}';
break;
case 'Length':
$messages = array();
if(isset($constraint->min)) {
$messages[] = "min: {$constraint->min}";
}
if(isset($constraint->max)) {
$messages[] = "max: {$constraint->max}";
}
$vparams['format'][] = '{length: ' . join(', ', $messages) . '}';
break;
case 'Choice':
$format = '[' . join('|', $constraint->choices) . ']';
if($constraint->multiple) {
$messages = array();
if(isset($constraint->min)) {
$messages[] = "min: {$constraint->min} ";
}
if(isset($constraint->max)) {
$messages[] = "max: {$constraint->max} ";
}
$vparams['format'][] = '{' . join ('', $messages) . 'choice of ' . $format . '}';
} else {
$vparams['format'][] = $format;
}
break;
case 'Regex':
if($constraint->match) {
$vparams['format'][] = '{match: ' . $constraint->pattern . '}';
} else {
$vparams['format'][] = '{not match: ' . $constraint->pattern . '}';
}
break;
}
return $vparams;
}
}

View File

@ -104,8 +104,9 @@ The following properties are available:
* `filters`: an array of filters; * `filters`: an array of filters;
* `input`: the input type associated to the method, currently this supports Form Types, and classes with JMS Serializer * `input`: the input type associated to the method (currently this supports Form Types, classes with JMS Serializer
metadata, useful for POST|PUT methods, either as FQCN or as form type (if it is registered in the form factory in the container). metadata, and classes with Validation component metadata) useful for POST|PUT methods, either as FQCN or as form type
(if it is registered in the form factory in the container).
* `output`: the output type associated with the response. Specified and parsed the same way as `input`. * `output`: the output type associated with the response. Specified and parsed the same way as `input`.

View File

@ -5,6 +5,7 @@
<parameters> <parameters>
<parameter key="nelmio_api_doc.parser.form_type_parser.class">Nelmio\ApiDocBundle\Parser\FormTypeParser</parameter> <parameter key="nelmio_api_doc.parser.form_type_parser.class">Nelmio\ApiDocBundle\Parser\FormTypeParser</parameter>
<parameter key="nelmio_api_doc.parser.validation_parser.class">Nelmio\ApiDocBundle\Parser\ValidationParser</parameter>
<parameter key="nelmio_api_doc.formatter.abstract_formatter.class">Nelmio\ApiDocBundle\Formatter\AbstractFormatter</parameter> <parameter key="nelmio_api_doc.formatter.abstract_formatter.class">Nelmio\ApiDocBundle\Formatter\AbstractFormatter</parameter>
<parameter key="nelmio_api_doc.formatter.markdown_formatter.class">Nelmio\ApiDocBundle\Formatter\MarkdownFormatter</parameter> <parameter key="nelmio_api_doc.formatter.markdown_formatter.class">Nelmio\ApiDocBundle\Formatter\MarkdownFormatter</parameter>
<parameter key="nelmio_api_doc.formatter.simple_formatter.class">Nelmio\ApiDocBundle\Formatter\SimpleFormatter</parameter> <parameter key="nelmio_api_doc.formatter.simple_formatter.class">Nelmio\ApiDocBundle\Formatter\SimpleFormatter</parameter>
@ -17,6 +18,10 @@
<argument type="service" id="form.factory" /> <argument type="service" id="form.factory" />
<tag name="nelmio_api_doc.extractor.parser" /> <tag name="nelmio_api_doc.extractor.parser" />
</service> </service>
<service id="nelmio_api_doc.parser.validation_parser" class="%nelmio_api_doc.parser.validation_parser.class%">
<argument type="service" id="validator.mapping.class_metadata_factory"/>
<tag name="nelmio_api_doc.extractor.parser" />
</service>
<service id="nelmio_api_doc.formatter.abstract_formatter" class="%nelmio_api_doc.formatter.abstract_formatter.class%" /> <service id="nelmio_api_doc.formatter.abstract_formatter" class="%nelmio_api_doc.formatter.abstract_formatter.class%" />
<service id="nelmio_api_doc.formatter.markdown_formatter" class="%nelmio_api_doc.formatter.markdown_formatter.class%" <service id="nelmio_api_doc.formatter.markdown_formatter" class="%nelmio_api_doc.formatter.markdown_formatter.class%"
parent="nelmio_api_doc.formatter.abstract_formatter" /> parent="nelmio_api_doc.formatter.abstract_formatter" />

View File

@ -15,7 +15,7 @@
</parameters> </parameters>
<services> <services>
<service id='nelmio_api_doc.doc_comment_extractor' class='%nelmio_api_doc.doc_comment_extractor.class%' /> <service id='nelmio_api_doc.doc_comment_extractor' class="%nelmio_api_doc.doc_comment_extractor.class%" />
<service id="nelmio_api_doc.extractor.api_doc_extractor" class="%nelmio_api_doc.extractor.api_doc_extractor.class%"> <service id="nelmio_api_doc.extractor.api_doc_extractor" class="%nelmio_api_doc.extractor.api_doc_extractor.class%">
<argument type="service" id="service_container"/> <argument type="service" id="service_container"/>
@ -46,7 +46,6 @@
<service id="nelmio_api_doc.extractor.handler.sensio_framework_extra" class="%nelmio_api_doc.extractor.handler.sensio_framework_extra.class%" public="false"> <service id="nelmio_api_doc.extractor.handler.sensio_framework_extra" class="%nelmio_api_doc.extractor.handler.sensio_framework_extra.class%" public="false">
<tag name="nelmio_api_doc.extractor.handler"/> <tag name="nelmio_api_doc.extractor.handler"/>
</service> </service>
</services> </services>
</container> </container>

View File

@ -109,6 +109,7 @@
<th>Parameter</th> <th>Parameter</th>
<th>Type</th> <th>Type</th>
<th>Required?</th> <th>Required?</th>
<th>Format</th>
<th>Description</th> <th>Description</th>
</tr> </tr>
</thead> </thead>
@ -119,6 +120,7 @@
<td>{{ name }}</td> <td>{{ name }}</td>
<td>{{ infos.dataType }}</td> <td>{{ infos.dataType }}</td>
<td>{{ infos.required ? 'true' : 'false' }}</td> <td>{{ infos.required ? 'true' : 'false' }}</td>
<td>{{ infos.format }}</td>
<td>{{ infos.description }}</td> <td>{{ infos.description }}</td>
</tr> </tr>
{% endif %} {% endif %}
@ -216,7 +218,7 @@
<p class="tuple"> <p class="tuple">
<input type="text" class="key" value="{{ name }}" placeholder="Key" /> <input type="text" class="key" value="{{ name }}" placeholder="Key" />
<span>=</span> <span>=</span>
<input type="text" class="value" placeholder="{% if infos.dataType %}[{{ infos.dataType }}] {% endif %}{% if infos.description %}{{ infos.description }}{% else %}Value{% endif %}" /> <span class="remove">-</span> <input type="text" class="value" placeholder="{% if infos.dataType %}[{{ infos.dataType }}] {% endif %}{% if infos.format %}{{ infos.format }}{% endif %}{% if infos.description %}{{ infos.description }}{% else %}Value{% endif %}" /> <span class="remove">-</span>
</p> </p>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View File

@ -0,0 +1,95 @@
<?php
/*
* This file is part of the NelmioApiDocBundle.
*
* (c) Nelmio <hello@nelm.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Tests\Fixtures\Model;
use Symfony\Component\Validator\Constraints as Assert;
class ValidatorTest
{
/**
* @Assert\Length(min=10);
*/
public $length10;
/**
* @Assert\Length(min=1, max=10)
*/
public $length1to10;
/**
* @Assert\NotBlank()
*/
public $notblank;
/**
* @Assert\NotNull()
*/
public $notnull;
/**
* @Assert\Type("DateTime");
*/
public $type;
/**
* @Assert\Email()
*/
public $email;
/**
* @Assert\Url()
*/
public $url;
/**
* @Assert\Ip()
*/
public $ip;
/**
* @Assert\Choice(choices={"a", "b"})
*/
public $singlechoice;
/**
* @Assert\Choice(choices={"x", "y", "z"}, multiple=true)
*/
public $multiplechoice;
/**
* @Assert\Choice(choices={"foo", "bar", "baz", "qux"}, multiple=true, min=2, max=3)
*/
public $multiplerangechoice;
/**
* @Assert\Regex(pattern="/^\d{1,4}\w{1,4}$/")
*/
public $regexmatch;
/**
* @Assert\Regex(pattern="/\d/", match=false)
*/
public $regexnomatch;
/**
* @Assert\NotNull()
* @Assert\Type("string")
* @Assert\Email()
*/
public $multipleassertions;
/**
* @Assert\Url()
* @Assert\Length(min=10)
*/
public $multipleformats;
}

View File

@ -0,0 +1,130 @@
<?php
namespace NelmioApiDocBundle\Tests\Parser;
use Nelmio\ApiDocBundle\Tests\WebTestCase;
use Nelmio\ApiDocBundle\Parser\ValidationParser;
class ValidationParserTest extends WebTestCase
{
protected $handler;
public function setUp()
{
$container = $this->getContainer();
$factory = $container->get('validator.mapping.class_metadata_factory');
$this->parser = new ValidationParser($factory);
}
/**
* @dataProvider dataTestParser
*/
public function testParser($property, $expected)
{
$result = $this->parser->parse(array('class' => 'Nelmio\ApiDocBundle\Tests\Fixtures\Model\ValidatorTest'));
foreach($expected as $name => $value) {
$this->assertEquals($value, $expected[$name]);
}
}
public function dataTestParser()
{
return array(
array(
'property' => 'length10',
'expected' => array(
'format' => '{length: min: 10}'
)
),
array(
'property' => 'length1to10',
'expected' => array(
'format' => '{length: min: 1, max: 10}'
)
),
array(
'property' => 'notblank',
'expected' => array(
'required' => true
)
),
array(
'property' => 'notnull',
'expected' => array(
'required' => true
)
),
array(
'property' => 'type',
'expected' => array(
'dataType' => 'DateTime'
)
),
array(
'property' => 'email',
'expected' => array(
'format' => '{email address}'
)
),
array(
'property' => 'url',
'expected' => array(
'format' => '{url}'
)
),
array(
'property' => 'ip',
'expected' => array(
'format' => '{ip address}'
)
),
array(
'property' => 'singlechoice',
'expected' => array(
'format' => '[a|b]'
)
),
array(
'property' => 'multiplechoice',
'expected' => array(
'format' => '{choice of [x|y|z]}'
)
),
array(
'property' => 'multiplerangechoice',
'expected' => array(
'format' => '{min: 2 max: 3 choice of [foo|bar|baz|qux]}'
)
),
array(
'property' => 'regexmatch',
'expected' => array(
'format' => '{match: /^\d{1,4}\w{1,4}$/}'
)
),
array(
'property' => 'regexnomatch',
'expected' => array(
'format' => '{not match: /\d/}'
)
),
array(
'property' => 'multipleassertions',
'expected' => array(
'required' => true,
'dataType' => 'string',
'format' => '{email address}'
)
),
array(
'property' => 'multipleformats',
'expected' => array(
'format' => '{url}, {length: min: 10}'
)
)
);
}
}