diff --git a/src/JsonSchema/DefinitionNameFactory.php b/src/JsonSchema/DefinitionNameFactory.php index 0d1948742a0..082d6c372dd 100644 --- a/src/JsonSchema/DefinitionNameFactory.php +++ b/src/JsonSchema/DefinitionNameFactory.php @@ -63,7 +63,19 @@ public function create(string $className, string $format = 'json', ?string $inpu $name = \sprintf('%s%s', $prefix, $definitionName ? '-'.$definitionName : $definitionName); } else { $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []); - $name = $groups ? \sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; + $attributes = (array) ($serializerContext[AbstractNormalizer::ATTRIBUTES] ?? []); + + $parts = []; + + if ($groups) { + $parts[] = implode('_', $groups); + } + + if ($attributes) { + $parts[] = $this->getAttributesAsString($attributes); + } + + $name = $parts ? \sprintf('%s-%s', $prefix, implode('_', $parts)) : $prefix; } if (false === ($serializerContext['gen_id'] ?? true)) { @@ -99,4 +111,26 @@ private function createPrefixFromClass(string $fullyQualifiedClassName, int $nam return $name; } + + private function getAttributesAsString(array $attributes): string + { + $parts = []; + + foreach ($attributes as $key => $value) { + if (\is_array($value)) { + $childString = $this->getAttributesAsString($value); + $children = explode('_', $childString); + + foreach ($children as $child) { + $parts[] = $key.'.'.$child; + } + } elseif (\is_string($key)) { + $parts[] = $key.'_'.$value; + } else { + $parts[] = $value; + } + } + + return implode('_', $parts); + } } diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index b417a449d17..e33714f8f91 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -244,6 +244,16 @@ private function buildLegacyPropertySchema(Schema $schema, string $definitionNam continue; } + $childSerializerContext = $serializerContext + [self::FORCE_SUBSCHEMA => true, 'gen_id' => $propertyMetadata->getGenId() ?? true]; + if (isset($serializerContext[AbstractNormalizer::ATTRIBUTES])) { + $attributes = $serializerContext[AbstractNormalizer::ATTRIBUTES]; + if (\is_array($attributes) && \array_key_exists($normalizedPropertyName, $attributes) && \is_array($attributes[$normalizedPropertyName])) { + $childSerializerContext[AbstractNormalizer::ATTRIBUTES] = $attributes[$normalizedPropertyName]; + } else { + unset($childSerializerContext[AbstractNormalizer::ATTRIBUTES]); + } + } + $subSchemaFactory = $this->schemaFactory ?: $this; $subSchema = $subSchemaFactory->buildSchema( $className, @@ -251,7 +261,7 @@ private function buildLegacyPropertySchema(Schema $schema, string $definitionNam $parentType, null, $subSchema, - $serializerContext + [self::FORCE_SUBSCHEMA => true, 'gen_id' => $propertyMetadata->getGenId() ?? true], + $childSerializerContext, false, ); @@ -365,6 +375,16 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $className = $valueType->getWrappedType()->getClassName(); } + $childSerializerContext = $serializerContext + [self::FORCE_SUBSCHEMA => true, 'gen_id' => $propertyMetadata->getGenId() ?? true]; + if (isset($serializerContext[AbstractNormalizer::ATTRIBUTES])) { + $attributes = $serializerContext[AbstractNormalizer::ATTRIBUTES]; + if (\is_array($attributes) && \array_key_exists($normalizedPropertyName, $attributes) && \is_array($attributes[$normalizedPropertyName])) { + $childSerializerContext[AbstractNormalizer::ATTRIBUTES] = $attributes[$normalizedPropertyName]; + } else { + unset($childSerializerContext[AbstractNormalizer::ATTRIBUTES]); + } + } + $subSchemaInstance = new Schema($version); $subSchemaInstance->setDefinitions($schema->getDefinitions()); $subSchemaFactory = $this->schemaFactory ?: $this; @@ -374,7 +394,7 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $parentType, null, $subSchemaInstance, - $serializerContext + [self::FORCE_SUBSCHEMA => true, 'gen_id' => $propertyMetadata->getGenId() ?? true], + $childSerializerContext, false, ); if (!isset($subSchemaResult['$ref'])) { @@ -448,6 +468,18 @@ private function getFactoryOptions(array $serializerContext, array $validationGr $options['denormalization_groups'] = $denormalizationGroups; } + if (isset($serializerContext[AbstractNormalizer::ATTRIBUTES])) { + $options['serializer_attributes'] = (array) $serializerContext[AbstractNormalizer::ATTRIBUTES]; + } + + if ($operation && ($normalizationAttributes = $operation->getNormalizationContext()['attributes'] ?? null)) { + $options['normalization_attributes'] = $normalizationAttributes; + } + + if ($operation && ($denormalizationAttributes = $operation->getDenormalizationContext()['attributes'] ?? null)) { + $options['denormalization_attributes'] = $denormalizationAttributes; + } + if ($validationGroups) { $options['validation_groups'] = $validationGroups; } diff --git a/src/JsonSchema/Tests/DefinitionNameFactoryTest.php b/src/JsonSchema/Tests/DefinitionNameFactoryTest.php index 90ce89c9272..d7167840f52 100644 --- a/src/JsonSchema/Tests/DefinitionNameFactoryTest.php +++ b/src/JsonSchema/Tests/DefinitionNameFactoryTest.php @@ -70,6 +70,41 @@ public static function providerDefinitions(): iterable yield ['Bar.DtoOutput.jsonapi-read_write', Dummy::class, 'jsonapi', DtoOutput::class, new Get(shortName: 'Bar'), [AbstractNormalizer::GROUPS => ['read', 'write']]]; yield ['Bar.DtoOutput.jsonhal-read_write', Dummy::class, 'jsonhal', DtoOutput::class, new Get(shortName: 'Bar'), [AbstractNormalizer::GROUPS => ['read', 'write']]]; yield ['Bar.DtoOutput.jsonld-read_write', Dummy::class, 'jsonld', DtoOutput::class, new Get(shortName: 'Bar'), [AbstractNormalizer::GROUPS => ['read', 'write']]]; + + yield ['Dummy-id', Dummy::class, 'json', null, null, [AbstractNormalizer::ATTRIBUTES => ['id']]]; + yield ['Dummy.jsonapi-id', Dummy::class, 'jsonapi', null, null, [AbstractNormalizer::ATTRIBUTES => ['id']]]; + yield ['Dummy.jsonhal-id', Dummy::class, 'jsonhal', null, null, [AbstractNormalizer::ATTRIBUTES => ['id']]]; + yield ['Dummy.jsonld-id', Dummy::class, 'jsonld', null, null, [AbstractNormalizer::ATTRIBUTES => ['id']]]; + + yield ['Dummy-id_name', Dummy::class, 'json', null, null, [AbstractNormalizer::ATTRIBUTES => ['id', 'name']]]; + yield ['Dummy.jsonapi-id_name', Dummy::class, 'jsonapi', null, null, [AbstractNormalizer::ATTRIBUTES => ['id', 'name']]]; + yield ['Dummy.jsonhal-id_name', Dummy::class, 'jsonhal', null, null, [AbstractNormalizer::ATTRIBUTES => ['id', 'name']]]; + yield ['Dummy.jsonld-id_name', Dummy::class, 'jsonld', null, null, [AbstractNormalizer::ATTRIBUTES => ['id', 'name']]]; + + yield ['Dummy-title_author.name', Dummy::class, 'json', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name']]]]; + yield ['Dummy.jsonapi-title_author.name', Dummy::class, 'jsonapi', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name']]]]; + yield ['Dummy.jsonhal-title_author.name', Dummy::class, 'jsonhal', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name']]]]; + yield ['Dummy.jsonld-title_author.name', Dummy::class, 'jsonld', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name']]]]; + + yield ['Dummy-title_author_name', Dummy::class, 'json', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author', 'name']]]; + yield ['Dummy.jsonapi-title_author_name', Dummy::class, 'jsonapi', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author', 'name']]]; + yield ['Dummy.jsonhal-title_author_name', Dummy::class, 'jsonhal', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author', 'name']]]; + yield ['Dummy.jsonld-title_author_name', Dummy::class, 'jsonld', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author', 'name']]]; + + yield ['Dummy-title_author.name_name', Dummy::class, 'json', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name'], 'name']]]; + yield ['Dummy.jsonapi-title_author.name_name', Dummy::class, 'jsonapi', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name'], 'name']]]; + yield ['Dummy.jsonhal-title_author.name_name', Dummy::class, 'jsonhal', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name'], 'name']]]; + yield ['Dummy.jsonld-title_author.name_name', Dummy::class, 'jsonld', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name'], 'name']]]; + + yield ['Dummy-title_author.name_author.id_name', Dummy::class, 'json', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name', 'id'], 'name']]]; + yield ['Dummy.jsonapi-title_author.name_author.id_name', Dummy::class, 'jsonapi', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name', 'id'], 'name']]]; + yield ['Dummy.jsonhal-title_author.name_author.id_name', Dummy::class, 'jsonhal', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name', 'id'], 'name']]]; + yield ['Dummy.jsonld-title_author.name_author.id_name', Dummy::class, 'jsonld', null, null, [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name', 'id'], 'name']]]; + + yield ['Bar.DtoOutput-title_author.name_name', Dummy::class, 'json', DtoOutput::class, new Get(shortName: 'Bar'), [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name'], 'name']]]; + yield ['Bar.DtoOutput.jsonapi-title_author.name_name', Dummy::class, 'jsonapi', DtoOutput::class, new Get(shortName: 'Bar'), [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name'], 'name']]]; + yield ['Bar.DtoOutput.jsonhal-title_author.name_name', Dummy::class, 'jsonhal', DtoOutput::class, new Get(shortName: 'Bar'), [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name'], 'name']]]; + yield ['Bar.DtoOutput.jsonld-title_author.name_name', Dummy::class, 'jsonld', DtoOutput::class, new Get(shortName: 'Bar'), [AbstractNormalizer::ATTRIBUTES => ['title', 'author' => ['name'], 'name']]]; } #[\PHPUnit\Framework\Attributes\DataProvider('providerDefinitions')] diff --git a/src/JsonSchema/Tests/Fixtures/ApiResource/ChildAttributeDummy.php b/src/JsonSchema/Tests/Fixtures/ApiResource/ChildAttributeDummy.php new file mode 100644 index 00000000000..0d3e24e58c4 --- /dev/null +++ b/src/JsonSchema/Tests/Fixtures/ApiResource/ChildAttributeDummy.php @@ -0,0 +1,62 @@ + + * + * 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\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource] +class ChildAttributeDummy +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 255)] + private ?string $name = null; + + #[ORM\Column(length: 50)] + private ?string $code = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): self + { + $this->code = $code; + + return $this; + } +} diff --git a/src/JsonSchema/Tests/Fixtures/ApiResource/ParentAttributeDummy.php b/src/JsonSchema/Tests/Fixtures/ApiResource/ParentAttributeDummy.php new file mode 100644 index 00000000000..705705e6662 --- /dev/null +++ b/src/JsonSchema/Tests/Fixtures/ApiResource/ParentAttributeDummy.php @@ -0,0 +1,84 @@ + + * + * 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\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource( + operations: [ + new Get( + normalizationContext: ['attributes' => ['title', 'child' => ['name']]] + ), + ] +)] +class ParentAttributeDummy +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 255)] + private ?string $title = null; + + #[ORM\Column(type: 'text')] + private ?string $description = null; + + #[ORM\ManyToOne(targetEntity: ChildAttributeDummy::class)] + private ?ChildAttributeDummy $child = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(string $description): self + { + $this->description = $description; + + return $this; + } + + public function getChild(): ?ChildAttributeDummy + { + return $this->child; + } + + public function setChild(?ChildAttributeDummy $child): self + { + $this->child = $child; + + return $this; + } +} diff --git a/src/JsonSchema/Tests/SchemaFactoryTest.php b/src/JsonSchema/Tests/SchemaFactoryTest.php index 78b8b4e6045..10bee202881 100644 --- a/src/JsonSchema/Tests/SchemaFactoryTest.php +++ b/src/JsonSchema/Tests/SchemaFactoryTest.php @@ -16,7 +16,9 @@ use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory; +use ApiPlatform\JsonSchema\Tests\Fixtures\ApiResource\ChildAttributeDummy; use ApiPlatform\JsonSchema\Tests\Fixtures\ApiResource\OverriddenOperationDummy; +use ApiPlatform\JsonSchema\Tests\Fixtures\ApiResource\ParentAttributeDummy; use ApiPlatform\JsonSchema\Tests\Fixtures\DummyResourceInterface; use ApiPlatform\JsonSchema\Tests\Fixtures\Enum\GenderTypeEnum; use ApiPlatform\JsonSchema\Tests\Fixtures\GenericChild; @@ -25,6 +27,7 @@ use ApiPlatform\JsonSchema\Tests\Fixtures\Serializable; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -531,6 +534,95 @@ public function testBuildSchemaWithSerializerGroups(): void $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['genderType']['type']); } + public function testBuildSchemaWithSerializerAttributes(): void + { + $shortName = (new \ReflectionClass(ParentAttributeDummy::class))->getShortName(); + $childShortName = (new \ReflectionClass(ChildAttributeDummy::class))->getShortName(); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $serializerAttributes = ['title', 'child' => ['name']]; + $operation = (new Get())->withName('get')->withNormalizationContext([ + 'attributes' => $serializerAttributes, + AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false, + ])->withShortName($shortName); + $resourceMetadataFactoryProphecy->create(ParentAttributeDummy::class) + ->willReturn( + new ResourceMetadataCollection(ParentAttributeDummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $operation])), + ]) + ); + $childOperation = (new Get())->withName('get')->withShortName($childShortName); + $resourceMetadataFactoryProphecy->create(ChildAttributeDummy::class) + ->willReturn( + new ResourceMetadataCollection(ChildAttributeDummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $childOperation])), + ]) + ); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(ParentAttributeDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['title', 'child'])); + $propertyNameCollectionFactoryProphecy->create(ChildAttributeDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(ParentAttributeDummy::class, 'title', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withNativeType(Type::string()) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(ParentAttributeDummy::class, 'child', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withNativeType(Type::object(ChildAttributeDummy::class)) + ->withReadable(true) + ->withSchema(['type' => Schema::UNKNOWN_TYPE]) + ); + $propertyMetadataFactoryProphecy->create(ChildAttributeDummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withNativeType(Type::string()) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(ParentAttributeDummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(ChildAttributeDummy::class)->willReturn(true); + + $definitionNameFactory = new DefinitionNameFactory(); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + resourceClassResolver: $resourceClassResolverProphecy->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + $resultSchema = $schemaFactory->buildSchema(ParentAttributeDummy::class, 'json', Schema::TYPE_OUTPUT, null, null, ['attributes' => $serializerAttributes, AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false]); + + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $definitions = $resultSchema->getDefinitions(); + + $this->assertSame((new \ReflectionClass(ParentAttributeDummy::class))->getShortName().'-title_child.name', $rootDefinitionKey); + $this->assertTrue(isset($definitions[$rootDefinitionKey])); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); + $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); + $this->assertFalse($definitions[$rootDefinitionKey]['additionalProperties']); + $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); + $this->assertArrayHasKey('title', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['title']); + $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['title']['type']); + $this->assertArrayHasKey('child', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('$ref', $definitions[$rootDefinitionKey]['properties']['child']); + $this->assertSame('#/definitions/ChildAttributeDummy-name', $definitions[$rootDefinitionKey]['properties']['child']['$ref']); + + $childDefinitionKey = 'ChildAttributeDummy-name'; + $this->assertTrue(isset($definitions[$childDefinitionKey])); + $this->assertArrayHasKey('type', $definitions[$childDefinitionKey]); + $this->assertSame('object', $definitions[$childDefinitionKey]['type']); + $this->assertFalse($definitions[$childDefinitionKey]['additionalProperties']); + $this->assertArrayHasKey('properties', $definitions[$childDefinitionKey]); + $this->assertArrayHasKey('name', $definitions[$childDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$childDefinitionKey]['properties']['name']); + $this->assertSame('string', $definitions[$childDefinitionKey]['properties']['name']['type']); + } + #[IgnoreDeprecations] public function testBuildSchemaForAssociativeArrayLegacy(): void { diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index 0166806eb14..9fe3187b4bc 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -57,13 +57,23 @@ public function create(string $resourceClass, string $property, array $options = $denormalizationGroups = [$denormalizationGroups]; } + [$normalizationAttributes, $denormalizationAttributes] = $this->getEffectiveSerializerAttributes($options); + + if ($normalizationAttributes && !\is_array($normalizationAttributes)) { + $normalizationAttributes = [$normalizationAttributes]; + } + + if ($denormalizationAttributes && !\is_array($denormalizationAttributes)) { + $denormalizationAttributes = [$denormalizationAttributes]; + } + $ignoredAttributes = $options['ignored_attributes'] ?? []; } catch (ResourceClassNotFoundException) { // TODO: for input/output classes, the serializer groups must be read from the actual resource class return $propertyMetadata; } - $propertyMetadata = $this->transformReadWrite($propertyMetadata, $resourceClass, $property, $normalizationGroups, $denormalizationGroups, $ignoredAttributes); + $propertyMetadata = $this->transformReadWrite($propertyMetadata, $resourceClass, $property, $normalizationGroups, $denormalizationGroups, $normalizationAttributes, $denormalizationAttributes, $ignoredAttributes); // TODO: remove in 5.x if (!method_exists(PropertyInfoExtractor::class, 'getType')) { @@ -77,18 +87,18 @@ public function create(string $resourceClass, string $property, array $options = } } - return $this->transformLinkStatusLegacy($propertyMetadata, $normalizationGroups, $denormalizationGroups, $types); + return $this->transformLinkStatusLegacy($propertyMetadata, $property, $normalizationGroups, $denormalizationGroups, $normalizationAttributes, $denormalizationAttributes, $types); } $type = $propertyMetadata->getNativeType(); if (null !== $type && !$this->isResourceClass($resourceClass) && $type->isSatisfiedBy(static fn (Type $t): bool => $t instanceof CollectionType)) { return $propertyMetadata->withReadableLink(true)->withWritableLink(true); } - return $this->transformLinkStatus($propertyMetadata, $normalizationGroups, $denormalizationGroups, $type); + return $this->transformLinkStatus($propertyMetadata, $property, $normalizationGroups, $denormalizationGroups, $normalizationAttributes, $denormalizationAttributes, $type); } /** - * Sets readable/writable based on matching normalization/denormalization groups and property's ignorance. + * Sets readable/writable based on matching normalization/denormalization groups/attributes and property's ignorance. * * A false value is never reset as it could be unreadable/unwritable for other reasons. * If normalization/denormalization groups are not specified and the property is not ignored, the property is implicitly readable/writable. @@ -96,7 +106,7 @@ public function create(string $resourceClass, string $property, array $options = * @param string[]|null $normalizationGroups * @param string[]|null $denormalizationGroups */ - private function transformReadWrite(ApiProperty $propertyMetadata, string $resourceClass, string $propertyName, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, array $ignoredAttributes = []): ApiProperty + private function transformReadWrite(ApiProperty $propertyMetadata, string $resourceClass, string $propertyName, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?array $normalizationAttributes = null, ?array $denormalizationAttributes = null, array $ignoredAttributes = []): ApiProperty { if (\in_array($propertyName, $ignoredAttributes, true)) { return $propertyMetadata->withWritable(false)->withReadable(false); @@ -107,26 +117,26 @@ private function transformReadWrite(ApiProperty $propertyMetadata, string $resou $ignored = $serializerAttributeMetadata?->isIgnored() ?? false; if (false !== $propertyMetadata->isReadable()) { - $propertyMetadata = $propertyMetadata->withReadable(!$ignored && (null === $normalizationGroups || array_intersect($normalizationGroups, $groups))); + $propertyMetadata = $propertyMetadata->withReadable(!$ignored && (null === $normalizationGroups || array_intersect($normalizationGroups, $groups)) && (null === $normalizationAttributes || $this->isPropertyInAttributes($propertyName, $normalizationAttributes))); } if (false !== $propertyMetadata->isWritable()) { - $propertyMetadata = $propertyMetadata->withWritable(!$ignored && (null === $denormalizationGroups || array_intersect($denormalizationGroups, $groups))); + $propertyMetadata = $propertyMetadata->withWritable(!$ignored && (null === $denormalizationGroups || array_intersect($denormalizationGroups, $groups)) && (null === $denormalizationAttributes || $this->isPropertyInAttributes($propertyName, $denormalizationAttributes))); } return $propertyMetadata; } /** - * Sets readableLink/writableLink based on matching normalization/denormalization groups. + * Sets readableLink/writableLink based on matching normalization/denormalization groups/attributes. * - * If normalization/denormalization groups are not specified, + * If normalization/denormalization groups/attributes are not specified, * set link status to false since embedding of resource must be explicitly enabled * * @param string[]|null $normalizationGroups * @param string[]|null $denormalizationGroups */ - private function transformLinkStatusLegacy(ApiProperty $propertyMetadata, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?array $types = null): ApiProperty + private function transformLinkStatusLegacy(ApiProperty $propertyMetadata, string $propertyName, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?array $normalizationAttributes = null, ?array $denormalizationAttributes = 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()) { @@ -157,11 +167,11 @@ private function transformLinkStatusLegacy(ApiProperty $propertyMetadata, ?array $relatedGroups = $this->getClassSerializerGroups($relatedClass); if (null === $propertyMetadata->isReadableLink()) { - $propertyMetadata = $propertyMetadata->withReadableLink(null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))); + $propertyMetadata = $propertyMetadata->withReadableLink((null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))) || (null !== $normalizationAttributes && $this->isPropertyInAttributes($propertyName, $normalizationAttributes))); } if (null === $propertyMetadata->isWritableLink()) { - $propertyMetadata = $propertyMetadata->withWritableLink(null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))); + $propertyMetadata = $propertyMetadata->withWritableLink((null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))) || (null !== $denormalizationAttributes && $this->isPropertyInAttributes($propertyName, $denormalizationAttributes))); } return $propertyMetadata; @@ -171,15 +181,15 @@ private function transformLinkStatusLegacy(ApiProperty $propertyMetadata, ?array } /** - * Sets readableLink/writableLink based on matching normalization/denormalization groups. + * Sets readableLink/writableLink based on matching normalization/denormalization groups/attributes. * - * If normalization/denormalization groups are not specified, + * If normalization/denormalization groups/attributes are not specified, * set link status to false since embedding of resource must be explicitly enabled * * @param string[]|null $normalizationGroups * @param string[]|null $denormalizationGroups */ - private function transformLinkStatus(ApiProperty $propertyMetadata, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?Type $type = null): ApiProperty + private function transformLinkStatus(ApiProperty $propertyMetadata, string $propertyName, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?array $normalizationAttributes = null, ?array $denormalizationAttributes = null, ?Type $type = null): ApiProperty { // No need to check link status if property is not readable and not writable if (false === $propertyMetadata->isReadable() && false === $propertyMetadata->isWritable()) { @@ -207,11 +217,11 @@ private function transformLinkStatus(ApiProperty $propertyMetadata, ?array $norm $relatedGroups = $this->getClassSerializerGroups($className); if (null === $propertyMetadata->isReadableLink()) { - $propertyMetadata = $propertyMetadata->withReadableLink(null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))); + $propertyMetadata = $propertyMetadata->withReadableLink((null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))) || (null !== $normalizationAttributes && $this->isPropertyInAttributes($propertyName, $normalizationAttributes))); } if (null === $propertyMetadata->isWritableLink()) { - $propertyMetadata = $propertyMetadata->withWritableLink(null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))); + $propertyMetadata = $propertyMetadata->withWritableLink((null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))) || (null !== $denormalizationAttributes && $this->isPropertyInAttributes($propertyName, $denormalizationAttributes))); } return $propertyMetadata; @@ -243,6 +253,32 @@ private function getEffectiveSerializerGroups(array $options): array return [null, null]; } + /** + * Gets the effective serializer attributes used in normalization/denormalization. + * + * Attributes are extracted in the following order: + * + * - From the "serializer_attributes" key of the $options array. + * - From metadata of the given operation ("operation_name" key). + * - From metadata of the current resource. + * + * @return (array|null)[] + */ + private function getEffectiveSerializerAttributes(array $options): array + { + if (isset($options['serializer_attributes'])) { + $attributes = (array) $options['serializer_attributes']; + + return [$attributes, $attributes]; + } + + if (\array_key_exists('normalization_attributes', $options) && \array_key_exists('denormalization_attributes', $options)) { + return [$options['normalization_attributes'] ?? null, $options['denormalization_attributes'] ?? null]; + } + + return [null, null]; + } + private function getSerializerAttributeMetadata(string $class, string $attribute): ?AttributeMetadataInterface { $serializerClassMetadata = $this->serializerClassMetadataFactory->getMetadataFor($class); @@ -272,4 +308,9 @@ private function getClassSerializerGroups(string $class): array return array_unique(array_merge(...$groups)); } + + private function isPropertyInAttributes(string $propertyName, array $attributes): bool + { + return \in_array($propertyName, $attributes, true) || \array_key_exists($propertyName, $attributes); + } } diff --git a/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php b/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php index 9aa1da51806..7b1dc62d84f 100644 --- a/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php +++ b/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php @@ -41,7 +41,7 @@ public static function groupsProvider(): array } #[\PHPUnit\Framework\Attributes\DataProvider('groupsProvider')] - public function testCreate($readGroups, $writeGroups): void + public function testCreateWithGroups($readGroups, $writeGroups): void { $serializerClassMetadataFactoryProphecy = $this->prophesize(SerializerClassMetadataFactoryInterface::class); $dummySerializerClassMetadata = new SerializerClassMetadata(Dummy::class); @@ -110,6 +110,71 @@ public function testCreate($readGroups, $writeGroups): void $this->assertFalse($actual[2]->isWritable()); } + public static function attributesProvider(): array + { + return [ + [['foo', 'relatedDummy'], ['foo']], + [['foo', 'relatedDummy' => ['name']], ['foo' => []]], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('attributesProvider')] + public function testCreateWithAttributes($readAttributes, $writeAttributes): void + { + $serializerClassMetadataFactoryProphecy = $this->prophesize(SerializerClassMetadataFactoryInterface::class); + $dummySerializerClassMetadata = new SerializerClassMetadata(Dummy::class); + $dummySerializerClassMetadata->addAttributeMetadata(new SerializerAttributeMetadata('foo')); + $dummySerializerClassMetadata->addAttributeMetadata(new SerializerAttributeMetadata('relatedDummy')); + $dummySerializerClassMetadata->addAttributeMetadata(new SerializerAttributeMetadata('notIncluded')); + $serializerClassMetadataFactoryProphecy->getMetadataFor(Dummy::class)->willReturn($dummySerializerClassMetadata); + $relatedDummySerializerClassMetadata = new SerializerClassMetadata(RelatedDummy::class); + $serializerClassMetadataFactoryProphecy->getMetadataFor(RelatedDummy::class)->willReturn($relatedDummySerializerClassMetadata); + + $context = ['normalization_attributes' => $readAttributes, 'denormalization_attributes' => $writeAttributes]; + + $decoratedProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $fooPropertyMetadata = (new ApiProperty()) + ->withNativeType(Type::nullable(Type::array())) + ->withReadable(true) + ->withWritable(true); + $decoratedProphecy->create(Dummy::class, 'foo', $context)->willReturn($fooPropertyMetadata); + $relatedDummyPropertyMetadata = (new ApiProperty()) + ->withNativeType(Type::nullable(Type::object(RelatedDummy::class))) + ->withReadable(true) + ->withWritable(true); + $decoratedProphecy->create(Dummy::class, 'relatedDummy', $context)->willReturn($relatedDummyPropertyMetadata); + $notIncludedPropertyMetadata = (new ApiProperty()) + ->withNativeType(Type::nullable(Type::string())) + ->withReadable(true) + ->withWritable(true); + $decoratedProphecy->create(Dummy::class, 'notIncluded', $context)->willReturn($notIncludedPropertyMetadata); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + $resourceClassResolverProphecy->getResourceClass(null, RelatedDummy::class)->willReturn(RelatedDummy::class); + + $serializerPropertyMetadataFactory = new SerializerPropertyMetadataFactory($serializerClassMetadataFactoryProphecy->reveal(), $decoratedProphecy->reveal(), $resourceClassResolverProphecy->reveal()); + + $actual = []; + $actual[] = $serializerPropertyMetadataFactory->create(Dummy::class, 'foo', $context); + $actual[] = $serializerPropertyMetadataFactory->create(Dummy::class, 'relatedDummy', $context); + $actual[] = $serializerPropertyMetadataFactory->create(Dummy::class, 'notIncluded', $context); + + $this->assertInstanceOf(ApiProperty::class, $actual[0]); + $this->assertTrue($actual[0]->isReadable()); + $this->assertTrue($actual[0]->isWritable()); + + $this->assertInstanceOf(ApiProperty::class, $actual[1]); + $this->assertTrue($actual[1]->isReadable()); + $this->assertFalse($actual[1]->isWritable()); + $this->assertTrue($actual[1]->isReadableLink()); + + $this->assertInstanceOf(ApiProperty::class, $actual[2]); + $this->assertFalse($actual[2]->isReadable()); + $this->assertFalse($actual[2]->isWritable()); + } + public function testCreateWithIgnoredProperty(): void { $ignoredSerializerAttributeMetadata = new SerializerAttributeMetadata('ignored'); diff --git a/tests/Fixtures/TestBundle/ApiResource/ChildAttribute.php b/tests/Fixtures/TestBundle/ApiResource/ChildAttribute.php new file mode 100644 index 00000000000..0596769f20c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ChildAttribute.php @@ -0,0 +1,62 @@ + + * + * 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; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource] +class ChildAttribute +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 255)] + private ?string $label = null; + + #[ORM\Column(length: 255)] + private ?string $hiddenData = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + public function getHiddenData(): ?string + { + return $this->hiddenData; + } + + public function setHiddenData(string $hiddenData): self + { + $this->hiddenData = $hiddenData; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/ParentAttribute.php b/tests/Fixtures/TestBundle/ApiResource/ParentAttribute.php new file mode 100644 index 00000000000..c7b263583fc --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ParentAttribute.php @@ -0,0 +1,84 @@ + + * + * 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; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource( + operations: [ + new Get( + normalizationContext: ['attributes' => ['name', 'child' => ['label']]] + ), + ] +)] +class ParentAttribute +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 255)] + private ?string $name = null; + + #[ORM\Column(type: 'text')] + private ?string $description = null; + + #[ORM\ManyToOne(targetEntity: ChildAttribute::class)] + private ?ChildAttribute $child = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(string $description): self + { + $this->description = $description; + + return $this; + } + + public function getChild(): ?ChildAttribute + { + return $this->child; + } + + public function setChild(?ChildAttribute $child): self + { + $this->child = $child; + + return $this; + } +} diff --git a/tests/Functional/JsonSchema/JsonSchemaTest.php b/tests/Functional/JsonSchema/JsonSchemaTest.php index 00887fd563e..95d53ffdf3f 100644 --- a/tests/Functional/JsonSchema/JsonSchemaTest.php +++ b/tests/Functional/JsonSchema/JsonSchemaTest.php @@ -19,9 +19,11 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Symfony\Bundle\Test\Constraint\MatchesJsonSchema; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\AggregateRating; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ChildAttribute; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Book; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5501\BrokenDocs; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5501\Related; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ParentAttribute; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Product; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ResourceWithEnumProperty; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5793\BagOfTests; @@ -66,6 +68,8 @@ public static function getResources(): array JsonSchemaResourceRelated::class, Product::class, AggregateRating::class, + ParentAttribute::class, + ChildAttribute::class, ]; } @@ -223,4 +227,33 @@ public function testGenIdFalse() $schema = $this->schemaFactory->buildSchema(Product::class, 'jsonld', Schema::TYPE_OUTPUT, $this->operationMetadataFactory->create('_api_/json-stream-products_get_collection')); $this->assertThat(['member' => [['aggregateRating' => ['ratingValue' => '1.0', 'reviewCount' => 1]]]], new MatchesJsonSchema($schema)); } + + public function testSchemaWithAttributes(): void + { + $operation = $this->operationMetadataFactory->create('_api_/parent_attributes/{id}{._format}_get'); + $context = $operation->getNormalizationContext() ?? []; + + $schema = $this->schemaFactory->buildSchema(ParentAttribute::class, 'json', Schema::TYPE_OUTPUT, $operation, null, $context); + + $parentDefinitionName = 'ParentAttribute-name_child.label'; + $this->assertArrayHasKey($parentDefinitionName, $schema['definitions']); + + $parentProperties = $schema['definitions'][$parentDefinitionName]['properties']; + + $this->assertArrayHasKey('name', $parentProperties); + $this->assertArrayHasKey('child', $parentProperties); + + $this->assertArrayNotHasKey('id', $parentProperties); + $this->assertArrayNotHasKey('description', $parentProperties); + + $childDefinitionName = 'ChildAttribute-label'; + $this->assertArrayHasKey($childDefinitionName, $schema['definitions']); + + $childProperties = $schema['definitions'][$childDefinitionName]['properties']; + + $this->assertArrayHasKey('label', $childProperties); + + $this->assertArrayNotHasKey('hiddenData', $childProperties); + $this->assertArrayNotHasKey('id', $childProperties); + } }