* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Nelmio\ApiDocBundle\Parser; use Nelmio\ApiDocBundle\DataTypes; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Type; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; use Symfony\Component\Validator\MetadataFactoryInterface as LegacyMetadataFactoryInterface; /** * Uses the Symfony Validation component to extract information about API objects. */ class ValidationParser implements ParserInterface, PostParserInterface { /** * @var LegacyMetadataFactoryInterface */ protected $factory; protected $typeMap = [ 'integer' => DataTypes::INTEGER, 'int' => DataTypes::INTEGER, 'scalar' => DataTypes::STRING, 'numeric' => DataTypes::INTEGER, 'boolean' => DataTypes::BOOLEAN, 'string' => DataTypes::STRING, 'float' => DataTypes::FLOAT, 'double' => DataTypes::FLOAT, 'long' => DataTypes::INTEGER, 'object' => DataTypes::MODEL, 'array' => DataTypes::COLLECTION, 'DateTime' => DataTypes::DATETIME, ]; /** * Requires a validation MetadataFactory. * * @param MetadataFactoryInterface|LegacyMetadataFactoryInterface $factory */ public function __construct($factory) { if (!($factory instanceof MetadataFactoryInterface) && !($factory instanceof LegacyMetadataFactoryInterface)) { throw new \InvalidArgumentException('Argument 1 of %s constructor must be either an instance of Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface or Symfony\Component\Validator\MetadataFactoryInterface.'); } $this->factory = $factory; } public function supports(array $input) { $className = $input['class']; return $this->factory->hasMetadataFor($className); } public function parse(array $input) { $className = $input['class']; $groups = []; if (isset($input['groups']) && $input['groups']) { $groups = $input['groups']; } $parsed = $this->doParse($className, [], $groups); if (!isset($input['name']) || empty($input['name'])) { return $parsed; } if ($className && class_exists($className)) { $parts = explode('\\', $className); $dataType = sprintf('object (%s)', end($parts)); } else { $dataType = sprintf('object (%s)', $className); } return [ $input['name'] => [ 'dataType' => $dataType, 'actualType' => DataTypes::MODEL, 'class' => $className, 'subType' => $dataType, 'required' => null, 'readonly' => null, 'children' => $parsed, 'default' => null, ], ]; } /** * Recursively parse constraints. * * @return array */ protected function doParse($className, array $visited, array $groups = []) { $params = []; $classdata = $this->factory->getMetadataFor($className); $properties = $classdata->getConstrainedProperties(); $refl = $classdata->getReflectionClass(); $defaults = $refl->getDefaultProperties(); foreach ($properties as $property) { $vparams = []; $vparams['default'] = $defaults[$property] ?? null; $pds = $classdata->getPropertyMetadata($property); foreach ($pds as $propdata) { $constraints = $propdata->getConstraints(); foreach ($constraints as $constraint) { $vparams = $this->parseConstraint($constraint, $vparams, $className, $visited, $groups); } } if (isset($vparams['format'])) { $vparams['format'] = implode(', ', array_unique($vparams['format'])); } foreach (['dataType', 'readonly', 'required', 'subType'] as $reqprop) { if (!isset($vparams[$reqprop])) { $vparams[$reqprop] = null; } } // check for nested classes with All constraint if (isset($vparams['class']) && !in_array($vparams['class'], $visited) && null !== $this->factory->getMetadataFor($vparams['class'])) { $visited[] = $vparams['class']; $vparams['children'] = $this->doParse($vparams['class'], $visited, $groups); } $vparams['actualType'] = $vparams['actualType'] ?? DataTypes::STRING; $params[$property] = $vparams; } return $params; } public function postParse(array $input, array $parameters) { $groups = []; if (isset($input['groups']) && $input['groups']) { $groups = $input['groups']; } foreach ($parameters as $param => $data) { if (isset($data['class']) && isset($data['children'])) { $input = ['class' => $data['class'], 'groups' => $groups]; $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. * @param array $groups Validation groups. * * @return mixed The parsed list of validation parameters. */ protected function parseConstraint( Constraint $constraint, $vparams, $className, &$visited = [], array $groups = [], ) { $class = substr($constraint::class, strlen('Symfony\\Component\\Validator\\Constraints\\')); $vparams['actualType'] = DataTypes::STRING; $vparams['subType'] = null; $vparams['groups'] = $constraint->groups; if ($groups) { $containGroup = false; foreach ($groups as $group) { if (in_array($group, $vparams['groups'])) { $containGroup = true; } } if (!$containGroup) { return $vparams; } } switch ($class) { case 'NotBlank': $vparams['format'][] = '{not blank}'; // no break case 'NotNull': $vparams['required'] = true; break; case 'Type': if (isset($this->typeMap[$constraint->type])) { $vparams['actualType'] = $this->typeMap[$constraint->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 'Date': $vparams['format'][] = '{Date YYYY-MM-DD}'; $vparams['actualType'] = DataTypes::DATE; break; case 'DateTime': $vparams['format'][] = '{DateTime YYYY-MM-DD HH:MM:SS}'; $vparams['actualType'] = DataTypes::DATETIME; break; case 'Time': $vparams['format'][] = '{Time HH:MM:SS}'; $vparams['actualType'] = DataTypes::TIME; break; case 'Range': $messages = []; if (isset($constraint->min)) { $messages[] = ">={$constraint->min}"; } if (isset($constraint->max)) { $messages[] = "<={$constraint->max}"; } $vparams['format'][] = '{range: {' . implode(', ', $messages) . '}}'; break; case 'Length': $messages = []; if (isset($constraint->min)) { $messages[] = "min: {$constraint->min}"; } if (isset($constraint->max)) { $messages[] = "max: {$constraint->max}"; } $vparams['format'][] = '{length: {' . implode(', ', $messages) . '}}'; break; case 'Choice': $choices = $this->getChoices($constraint, $className); sort($choices); $format = '[' . implode('|', $choices) . ']'; if ($constraint->multiple) { $vparams['actualType'] = DataTypes::COLLECTION; $vparams['subType'] = DataTypes::ENUM; $messages = []; if (isset($constraint->min)) { $messages[] = "min: {$constraint->min} "; } if (isset($constraint->max)) { $messages[] = "max: {$constraint->max} "; } $vparams['format'][] = '{' . implode('', $messages) . 'choice of ' . $format . '}'; } else { $vparams['actualType'] = DataTypes::ENUM; $vparams['format'][] = $format; } break; case 'Regex': if ($constraint->match) { $vparams['format'][] = '{match: ' . $constraint->pattern . '}'; } else { $vparams['format'][] = '{not match: ' . $constraint->pattern . '}'; } break; case 'All': foreach ($constraint->constraints as $childConstraint) { if ($childConstraint instanceof Type) { $nestedType = $childConstraint->type; $exp = explode('\\', $nestedType); if (!$nestedType || !class_exists($nestedType)) { $nestedType = substr($className, 0, strrpos($className, '\\') + 1) . $nestedType; if (!class_exists($nestedType)) { continue; } } $vparams['dataType'] = sprintf('array of objects (%s)', end($exp)); $vparams['actualType'] = DataTypes::COLLECTION; $vparams['subType'] = $nestedType; $vparams['class'] = $nestedType; if (!in_array($nestedType, $visited)) { $visited[] = $nestedType; $vparams['children'] = $this->doParse($nestedType, $visited); } } } break; } return $vparams; } /** * Return Choice constraint choices. * * @return array * * @throws ConstraintDefinitionException */ protected function getChoices(Constraint $constraint, $className) { if ($constraint->callback) { if (is_callable([$className, $constraint->callback])) { $choices = call_user_func([$className, $constraint->callback]); } elseif (is_callable($constraint->callback)) { $choices = call_user_func($constraint->callback); } else { throw new ConstraintDefinitionException('The Choice constraint expects a valid callback'); } } else { $choices = $constraint->choices; } return $choices; } }