diff --git a/src/JsonLd/JsonStreamer/ValueTransformer/TypeValueTransformer.php b/src/JsonLd/JsonStreamer/ValueTransformer/TypeValueTransformer.php index aed1098b8fe..238a19d72b4 100644 --- a/src/JsonLd/JsonStreamer/ValueTransformer/TypeValueTransformer.php +++ b/src/JsonLd/JsonStreamer/ValueTransformer/TypeValueTransformer.php @@ -15,26 +15,56 @@ use ApiPlatform\Hydra\Collection; use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\JsonStreamer\ValueTransformer\ValueTransformerInterface; use Symfony\Component\TypeInfo\Type; final class TypeValueTransformer implements ValueTransformerInterface { + public function __construct( + private readonly ResourceClassResolverInterface $resourceClassResolver, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + ) { + } + public function transform(mixed $value, array $options = []): mixed { if ($options['_current_object'] instanceof Collection) { return 'Collection'; } - if (!isset($options['operation'])) { - throw new RuntimeException('Operation is not defined'); + $dataClass = isset($options['data']) && \is_object($options['data']) ? $options['data']::class : null; + if (($currentClass = $options['_current_object']::class) === $dataClass) { + if (!isset($options['operation'])) { + throw new RuntimeException('Operation is not defined'); + } + + return $this->getOperationType($options['operation']); + } + + if (!$this->resourceClassResolver->isResourceClass($currentClass)) { + return null; } - return $options['operation']->getShortName(); + /** @var HttpOperation $op */ + $op = $this->resourceMetadataCollectionFactory->create($currentClass)->getOperation(httpOperation: true); + + return $this->getOperationType($op); } public static function getStreamValueType(): Type { return Type::string(); } + + private function getOperationType(HttpOperation $operation): array|string + { + if (($t = $operation->getTypes()) && 1 === \count($t)) { + return $operation->getTypes()[0]; + } + + return $t ?: $operation->getShortname(); + } } diff --git a/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php b/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php index a3cba6c0246..147be2ee14d 100644 --- a/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php +++ b/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php @@ -15,6 +15,8 @@ use ApiPlatform\Hydra\Collection; use ApiPlatform\Hydra\IriTemplate; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\TypeHelper; use Symfony\Component\JsonStreamer\Mapping\PropertyMetadata; @@ -26,6 +28,8 @@ final class WritePropertyMetadataLoader implements PropertyMetadataLoaderInterfa public function __construct( private readonly PropertyMetadataLoaderInterface $loader, private readonly ResourceClassResolverInterface $resourceClassResolver, + private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ) { } @@ -47,23 +51,47 @@ public function load(string $className, array $options = [], array $context = [] return $properties; } - $properties['@id'] = new PropertyMetadata( - 'id', // virtual property - Type::mixed(), // virtual property - ['api_platform.jsonld.json_streamer.write.value_transformer.iri'], - ); + $originalClassName = TypeHelper::getClassName($context['original_type']); + $hasIri = true; + $virtualProperty = 'id'; + + foreach ($this->propertyNameCollectionFactory->create($originalClassName) as $property) { + $propertyMetadata = $this->propertyMetadataFactory->create($originalClassName, $property); + if ($propertyMetadata->isIdentifier()) { + $virtualProperty = $property; + } + + if ($className === $originalClassName) { + continue; + } + + if ($propertyMetadata->getNativeType()->isIdentifiedBy($className)) { + $hasIri = $propertyMetadata->getGenId(); + $virtualProperty = iterator_to_array($this->propertyNameCollectionFactory->create($className))[0]; + } + } + + if ($hasIri) { + $properties['@id'] = new PropertyMetadata( + $virtualProperty, // virtual property + Type::mixed(), // virtual property + ['api_platform.jsonld.json_streamer.write.value_transformer.iri'], + ); + } $properties['@type'] = new PropertyMetadata( - 'id', // virtual property + $virtualProperty, // virtual property Type::mixed(), // virtual property ['api_platform.jsonld.json_streamer.write.value_transformer.type'], ); - $originalClassName = TypeHelper::getClassName($context['original_type']); + if ($className !== $originalClassName) { + return $properties; + } if (Collection::class === $originalClassName || ($this->resourceClassResolver->isResourceClass($originalClassName) && !isset($context['generated_classes'][Collection::class]))) { $properties['@context'] = new PropertyMetadata( - 'id', // virual property + $virtualProperty, // virual property Type::string(), // virtual property ['api_platform.jsonld.json_streamer.write.value_transformer.context'], ); diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/common.xml b/src/Symfony/Bundle/Resources/config/json_streamer/common.xml index 38fe492a19d..dfa97ccb8f7 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/common.xml +++ b/src/Symfony/Bundle/Resources/config/json_streamer/common.xml @@ -19,6 +19,8 @@ + + @@ -27,6 +29,8 @@ + + diff --git a/tests/Fixtures/TestBundle/ApiResource/AggregateRating.php b/tests/Fixtures/TestBundle/ApiResource/AggregateRating.php new file mode 100644 index 00000000000..00f69b990c4 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/AggregateRating.php @@ -0,0 +1,29 @@ + + * + * 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\ApiProperty; +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource(types: 'https://schema.org/AggregateRating', operations: [])] +final class AggregateRating +{ + public function __construct( + #[ApiProperty(iris: ['https://schema.org/ratingValue'])] + public float $ratingValue, + #[ApiProperty(iris: ['https://schema.org/reviewCount'])] + public int $reviewCount, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Product.php b/tests/Fixtures/TestBundle/ApiResource/Product.php new file mode 100644 index 00000000000..de231d0ea58 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Product.php @@ -0,0 +1,46 @@ + + * + * 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\ApiProperty; +use ApiPlatform\Metadata\Get; + +#[Get( + types: ['https://schema.org/Product'], + uriTemplate: '/json-stream-products/{code}', + uriVariables: ['code'], + provider: [self::class, 'provide'], + jsonStream: true +)] +class Product +{ + #[ApiProperty(identifier: true)] + public string $code; + + #[ApiProperty(genId: false, iris: ['https://schema.org/aggregateRating'])] + public AggregateRating $aggregateRating; + + #[ApiProperty(property: 'name', iris: ['https://schema.org/name'])] + public string $name; + + public static function provide() + { + $s = new self(); + $s->code = 'test'; + $s->name = 'foo'; + $s->aggregateRating = new AggregateRating(1.0, 2); + + return $s; + } +} diff --git a/tests/Functional/JsonStreamerTest.php b/tests/Functional/JsonStreamerTest.php index 3760e707503..bf5d49b436f 100644 --- a/tests/Functional/JsonStreamerTest.php +++ b/tests/Functional/JsonStreamerTest.php @@ -14,10 +14,14 @@ namespace ApiPlatform\Tests\Functional; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\AggregateRating; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Product; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\JsonStreamResource; use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; +use Symfony\Bundle\FrameworkBundle\Controller\ControllerHelper; +use Symfony\Component\JsonStreamer\JsonStreamWriter; class JsonStreamerTest extends ApiTestCase { @@ -30,7 +34,7 @@ class JsonStreamerTest extends ApiTestCase */ public static function getResources(): array { - return [JsonStreamResource::class]; + return [JsonStreamResource::class, Product::class, AggregateRating::class]; } protected function setUp(): void @@ -98,6 +102,10 @@ protected function tearDown(): void public function testJsonStreamerJsonLd(): void { $container = static::getContainer(); + if (false === (class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class))) { + $this->markTestSkipped('JsonStreamer component not installed.'); + } + if ('mongodb' === $container->getParameter('kernel.environment')) { $this->markTestSkipped(); } @@ -123,6 +131,9 @@ public function testJsonStreamerJsonLd(): void public function testJsonStreamerCollectionJsonLd(): void { + if (false === (class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class))) { + $this->markTestSkipped('JsonStreamer component not installed.'); + } $container = static::getContainer(); if ('mongodb' === $container->getParameter('kernel.environment')) { $this->markTestSkipped(); @@ -153,6 +164,9 @@ public function testJsonStreamerCollectionJsonLd(): void public function testJsonStreamerJson(): void { + if (false === (class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class))) { + $this->markTestSkipped('JsonStreamer component not installed.'); + } $container = static::getContainer(); if ('mongodb' === $container->getParameter('kernel.environment')) { $this->markTestSkipped(); @@ -179,6 +193,9 @@ public function testJsonStreamerJson(): void public function testJsonStreamerCollectionJson(): void { + if (false === (class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class))) { + $this->markTestSkipped('JsonStreamer component not installed.'); + } $container = static::getContainer(); if ('mongodb' === $container->getParameter('kernel.environment')) { $this->markTestSkipped(); @@ -203,6 +220,9 @@ public function testJsonStreamerCollectionJson(): void public function testJsonStreamerWriteJsonLd(): void { + if (false === (class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class))) { + $this->markTestSkipped('JsonStreamer component not installed.'); + } $container = static::getContainer(); if ('mongodb' === $container->getParameter('kernel.environment')) { $this->markTestSkipped(); @@ -299,4 +319,29 @@ public function testJsonStreamerWriteJson(): void $jsonStreamResource = $manager->find(JsonStreamResource::class, $res['id']); $this->assertNotNull($jsonStreamResource); } + + public function testJsonStreamerJsonLdGenIdFalseWithDifferentTypeThenShortname(): void + { + if (false === (class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class))) { + $this->markTestSkipped('JsonStreamer component not installed.'); + } + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $buffer = ''; + ob_start(function (string $chunk) use (&$buffer): void { + $buffer .= $chunk; + }); + + self::createClient()->request('GET', '/json-stream-products/test', ['headers' => ['accept' => 'application/ld+json']]); + + ob_get_clean(); + + $res = json_decode($buffer, true); + $this->assertArrayNotHasKey('@id', $res['aggregateRating']); + $this->assertEquals('https://schema.org/AggregateRating', $res['aggregateRating']['@type']); + $this->assertEquals('https://schema.org/Product', $res['@type']); + } } diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 000d321c2d9..76264c21278 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Configuration; use Doctrine\ORM\OptimisticLockException; use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Controller\ControllerHelper; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; @@ -74,7 +75,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm $this->assertEquals([ 'title' => 'title', 'description' => 'description', - 'enable_json_streamer' => class_exists(JsonStreamWriter::class), + 'enable_json_streamer' => class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class), 'version' => '1.0.0', 'show_webby' => true, 'formats' => [