NelmioApiDocBundle/ModelDescriber/FormModelDescriber.php

334 lines
11 KiB
PHP
Raw Normal View History

2017-06-24 17:49:00 +02:00
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber;
use Doctrine\Common\Annotations\Reader;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait;
2017-06-24 17:49:00 +02:00
use Nelmio\ApiDocBundle\Model\Model;
use Nelmio\ApiDocBundle\ModelDescriber\Annotations\AnnotationsReader;
use Nelmio\ApiDocBundle\OpenApiPhp\ModelRegister;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Analysis;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormConfigInterface;
2017-06-24 17:49:00 +02:00
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
2017-06-24 17:49:00 +02:00
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\Form\ResolvedFormTypeInterface;
use Symfony\Component\PropertyInfo\Type;
2017-06-24 17:49:00 +02:00
/**
* @internal
*/
final class FormModelDescriber implements ModelDescriberInterface, ModelRegistryAwareInterface
2017-06-24 17:49:00 +02:00
{
use ModelRegistryAwareTrait;
2017-06-24 17:49:00 +02:00
private $formFactory;
private $doctrineReader;
2020-12-10 22:28:55 +01:00
private $mediaTypes;
private $useValidationGroups;
2017-06-24 17:49:00 +02:00
public function __construct(
FormFactoryInterface $formFactory = null,
Reader $reader = null,
array $mediaTypes = null,
bool $useValidationGroups = false
) {
2017-06-24 17:49:00 +02:00
$this->formFactory = $formFactory;
$this->doctrineReader = $reader;
if (null === $reader) {
@trigger_error(sprintf('Not passing a doctrine reader to the constructor of %s is deprecated since version 3.8 and won\'t be allowed in version 5.', self::class), E_USER_DEPRECATED);
}
2020-12-10 22:28:55 +01:00
if (null === $mediaTypes) {
$mediaTypes = ['json'];
@trigger_error(sprintf('Not passing media types to the constructor of %s is deprecated since version 4.1 and won\'t be allowed in version 5.', self::class), E_USER_DEPRECATED);
}
$this->mediaTypes = $mediaTypes;
$this->useValidationGroups = $useValidationGroups;
2017-06-24 17:49:00 +02:00
}
public function describe(Model $model, OA\Schema $schema)
2017-06-24 17:49:00 +02:00
{
if (method_exists(AbstractType::class, 'setDefaultOptions')) {
2017-06-24 17:49:00 +02:00
throw new \LogicException('symfony/form < 3.0 is not supported, please upgrade to an higher version to use a form as a model.');
}
if (null === $this->formFactory) {
throw new \LogicException('You need to enable forms in your application to use a form as a model.');
}
$class = $model->getType()->getClassName();
$annotationsReader = new AnnotationsReader(
$this->doctrineReader,
$this->modelRegistry,
$this->mediaTypes,
$this->useValidationGroups
);
Stop Model Property Description When a Schema Type or Ref is Already Defined (#1978) * Return a Result Object from AnnotationsReader::updateDefinition This is so we can make a decision on whether or not a schema's type or ref has been manually defined by a user via an `@OA\Schema` annotation as something other than an object. If it has been defined, this bundle should not read model properties any further as it causes errors. I put this in AnnotationReader as it seemed the most flexible in the long run. It could have gone in `OpenApiAnnotationsReader`, but then any additional things added to `updateDefinition` could be left out of the decision down the road. This is also a convenient place to decide this once for `ObjectModelDescriber` and `JMSModelDescriber`. * Stop Model Describer if a Schema Type or Ref Has Been Defined Via the result object added in the previous commit. This lets user "short circuit" the model describers by manually defining the schema type or ref on a plain PHP object or form. For example, a collection class could be defined like this: /** * @OA\Schema(type="array", @OA\Items(ref=@Model(type=SomeEntity::class))) */ class SomeCollection implements \IteratorAggregate { } Previously the model describer would error as it tries to merge the `array` schema with the already defiend `object` schema. Now it will prefer the array schema and skip reading all the properties of the object. * Add a Documentation Bit on Stopping Property Description * Mark UpdateClassDefinitionResult as Internal
2022-04-30 13:28:05 -05:00
$classResult = $annotationsReader->updateDefinition(new \ReflectionClass($class), $schema);
if (!$classResult->shouldDescribeModelProperties()) {
return;
}
$schema->type = 'object';
$form = $this->formFactory->create($class, null, $model->getOptions() ?? []);
2017-06-24 17:49:00 +02:00
$this->parseForm($schema, $form);
}
public function supports(Model $model): bool
{
return is_a($model->getType()->getClassName(), FormTypeInterface::class, true);
}
private function parseForm(OA\Schema $schema, FormInterface $form)
2017-06-24 17:49:00 +02:00
{
foreach ($form as $name => $child) {
$config = $child->getConfig();
// This field must not be documented
if ($config->hasOption('documentation') && false === $config->getOption('documentation')) {
continue;
}
$property = Util::getProperty($schema, $name);
if ($config->getRequired()) {
$required = Generator::UNDEFINED !== $schema->required ? $schema->required : [];
$required[] = $name;
$schema->required = $required;
}
if ($config->hasOption('documentation')) {
$property->mergeProperties($config->getOption('documentation'));
// Parse inner @Model annotations
$modelRegister = new ModelRegister($this->modelRegistry, $this->mediaTypes);
$modelRegister->__invoke(new Analysis([$property], Util::createContext()));
}
if (Generator::UNDEFINED !== $property->type || Generator::UNDEFINED !== $property->ref) {
continue; // Type manually defined
}
$this->findFormType($config, $property);
}
}
2017-06-24 17:49:00 +02:00
/**
* Finds and sets the schema type on $property based on $config info.
*
* Returns true if a native OpenAPi type was found, false otherwise
*/
private function findFormType(FormConfigInterface $config, OA\Schema $property)
{
$type = $config->getType();
if (!$builtinFormType = $this->getBuiltinFormType($type)) {
// if form type is not builtin in Form component.
$model = new Model(
new Type(Type::BUILTIN_TYPE_OBJECT, false, get_class($type->getInnerType())),
null,
$config->getOptions()
);
$ref = $this->modelRegistry->register($model);
// We need to use allOf for description and title to be displayed
if ($config->hasOption('documentation') && !empty($config->getOption('documentation'))) {
$property->allOf = [new OA\Schema(['ref' => $ref])];
} else {
$property->ref = $ref;
}
return;
}
do {
$blockPrefix = $builtinFormType->getBlockPrefix();
2017-12-22 17:42:18 +00:00
if ('text' === $blockPrefix) {
$property->type = 'string';
break;
}
2017-12-22 17:42:18 +00:00
if ('number' === $blockPrefix) {
$property->type = 'number';
break;
}
2017-12-22 17:42:18 +00:00
if ('integer' === $blockPrefix) {
$property->type = 'integer';
break;
}
2017-12-22 17:42:18 +00:00
if ('date' === $blockPrefix) {
$property->type = 'string';
$property->format = 'date';
break;
}
2017-12-22 17:42:18 +00:00
if ('datetime' === $blockPrefix) {
$property->type = 'string';
$property->format = 'date-time';
break;
}
if ('choice' === $blockPrefix) {
if ($config->getOption('multiple')) {
$property->type = 'array';
} else {
$property->type = 'string';
}
if (($choices = $config->getOption('choices')) && is_array($choices) && count($choices)) {
$enums = array_values($choices);
2018-09-24 17:35:57 +02:00
if ($this->isNumbersArray($enums)) {
$type = 'number';
} elseif ($this->isBooleansArray($enums)) {
$type = 'boolean';
} else {
$type = 'string';
}
if ($config->getOption('multiple')) {
$property->items = Util::createChild($property, OA\Items::class, ['type' => $type, 'enum' => $enums]);
} else {
$property->type = $type;
$property->enum = $enums;
2017-06-24 17:49:00 +02:00
}
}
break;
}
if ('checkbox' === $blockPrefix) {
$property->type= 'boolean';
break;
}
if ('password' === $blockPrefix) {
$property->type = 'string';
$property->format = 'password';
break;
}
if ('repeated' === $blockPrefix) {
$property->type = 'object';
$property->required = [$config->getOption('first_name'), $config->getOption('second_name')];
$subType = $config->getOption('type');
foreach (['first', 'second'] as $subField) {
$subName = $config->getOption($subField.'_name');
$subForm = $this->formFactory->create($subType, null, array_merge($config->getOption('options'), $config->getOption($subField.'_options')));
$this->findFormType($subForm->getConfig(), Util::getProperty($property, $subName));
}
break;
}
if ('collection' === $blockPrefix) {
$subType = $config->getOption('entry_type');
$subOptions = $config->getOption('entry_options');
$subForm = $this->formFactory->create($subType, null, $subOptions);
2017-12-22 17:42:18 +00:00
$property->type = 'array';
$property->items = Util::createChild($property, OA\Items::class);
$this->findFormType($subForm->getConfig(), $property->items);
break;
}
// The DocumentType is bundled with the DoctrineMongoDBBundle
if ('entity' === $blockPrefix || 'document' === $blockPrefix) {
$entityClass = $config->getOption('class');
2017-12-22 17:42:18 +00:00
if ($config->getOption('multiple')) {
$property->format = sprintf('[%s id]', $entityClass);
$property->type = 'array';
$property->items = Util::createChild($property, OA\Items::class, ['type' => 'string']);
} else {
$property->type = 'string';
$property->format = sprintf('%s id', $entityClass);
}
break;
}
} while ($builtinFormType = $builtinFormType->getParent());
2017-06-24 17:49:00 +02:00
}
/**
* @return bool true if $array contains only numbers, false otherwise
*/
private function isNumbersArray(array $array): bool
{
foreach ($array as $item) {
if (!is_numeric($item)) {
return false;
}
}
return true;
}
2018-09-24 17:35:57 +02:00
/**
* @return bool true if $array contains only booleans, false otherwise
*/
private function isBooleansArray(array $array): bool
{
foreach ($array as $item) {
if (!is_bool($item)) {
return false;
}
}
return true;
}
/**
* @return ResolvedFormTypeInterface|null
*/
private function getBuiltinFormType(ResolvedFormTypeInterface $type)
{
do {
$class = get_class($type->getInnerType());
if (FormType::class === $class) {
return null;
}
if ('entity' === $type->getBlockPrefix() || 'document' === $type->getBlockPrefix()) {
return $type;
}
if (0 === strpos($class, 'Symfony\Component\Form\Extension\Core\Type\\')) {
return $type;
}
} while ($type = $type->getParent());
return null;
}
2017-06-24 17:49:00 +02:00
}