diff --git a/features/main/union_intersect_types.feature b/features/main/union_intersect_types.feature new file mode 100644 index 00000000000..ba47388e554 --- /dev/null +++ b/features/main/union_intersect_types.feature @@ -0,0 +1,121 @@ +Feature: Union/Intersect types + + Scenario Outline: Create a resource with union type + When I add "Content-Type" header equal to "application/ld+json" + And I add "Accept" header equal to "application/ld+json" + And I send a "POST" request to "/issue-5452/books" with body: + """ + { + "number": , + "isbn": "978-3-16-148410-0" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@type": { + "type": "string", + "pattern": "^Book$" + }, + "@context": { + "type": "string", + "pattern": "^/contexts/Book$" + }, + "@id": { + "type": "string", + "pattern": "^/.well-known/genid/.+$" + }, + "number": { + "type": "" + }, + "isbn": { + "type": "string", + "pattern": "^978-3-16-148410-0$" + } + }, + "required": [ + "@type", + "@context", + "@id", + "number", + "isbn" + ] + } + """ + Examples: + | number | type | + | "1" | string | + | 1 | integer | + + Scenario: Create a resource with valid intersect type + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/issue-5452/books" with body: + """ + { + "number": 1, + "isbn": "978-3-16-148410-0", + "author": "/issue-5452/authors/1" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@type": { + "type": "string", + "pattern": "^Book$" + }, + "@context": { + "type": "string", + "pattern": "^/contexts/Book$" + }, + "@id": { + "type": "string", + "pattern": "^/.well-known/genid/.+$" + }, + "number": { + "type": "integer" + }, + "isbn": { + "type": "string", + "pattern": "^978-3-16-148410-0$" + }, + "author": { + "type": "string", + "pattern": "^/issue-5452/authors/1$" + } + }, + "required": [ + "@type", + "@context", + "@id", + "number", + "isbn", + "author" + ] + } + """ + + Scenario: Create a resource with invalid intersect type + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/issue-5452/books" with body: + """ + { + "number": 1, + "isbn": "978-3-16-148410-0", + "library": "/issue-5452/libraries/1" + } + """ + Then the response status code should be 400 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON node "hydra:description" should be equal to 'Could not denormalize object of type "ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\ActivableInterface", no supporting normalizer found.' diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index df6c9081f3f..f90ff496394 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -86,19 +86,19 @@ Feature: Documentation support { "default": "male", "example": "male", - "type": "string", + "type": ["string", "null"], "enum": [ "male", "female", null - ], - "nullable": true + ] } """ And the "playMode" property exists for the OpenAPI class "VideoGame" And the "playMode" property for the OpenAPI class "VideoGame" should be equal to: """ { + "owl:maxCardinality": 1, "type": "string", "format": "iri-reference" } @@ -238,8 +238,7 @@ Feature: Documentation support "type": "string" }, "property": { - "type": "string", - "nullable": true + "type": ["string", "null"] }, "required": { "type": "boolean" @@ -310,12 +309,15 @@ Feature: Documentation support And the "resourceRelated" property for the OpenAPI class "Resource" should be equal to: """ { - "readOnly":true, - "anyOf":[ + "owl:maxCardinality": 1, + "readOnly": true, + "anyOf": [ + { + "$ref": "#/components/schemas/ResourceRelated" + }, { - "$ref":"#/components/schemas/ResourceRelated" + "type": "null" } - ], - "nullable":true + ] } """ diff --git a/src/Elasticsearch/Filter/AbstractFilter.php b/src/Elasticsearch/Filter/AbstractFilter.php index 90b37273c69..585bf5ba899 100644 --- a/src/Elasticsearch/Filter/AbstractFilter.php +++ b/src/Elasticsearch/Filter/AbstractFilter.php @@ -93,46 +93,70 @@ protected function getMetadata(string $resourceClass, string $property): array return $noop; } - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + $types = $propertyMetadata->getBuiltinTypes(); - if (null === $type) { + if (null === $types) { return $noop; } ++$index; - $builtinType = $type->getBuiltinType(); - if (Type::BUILTIN_TYPE_OBJECT !== $builtinType && Type::BUILTIN_TYPE_ARRAY !== $builtinType) { - if ($totalProperties === $index) { - break; + // check each type before deciding if it's noop or not + // e.g: maybe the first type is noop, but the second is valid + $isNoop = false; + + foreach ($types as $type) { + $builtinType = $type->getBuiltinType(); + + if (Type::BUILTIN_TYPE_OBJECT !== $builtinType && Type::BUILTIN_TYPE_ARRAY !== $builtinType) { + if ($totalProperties === $index) { + break 2; + } + + $isNoop = true; + + continue; } - return $noop; - } + if ($type->isCollection() && null === $type = $type->getCollectionValueTypes()[0] ?? null) { + $isNoop = true; - if ($type->isCollection() && null === $type = $type->getCollectionValueTypes()[0] ?? null) { - return $noop; - } + continue; + } + + if (Type::BUILTIN_TYPE_ARRAY === $builtinType && Type::BUILTIN_TYPE_OBJECT !== $type->getBuiltinType()) { + if ($totalProperties === $index) { + break 2; + } + + $isNoop = true; - if (Type::BUILTIN_TYPE_ARRAY === $builtinType && Type::BUILTIN_TYPE_OBJECT !== $type->getBuiltinType()) { - if ($totalProperties === $index) { - break; + continue; } - return $noop; - } + if (null === $className = $type->getClassName()) { + $isNoop = true; - if (null === $className = $type->getClassName()) { - return $noop; + continue; + } + + if ($isResourceClass = $this->resourceClassResolver->isResourceClass($className)) { + $currentResourceClass = $className; + } elseif ($totalProperties !== $index) { + $isNoop = true; + + continue; + } + + $hasAssociation = $totalProperties === $index && $isResourceClass; + $isNoop = false; + + break; } - if ($isResourceClass = $this->resourceClassResolver->isResourceClass($className)) { - $currentResourceClass = $className; - } elseif ($totalProperties !== $index) { + if ($isNoop) { return $noop; } - - $hasAssociation = $totalProperties === $index && $isResourceClass; } return [$type, $hasAssociation, $currentResourceClass, $currentProperty]; diff --git a/src/Elasticsearch/Util/FieldDatatypeTrait.php b/src/Elasticsearch/Util/FieldDatatypeTrait.php index 99894eb7109..891543599e1 100644 --- a/src/Elasticsearch/Util/FieldDatatypeTrait.php +++ b/src/Elasticsearch/Util/FieldDatatypeTrait.php @@ -59,30 +59,27 @@ private function getNestedFieldPath(string $resourceClass, string $property): ?s return null; } - // TODO: 3.0 allow multiple types - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - - if (null === $type) { - return null; - } - - if ( - Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() - && null !== ($nextResourceClass = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($nextResourceClass) - ) { - $nestedPath = $this->getNestedFieldPath($nextResourceClass, implode('.', $properties)); - - return null === $nestedPath ? $nestedPath : "$currentProperty.$nestedPath"; - } - - if ( - null !== ($type = $type->getCollectionValueTypes()[0] ?? null) - && Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() - && null !== ($className = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - return $currentProperty; + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + foreach ($types as $type) { + if ( + Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() + && null !== ($nextResourceClass = $type->getClassName()) + && $this->resourceClassResolver->isResourceClass($nextResourceClass) + ) { + $nestedPath = $this->getNestedFieldPath($nextResourceClass, implode('.', $properties)); + + return null === $nestedPath ? $nestedPath : "$currentProperty.$nestedPath"; + } + + if ( + null !== ($type = $type->getCollectionValueTypes()[0] ?? null) + && Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() + && null !== ($className = $type->getClassName()) + && $this->resourceClassResolver->isResourceClass($className) + ) { + return $currentProperty; + } } return null; diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 53117577639..3e577dea012 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -213,17 +213,23 @@ public function getResourceObjectTypeFields(?string $resourceClass, Operation $o 'denormalization_groups' => $operation->getDenormalizationContext()['groups'] ?? null, ]; $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $context); + $propertyTypes = $propertyMetadata->getBuiltinTypes(); if ( - null === ($propertyType = $propertyMetadata->getBuiltinTypes()[0] ?? null) + !$propertyTypes || (!$input && false === $propertyMetadata->isReadable()) || ($input && $operation instanceof Mutation && false === $propertyMetadata->isWritable()) ) { continue; } - if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) { - $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; + // guess union/intersect types: check each type until finding a valid one + foreach ($propertyTypes as $propertyType) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) { + $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; + // stop at the first valid type + break; + } } } } diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index 4c9bb677c70..eb9842acb4c 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -138,31 +138,42 @@ private function getComponents(object $object, ?string $format, array $context): foreach ($attributes as $attribute) { $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options); - // TODO: 3.0 support multiple types, default value of types will be [] instead of null - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - $isOne = $isMany = false; - - if (null !== $type) { - if ($type->isCollection()) { - $valueType = $type->getCollectionValueTypes()[0] ?? null; - $isMany = null !== $valueType && ($className = $valueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } else { - $className = $type->getClassName(); - $isOne = $className && $this->resourceClassResolver->isResourceClass($className); + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + // prevent declaring $attribute as attribute if it's already declared as relationship + $isRelationship = false; + + foreach ($types as $type) { + $isOne = $isMany = false; + + if (null !== $type) { + if ($type->isCollection()) { + $valueType = $type->getCollectionValueTypes()[0] ?? null; + $isMany = null !== $valueType && ($className = $valueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); + } else { + $className = $type->getClassName(); + $isOne = $className && $this->resourceClassResolver->isResourceClass($className); + } } - } - if (!$isOne && !$isMany) { - $components['states'][] = $attribute; - continue; - } + if (!$isOne && !$isMany) { + // don't declare it as an attribute too quick: maybe the next type is a valid resource + continue; + } + + $relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many']; + if ($propertyMetadata->isReadableLink()) { + $components['embedded'][] = $relation; + } - $relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many']; - if ($propertyMetadata->isReadableLink()) { - $components['embedded'][] = $relation; + $components['links'][] = $relation; + $isRelationship = true; } - $components['links'][] = $relation; + // if all types are not relationships, declare it as an attribute + if (!$isRelationship) { + $components['states'][] = $attribute; + } } if (false !== $context['cache_key']) { diff --git a/src/Hydra/JsonSchema/SchemaFactory.php b/src/Hydra/JsonSchema/SchemaFactory.php index dc90fc9036e..540834d92f5 100644 --- a/src/Hydra/JsonSchema/SchemaFactory.php +++ b/src/Hydra/JsonSchema/SchemaFactory.php @@ -91,14 +91,15 @@ public function buildSchema(string $className, string $format = 'jsonld', string $items = $schema['items']; unset($schema['items']); - $nullableStringDefinition = ['type' => 'string']; - switch ($schema->getVersion()) { + // JSON Schema + OpenAPI 3.1 + case Schema::VERSION_OPENAPI: case Schema::VERSION_JSON_SCHEMA: $nullableStringDefinition = ['type' => ['string', 'null']]; break; - case Schema::VERSION_OPENAPI: - $nullableStringDefinition = ['type' => 'string', 'nullable' => true]; + // Swagger + default: + $nullableStringDefinition = ['type' => 'string']; break; } diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 248816eb80c..2156a2c85b0 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -310,7 +310,7 @@ private function getHydraOperation(HttpOperation $operation, string $prefixedSho /** * Gets the range of the property. */ - private function getRange(ApiProperty $propertyMetadata): ?string + private function getRange(ApiProperty $propertyMetadata): array|string|null { $jsonldContext = $propertyMetadata->getJsonldContext(); @@ -318,47 +318,69 @@ private function getRange(ApiProperty $propertyMetadata): ?string return $jsonldContext['@type']; } - // TODO: 3.0 support multiple types, default value of types will be [] instead of null - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if (null === $type) { - return null; - } + $builtInTypes = $propertyMetadata->getBuiltinTypes() ?? []; + $types = []; - if ($type->isCollection() && null !== $collectionType = $type->getCollectionValueTypes()[0] ?? null) { - $type = $collectionType; - } + foreach ($builtInTypes as $type) { + if ($type->isCollection() && null !== $collectionType = $type->getCollectionValueTypes()[0] ?? null) { + $type = $collectionType; + } - switch ($type->getBuiltinType()) { - case Type::BUILTIN_TYPE_STRING: - return 'xmls:string'; - case Type::BUILTIN_TYPE_INT: - return 'xmls:integer'; - case Type::BUILTIN_TYPE_FLOAT: - return 'xmls:decimal'; - case Type::BUILTIN_TYPE_BOOL: - return 'xmls:boolean'; - case Type::BUILTIN_TYPE_OBJECT: - if (null === $className = $type->getClassName()) { - return null; - } + switch ($type->getBuiltinType()) { + case Type::BUILTIN_TYPE_STRING: + if (!\in_array('xmls:string', $types, true)) { + $types[] = 'xmls:string'; + } + break; + case Type::BUILTIN_TYPE_INT: + if (!\in_array('xmls:integer', $types, true)) { + $types[] = 'xmls:integer'; + } + break; + case Type::BUILTIN_TYPE_FLOAT: + if (!\in_array('xmls:decimal', $types, true)) { + $types[] = 'xmls:decimal'; + } + break; + case Type::BUILTIN_TYPE_BOOL: + if (!\in_array('xmls:boolean', $types, true)) { + $types[] = 'xmls:boolean'; + } + break; + case Type::BUILTIN_TYPE_OBJECT: + if (null === $className = $type->getClassName()) { + continue 2; + } - if (is_a($className, \DateTimeInterface::class, true)) { - return 'xmls:dateTime'; - } + if (is_a($className, \DateTimeInterface::class, true)) { + if (!\in_array('xmls:dateTime', $types, true)) { + $types[] = 'xmls:dateTime'; + } + break; + } + + if ($this->resourceClassResolver->isResourceClass($className)) { + $resourceMetadata = $this->resourceMetadataFactory->create($className); + $operation = $resourceMetadata->getOperation(); - if ($this->resourceClassResolver->isResourceClass($className)) { - $resourceMetadata = $this->resourceMetadataFactory->create($className); - $operation = $resourceMetadata->getOperation(); + if (!$operation instanceof HttpOperation || !$operation->getTypes()) { + if (!\in_array("#{$operation->getShortName()}", $types, true)) { + $types[] = "#{$operation->getShortName()}"; + } + break; + } - if (!$operation instanceof HttpOperation) { - return "#{$operation->getShortName()}"; + $types = array_unique(array_merge($types, $operation->getTypes())); + break; } + } + } - return $operation->getTypes()[0] ?? "#{$operation->getShortName()}"; - } + if ([] === $types) { + return null; } - return null; + return 1 === \count($types) ? $types[0] : $types; } /** @@ -463,13 +485,6 @@ private function getProperty(ApiProperty $propertyMetadata, string $propertyName 'domain' => $prefixedShortName, ]; - // TODO: 3.0 support multiple types, default value of types will be [] instead of null - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - - if (null !== $type && !$type->isCollection() && (null !== $className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { - $propertyData['owl:maxCardinality'] = 1; - } - $property = [ '@type' => 'hydra:SupportedProperty', 'hydra:property' => $propertyData, @@ -487,7 +502,7 @@ private function getProperty(ApiProperty $propertyMetadata, string $propertyName $property['hydra:description'] = $description; } - if ($deprecationReason = $propertyMetadata->getDeprecationReason()) { + if ($propertyMetadata->getDeprecationReason()) { $property['owl:deprecated'] = true; } diff --git a/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php b/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php index 3319e036c61..acedb9faba2 100644 --- a/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php +++ b/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php @@ -84,9 +84,12 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio $fieldName = $this->nameConverter->normalize($fieldName, $class, self::FORMAT); } - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if ($type && null !== $type->getClassName()) { - return "data/relationships/$fieldName"; + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + foreach ($types as $type) { + if (null !== $type->getClassName()) { + return "data/relationships/$fieldName"; + } } return "data/attributes/$fieldName"; diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 8b3d96a5263..2998054c1ae 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -280,32 +280,40 @@ private function getComponents(object $object, ?string $format, array $context): ->propertyMetadataFactory ->create($context['resource_class'], $attribute, $options); - // TODO: 3.0 support multiple types, default value of types will be [] instead of null - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - $isOne = $isMany = false; + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + // prevent declaring $attribute as attribute if it's already declared as relationship + $isRelationship = false; + + foreach ($types as $type) { + $isOne = $isMany = false; - if (null !== $type) { if ($type->isCollection()) { $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); } else { $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className); } - } - if (!isset($className) || !$isOne && !$isMany) { - $components['attributes'][] = $attribute; + if (!isset($className) || !$isOne && !$isMany) { + // don't declare it as an attribute too quick: maybe the next type is a valid resource + continue; + } - continue; - } + $relation = [ + 'name' => $attribute, + 'type' => $this->getResourceShortName($className), + 'cardinality' => $isOne ? 'one' : 'many', + ]; - $relation = [ - 'name' => $attribute, - 'type' => $this->getResourceShortName($className), - 'cardinality' => $isOne ? 'one' : 'many', - ]; + $components['relationships'][] = $relation; + $isRelationship = true; + } - $components['relationships'][] = $relation; + // if all types are not relationships, declare it as an attribute + if (!$isRelationship) { + $components['attributes'][] = $attribute; + } } if (false !== $context['cache_key']) { diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php new file mode 100644 index 00000000000..cf1c294e19e --- /dev/null +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -0,0 +1,276 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema\Metadata\Property\Factory; + +use ApiPlatform\Exception\PropertyNotFoundException; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; +use Ramsey\Uuid\UuidInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\Uuid; + +/** + * Build ApiProperty::schema. + */ +final class SchemaPropertyMetadataFactory implements PropertyMetadataFactoryInterface +{ + use ResourceClassInfoTrait; + + public function __construct(ResourceClassResolverInterface $resourceClassResolver, private readonly ?PropertyMetadataFactoryInterface $decorated = null) + { + $this->resourceClassResolver = $resourceClassResolver; + } + + public function create(string $resourceClass, string $property, array $options = []): ApiProperty + { + if (null === $this->decorated) { + $propertyMetadata = new ApiProperty(); + } else { + try { + $propertyMetadata = $this->decorated->create($resourceClass, $property, $options); + } catch (PropertyNotFoundException) { + $propertyMetadata = new ApiProperty(); + } + } + + $propertySchema = $propertyMetadata->getSchema() ?? []; + + if (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) { + $propertySchema['readOnly'] = true; + } + + if (!\array_key_exists('writeOnly', $propertySchema) && false === $propertyMetadata->isReadable()) { + $propertySchema['writeOnly'] = true; + } + + if (!\array_key_exists('description', $propertySchema) && null !== ($description = $propertyMetadata->getDescription())) { + $propertySchema['description'] = $description; + } + + // see https://github.com/json-schema-org/json-schema-spec/pull/737 + if (!\array_key_exists('deprecated', $propertySchema) && null !== $propertyMetadata->getDeprecationReason()) { + $propertySchema['deprecated'] = true; + } + + // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it + // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4 + if (!\array_key_exists('externalDocs', $propertySchema) && null !== ($iri = $propertyMetadata->getTypes()[0] ?? null)) { + $propertySchema['externalDocs'] = ['url' => $iri]; + } + + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!\count($types) || null === ($className = $types[0]->getClassName()) || !$this->isResourceClass($className))) { + if ($default instanceof \BackedEnum) { + $default = $default->value; + } + $propertySchema['default'] = $default; + } + + if (!\array_key_exists('example', $propertySchema) && !empty($example = $propertyMetadata->getExample())) { + $propertySchema['example'] = $example; + } + + if (!\array_key_exists('example', $propertySchema) && \array_key_exists('default', $propertySchema)) { + $propertySchema['example'] = $propertySchema['default']; + } + + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + // never override the following keys if at least one is already set + if ([] === $types + || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) + ) { + return $propertyMetadata->withSchema($propertySchema); + } + + $valueSchema = []; + foreach ($types as $type) { + if ($isCollection = $type->isCollection()) { + $keyType = $type->getCollectionKeyTypes()[0] ?? null; + $valueType = $type->getCollectionValueTypes()[0] ?? null; + } else { + $keyType = null; + $valueType = $type; + } + + if (null === $valueType) { + $builtinType = 'string'; + $className = null; + } else { + $builtinType = $valueType->getBuiltinType(); + $className = $valueType->getClassName(); + } + + if (!\array_key_exists('owl:maxCardinality', $propertySchema) + && !$isCollection + && null !== $className + && $this->resourceClassResolver->isResourceClass($className) + ) { + $propertySchema['owl:maxCardinality'] = 1; + } + + $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $propertyMetadata->isReadableLink()); + if (!\in_array($propertyType, $valueSchema, true)) { + $valueSchema[] = $propertyType; + } + } + + // only one builtInType detected (should be "type" or "$ref") + if (1 === \count($valueSchema)) { + return $propertyMetadata->withSchema($propertySchema + $valueSchema[0]); + } + + // multiple builtInTypes detected: determine oneOf/allOf if union vs intersect types + try { + $reflectionClass = new \ReflectionClass($resourceClass); + $reflectionProperty = $reflectionClass->getProperty($property); + $composition = $reflectionProperty->getType() instanceof \ReflectionUnionType ? 'oneOf' : 'allOf'; + } catch (\ReflectionException) { + // cannot detect types + $composition = 'anyOf'; + } + + return $propertyMetadata->withSchema($propertySchema + [$composition => $valueSchema]); + } + + private function getType(Type $type, bool $readableLink = null): array + { + if (!$type->isCollection()) { + return $this->addNullabilityToTypeDefinition($this->typeToArray($type, $readableLink), $type); + } + + $keyType = $type->getCollectionKeyTypes()[0] ?? null; + $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false); + + if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { + return $this->addNullabilityToTypeDefinition([ + 'type' => 'object', + 'additionalProperties' => $this->getType($subType, $readableLink), + ], $type); + } + + return $this->addNullabilityToTypeDefinition([ + 'type' => 'array', + 'items' => $this->getType($subType, $readableLink), + ], $type); + } + + private function typeToArray(Type $type, bool $readableLink = null): array + { + return match ($type->getBuiltinType()) { + Type::BUILTIN_TYPE_INT => ['type' => 'integer'], + Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'], + Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], + Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable(), $readableLink), + default => ['type' => 'string'], + }; + } + + /** + * Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided. + * + * Note: if the class is not part of exceptions listed above, any class is considered as a resource. + */ + private function getClassType(?string $className, bool $nullable, ?bool $readableLink): array + { + if (null === $className) { + return ['type' => 'string']; + } + + if (is_a($className, \DateTimeInterface::class, true)) { + return [ + 'type' => 'string', + 'format' => 'date-time', + ]; + } + + if (is_a($className, \DateInterval::class, true)) { + return [ + 'type' => 'string', + 'format' => 'duration', + ]; + } + + if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'uuid', + ]; + } + + if (is_a($className, Ulid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'ulid', + ]; + } + + if (is_a($className, \SplFileInfo::class, true)) { + return [ + 'type' => 'string', + 'format' => 'binary', + ]; + } + + if (!$this->isResourceClass($className) && is_a($className, \BackedEnum::class, true)) { + $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases()); + + $type = \is_string($enumCases[0] ?? '') ? 'string' : 'int'; + + if ($nullable) { + $enumCases[] = null; + } + + return [ + 'type' => $type, + 'enum' => $enumCases, + ]; + } + + if (true !== $readableLink && $this->isResourceClass($className)) { + return [ + 'type' => 'string', + 'format' => 'iri-reference', + ]; + } + + return ['type' => 'string']; + } + + /** + * @param array $jsonSchema + * + * @return array + */ + private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array + { + if (!$type->isNullable()) { + return $jsonSchema; + } + + if (\array_key_exists('$ref', $jsonSchema)) { + return ['anyOf' => [$jsonSchema, 'type' => 'null']]; + } + + return [...$jsonSchema, ...[ + 'type' => \is_array($jsonSchema['type']) + ? array_merge($jsonSchema['type'], ['null']) + : [$jsonSchema['type'], 'null'], + ]]; + } +} diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 00188d1f431..521880c7329 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -24,7 +24,6 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; @@ -42,8 +41,12 @@ final class SchemaFactory implements SchemaFactoryInterface public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link'; public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - public function __construct(private readonly TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $resourceClassResolver = null) + public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $resourceClassResolver = null) { + if ($typeFactory) { + trigger_deprecation('api-platform/core', '3.2', sprintf('The "%s" is not needed anymore and will not be used anymore.', TypeFactoryInterface::class)); + } + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->resourceClassResolver = $resourceClassResolver; } @@ -144,7 +147,6 @@ public function buildSchema(string $className, string $format = 'json', string $ private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format): void { $version = $schema->getVersion(); - $swagger = Schema::VERSION_SWAGGER === $version; if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { $additionalPropertySchema = $propertyMetadata->getOpenapiContext(); } else { @@ -156,74 +158,49 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $additionalPropertySchema ?? [] ); - if (false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) { - $propertySchema['readOnly'] = true; - } - if (!$swagger && false === $propertyMetadata->isReadable()) { - $propertySchema['writeOnly'] = true; - } - if (null !== $description = $propertyMetadata->getDescription()) { - $propertySchema['description'] = $description; - } - - $deprecationReason = $propertyMetadata->getDeprecationReason(); - - // see https://github.com/json-schema-org/json-schema-spec/pull/737 - if (!$swagger && null !== $deprecationReason) { - $propertySchema['deprecated'] = true; - } - // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it - // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4 - $iri = $propertyMetadata->getTypes()[0] ?? null; - if (null !== $iri) { - $propertySchema['externalDocs'] = ['url' => $iri]; - } + $types = $propertyMetadata->getBuiltinTypes() ?? []; - // TODO: 3.0 support multiple types - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + // never override the following keys if at least one is already set + // or if property has no type(s) defined + // or if property schema is already fully defined (type=string + format || enum) + $propertySchemaType = $propertySchema['type'] ?? false; + if ([] === $types + || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) + || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType) + || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false) + ) { + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); - if (!isset($propertySchema['default']) && !empty($default = $propertyMetadata->getDefault()) && (null === $type?->getClassName() || !$this->isResourceClass($type->getClassName()))) { - if ($default instanceof \BackedEnum) { - $default = $default->value; - } - $propertySchema['default'] = $default; + return; } - if (!isset($propertySchema['example']) && !empty($example = $propertyMetadata->getExample())) { - $propertySchema['example'] = $example; - } + // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref) + // complete property schema with resource reference ($ref) only if it's related to an object - if (!isset($propertySchema['example']) && isset($propertySchema['default'])) { - $propertySchema['example'] = $propertySchema['default']; - } + $version = $schema->getVersion(); + $subSchema = new Schema($version); + $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema - $valueSchema = []; - if (null !== $type) { - if ($isCollection = $type->isCollection()) { - $keyType = $type->getCollectionKeyTypes()[0] ?? null; + foreach ($types as $type) { + if ($type->isCollection()) { $valueType = $type->getCollectionValueTypes()[0] ?? null; } else { - $keyType = null; $valueType = $type; } - if (null === $valueType) { - $builtinType = 'string'; - $className = null; - } else { - $builtinType = $valueType->getBuiltinType(); - $className = $valueType->getClassName(); + $className = $valueType?->getClassName(); + if (null === $className || !$this->isResourceClass($className)) { + continue; } - $valueSchema = $this->typeFactory->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $format, $propertyMetadata->isReadableLink(), $serializerContext, $schema); + $subSchema = $this->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + $propertySchema['anyOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']]; + // prevent "type" and "anyOf" conflict + unset($propertySchema['type']); + break; } - if (\array_key_exists('type', $propertySchema) && \array_key_exists('$ref', $valueSchema)) { - $propertySchema = new \ArrayObject($propertySchema); - } else { - $propertySchema = new \ArrayObject($propertySchema + $valueSchema); - } - $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema; + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); } private function buildDefinitionName(string $className, string $format = 'json', string $inputOrOutputClass = null, Operation $operation = null, array $serializerContext = null): string diff --git a/tests/Fixtures/DummyResourceImplementation.php b/src/JsonSchema/Tests/Fixtures/DummyResourceImplementation.php similarity index 90% rename from tests/Fixtures/DummyResourceImplementation.php rename to src/JsonSchema/Tests/Fixtures/DummyResourceImplementation.php index e96d7237acb..79fa0c3a016 100644 --- a/tests/Fixtures/DummyResourceImplementation.php +++ b/src/JsonSchema/Tests/Fixtures/DummyResourceImplementation.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures; +namespace ApiPlatform\JsonSchema\Tests\Fixtures; class DummyResourceImplementation implements DummyResourceInterface { diff --git a/tests/Fixtures/DummyResourceInterface.php b/src/JsonSchema/Tests/Fixtures/DummyResourceInterface.php similarity index 87% rename from tests/Fixtures/DummyResourceInterface.php rename to src/JsonSchema/Tests/Fixtures/DummyResourceInterface.php index 52419b72e2e..b0601a80610 100644 --- a/tests/Fixtures/DummyResourceInterface.php +++ b/src/JsonSchema/Tests/Fixtures/DummyResourceInterface.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures; +namespace ApiPlatform\JsonSchema\Tests\Fixtures; interface DummyResourceInterface { diff --git a/src/JsonSchema/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php b/src/JsonSchema/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php new file mode 100644 index 00000000000..1af958aaaf2 --- /dev/null +++ b/src/JsonSchema/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema\Tests\Fixtures; + +/** + * This class is not mapped as an API resource. + * It intends to test union and intersect types. + * + * @author Vincent Chalamon + */ +class NotAResourceWithUnionIntersectTypes +{ + public function __construct( + private $ignoredProperty, + private string|int|float|null $unionType, + private Serializable&DummyResourceInterface $intersectType + ) { + } + + public function getIgnoredProperty() + { + return $this->ignoredProperty; + } + + public function getUnionType() + { + return $this->unionType; + } + + public function getIntersectType() + { + return $this->intersectType; + } +} diff --git a/src/JsonSchema/Tests/Fixtures/Serializable.php b/src/JsonSchema/Tests/Fixtures/Serializable.php new file mode 100644 index 00000000000..028ac022971 --- /dev/null +++ b/src/JsonSchema/Tests/Fixtures/Serializable.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema\Tests\Fixtures; + +interface Serializable +{ + public function __serialize(): array; + + public function __unserialize(array $data); +} diff --git a/src/JsonSchema/Tests/SchemaFactoryTest.php b/src/JsonSchema/Tests/SchemaFactoryTest.php index 806db5dbd63..4d6dbf412e9 100644 --- a/src/JsonSchema/Tests/SchemaFactoryTest.php +++ b/src/JsonSchema/Tests/SchemaFactoryTest.php @@ -16,9 +16,11 @@ use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\JsonSchema\Tests\Fixtures\ApiResource\OverriddenOperationDummy; +use ApiPlatform\JsonSchema\Tests\Fixtures\DummyResourceInterface; use ApiPlatform\JsonSchema\Tests\Fixtures\Enum\GenderTypeEnum; use ApiPlatform\JsonSchema\Tests\Fixtures\NotAResource; -use ApiPlatform\JsonSchema\TypeFactoryInterface; +use ApiPlatform\JsonSchema\Tests\Fixtures\NotAResourceWithUnionIntersectTypes; +use ApiPlatform\JsonSchema\Tests\Fixtures\Serializable; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Operations; @@ -41,40 +43,38 @@ class SchemaFactoryTest extends TestCase public function testBuildSchemaForNonResourceClass(): void { - $typeFactoryProphecy = $this->prophesize(TypeFactoryInterface::class); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING) - ), Argument::cetera())->willReturn([ - 'type' => 'string', - ]); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_INT) - ), Argument::cetera())->willReturn([ - 'type' => 'integer', - ]); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_OBJECT) - ), Argument::cetera())->willReturn([ - 'type' => 'object', - ]); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar', 'genderType'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withReadable(true)->withDefault('default_bar')->withExample('example_bar')); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'genderType', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT)])->withReadable(true)->withDefault(GenderTypeEnum::MALE)); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withReadable(true) + ->withDefault('default_bar') + ->withExample('example_bar') + ->withSchema(['type' => 'integer', 'default' => 'default_bar', 'example' => 'example_bar']) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'genderType', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT)]) + ->withReadable(true) + ->withDefault('male') + ->withSchema(['type' => 'object', 'default' => 'male', 'example' => 'male']) + ); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); $resultSchema = $schemaFactory->buildSchema(NotAResource::class); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); @@ -109,22 +109,71 @@ public function testBuildSchemaForNonResourceClass(): void $this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['example']); } - public function testBuildSchemaWithSerializerGroups(): void + public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): void { - $typeFactoryProphecy = $this->prophesize(TypeFactoryInterface::class); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING) - ), Argument::cetera())->willReturn([ - 'type' => 'string', - ]); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_OBJECT) - ), Argument::cetera())->willReturn([ - 'type' => 'object', - ]); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, Argument::cetera())->willReturn(new PropertyNameCollection(['ignoredProperty', 'unionType', 'intersectType'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'ignoredProperty', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING, nullable: true)]) + ->withReadable(true) + ->withSchema(['type' => ['string', 'null']]) + ); + $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'unionType', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING, nullable: true), new Type(Type::BUILTIN_TYPE_INT, nullable: true), new Type(Type::BUILTIN_TYPE_FLOAT, nullable: true)]) + ->withReadable(true) + ->withSchema(['oneOf' => [ + ['type' => ['string', 'null']], + ['type' => ['integer', 'null']], + ]]) + ); + $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'intersectType', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, class: Serializable::class), new Type(Type::BUILTIN_TYPE_OBJECT, class: DummyResourceInterface::class)]) + ->withReadable(true) + ->withSchema(['type' => 'object']) + ); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(NotAResourceWithUnionIntersectTypes::class)->willReturn(false); + + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $resultSchema = $schemaFactory->buildSchema(NotAResourceWithUnionIntersectTypes::class); + + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $definitions = $resultSchema->getDefinitions(); + + $this->assertSame((new \ReflectionClass(NotAResourceWithUnionIntersectTypes::class))->getShortName(), $rootDefinitionKey); + // @noRector + $this->assertTrue(isset($definitions[$rootDefinitionKey])); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); + $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); + $this->assertArrayNotHasKey('additionalProperties', $definitions[$rootDefinitionKey]); + $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); + + $this->assertArrayHasKey('ignoredProperty', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['ignoredProperty']); + $this->assertSame(['string', 'null'], $definitions[$rootDefinitionKey]['properties']['ignoredProperty']['type']); + + $this->assertArrayHasKey('unionType', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('oneOf', $definitions[$rootDefinitionKey]['properties']['unionType']); + $this->assertCount(2, $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][0]); + $this->assertSame(['string', 'null'], $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][0]['type']); + $this->assertSame(['integer', 'null'], $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][1]['type']); + + $this->assertArrayHasKey('intersectType', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['intersectType']); + $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['intersectType']['type']); + } + + public function testBuildSchemaWithSerializerGroups(): void + { $shortName = (new \ReflectionClass(OverriddenOperationDummy::class))->getShortName(); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $operation = (new Put())->withName('put')->withNormalizationContext([ @@ -144,15 +193,31 @@ public function testBuildSchemaWithSerializerGroups(): void $propertyNameCollectionFactoryProphecy->create(OverriddenOperationDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['alias', 'description', 'genderType'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)])->withReadable(true)->withDefault(GenderTypeEnum::MALE)); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'alias', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)]) + ->withReadable(true) + ->withDefault(GenderTypeEnum::MALE) + ->withSchema(['type' => 'object']) + ); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(OverriddenOperationDummy::class)->willReturn(true); $resourceClassResolverProphecy->isResourceClass(GenderTypeEnum::class)->willReturn(true); - $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); $resultSchema = $schemaFactory->buildSchema(OverriddenOperationDummy::class, 'json', Schema::TYPE_OUTPUT, null, null, ['groups' => $serializerGroup, AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false]); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); @@ -180,46 +245,29 @@ public function testBuildSchemaWithSerializerGroups(): void public function testBuildSchemaForAssociativeArray(): void { - $typeFactoryProphecy = $this->prophesize(TypeFactoryInterface::class); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING), - Argument::which('isCollection', true), - Argument::that(function (Type $type): bool { - $keyTypes = $type->getCollectionKeyTypes(); - - return 1 === \count($keyTypes) && $keyTypes[0] instanceof Type && Type::BUILTIN_TYPE_INT === $keyTypes[0]->getBuiltinType(); - }) - ), Argument::cetera())->willReturn([ - 'type' => 'array', - ]); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING), - Argument::which('isCollection', true), - Argument::that(function (Type $type): bool { - $keyTypes = $type->getCollectionKeyTypes(); - - return 1 === \count($keyTypes) && $keyTypes[0] instanceof Type && Type::BUILTIN_TYPE_STRING === $keyTypes[0]->getBuiltinType(); - }) - ), Argument::cetera())->willReturn([ - 'type' => 'object', - 'additionalProperties' => Type::BUILTIN_TYPE_STRING, - ]); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING))])->withReadable(true)); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_STRING))])->withReadable(true)); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING))]) + ->withReadable(true) + ->withSchema(['type' => 'array', 'items' => ['string', 'int']]) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_STRING))]) + ->withReadable(true) + ->withSchema(['type' => 'object', 'additionalProperties' => 'string']) + ); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); $resultSchema = $schemaFactory->buildSchema(NotAResource::class); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index cc4028929b0..9f0c9e5a476 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -219,10 +219,10 @@ public function withUriTemplate(string $uriTemplate): self return $self; } - public function getTypes(): ?array - { - return $this->types; - } + public function getTypes(): ?array + { + return $this->types; + } /** * @param string[]|string $types diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index a5adfa9b3a5..0b409ac8c34 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -24,6 +24,12 @@ abstract class Metadata * @param string|null $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties * @param string|null $security https://api-platform.com/docs/core/security * @param string|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization + * @param mixed|null $mercure + * @param mixed|null $messenger + * @param mixed|null $input + * @param mixed|null $output + * @param mixed|null $provider + * @param mixed|null $processor */ public function __construct( protected ?string $shortName = null, diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index d42b8d7f4bd..ef0f660f8b2 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -58,12 +58,17 @@ public function create(string $resourceClass, string $property, array $options = } $propertyMetadata = $this->transformReadWrite($propertyMetadata, $resourceClass, $property, $normalizationGroups, $denormalizationGroups); + $types = $propertyMetadata->getBuiltinTypes() ?? []; - if (!$this->isResourceClass($resourceClass) && ($builtinType = $propertyMetadata->getBuiltinTypes()[0] ?? null) && $builtinType->isCollection()) { - return $propertyMetadata->withReadableLink(true)->withWritableLink(true); + if (!$this->isResourceClass($resourceClass) && $types) { + foreach ($types as $builtinType) { + if ($builtinType->isCollection()) { + return $propertyMetadata->withReadableLink(true)->withWritableLink(true); + } + } } - return $this->transformLinkStatus($propertyMetadata, $normalizationGroups, $denormalizationGroups); + return $this->transformLinkStatus($propertyMetadata, $normalizationGroups, $denormalizationGroups, $types); } /** @@ -101,48 +106,45 @@ private function transformReadWrite(ApiProperty $propertyMetadata, string $resou * @param string[]|null $normalizationGroups * @param string[]|null $denormalizationGroups */ - private function transformLinkStatus(ApiProperty $propertyMetadata, array $normalizationGroups = null, array $denormalizationGroups = null): ApiProperty + private function transformLinkStatus(ApiProperty $propertyMetadata, array $normalizationGroups = null, array $denormalizationGroups = null, array $types = null): ApiProperty { // No need to check link status if property is not readable and not writable if (false === $propertyMetadata->isReadable() && false === $propertyMetadata->isWritable()) { return $propertyMetadata; } - // TODO: 3.0 support multiple types, default value of types will be [] instead of null - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - - if (null === $type) { - return $propertyMetadata; - } + foreach ($types as $type) { + if ( + $type->isCollection() + && $collectionValueType = $type->getCollectionValueTypes()[0] ?? null + ) { + $relatedClass = $collectionValueType->getClassName(); + } else { + $relatedClass = $type->getClassName(); + } - if ( - $type->isCollection() - && $collectionValueType = $type->getCollectionValueTypes()[0] ?? null - ) { - $relatedClass = $collectionValueType->getClassName(); - } else { - $relatedClass = $type->getClassName(); - } + // if property is not a resource relation, don't set link status (as it would have no meaning) + if (null === $relatedClass || !$this->isResourceClass($relatedClass)) { + continue; + } - // if property is not a resource relation, don't set link status (as it would have no meaning) - if (null === $relatedClass || !$this->isResourceClass($relatedClass)) { - return $propertyMetadata; - } + // find the resource class + // this prevents serializer groups on non-resource child class from incorrectly influencing the decision + if (null !== $this->resourceClassResolver) { + $relatedClass = $this->resourceClassResolver->getResourceClass(null, $relatedClass); + } - // find the resource class - // this prevents serializer groups on non-resource child class from incorrectly influencing the decision - if (null !== $this->resourceClassResolver) { - $relatedClass = $this->resourceClassResolver->getResourceClass(null, $relatedClass); - } + $relatedGroups = $this->getClassSerializerGroups($relatedClass); - $relatedGroups = $this->getClassSerializerGroups($relatedClass); + if (null === $propertyMetadata->isReadableLink()) { + $propertyMetadata = $propertyMetadata->withReadableLink(null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))); + } - if (null === $propertyMetadata->isReadableLink()) { - $propertyMetadata = $propertyMetadata->withReadableLink(null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))); - } + if (null === $propertyMetadata->isWritableLink()) { + $propertyMetadata = $propertyMetadata->withWritableLink(null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))); + } - if (null === $propertyMetadata->isWritableLink()) { - $propertyMetadata = $propertyMetadata->withWritableLink(null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))); + return $propertyMetadata; } return $propertyMetadata; diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index 2ea1637e941..8d4c5dfef0a 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -254,34 +254,116 @@ public function testInvoke(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withDescription('This is an id.')->withReadable(true)->withWritable(false)->withIdentifier(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withDescription('This is an id.') + ->withReadable(true) + ->withWritable(false) + ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'readOnly' => true, 'description' => 'This is an id.']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is a name.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is a name.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$', 'description' => 'This is a name.', 'type' => 'string']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an initializable but not writable property.')->withReadable(true)->withWritable(false)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withInitializable(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an initializable but not writable property.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an initializable but not writable property.']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)])->withDescription('This is a \DateTimeInterface object.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) + ->withDescription('This is a \DateTimeInterface object.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['type' => ['string', 'null'], 'description' => 'This is a \DateTimeInterface object.', 'format' => 'date-time']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'enum', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an enum.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an enum.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['type' => 'string', 'description' => 'This is an enum.']) + ->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'id', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withDescription('This is an id.')->withReadable(true)->withWritable(false)->withIdentifier(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withDescription('This is an id.') + ->withReadable(true) + ->withWritable(false) + ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'name', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is a name.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is a name.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['type' => 'string', 'description' => 'This is a name.', 'minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'description', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an initializable but not writable property.')->withReadable(true)->withWritable(false)->withReadableLink(true)->withWritableLink(true)->withInitializable(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an initializable but not writable property.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an initializable but not writable property.']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)])->withDescription('This is a \DateTimeInterface object.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) + ->withDescription('This is a \DateTimeInterface object.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withSchema(['type' => ['string', 'null'], 'format' => 'date-time', 'description' => 'This is a \DateTimeInterface object.']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'enum', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an enum.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an enum.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withSchema(['type' => 'string', 'description' => 'This is an enum.']) + ->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) ); $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); @@ -329,8 +411,9 @@ public function testInvoke(): void $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + $schemaFactory = new SchemaFactory(null, $resourceCollectionMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory = new TypeFactory(); - $schemaFactory = new SchemaFactory($typeFactory, $resourceCollectionMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $typeFactory->setSchemaFactory($schemaFactory); $factory = new OpenApiFactory( @@ -379,10 +462,9 @@ public function testInvoke(): void 'description' => 'This is an initializable but not writable property.', ]), 'dummy_date' => new \ArrayObject([ - 'type' => 'string', + 'type' => ['string', 'null'], 'description' => 'This is a \DateTimeInterface object.', 'format' => 'date-time', - 'nullable' => true, ]), 'enum' => new \ArrayObject([ 'type' => 'string', diff --git a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php index dfe676b3a29..2787fd89faa 100644 --- a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php +++ b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php @@ -150,6 +150,7 @@ public function testNormalize(): void ->withReadable(true) ->withWritable(false) ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) @@ -161,7 +162,7 @@ public function testNormalize(): void ->withWritableLink(true) ->withRequired(false) ->withIdentifier(false) - ->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) + ->withSchema(['type' => 'string', 'description' => 'This is a name.', 'minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) @@ -173,6 +174,7 @@ public function testNormalize(): void ->withWritableLink(true) ->withRequired(false) ->withIdentifier(false) + ->withSchema(['type' => 'string', 'readOnly' => true, 'description' => 'This is an initializable but not writable property.']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) @@ -184,6 +186,7 @@ public function testNormalize(): void ->withWritableLink(true) ->withRequired(false) ->withIdentifier(false) + ->withSchema(['type' => 'string', 'format' => 'date-time', 'description' => 'This is a \DateTimeInterface object.']) ); $propertyMetadataFactoryProphecy->create('Zorro', 'id', Argument::any())->shouldBeCalled()->willReturn( @@ -193,6 +196,7 @@ public function testNormalize(): void ->withReadable(true) ->withWritable(false) ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); @@ -200,8 +204,9 @@ public function testNormalize(): void $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory = new TypeFactory(); - $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $typeFactory->setSchemaFactory($schemaFactory); $factory = new OpenApiFactory( diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index ffa7fa02546..8464dd69e8c 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -601,44 +601,63 @@ protected function getAttributeValue(object $object, string $attribute, string $ return $attributeValue; } - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + $types = $propertyMetadata->getBuiltinTypes() ?? []; - if ( - $type - && $type->isCollection() - && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null) - && ($className = $collectionValueType->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - if (!is_iterable($attributeValue)) { - throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.'); + foreach ($types as $type) { + if ( + $type->isCollection() + && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null) + && ($className = $collectionValueType->getClassName()) + && $this->resourceClassResolver->isResourceClass($className) + ) { + if (!is_iterable($attributeValue)) { + throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.'); + } + + $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); + $childContext = $this->createChildContext($context, $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); + + return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); } - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); + if ( + ($className = $type->getClassName()) + && $this->resourceClassResolver->isResourceClass($className) + ) { + if (!\is_object($attributeValue) && null !== $attributeValue) { + throw new UnexpectedValueException('Unexpected non-object value for to-one relation.'); + } - return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); - } + $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); + $childContext = $this->createChildContext($context, $attribute, $format); + $childContext['resource_class'] = $resourceClass; + if ($this->resourceMetadataCollectionFactory) { + $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); + } + unset($childContext['iri'], $childContext['uri_variables']); - if ( - $type - && ($className = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - if (!\is_object($attributeValue) && null !== $attributeValue) { - throw new UnexpectedValueException('Unexpected non-object value for to-one relation.'); + return $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); } - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - $childContext['resource_class'] = $resourceClass; - if ($this->resourceMetadataCollectionFactory) { - $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); + if (!$this->serializer instanceof NormalizerInterface) { + throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); } - unset($childContext['iri'], $childContext['uri_variables']); - return $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); + if ($type->getClassName()) { + $childContext = $this->createChildContext($context, $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['force_resource_class']); + $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true; + + return $this->serializer->normalize($attributeValue, $format, $childContext); + } + + if ('array' === $type->getBuiltinType()) { + $childContext = $this->createChildContext($context, $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables']); + + return $this->serializer->normalize($attributeValue, $format, $childContext); + } } if (!$this->serializer instanceof NormalizerInterface) { @@ -648,21 +667,6 @@ protected function getAttributeValue(object $object, string $attribute, string $ unset($context['resource_class']); unset($context['force_resource_class']); - if ($type && $type->getClassName()) { - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables']); - $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true; - - return $this->serializer->normalize($attributeValue, $format, $childContext); - } - - if ($type && 'array' === $type->getBuiltinType()) { - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables']); - - return $this->serializer->normalize($attributeValue, $format, $childContext); - } - return $this->serializer->normalize($attributeValue, $format, $context); } @@ -746,122 +750,146 @@ private function createAttributeValue(string $attribute, mixed $value, string $f private function createAndValidateAttributeValue(string $attribute, mixed $value, string $format = null, array $context = []): mixed { $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + $types = $propertyMetadata->getBuiltinTypes() ?? []; + $isMultipleTypes = \count($types) > 1; - if (null === $type) { - // No type provided, blindly return the value - return $value; - } + foreach ($types as $type) { + if (null === $value && ($type->isNullable() || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false))) { + return $value; + } - if (null === $value && ($type->isNullable() || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false))) { - return $value; - } + $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; + /* From @see AbstractObjectNormalizer::validateAndDenormalize() */ + // Fix a collection that contains the only one element + // This is special to xml format only + if ('xml' === $format && null !== $collectionValueType && (!\is_array($value) || !\is_int(key($value)))) { + $value = [$value]; + } - /* From @see AbstractObjectNormalizer::validateAndDenormalize() */ - // Fix a collection that contains the only one element - // This is special to xml format only - if ('xml' === $format && null !== $collectionValueType && (!\is_array($value) || !\is_int(key($value)))) { - $value = [$value]; - } + if ( + $type->isCollection() + && null !== $collectionValueType + && null !== ($className = $collectionValueType->getClassName()) + && $this->resourceClassResolver->isResourceClass($className) + ) { + $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); + $context['resource_class'] = $resourceClass; - if ( - $type->isCollection() - && null !== $collectionValueType - && null !== ($className = $collectionValueType->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); - $context['resource_class'] = $resourceClass; + return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $resourceClass, $value, $format, $context); + } - return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $resourceClass, $value, $format, $context); - } + if ( + null !== ($className = $type->getClassName()) + && $this->resourceClassResolver->isResourceClass($className) + ) { + $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); + $childContext = $this->createChildContext($context, $attribute, $format); + $childContext['resource_class'] = $resourceClass; + if ($this->resourceMetadataCollectionFactory) { + $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); + } - if ( - null !== ($className = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - $childContext['resource_class'] = $resourceClass; - if ($this->resourceMetadataCollectionFactory) { - $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); + return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext); } - return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext); - } + if ( + $type->isCollection() + && null !== $collectionValueType + && null !== ($className = $collectionValueType->getClassName()) + && \is_array($value) + ) { + if (!$this->serializer instanceof DenormalizerInterface) { + throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); + } - if ( - $type->isCollection() - && null !== $collectionValueType - && null !== ($className = $collectionValueType->getClassName()) - ) { - if (!$this->serializer instanceof DenormalizerInterface) { - throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); + unset($context['resource_class']); + + return $this->serializer->denormalize($value, $className.'[]', $format, $context); } - unset($context['resource_class']); + if (null !== $className = $type->getClassName()) { + if (!$this->serializer instanceof DenormalizerInterface) { + throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); + } - return $this->serializer->denormalize($value, $className.'[]', $format, $context); - } + unset($context['resource_class']); - if (null !== $className = $type->getClassName()) { - if (!$this->serializer instanceof DenormalizerInterface) { - throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); + return $this->serializer->denormalize($value, $className, $format, $context); } - unset($context['resource_class']); + /* From @see AbstractObjectNormalizer::validateAndDenormalize() */ + // In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine, + // if a value is meant to be a string, float, int or a boolean value from the serialized representation. + // That's why we have to transform the values, if one of these non-string basic datatypes is expected. + if (\is_string($value) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) { + if ('' === $value && $type->isNullable() && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_BOOL, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true)) { + return null; + } - return $this->serializer->denormalize($value, $className, $format, $context); - } + switch ($type->getBuiltinType()) { + case Type::BUILTIN_TYPE_BOOL: + // according to http://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1" + if ('false' === $value || '0' === $value) { + $value = false; + } elseif ('true' === $value || '1' === $value) { + $value = true; + } else { + // union/intersect types: try the next type, if not valid, an exception will be thrown at the end + if ($isMultipleTypes) { + break 2; + } + throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? null); + } + break; + case Type::BUILTIN_TYPE_INT: + if (ctype_digit($value) || ('-' === $value[0] && ctype_digit(substr($value, 1)))) { + $value = (int) $value; + } else { + // union/intersect types: try the next type, if not valid, an exception will be thrown at the end + if ($isMultipleTypes) { + break 2; + } + throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? null); + } + break; + case Type::BUILTIN_TYPE_FLOAT: + if (is_numeric($value)) { + return (float) $value; + } - /* From @see AbstractObjectNormalizer::validateAndDenormalize() */ - // In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine, - // if a value is meant to be a string, float, int or a boolean value from the serialized representation. - // That's why we have to transform the values, if one of these non-string basic datatypes is expected. - if (\is_string($value) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) { - if ('' === $value && $type->isNullable() && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_BOOL, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true)) { - return null; + switch ($value) { + case 'NaN': + return \NAN; + case 'INF': + return \INF; + case '-INF': + return -\INF; + default: + // union/intersect types: try the next type, if not valid, an exception will be thrown at the end + if ($isMultipleTypes) { + break 3; + } + throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null); + } + } } - switch ($type->getBuiltinType()) { - case Type::BUILTIN_TYPE_BOOL: - // according to http://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1" - if ('false' === $value || '0' === $value) { - $value = false; - } elseif ('true' === $value || '1' === $value) { - $value = true; - } else { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? null); - } - break; - case Type::BUILTIN_TYPE_INT: - if (ctype_digit($value) || ('-' === $value[0] && ctype_digit(substr($value, 1)))) { - $value = (int) $value; - } else { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? null); - } - break; - case Type::BUILTIN_TYPE_FLOAT: - if (is_numeric($value)) { - return (float) $value; - } - - return match ($value) { - 'NaN' => \NAN, - 'INF' => \INF, - '-INF' => -\INF, - default => throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null), - }; + if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) { + return $value; } - } - if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) { - return $value; - } + try { + $this->validateType($attribute, $type, $value, $format, $context); - $this->validateType($attribute, $type, $value, $format, $context); + break; + } catch (NotNormalizableValueException $e) { + // union/intersect types: try the next type + if (!$isMultipleTypes) { + throw $e; + } + } + } return $value; } diff --git a/src/State/CreateProvider.php b/src/State/CreateProvider.php index ad1d6dc094e..7343b37c18d 100644 --- a/src/State/CreateProvider.php +++ b/src/State/CreateProvider.php @@ -18,7 +18,6 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\Post; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; diff --git a/src/Symfony/Bundle/Resources/config/json_schema.xml b/src/Symfony/Bundle/Resources/config/json_schema.xml index 864e12900f2..4081591327e 100644 --- a/src/Symfony/Bundle/Resources/config/json_schema.xml +++ b/src/Symfony/Bundle/Resources/config/json_schema.xml @@ -16,7 +16,7 @@ - + null @@ -31,6 +31,11 @@ + + + + + diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php index e100bd98bc1..a0d22e66f53 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php @@ -52,9 +52,15 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a $restriction['type'] = 'array'; - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if ($type) { - $restriction['items'] = ['type' => Type::BUILTIN_TYPE_STRING === $type->getBuiltinType() ? 'string' : 'number', 'enum' => $choices]; + $builtInTypes = $propertyMetadata->getBuiltinTypes() ?? []; + $types = array_unique(array_map(fn (Type $type) => Type::BUILTIN_TYPE_STRING === $type->getBuiltinType() ? 'string' : 'number', $builtInTypes)); + + if ($count = \count($types)) { + if (1 === $count) { + $types = $types[0]; + } + + $restriction['items'] = ['type' => $types, 'enum' => $choices]; } if (null !== $constraint->min) { @@ -73,6 +79,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof Choice && null !== ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_STRING, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof Choice && \count($types) && array_intersect($types, [Type::BUILTIN_TYPE_STRING, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php index 26d6cf3faf9..4ec1568b749 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php @@ -40,6 +40,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof GreaterThanOrEqual && is_numeric($constraint->value) && ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof GreaterThanOrEqual && is_numeric($constraint->value) && \count($types) && array_intersect($types, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php index e503704aef0..4fc357d390a 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php @@ -41,6 +41,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof GreaterThan && is_numeric($constraint->value) && null !== ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof GreaterThan && is_numeric($constraint->value) && \count($types) && array_intersect($types, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php index 9a9c0b871e1..c3e6095aa71 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php @@ -50,6 +50,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof Length && null !== ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && Type::BUILTIN_TYPE_STRING === $type->getBuiltinType(); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof Length && \count($types) && \in_array(Type::BUILTIN_TYPE_STRING, $types, true); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php index f2ef047a5a1..2882e7f7faf 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php @@ -40,6 +40,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof LessThanOrEqual && is_numeric($constraint->value) && null !== ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof LessThanOrEqual && is_numeric($constraint->value) && \count($types) && array_intersect($types, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php index f8d4e484897..c7354f9af80 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php @@ -41,6 +41,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof LessThan && is_numeric($constraint->value) && null !== ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof LessThan && is_numeric($constraint->value) && \count($types) && array_intersect($types, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php index cab5903b601..642d5aacc04 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php @@ -48,6 +48,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof Range && null !== ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof Range && \count($types) && array_intersect($types, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]); } } diff --git a/tests/Fixtures/Symfony/Maker/CustomStateProcessor.php b/tests/Fixtures/Symfony/Maker/CustomStateProcessor.php index 2cce1954b12..23c0a2141f6 100644 --- a/tests/Fixtures/Symfony/Maker/CustomStateProcessor.php +++ b/tests/Fixtures/Symfony/Maker/CustomStateProcessor.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + namespace App\State; use ApiPlatform\Metadata\Operation; diff --git a/tests/Fixtures/Symfony/Maker/CustomStateProvider.php b/tests/Fixtures/Symfony/Maker/CustomStateProvider.php index b4e25e12ea5..0c4ff1f494a 100644 --- a/tests/Fixtures/Symfony/Maker/CustomStateProvider.php +++ b/tests/Fixtures/Symfony/Maker/CustomStateProvider.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + namespace App\State; use ApiPlatform\Metadata\Operation; diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5452/ActivableInterface.php b/tests/Fixtures/TestBundle/ApiResource/Issue5452/ActivableInterface.php new file mode 100644 index 00000000000..fe6e41a1c32 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5452/ActivableInterface.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452; + +interface ActivableInterface +{ +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5452/Author.php b/tests/Fixtures/TestBundle/ApiResource/Issue5452/Author.php new file mode 100644 index 00000000000..d2aaa1ec3a5 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5452/Author.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\AuthorItemProvider; + +#[ApiResource( + operations: [ + new Get(uriTemplate: '/issue-5452/authors/{id}{._format}', provider: AuthorItemProvider::class), + ] +)] +class Author implements ActivableInterface, TimestampableInterface +{ + public function __construct( + #[ApiProperty(identifier: true)] + public readonly string|int $id, + public readonly string $name + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5452/Book.php b/tests/Fixtures/TestBundle/ApiResource/Issue5452/Book.php new file mode 100644 index 00000000000..2abd3f22068 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5452/Book.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452; + +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\BookCollectionProvider; + +#[GetCollection(uriTemplate: '/issue-5452/books{._format}', provider: BookCollectionProvider::class)] +#[Post(uriTemplate: '/issue-5452/books{._format}')] +class Book +{ + // union types + public string|int|null $number = null; + + // simple types + public ?string $isbn = null; + + // intersect types without specific typehint (throw an error: AbstractItemNormalizer line 872) + public ActivableInterface&TimestampableInterface $library; + + /** + * @var Author + */ + // intersect types with PHPDoc + public ActivableInterface&TimestampableInterface $author; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5452/Library.php b/tests/Fixtures/TestBundle/ApiResource/Issue5452/Library.php new file mode 100644 index 00000000000..0a7ad0a54f7 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5452/Library.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\LibraryItemProvider; + +#[ApiResource( + operations: [ + new Get(uriTemplate: '/issue-5452/libraries/{id}{._format}', provider: LibraryItemProvider::class), + ] +)] +class Library implements ActivableInterface, TimestampableInterface +{ + public function __construct( + #[ApiProperty(identifier: true)] + public readonly string|int $id, + public readonly string $name + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5452/TimestampableInterface.php b/tests/Fixtures/TestBundle/ApiResource/Issue5452/TimestampableInterface.php new file mode 100644 index 00000000000..b52501b4293 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5452/TimestampableInterface.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452; + +interface TimestampableInterface +{ +} diff --git a/tests/Fixtures/TestBundle/State/Issue5452/AuthorItemProvider.php b/tests/Fixtures/TestBundle/State/Issue5452/AuthorItemProvider.php new file mode 100644 index 00000000000..5789f098701 --- /dev/null +++ b/tests/Fixtures/TestBundle/State/Issue5452/AuthorItemProvider.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Author; + +class AuthorItemProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + return new Author(1, 'John DOE'); + } +} diff --git a/tests/Fixtures/TestBundle/State/Issue5452/BookCollectionProvider.php b/tests/Fixtures/TestBundle/State/Issue5452/BookCollectionProvider.php new file mode 100644 index 00000000000..4a5a50269ca --- /dev/null +++ b/tests/Fixtures/TestBundle/State/Issue5452/BookCollectionProvider.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Author; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Book; + +class BookCollectionProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $book = new Book(); + $book->number = 1; + $book->isbn = '978-3-16-148410-0'; + $book->author = new Author(1, 'John DOE'); + + return [$book]; + } +} diff --git a/tests/Fixtures/TestBundle/State/Issue5452/LibraryItemProvider.php b/tests/Fixtures/TestBundle/State/Issue5452/LibraryItemProvider.php new file mode 100644 index 00000000000..f6fa257debf --- /dev/null +++ b/tests/Fixtures/TestBundle/State/Issue5452/LibraryItemProvider.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Library; + +class LibraryItemProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + return new Library(1, 'Le Bâteau Livre'); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index a872291c6a6..7d388ab667d 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -429,3 +429,14 @@ services: tags: - { name: 'serializer.normalizer' } + ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\AuthorItemProvider: + tags: + - { name: 'api_platform.state_provider' } + + ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\BookCollectionProvider: + tags: + - { name: 'api_platform.state_provider' } + + ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\LibraryItemProvider: + tags: + - { name: 'api_platform.state_provider' } diff --git a/tests/Hal/JsonSchema/SchemaFactoryTest.php b/tests/Hal/JsonSchema/SchemaFactoryTest.php index 582afef8d65..70cc17da0a9 100644 --- a/tests/Hal/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hal/JsonSchema/SchemaFactoryTest.php @@ -17,7 +17,6 @@ use ApiPlatform\Hydra\JsonSchema\SchemaFactory as HydraSchemaFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory; -use ApiPlatform\JsonSchema\TypeFactoryInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; @@ -39,7 +38,6 @@ class SchemaFactoryTest extends TestCase protected function setUp(): void { - $typeFactory = $this->prophesize(TypeFactoryInterface::class); $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceMetadataFactory->create(Dummy::class)->willReturn( new ResourceMetadataCollection(Dummy::class, [ @@ -52,7 +50,7 @@ protected function setUp(): void $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); $baseSchemaFactory = new BaseSchemaFactory( - $typeFactory->reveal(), + null, $resourceMetadataFactory->reveal(), $propertyNameCollectionFactory->reveal(), $propertyMetadataFactory->reveal() diff --git a/tests/Hal/Serializer/ItemNormalizerTest.php b/tests/Hal/Serializer/ItemNormalizerTest.php index ea40dab3092..b686475f285 100644 --- a/tests/Hal/Serializer/ItemNormalizerTest.php +++ b/tests/Hal/Serializer/ItemNormalizerTest.php @@ -20,6 +20,11 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\ActivableInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Author; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Book; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Library; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\TimestampableInterface; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; @@ -161,6 +166,71 @@ public function testNormalize(): void $this->assertEquals($expected, $normalizer->normalize($dummy)); } + public function testNormalizeWithUnionIntersectTypes(): void + { + $author = new Author(id: 2, name: 'Isaac Asimov'); + $library = new Library(id: 3, name: 'Le Bâteau Livre'); + $book = new Book(); + $book->author = $author; + $book->library = $library; + + $propertyNameCollection = new PropertyNameCollection(['author', 'library']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Book::class, [])->willReturn($propertyNameCollection); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Book::class, 'author', [])->willReturn( + (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_OBJECT, false, ActivableInterface::class), + new Type(Type::BUILTIN_TYPE_OBJECT, false, TimestampableInterface::class), + ])->withReadable(true) + ); + $propertyMetadataFactoryProphecy->create(Book::class, 'library', [])->willReturn( + (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_OBJECT, false, ActivableInterface::class), + new Type(Type::BUILTIN_TYPE_OBJECT, false, TimestampableInterface::class), + ])->withReadable(true) + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($book, Argument::cetera())->willReturn('/books/1'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Book::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(ActivableInterface::class)->willReturn(false); + $resourceClassResolverProphecy->isResourceClass(TimestampableInterface::class)->willReturn(false); + $resourceClassResolverProphecy->getResourceClass($book, null)->willReturn(Book::class); + $resourceClassResolverProphecy->getResourceClass(null, Book::class)->willReturn(Book::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + + $nameConverter = $this->prophesize(NameConverterInterface::class); + $nameConverter->normalize('author', Argument::any(), Argument::any(), Argument::any())->willReturn('author'); + $nameConverter->normalize('library', Argument::any(), Argument::any(), Argument::any())->willReturn('library'); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + null, + $nameConverter->reveal() + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '_links' => [ + 'self' => [ + 'href' => '/books/1', + ], + ], + 'author' => null, + 'library' => null, + ]; + $this->assertEquals($expected, $normalizer->normalize($book)); + } + public function testNormalizeWithoutCache(): void { $relatedDummy = new RelatedDummy(); diff --git a/tests/Hydra/JsonSchema/SchemaFactoryTest.php b/tests/Hydra/JsonSchema/SchemaFactoryTest.php index b400ffbfa56..28f609db394 100644 --- a/tests/Hydra/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hydra/JsonSchema/SchemaFactoryTest.php @@ -17,7 +17,6 @@ use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory; -use ApiPlatform\JsonSchema\TypeFactoryInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; @@ -38,7 +37,6 @@ class SchemaFactoryTest extends TestCase protected function setUp(): void { - $typeFactory = $this->prophesize(TypeFactoryInterface::class); $resourceMetadataFactoryCollection = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceMetadataFactoryCollection->create(Dummy::class)->willReturn( new ResourceMetadataCollection(Dummy::class, [ @@ -53,7 +51,7 @@ protected function setUp(): void $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); $baseSchemaFactory = new BaseSchemaFactory( - $typeFactory->reveal(), + null, $resourceMetadataFactoryCollection->reveal(), $propertyNameCollectionFactory->reveal(), $propertyMetadataFactory->reveal() diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php index 44cf686459e..cd4c19929d2 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php @@ -46,7 +46,14 @@ public function testSupports(Constraint $constraint, ApiProperty $propertyMetada public function supportsProvider(): \Generator { - yield 'supported' => [new Choice(['choices' => ['a', 'b']]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]), true]; + yield 'supported string' => [new Choice(['choices' => ['a', 'b']]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]), true]; + yield 'supported int' => [new Choice(['choices' => [1, 2]]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; + yield 'supported float' => [new Choice(['choices' => [1.1, 2.2]]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; + yield 'supported string/int/float with union types' => [new Choice(['choices' => [1, 2, 1.1, 2.2, 'a', 'b']]), (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_FLOAT), + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_STRING), + ]), true]; yield 'not supported constraint' => [new Positive(), new ApiProperty(), false]; yield 'not supported type' => [new Choice(['choices' => [new \stdClass(), new \stdClass()]]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT)]), false]; @@ -80,6 +87,32 @@ public function createProvider(): \Generator yield 'multi float choice max' => [new Choice(['choices' => [1.1, 2.2, 3.3, 4.4], 'multiple' => true, 'max' => 4]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2, 3.3, 4.4]], 'maxItems' => 4]]; yield 'multi float choice min/max' => [new Choice(['choices' => [1.1, 2.2, 3.3, 4.4], 'multiple' => true, 'min' => 2, 'max' => 4]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2, 3.3, 4.4]], 'minItems' => 2, 'maxItems' => 4]]; + yield 'single string/int/float choice with union types' => [new Choice(['choices' => [1, 2, 'a', 'b', 1.1, 2.2]]), (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_STRING), + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_FLOAT), + ]), ['enum' => [1, 2, 'a', 'b', 1.1, 2.2]]]; + yield 'multi string/int/float choice with union types' => [new Choice(['choices' => [1, 2, 'a', 'b', 1.1, 2.2], 'multiple' => true]), (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_STRING), + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_FLOAT), + ]), ['type' => 'array', 'items' => ['type' => ['string', 'number'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2]]]]; + yield 'multi string/int/float choice min with union types' => [new Choice(['choices' => [1, 2, 'a', 'b', 1.1, 2.2], 'multiple' => true, 'min' => 2]), (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_STRING), + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_FLOAT), + ]), ['type' => 'array', 'items' => ['type' => ['string', 'number'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2]], 'minItems' => 2]]; + yield 'multi string/int/float choice max with union types' => [new Choice(['choices' => [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4], 'multiple' => true, 'max' => 4]), (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_STRING), + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_FLOAT), + ]), ['type' => 'array', 'items' => ['type' => ['string', 'number'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4]], 'maxItems' => 4]]; + yield 'multi string/int/float choice min/max with union types' => [new Choice(['choices' => [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4], 'multiple' => true, 'min' => 2, 'max' => 4]), (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_STRING), + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_FLOAT), + ]), ['type' => 'array', 'items' => ['type' => ['string', 'number'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4]], 'minItems' => 2, 'maxItems' => 4]]; + yield 'single choice callback' => [new Choice(['callback' => ChoiceCallback::getChoices(...)]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]), ['enum' => ['a', 'b', 'c', 'd']]]; yield 'multi choice callback' => [new Choice(['callback' => ChoiceCallback::getChoices(...), 'multiple' => true]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']]]]; } diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php index 8e0cb039655..f66fca6c98f 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php @@ -47,6 +47,7 @@ public function testSupports(Constraint $constraint, ApiProperty $propertyMetada public function supportsProvider(): \Generator { + yield 'supported int/float with union types' => [new GreaterThanOrEqual(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported int' => [new GreaterThanOrEqual(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; yield 'supported float' => [new GreaterThanOrEqual(['value' => 10.99]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported positive or zero' => [new PositiveOrZero(), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php index a292e83554b..6cd5914acdd 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php @@ -20,7 +20,6 @@ use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\GreaterThan; -use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; use Symfony\Component\Validator\Constraints\Positive; use Symfony\Component\Validator\Constraints\PositiveOrZero; @@ -48,6 +47,7 @@ public function testSupports(Constraint $constraint, ApiProperty $propertyMetada public function supportsProvider(): \Generator { + yield 'supported int/float with union types' => [new GreaterThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported int' => [new GreaterThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; yield 'supported float' => [new GreaterThan(['value' => 10.99]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported positive' => [new Positive(), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; @@ -60,6 +60,6 @@ public function testCreate(): void self::assertEquals([ 'minimum' => 10, 'exclusiveMinimum' => true, - ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThanOrEqual(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]))); + ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]))); } } diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php index 96456097a64..421e28fd190 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php @@ -47,6 +47,7 @@ public function testSupports(Constraint $constraint, ApiProperty $propertyMetada public function supportsProvider(): \Generator { + yield 'supported int/float with union types' => [new LessThanOrEqual(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported int' => [new LessThanOrEqual(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; yield 'supported float' => [new LessThanOrEqual(['value' => 10.99]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported negative or zero' => [new NegativeOrZero(), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php index 11010465073..c9125335616 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php @@ -47,6 +47,7 @@ public function testSupports(Constraint $constraint, ApiProperty $propertyMetada public function supportsProvider(): \Generator { + yield 'supported int/float with union types' => [new LessThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported int' => [new LessThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; yield 'supported float' => [new LessThan(['value' => 10.99]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported negative' => [new Negative(), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php index a464f1e0156..ad2c1246d77 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php @@ -46,6 +46,7 @@ public function testSupports(Constraint $constraint, ApiProperty $propertyMetada public function supportsProvider(): \Generator { + yield 'supported int/float with union types' => [new Range(['min' => 1, 'max' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported int' => [new Range(['min' => 1, 'max' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; yield 'supported float' => [new Range(['min' => 1, 'max' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), true];