diff --git a/features/jsonld/input_output.feature b/features/jsonld/input_output.feature index 3840877de05..ab793b34065 100644 --- a/features/jsonld/input_output.feature +++ b/features/jsonld/input_output.feature @@ -374,8 +374,6 @@ Feature: JSON-LD DTO input and output """ @v3 - # Cannot generate proper IRI because DTO does not support resource yet - # todo Change member IRI to `/dummy_foos/bar` once DTO support resource Scenario: Get a collection with a custom output and itemUriTemplate, from a resource without identifier When I send a "GET" request to "/dummy_foo_collection_dtos" Then the response status code should be 200 @@ -401,7 +399,7 @@ Feature: JSON-LD DTO input and output "additionalProperties": false, "required": ["@id", "@type", "foo", "bar"], "properties": { - "@id": {"pattern": "^/.well-known/genid/.+$"}, + "@id": {"pattern": "/dummy_foos/bar"}, "@type": {"pattern": "^DummyCollectionDtoOutput$"}, "foo": {"type": "string"}, "bar": {"type": "integer"} diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index 4f6343b5511..0ee28252ce2 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -82,7 +82,7 @@ protected function getItemsData(iterable $object, ?string $format = null, array foreach ($object as $obj) { if ($iriOnly) { - $data[$hydraPrefix.'member'][] = $this->iriConverter->getIriFromResource($obj); + $data[$hydraPrefix.'member'][] = $this->iriConverter->getIriFromResource($obj, UrlGeneratorInterface::ABS_PATH, null, $context); } else { $data[$hydraPrefix.'member'][] = $this->normalizer->normalize($obj, $format, $context + ['jsonld_has_context' => true]); } diff --git a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php index 9dfc068a5ea..5d48f85ee90 100644 --- a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php @@ -224,8 +224,8 @@ public function testNormalizeIriOnlyResourceCollection(): void $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource(Foo::class, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos'); - $iriConverterProphecy->getIriFromResource($fooOne)->willReturn('/foos/1'); - $iriConverterProphecy->getIriFromResource($fooThree)->willReturn('/foos/3'); + $iriConverterProphecy->getIriFromResource($fooOne, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos/1'); + $iriConverterProphecy->getIriFromResource($fooThree, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos/3'); $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); @@ -276,8 +276,8 @@ public function testNormalizeIriOnlyEmbedContextResourceCollection(): void $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource(Foo::class, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos'); - $iriConverterProphecy->getIriFromResource($fooOne)->willReturn('/foos/1'); - $iriConverterProphecy->getIriFromResource($fooThree)->willReturn('/foos/3'); + $iriConverterProphecy->getIriFromResource($fooOne, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos/1'); + $iriConverterProphecy->getIriFromResource($fooThree, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos/3'); $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 65bb2a68e37..594dc68f5e0 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -125,6 +125,13 @@ public function getAnonymousResourceContext(object $object, array $context = [], 'groups' => [], ] ); + + $types = $context['types'] ?? null; + + if (\is_array($types) && 1 === \count($types)) { + $types = $types[0]; + } + $shortName = $operation->getShortName(); $jsonLdContext = [ @@ -134,12 +141,12 @@ public function getAnonymousResourceContext(object $object, array $context = [], $shortName, $operation ), - '@type' => $shortName, + '@type' => $types ?? $shortName, ]; if (isset($context['iri'])) { $jsonLdContext['@id'] = $context['iri']; - } elseif (true === ($context['gen_id'] ?? true) && $this->iriConverter) { + } elseif (true === ($context['gen_id'] ?? true) && $this->iriConverter && !isset($context['item_uri_template'])) { $jsonLdContext['@id'] = $this->iriConverter->getIriFromResource($object); } diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 5ee28e87d41..e4e42b9a30d 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -99,8 +99,9 @@ public function getSupportedTypes($format): array public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { $resourceClass = $this->getObjectClass($object); + $outputClass = $this->getOutputClass($context); - if ($this->getOutputClass($context)) { + if ($outputClass && !($context['item_uri_template'] ?? null)) { return parent::normalize($object, $format, $context); } @@ -127,7 +128,7 @@ public function normalize(mixed $object, ?string $format = null, array $context } // Special case: non-resource got serialized and contains a resource therefore we need to reset part of the context - if ($previousResourceClass !== $resourceClass) { + if ($previousResourceClass !== $resourceClass && $resourceClass !== $outputClass) { unset($context['operation'], $context['operation_name'], $context['output']); } diff --git a/src/JsonLd/Serializer/JsonLdContextTrait.php b/src/JsonLd/Serializer/JsonLdContextTrait.php index 79008dfe41c..593ed7e489b 100644 --- a/src/JsonLd/Serializer/JsonLdContextTrait.php +++ b/src/JsonLd/Serializer/JsonLdContextTrait.php @@ -50,7 +50,17 @@ private function addJsonLdContext(ContextBuilderInterface $contextBuilder, strin private function createJsonLdContext(AnonymousContextBuilderInterface $contextBuilder, object $object, array &$context): array { - $anonymousContext = ($context['output'] ?? []) + ['api_resource' => $context['api_resource'] ?? null]; + $anonymousContext = ($context['output'] ?? []) + [ + 'api_resource' => $context['api_resource'] ?? null, + ]; + + if (isset($context['item_uri_template'])) { + $anonymousContext['item_uri_template'] = $context['item_uri_template']; + } + + if (isset($context['types'])) { + $anonymousContext['types'] = $context['types']; + } if (isset($context[ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX])) { $anonymousContext[ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX] = $context[ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX]; diff --git a/src/Metadata/IdentifiersExtractor.php b/src/Metadata/IdentifiersExtractor.php index 02148ce38c3..7c0c8c5c98c 100644 --- a/src/Metadata/IdentifiersExtractor.php +++ b/src/Metadata/IdentifiersExtractor.php @@ -51,7 +51,11 @@ public function __construct(ResourceMetadataCollectionFactoryInterface $resource public function getIdentifiersFromItem(object $item, ?Operation $operation = null, array $context = []): array { if (!$this->isResourceClass($this->getObjectClass($item))) { - return ['id' => $this->propertyAccessor->getValue($item, 'id')]; + try { + return ['id' => $this->propertyAccessor->getValue($item, 'id')]; + } catch (NoSuchPropertyException $e) { + throw new RuntimeException('Not able to retrieve identifiers.', $e->getCode(), $e); + } } if ($operation && $operation->getClass()) { diff --git a/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php index d4026dd30fb..6ab2adf4efe 100644 --- a/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php @@ -53,10 +53,11 @@ public function create(string $resourceClass): ResourceMetadataCollection } $class = $operation->getInput()['class'] ?? $operation->getClass(); + $outputClass = $operation->getOutput()['class'] ?? null; $entityMap = null; // Look for Mapping metadata - if ($this->canBeMapped($class) || ($entityClass && ($entityMap = $this->canBeMapped($entityClass)))) { + if ($this->canBeMapped($class) || ($outputClass && $this->canBeMapped($outputClass)) || ($entityClass && ($entityMap = $this->canBeMapped($entityClass)))) { $found = true; if ($entityMap) { foreach ($entityMap as $mapping) { diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index 7828db05aaf..0a69b77be49 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -127,12 +127,15 @@ public function getIriFromResource(object|string $resource, int $referenceType = return $this->generateSymfonyRoute($resource, $referenceType, $this->localOperationCache[$localOperationCacheKey], $context, $this->localIdentifiersExtractorOperationCache[$localOperationCacheKey] ?? null); } - if (!$this->resourceClassResolver->isResourceClass($resourceClass)) { + if (!($isResourceClass = $this->resourceClassResolver->isResourceClass($resourceClass)) && !isset($context['item_uri_template'])) { return $this->generateSkolemIri($resource, $referenceType, $operation, $context, $resourceClass); } + $context['is_resource_class'] = $isResourceClass; + $context['current_resource_class'] = $resourceClass; + // This is only for when a class (that is not a resource) extends another one that is a resource, we should remove this behavior - if (!\is_string($resource) && !isset($context['force_resource_class'])) { + if (!\is_string($resource) && !isset($context['force_resource_class']) && !isset($context['item_uri_template'])) { $resourceClass = $this->getResourceClass($resource, true); } @@ -188,7 +191,7 @@ private function generateSymfonyRoute(object|string $resource, int $referenceTyp $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($resource, $identifiersExtractorOperation, $context); } catch (InvalidArgumentException|RuntimeException $e) { // We can try using context uri variables if any - if (!$identifiers) { + if (!$identifiers && ($context['is_resource_class'] ?? false)) { throw new InvalidArgumentException(\sprintf('Unable to generate an IRI for the item of type "%s"', $operation->getClass()), $e->getCode(), $e); } } diff --git a/tests/Fixtures/TestBundle/ApiResource/BookStoreResource.php b/tests/Fixtures/TestBundle/ApiResource/BookStoreResource.php new file mode 100644 index 00000000000..9cb748f27f5 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/BookStoreResource.php @@ -0,0 +1,42 @@ + + * + * 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\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\BookStoreCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\BookStore as BookStoreEntity; + +#[ApiResource( + stateOptions: new Options(entityClass: BookStoreEntity::class), + operations: [ + new Get(), + new GetCollection( + output: BookStoreCollection::class, + itemUriTemplate: '_api_/book_store_resources/{id}{._format}_get', + types: ['BookStoreResource'] + ), + ], + normalizationContext: ['hydra_prefix' => false], +)] +final class BookStoreResource +{ + public ?int $id = null; + public ?string $title = null; + public ?string $isbn = null; + public ?string $description = null; + public ?string $author = null; +} diff --git a/tests/Fixtures/TestBundle/Dto/BookStoreCollection.php b/tests/Fixtures/TestBundle/Dto/BookStoreCollection.php new file mode 100644 index 00000000000..6acfe59042b --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/BookStoreCollection.php @@ -0,0 +1,28 @@ + + * + * 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\Dto; + +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\BookStore as BookStoreEntity; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(source: BookStoreEntity::class)] +final class BookStoreCollection +{ + public int $id; + + #[Map(source: 'title')] + public string $name; + + public string $isbn; +} diff --git a/tests/Fixtures/TestBundle/Entity/BookStore.php b/tests/Fixtures/TestBundle/Entity/BookStore.php new file mode 100644 index 00000000000..fbd96fe1fa4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/BookStore.php @@ -0,0 +1,42 @@ + + * + * 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 Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class BookStore +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column] + public ?string $title = null; + + #[ORM\Column] + public ?string $isbn = null; + + #[ORM\Column(nullable: true)] + public ?string $description = null; + + #[ORM\Column(nullable: true)] + public ?string $author = null; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Functional/MappingTest.php b/tests/Functional/MappingTest.php index 28f68418c6c..d026363d9a2 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/MappingTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\Functional; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\BookStoreResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FirstResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7563\BookDto; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource; @@ -26,6 +27,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\SecondResource; use ApiPlatform\Tests\Fixtures\TestBundle\Document\MappedDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Book; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\BookStore; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntityNoMap; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntitySourceOnly; @@ -58,6 +60,7 @@ public static function getResources(): array MappedResourceSourceOnly::class, MappedResourceNoMap::class, BookDto::class, + BookStoreResource::class, ]; } @@ -363,4 +366,79 @@ public function testGetCollectionBookDtoUnpaginated(): void self::assertStringStartsWith('customISBN-', $member['customIsbn']); } } + + public function testOutputDtoForCollectionRead(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not tested.'); + } + + if (!$this->getContainer()->has('api_platform.object_mapper')) { + $this->markTestSkipped('ObjectMapper not installed'); + } + + $this->recreateSchema([BookStore::class]); + $manager = $this->getManager(); + + $book1 = new BookStore(); + $book1->title = 'API Platform Guide'; + $book1->isbn = '978-1234567890'; + $book1->description = 'A comprehensive guide to API Platform'; + $book1->author = 'John Doe'; + $manager->persist($book1); + + $book2 = new BookStore(); + $book2->title = 'REST APIs Handbook'; + $book2->isbn = '978-0987654321'; + $book2->description = 'Everything about REST APIs'; + $book2->author = 'Jane Smith'; + $manager->persist($book2); + + $manager->flush(); + + // Test GetCollection returns only the lighter DTO fields (id, name, isbn) + $response = self::createClient()->request('GET', '/book_store_resources'); + self::assertResponseIsSuccessful(); + self::assertJsonContains([ + '@id' => '/book_store_resources', + '@type' => 'Collection', + 'member' => [ + [ + '@id' => '/book_store_resources/1', + '@type' => 'BookStoreResource', + 'id' => 1, + 'name' => 'API Platform Guide', + 'isbn' => '978-1234567890', + ], + [ + '@id' => '/book_store_resources/2', + '@type' => 'BookStoreResource', + 'id' => 2, + 'name' => 'REST APIs Handbook', + 'isbn' => '978-0987654321', + ], + ], + ]); + + $json = $response->toArray(); + // Verify that description and author are NOT present in collection output + foreach ($json['member'] as $member) { + self::assertArrayNotHasKey('description', $member); + self::assertArrayNotHasKey('author', $member); + self::assertArrayHasKey('id', $member); + self::assertArrayHasKey('name', $member); + self::assertArrayHasKey('isbn', $member); + } + + // Test Get (single item) returns all fields from the full resource + $response = self::createClient()->request('GET', '/book_store_resources/1'); + self::assertResponseIsSuccessful(); + self::assertJsonContains([ + 'id' => 1, + 'title' => 'API Platform Guide', + 'isbn' => '978-1234567890', + 'description' => 'A comprehensive guide to API Platform', + 'author' => 'John Doe', + ]); + } }