NelmioApiDocBundle/ModelDescriber/JMSModelDescriber.php

349 lines
12 KiB
PHP
Raw Normal View History

2017-06-25 15:40:07 +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;
2019-03-11 12:53:35 +01:00
use JMS\Serializer\Context;
use JMS\Serializer\ContextFactory\SerializationContextFactoryInterface;
2017-06-25 15:40:07 +02:00
use JMS\Serializer\Exclusion\GroupsExclusionStrategy;
use JMS\Serializer\Naming\PropertyNamingStrategyInterface;
use JMS\Serializer\SerializationContext;
use Metadata\MetadataFactoryInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait;
use Nelmio\ApiDocBundle\Model\Model;
use Nelmio\ApiDocBundle\ModelDescriber\Annotations\AnnotationsReader;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
2017-06-25 15:40:07 +02:00
use Symfony\Component\PropertyInfo\Type;
/**
* Uses the JMS metadata factory to extract input/output model information.
*/
class JMSModelDescriber implements ModelDescriberInterface, ModelRegistryAwareInterface
{
use ModelRegistryAwareTrait;
private $factory;
2019-03-11 12:53:35 +01:00
private $contextFactory;
2017-06-25 15:40:07 +02:00
private $namingStrategy;
2019-03-11 12:53:35 +01:00
private $doctrineReader;
2019-03-11 12:53:35 +01:00
private $contexts = [];
private $metadataStacks = [];
private $mediaTypes;
/**
* @var array
*/
private $propertyTypeUseGroupsCache = [];
2017-12-19 08:41:24 +01:00
/**
* @var bool
*/
private $useValidationGroups;
public function __construct(
MetadataFactoryInterface $factory,
Reader $reader,
array $mediaTypes,
?PropertyNamingStrategyInterface $namingStrategy = null,
bool $useValidationGroups = false,
?SerializationContextFactoryInterface $contextFactory = null
2017-12-17 10:44:07 +01:00
) {
2017-06-25 15:40:07 +02:00
$this->factory = $factory;
$this->namingStrategy = $namingStrategy;
$this->doctrineReader = $reader;
$this->mediaTypes = $mediaTypes;
$this->useValidationGroups = $useValidationGroups;
$this->contextFactory = $contextFactory;
2017-06-25 15:40:07 +02:00
}
/**
* {@inheritdoc}
*/
public function describe(Model $model, OA\Schema $schema)
2017-06-25 15:40:07 +02:00
{
$className = $model->getType()->getClassName();
$metadata = $this->factory->getMetadataForClass($className);
if (null === $metadata) {
throw new \InvalidArgumentException(sprintf('No metadata found for class %s.', $className));
}
$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($className), $schema);
if (!$classResult->shouldDescribeModelProperties()) {
return;
}
$schema->type = 'object';
2019-01-26 14:52:13 +01:00
$isJmsV1 = null !== $this->namingStrategy;
2019-03-11 12:53:35 +01:00
$context = $this->getSerializationContext($model);
$context->pushClassMetadata($metadata);
2017-06-25 15:40:07 +02:00
foreach ($metadata->propertyMetadata as $item) {
// filter groups
2019-03-11 12:53:35 +01:00
if (null !== $context->getExclusionStrategy() && $context->getExclusionStrategy()->shouldSkipProperty($item, $context)) {
2017-06-25 15:40:07 +02:00
continue;
}
2019-03-11 12:53:35 +01:00
$context->pushPropertyMetadata($item);
2019-01-26 14:52:13 +01:00
$name = true === $isJmsV1 ? $this->namingStrategy->translateName($item) : $item->serializedName;
// read property options from Swagger Property annotation if it exists
2019-01-11 10:40:53 +01:00
$reflections = [];
if (true === $isJmsV1 && property_exists($item, 'reflection') && null !== $item->reflection) {
$reflections[] = $item->reflection;
} elseif (\property_exists($item->class, $item->name)) {
$reflections[] = new \ReflectionProperty($item->class, $item->name);
}
2020-09-09 08:35:01 +02:00
if (null !== $item->getter) {
try {
$reflections[] = new \ReflectionMethod($item->class, $item->getter);
2020-09-09 08:35:01 +02:00
} catch (\ReflectionException $ignored) {
}
2020-09-09 08:35:01 +02:00
}
if (null !== $item->setter) {
try {
$reflections[] = new \ReflectionMethod($item->class, $item->setter);
2020-09-09 08:35:01 +02:00
} catch (\ReflectionException $ignored) {
}
}
$groups = $this->computeGroups($context, $item->type);
if (true === $item->inline && isset($item->type['name'])) {
// currently array types can not be documented :-/
if (!in_array($item->type['name'], ['array', 'ArrayCollection'], true)) {
$inlineModel = new Model(new Type(Type::BUILTIN_TYPE_OBJECT, false, $item->type['name']), $groups);
$this->describe($inlineModel, $schema);
}
$context->popPropertyMetadata();
continue;
}
foreach ($reflections as $reflection) {
$name = $annotationsReader->getPropertyName($reflection, $name);
}
$property = Util::getProperty($schema, $name);
foreach ($reflections as $reflection) {
2019-01-11 10:40:53 +01:00
$annotationsReader->updateProperty($reflection, $property, $groups);
}
if (Generator::UNDEFINED !== $property->type || Generator::UNDEFINED !== $property->ref) {
2019-03-11 12:53:35 +01:00
$context->popPropertyMetadata();
continue;
}
if (null === $item->type) {
$key = Util::searchIndexedCollectionItem($schema->properties, 'property', $name);
unset($schema->properties[$key]);
2019-03-11 12:53:35 +01:00
$context->popPropertyMetadata();
continue;
}
2017-06-25 15:40:07 +02:00
$this->describeItem($item->type, $property, $context);
2019-03-11 12:53:35 +01:00
$context->popPropertyMetadata();
}
$context->popClassMetadata();
}
/**
* @internal
*/
public function getSerializationContext(Model $model): SerializationContext
2019-03-11 12:53:35 +01:00
{
if (isset($this->contexts[$model->getHash()])) {
$context = $this->contexts[$model->getHash()];
$stack = $context->getMetadataStack();
while (!$stack->isEmpty()) {
$stack->pop();
}
foreach ($this->metadataStacks[$model->getHash()] as $metadataCopy) {
$stack->unshift($metadataCopy);
}
} else {
$context = $this->contextFactory ? $this->contextFactory->createSerializationContext() : SerializationContext::create();
2019-03-11 12:53:35 +01:00
if (null !== $model->getGroups()) {
$context->addExclusionStrategy(new GroupsExclusionStrategy($model->getGroups()));
}
}
return $context;
}
private function computeGroups(Context $context, array $type = null)
{
if (null === $type || true !== $this->propertyTypeUsesGroups($type)) {
return null;
}
$groupsExclusion = $context->getExclusionStrategy();
if (!($groupsExclusion instanceof GroupsExclusionStrategy)) {
return null;
}
$groups = $groupsExclusion->getGroupsFor($context);
if ([GroupsExclusionStrategy::DEFAULT_GROUP] === $groups) {
return null;
2017-06-25 15:40:07 +02:00
}
2019-03-11 12:53:35 +01:00
return $groups;
2017-06-25 15:40:07 +02:00
}
/**
* {@inheritdoc}
*/
public function supports(Model $model): bool
{
$className = $model->getType()->getClassName();
2017-12-22 17:42:18 +00:00
2017-06-25 15:40:07 +02:00
try {
if ($this->factory->getMetadataForClass($className)) {
return true;
}
} catch (\ReflectionException $e) {
}
return false;
}
/**
* @internal
*/
public function describeItem(array $type, OA\Schema $property, Context $context)
2017-06-25 15:40:07 +02:00
{
$nestedTypeInfo = $this->getNestedTypeInArray($type);
if (null !== $nestedTypeInfo) {
list($nestedType, $isHash) = $nestedTypeInfo;
if ($isHash) {
$property->type = 'object';
$property->additionalProperties = Util::createChild($property, OA\Property::class);
// this is a free form object (as nested array)
if ('array' === $nestedType['name'] && !isset($nestedType['params'][0])) {
// in the case of a virtual property, set it as free object type
$property->additionalProperties = true;
return;
}
$this->describeItem($nestedType, $property->additionalProperties, $context);
return;
}
$property->type = 'array';
$property->items = Util::createChild($property, OA\Items::class);
$this->describeItem($nestedType, $property->items, $context);
} elseif ('array' === $type['name']) {
$property->type = 'object';
$property->additionalProperties = true;
} elseif ('string' === $type['name']) {
$property->type = 'string';
} elseif (in_array($type['name'], ['bool', 'boolean'], true)) {
$property->type = 'boolean';
} elseif (in_array($type['name'], ['int', 'integer'], true)) {
$property->type = 'integer';
} elseif (in_array($type['name'], ['double', 'float'], true)) {
$property->type = 'number';
$property->format = $type['name'];
} elseif (is_a($type['name'], \DateTimeInterface::class, true)) {
$property->type = 'string';
$property->format = 'date-time';
} else {
2019-03-11 12:53:35 +01:00
$groups = $this->computeGroups($context, $type);
$model = new Model(new Type(Type::BUILTIN_TYPE_OBJECT, false, $type['name']), $groups);
$modelRef = $this->modelRegistry->register($model);
$customFields = (array) $property->jsonSerialize();
unset($customFields['property']);
if (empty($customFields)) { // no custom fields
$property->ref = $modelRef;
} else {
$property->allOf = [new OA\Schema(['ref' => $modelRef])];
}
2019-03-11 12:53:35 +01:00
$this->contexts[$model->getHash()] = $context;
$this->metadataStacks[$model->getHash()] = clone $context->getMetadataStack();
2017-06-25 15:40:07 +02:00
}
}
2017-06-25 15:40:07 +02:00
private function getNestedTypeInArray(array $type)
{
if ('array' !== $type['name'] && 'ArrayCollection' !== $type['name']) {
return null;
}
// array<string, MyNamespaceMyObject>
if (isset($type['params'][1]['name'])) {
return [$type['params'][1], true];
}
2017-06-25 15:40:07 +02:00
// array<MyNamespaceMyObject>
if (isset($type['params'][0]['name'])) {
return [$type['params'][0], false];
2017-06-25 15:40:07 +02:00
}
return null;
2017-06-25 15:40:07 +02:00
}
/**
* @return bool|null
*/
private function propertyTypeUsesGroups(array $type)
{
if (array_key_exists($type['name'], $this->propertyTypeUseGroupsCache)) {
return $this->propertyTypeUseGroupsCache[$type['name']];
}
try {
$metadata = $this->factory->getMetadataForClass($type['name']);
foreach ($metadata->propertyMetadata as $item) {
if (null !== $item->groups && $item->groups != [GroupsExclusionStrategy::DEFAULT_GROUP]) {
$this->propertyTypeUseGroupsCache[$type['name']] = true;
return true;
}
}
$this->propertyTypeUseGroupsCache[$type['name']] = false;
return false;
} catch (\ReflectionException $e) {
$this->propertyTypeUseGroupsCache[$type['name']] = null;
return null;
}
}
2017-06-25 15:40:07 +02:00
}