diff --git a/Extractor/ApiDocExtractor.php b/Extractor/ApiDocExtractor.php
index 720a593..7277732 100644
--- a/Extractor/ApiDocExtractor.php
+++ b/Extractor/ApiDocExtractor.php
@@ -14,6 +14,7 @@ namespace Nelmio\ApiDocBundle\Extractor;
use Doctrine\Common\Annotations\Reader;
use Nelmio\ApiDocBundle\Annotation\ApiDoc;
use Nelmio\ApiDocBundle\Parser\ParserInterface;
+use Nelmio\ApiDocBundle\Parser\PostParserInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -268,13 +269,24 @@ class ApiDocExtractor
$normalizedInput = $this->normalizeClassParameter($input);
+ $supportedParsers = array();
+ $parameters = array();
foreach ($this->parsers as $parser) {
if ($parser->supports($normalizedInput)) {
- $parameters = $parser->parse($normalizedInput);
- break;
+ $supportedParsers[] = $parser;
+ $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) {
// All parameters are optional with PUT (update)
array_walk($parameters, function($val, $key) use (&$data) {
@@ -294,9 +306,9 @@ class ApiDocExtractor
foreach ($this->parsers as $parser) {
if ($parser->supports($normalizedOutput)) {
$response = $parser->parse($normalizedOutput);
- break;
}
}
+ $response = $this->clearClasses($response);
$annotation->setResponse($response);
}
@@ -374,6 +386,57 @@ class ApiDocExtractor
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
* annotation. Useful to extract information from the FOSRestBundle annotations.
@@ -389,4 +452,21 @@ class ApiDocExtractor
$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;
+ }
}
diff --git a/Formatter/AbstractFormatter.php b/Formatter/AbstractFormatter.php
index b5d2fc7..ca52fdd 100644
--- a/Formatter/AbstractFormatter.php
+++ b/Formatter/AbstractFormatter.php
@@ -69,10 +69,11 @@ abstract class AbstractFormatter implements FormatterInterface
$newName = $this->getNewName($name, $info, $parentName);
$newParams[$newName] = array(
- 'description' => $info['description'],
'dataType' => $info['dataType'],
'readonly' => $info['readonly'],
'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,
'untilVersion' => array_key_exists('untilVersion', $info) ? $info['untilVersion'] : null,
);
diff --git a/NelmioApiDocBundle.php b/NelmioApiDocBundle.php
index c165888..bfce996 100644
--- a/NelmioApiDocBundle.php
+++ b/NelmioApiDocBundle.php
@@ -7,6 +7,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder;
use Nelmio\ApiDocBundle\DependencyInjection\RegisterJmsParserPass;
use Nelmio\ApiDocBundle\DependencyInjection\RegisterExtractorParsersPass;
use Nelmio\ApiDocBundle\DependencyInjection\ExtractorHandlerCompilerPass;
+use Nelmio\ApiDocBundle\DependencyInjection\ParserHandlerCompilerPass;
class NelmioApiDocBundle extends Bundle
{
diff --git a/Parser/FormTypeParser.php b/Parser/FormTypeParser.php
index 5d53168..fb8975e 100644
--- a/Parser/FormTypeParser.php
+++ b/Parser/FormTypeParser.php
@@ -24,6 +24,11 @@ class FormTypeParser implements ParserInterface
*/
protected $formFactory;
+ /**
+ * @var \Symfony\Component\Form\FormRegistry
+ */
+ protected $formRegistry;
+
/**
* @var array
*/
@@ -129,6 +134,8 @@ class FormTypeParser implements ParserInterface
'description' => $config->getAttribute('description'),
'readonly' => $config->getDisabled(),
);
+ } else {
+ $parameters[$name]['class'] = $type;
}
continue;
diff --git a/Parser/JmsMetadataParser.php b/Parser/JmsMetadataParser.php
index 8286e20..1b3d149 100644
--- a/Parser/JmsMetadataParser.php
+++ b/Parser/JmsMetadataParser.php
@@ -126,6 +126,10 @@ class JmsMetadataParser implements ParserInterface
'untilVersion' => $item->untilVersion,
);
+ if(!is_null($dataType['class'])) {
+ $params[$name]['class'] = $dataType['class'];
+ }
+
// if class already parsed, continue, to avoid infinite recursion
if (in_array($dataType['class'], $visited)) {
continue;
diff --git a/Parser/ParserInterface.php b/Parser/ParserInterface.php
index 398b230..3710bcf 100644
--- a/Parser/ParserInterface.php
+++ b/Parser/ParserInterface.php
@@ -33,6 +33,8 @@ interface ParserInterface
* - readonly boolean
* - children (optional) array of nested property names mapped to arrays
* 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.
* @return array
diff --git a/Parser/PostParserInterface.php b/Parser/PostParserInterface.php
new file mode 100644
index 0000000..51832a7
--- /dev/null
+++ b/Parser/PostParserInterface.php
@@ -0,0 +1,38 @@
+
+ *
+ * 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);
+}
diff --git a/Parser/ValidationParser.php b/Parser/ValidationParser.php
new file mode 100644
index 0000000..363a663
--- /dev/null
+++ b/Parser/ValidationParser.php
@@ -0,0 +1,178 @@
+
+ *
+ * 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;
+ }
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index c0ee643..dbe3f0c 100644
--- a/README.md
+++ b/README.md
@@ -104,8 +104,9 @@ The following properties are available:
* `filters`: an array of filters;
-* `input`: the input type associated to the method, currently this supports Form Types, and 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).
+* `input`: the input type associated to the method (currently this supports Form Types, classes with JMS Serializer
+ 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`.
diff --git a/Resources/config/formatters.xml b/Resources/config/formatters.xml
index 2cff274..bbb404b 100644
--- a/Resources/config/formatters.xml
+++ b/Resources/config/formatters.xml
@@ -5,6 +5,7 @@
= - - + -
{% endif %} {% endfor %} diff --git a/Tests/Fixtures/Model/ValidatorTest.php b/Tests/Fixtures/Model/ValidatorTest.php new file mode 100644 index 0000000..46ad6b9 --- /dev/null +++ b/Tests/Fixtures/Model/ValidatorTest.php @@ -0,0 +1,95 @@ + + * + * 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; +} diff --git a/Tests/Parser/ValidationParserTest.php b/Tests/Parser/ValidationParserTest.php new file mode 100644 index 0000000..4e5c256 --- /dev/null +++ b/Tests/Parser/ValidationParserTest.php @@ -0,0 +1,130 @@ +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}' + ) + ) + ); + } +}