diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index af693498a55..18ec68f475a 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -30,7 +30,7 @@ * * @author Kévin Dunglas */ -final class ContextBuilder implements AnonymousContextBuilderInterface +final class ContextBuilder implements AnonymousContextBuilderInterface, OperationContextBuilderInterface { use ClassInfoTrait; use HydraPrefixTrait; @@ -164,6 +164,38 @@ public function getAnonymousResourceContext(object $object, array $context = [], return $jsonLdContext; } + /** + * {@inheritdoc} + */ + public function getResourceContextUriFromOperation(HttpOperation $operation, ?int $referenceType = null): string + { + if (null === $referenceType) { + $referenceType = $operation->getUrlGenerationStrategy(); + } + + return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $operation->getShortName()], $referenceType ?? UrlGeneratorInterface::ABS_PATH); + } + + /** + * {@inheritdoc} + */ + public function getResourceContextFromOperation(HttpOperation $operation, string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): array + { + if (null === $shortName = $operation->getShortName()) { + return []; + } + + $context = $operation->getNormalizationContext(); + if ($context['iri_only'] ?? false) { + $context = $this->getBaseContext($referenceType); + $context[$this->getHydraPrefix($context).'member']['@type'] = '@id'; + + return $context; + } + + return $this->getResourceContextWithShortname($resourceClass, $referenceType, $shortName, $operation); + } + private function getResourceContextWithShortname(string $resourceClass, int $referenceType, string $shortName, ?HttpOperation $operation = null): array { $context = $this->getBaseContext($referenceType); diff --git a/src/JsonLd/OperationContextBuilderInterface.php b/src/JsonLd/OperationContextBuilderInterface.php new file mode 100644 index 00000000000..16a56df002a --- /dev/null +++ b/src/JsonLd/OperationContextBuilderInterface.php @@ -0,0 +1,39 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +/** + * JSON-LD context builder that is aware of the current operation. + * + * This interface extends ContextBuilderInterface with operation-aware methods + * to correctly resolve context URIs when a resource class has multiple + * ApiResource attributes with different shortNames. + * + * @author Antoine Bluchet + */ +interface OperationContextBuilderInterface extends ContextBuilderInterface +{ + /** + * Gets the URI of the resource context for a specific operation. + */ + public function getResourceContextUriFromOperation(HttpOperation $operation, ?int $referenceType = null): string; + + /** + * Gets the resource context for a specific operation. + */ + public function getResourceContextFromOperation(HttpOperation $operation, string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): array; +} diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index b0f64a4d21d..7da1e54a213 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -112,6 +112,9 @@ public function normalize(mixed $data, ?string $format = null, array $context = $metadata = []; if ($isResourceClass = $this->resourceClassResolver->isResourceClass($resourceClass) && (null === $previousResourceClass || $this->resourceClassResolver->isResourceClass($previousResourceClass))) { $resourceClass = $this->resourceClassResolver->getResourceClass($data, $previousResourceClass); + if (isset($context['operation']) && $context['operation'] instanceof HttpOperation && $context['operation']->getClass() !== $resourceClass) { + $context['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(null, false, true); + } $context = $this->initContext($resourceClass, $context); $metadata = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); } elseif ($this->contextBuilder instanceof AnonymousContextBuilderInterface) { diff --git a/src/JsonLd/Serializer/JsonLdContextTrait.php b/src/JsonLd/Serializer/JsonLdContextTrait.php index 593ed7e489b..34d7e8bbe18 100644 --- a/src/JsonLd/Serializer/JsonLdContextTrait.php +++ b/src/JsonLd/Serializer/JsonLdContextTrait.php @@ -16,6 +16,8 @@ use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\JsonLd\OperationContextBuilderInterface; +use ApiPlatform\Metadata\HttpOperation; /** * Creates and manipulates the Serializer context. @@ -37,13 +39,20 @@ private function addJsonLdContext(ContextBuilderInterface $contextBuilder, strin $context['jsonld_has_context'] = true; + $operation = $context['operation'] ?? null; + $useOperationAware = $operation instanceof HttpOperation && $contextBuilder instanceof OperationContextBuilderInterface; + if (isset($context['jsonld_embed_context'])) { - $data['@context'] = $contextBuilder->getResourceContext($resourceClass); + $data['@context'] = $useOperationAware + ? $contextBuilder->getResourceContextFromOperation($operation, $resourceClass) + : $contextBuilder->getResourceContext($resourceClass); return $data; } - $data['@context'] = $contextBuilder->getResourceContextUri($resourceClass); + $data['@context'] = $useOperationAware + ? $contextBuilder->getResourceContextUriFromOperation($operation) + : $contextBuilder->getResourceContextUri($resourceClass); return $data; } diff --git a/tests/Fixtures/TestBundle/Entity/MultiResourceEntity.php b/tests/Fixtures/TestBundle/Entity/MultiResourceEntity.php new file mode 100644 index 00000000000..1ced56a1e02 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MultiResourceEntity.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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource( + shortName: 'AdminMultiResource', + operations: [ + new Get(uriTemplate: '/admin/multi_resources/{id}'), + new GetCollection(uriTemplate: '/admin/multi_resources'), + ], +)] +#[ApiResource( + shortName: 'MultiResource', + operations: [ + new Get(uriTemplate: '/multi_resources/{id}'), + new GetCollection(uriTemplate: '/multi_resources'), + ], +)] +class MultiResourceEntity +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + public ?int $id = null; + + #[ORM\Column(length: 255)] + public string $title = ''; +} diff --git a/tests/Functional/JsonLdTest.php b/tests/Functional/JsonLdTest.php index 09c626f9350..d72176f5856 100644 --- a/tests/Functional/JsonLdTest.php +++ b/tests/Functional/JsonLdTest.php @@ -26,13 +26,14 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ItemUriTemplateWithCollection\RecipeCollection; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Bar; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiResourceEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Recipe as EntityRecipe; +use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Tools\SchemaTool; class JsonLdTest extends ApiTestCase { + use RecreateSchemaTrait; use SetupClassResourcesTrait; protected static ?bool $alwaysBootKernel = false; @@ -55,6 +56,7 @@ public static function getResources(): array ImageModuleResource::class, Recipe::class, RecipeCollection::class, + MultiResourceEntity::class, ]; } @@ -226,28 +228,41 @@ public function testItemUriTemplateWithStateOption(): void ]); } + /** + * Tests that @context uses the correct shortName when an entity has multiple ApiResource attributes. + */ + public function testMultiResourceContextUsesCorrectShortName(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + // Test the second declared ApiResource (shortName: 'MultiResource') + $response = self::createClient()->request('GET', '/multi_resources'); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/MultiResource', + ]); + + // Test the first declared ApiResource (shortName: 'AdminMultiResource') + $response = self::createClient()->request('GET', '/admin/multi_resources'); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/AdminMultiResource', + ]); + } + protected function setUp(): void { self::bootKernel(); - $container = static::getContainer(); - $registry = $container->get('doctrine'); - $manager = $registry->getManager(); - if (!$manager instanceof EntityManagerInterface) { - return; - } - - $classes = []; - foreach ([Foo::class, Bar::class, EntityRecipe::class] as $entityClass) { - $classes[] = $manager->getClassMetadata($entityClass); + if ($this->isMongoDB()) { + $this->markTestSkipped('This test uses Doctrine ORM entities without MongoDB equivalents.'); } - try { - $schemaTool = new SchemaTool($manager); - @$schemaTool->createSchema($classes); - } catch (\Exception $e) { - } + $this->recreateSchema([Foo::class, Bar::class, EntityRecipe::class, MultiResourceEntity::class]); + $manager = $this->getManager(); $foo = new Foo(); $foo->title = 'Foo'; $manager->persist($foo); @@ -260,25 +275,9 @@ protected function setUp(): void $bar2 = new Bar(); $bar2->title = 'Bar two'; $manager->persist($bar2); + $multi = new MultiResourceEntity(); + $multi->title = 'Multi Resource'; + $manager->persist($multi); $manager->flush(); } - - protected function tearDown(): void - { - $container = static::getContainer(); - $registry = $container->get('doctrine'); - $manager = $registry->getManager(); - if (!$manager instanceof EntityManagerInterface) { - return; - } - - $classes = []; - foreach ([Foo::class, Bar::class] as $entityClass) { - $classes[] = $manager->getClassMetadata($entityClass); - } - - $schemaTool = new SchemaTool($manager); - @$schemaTool->dropSchema($classes); - parent::tearDown(); - } }