diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index 3901c90bc66..bdc32e8b5aa 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\AbsoluteUrlRelationDummy as AbsoluteUrlRelationDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Address as AddressDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Answer as AnswerDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Book as BookDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\CompositeItem as CompositeItemDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\CompositeLabel as CompositeLabelDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\CompositePrimitiveItem as CompositePrimitiveItemDocument; @@ -77,6 +78,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlRelationDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Address; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Book; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeItem; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeLabel; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositePrimitiveItem; @@ -1589,6 +1591,18 @@ public function thereAreNetworkPathDummies(int $nb) $this->manager->flush(); } + /** + * @Given there is a book + */ + public function thereIsABook() + { + $book = $this->buildBook(); + $book->name = '1984'; + $book->isbn = '9780451524935'; + $this->manager->persist($book); + $this->manager->flush(); + } + private function isOrm(): bool { return null !== $this->schemaTool; @@ -2006,4 +2020,12 @@ private function buildNetworkPathRelationDummy() { return $this->isOrm() ? new NetworkPathRelationDummy() : new NetworkPathRelationDummyDocument(); } + + /** + * @return BookDocument | Book + */ + private function buildBook() + { + return $this->isOrm() ? new Book() : new BookDocument(); + } } diff --git a/features/main/operation.feature b/features/main/operation.feature index 8c70d1c2b99..3bdf02b2d9f 100644 --- a/features/main/operation.feature +++ b/features/main/operation.feature @@ -63,3 +63,23 @@ Feature: Operation support Scenario: Get a 404 response for the disabled item operation When I send a "GET" request to "/disable_item_operations/1" Then the response status code should be 404 + + @createSchema + Scenario: Get a book by it's ISBN + Given there is a book + When I send a "GET" request to "books/by_isbn/9780451524935" + Then the response status code should be 200 + 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 equal to: + """ + { + "@context": "/contexts/Book", + "@id": "/books/1", + "@type": "Book", + "name": "1984", + "isbn": "9780451524935", + "id": 1 + } + """ + diff --git a/src/Bridge/Symfony/Routing/ApiLoader.php b/src/Bridge/Symfony/Routing/ApiLoader.php index aaa093a1cfb..217ef16a1a7 100644 --- a/src/Bridge/Symfony/Routing/ApiLoader.php +++ b/src/Bridge/Symfony/Routing/ApiLoader.php @@ -224,12 +224,15 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas $path = trim(trim($resourceMetadata->getAttribute('route_prefix', '')), '/'); $path .= $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName); + $identifiers = $operation['identifiedBy'] ?? ['id']; + $route = new Route( $path, [ '_controller' => $controller, '_format' => null, '_api_resource_class' => $resourceClass, + '_api_identified_by' => \is_array($identifiers) ? $identifiers : [$identifiers], sprintf('_api_%s_operation_name', $operationType) => $operationName, ] + ($operation['defaults'] ?? []), $operation['requirements'] ?? [], diff --git a/src/Bridge/Symfony/Routing/IriConverter.php b/src/Bridge/Symfony/Routing/IriConverter.php index 1e031609f8a..a624203d11f 100644 --- a/src/Bridge/Symfony/Routing/IriConverter.php +++ b/src/Bridge/Symfony/Routing/IriConverter.php @@ -147,7 +147,6 @@ public function getIriFromResourceClass(string $resourceClass, int $referenceTyp public function getItemIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = null): string { $routeName = $this->routeNameResolver->getRouteName($resourceClass, OperationType::ITEM); - try { $identifiers = $this->generateIdentifiersUrl($identifiers, $resourceClass); diff --git a/src/DataProvider/OperationDataProviderTrait.php b/src/DataProvider/OperationDataProviderTrait.php index f08521ce379..c992977c51c 100644 --- a/src/DataProvider/OperationDataProviderTrait.php +++ b/src/DataProvider/OperationDataProviderTrait.php @@ -85,6 +85,19 @@ private function getSubresourceData($identifiers, array $attributes, array $cont */ private function extractIdentifiers(array $parameters, array $attributes) { + if (isset($attributes['identified_by'])) { + $identifiers = []; + foreach ($attributes['identified_by'] as $identifier) { + if (!isset($parameters[$identifier])) { + throw new InvalidIdentifierException(sprintf('Parameter "%s" not found', $identifier)); + } + + $identifiers[$identifier] = $parameters[$identifier]; + } + + return $this->identifierConverter->normalizeIdentifiers($identifiers, $attributes['resource_class'], array_keys($identifiers)); + } + if (isset($attributes['item_operation_name'])) { if (!isset($parameters['id'])) { throw new InvalidIdentifierException('Parameter "id" not found'); diff --git a/src/Identifier/IdentifierConverter.php b/src/Identifier/IdentifierConverter.php index 9faafd8ef54..9acf5773047 100644 --- a/src/Identifier/IdentifierConverter.php +++ b/src/Identifier/IdentifierConverter.php @@ -25,7 +25,7 @@ * * @author Antoine Bluchet */ -final class IdentifierConverter implements ContextAwareIdentifierConverterInterface +final class IdentifierConverter implements NormalizeIdentifierConverterInterface { private $propertyMetadataFactory; private $identifiersExtractor; @@ -63,6 +63,11 @@ public function convert(string $data, string $class, array $context = []): array $identifiers = [$keys[0] => $data]; } + return $this->normalizeIdentifiers($identifiers, $class, $keys); + } + + public function normalizeIdentifiers(array $identifiers, string $class, array $keys, array $context = []): array + { // Normalize every identifier (DateTime, UUID etc.) foreach ($keys as $key) { if (!isset($identifiers[$key])) { diff --git a/src/Identifier/NormalizeIdentifierConverterInterface.php b/src/Identifier/NormalizeIdentifierConverterInterface.php new file mode 100644 index 00000000000..c8286979002 --- /dev/null +++ b/src/Identifier/NormalizeIdentifierConverterInterface.php @@ -0,0 +1,27 @@ + + * + * 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\Core\Identifier; + +/** + * Gives access to the context in the IdentifierConverter. + * + * @author Antoine Bluchet + */ +interface NormalizeIdentifierConverterInterface extends ContextAwareIdentifierConverterInterface +{ + /** + * {@inheritdoc} + */ + public function normalizeIdentifiers(array $identifiers, string $class, array $keys, array $context = []): array; +} diff --git a/src/Util/AttributesExtractor.php b/src/Util/AttributesExtractor.php index fcb93a59242..329b85c406d 100644 --- a/src/Util/AttributesExtractor.php +++ b/src/Util/AttributesExtractor.php @@ -34,7 +34,7 @@ private function __construct() */ public static function extractAttributes(array $attributes): array { - $result = ['resource_class' => $attributes['_api_resource_class'] ?? null]; + $result = ['resource_class' => $attributes['_api_resource_class'] ?? null, 'identified_by' => $attributes['_api_identified_by'] ?? null]; if ($subresourceContext = $attributes['_api_subresource_context'] ?? null) { $result['subresource_context'] = $subresourceContext; } diff --git a/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php b/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php index 7b3c867d7c0..9440b766049 100644 --- a/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php +++ b/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php @@ -138,6 +138,7 @@ public function testWithResource() $this->assertSame([ 'resource_class' => DummyEntity::class, + 'identified_by' => null, 'item_operation_name' => 'get', 'receive' => true, 'respond' => true, diff --git a/tests/Bridge/Symfony/Routing/ApiLoaderTest.php b/tests/Bridge/Symfony/Routing/ApiLoaderTest.php index 032b0cf2cbd..a70543cb678 100644 --- a/tests/Bridge/Symfony/Routing/ApiLoaderTest.php +++ b/tests/Bridge/Symfony/Routing/ApiLoaderTest.php @@ -302,6 +302,7 @@ private function getRoute(string $path, string $controller, string $resourceClas '_controller' => $controller, '_format' => null, '_api_resource_class' => $resourceClass, + '_api_identified_by' => ['id'], sprintf('_api_%s_operation_name', $collection ? 'collection' : 'item') => $operationName, ] + $extraDefaults, $requirements, diff --git a/tests/EventListener/DeserializeListenerTest.php b/tests/EventListener/DeserializeListenerTest.php index 25b4e3b5507..d65ab65b18f 100644 --- a/tests/EventListener/DeserializeListenerTest.php +++ b/tests/EventListener/DeserializeListenerTest.php @@ -200,6 +200,7 @@ public function testLegacyDeserializeResourceClassSupportedFormat(string $method $formatsProviderProphecy->getFormatsFromAttributes([ 'resource_class' => 'Foo', 'collection_operation_name' => 'post', + 'identified_by' => null, 'receive' => true, 'respond' => true, 'persist' => true, diff --git a/tests/Fixtures/TestBundle/Document/Book.php b/tests/Fixtures/TestBundle/Document/Book.php new file mode 100644 index 00000000000..5ceeb323f10 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Book.php @@ -0,0 +1,51 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Book. + * + * @author Antoine Bluchet + * + * @ApiResource(collectionOperations={}, itemOperations={ + * "get", + * "get_by_isbn"={"method"="GET", "path"="/books/by_isbn/{isbn}.{_format}", "requirements"={"isbn"=".+"}, "identifiedBy"="isbn"} + * }) + * @ODM\Document + */ +class Book +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @ODM\Field(type="string", nullable=true) + */ + public $name; + + /** + * @ODM\Field(type="string") + */ + public $isbn; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Book.php b/tests/Fixtures/TestBundle/Entity/Book.php new file mode 100644 index 00000000000..675abb06ecf --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Book.php @@ -0,0 +1,53 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * Book. + * + * @author Antoine Bluchet + * + * @ApiResource(collectionOperations={}, itemOperations={ + * "get", + * "get_by_isbn"={"method"="GET", "path"="/books/by_isbn/{isbn}.{_format}", "requirements"={"isbn"=".+"}, "identifiedBy"="isbn"} + * }) + * @ORM\Entity + */ +class Book +{ + /** + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @ORM\Column + */ + public $name; + + /** + * @ORM\Column(unique=true) + */ + public $isbn; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Serializer/SerializerFilterContextBuilderTest.php b/tests/Serializer/SerializerFilterContextBuilderTest.php index 08a9583f0a0..3c0a0315dc0 100644 --- a/tests/Serializer/SerializerFilterContextBuilderTest.php +++ b/tests/Serializer/SerializerFilterContextBuilderTest.php @@ -147,6 +147,7 @@ public function testCreateFromRequestWithoutAttributes() $attributes = [ 'resource_class' => DummyGroup::class, 'collection_operation_name' => 'get', + 'identified_by' => null, 'receive' => true, 'respond' => true, 'persist' => true, diff --git a/tests/Util/RequestAttributesExtractorTest.php b/tests/Util/RequestAttributesExtractorTest.php index 37b2d082467..6ba3bd37a2a 100644 --- a/tests/Util/RequestAttributesExtractorTest.php +++ b/tests/Util/RequestAttributesExtractorTest.php @@ -33,6 +33,7 @@ public function testExtractCollectionAttributes() 'receive' => true, 'respond' => true, 'persist' => true, + 'identified_by' => null, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -49,6 +50,7 @@ public function testExtractItemAttributes() 'receive' => true, 'respond' => true, 'persist' => true, + 'identified_by' => null, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -65,6 +67,7 @@ public function testExtractReceive() 'receive' => false, 'respond' => true, 'persist' => true, + 'identified_by' => null, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -78,6 +81,7 @@ public function testExtractReceive() 'receive' => true, 'respond' => true, 'persist' => true, + 'identified_by' => null, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -91,6 +95,7 @@ public function testExtractReceive() 'receive' => true, 'respond' => true, 'persist' => true, + 'identified_by' => null, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -107,6 +112,7 @@ public function testExtractRespond() 'receive' => true, 'respond' => false, 'persist' => true, + 'identified_by' => null, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -120,6 +126,7 @@ public function testExtractRespond() 'receive' => true, 'respond' => true, 'persist' => true, + 'identified_by' => null, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -133,6 +140,7 @@ public function testExtractRespond() 'receive' => true, 'respond' => true, 'persist' => true, + 'identified_by' => null, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -149,6 +157,7 @@ public function testExtractPersist() 'receive' => true, 'respond' => true, 'persist' => false, + 'identified_by' => null, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -162,6 +171,7 @@ public function testExtractPersist() 'receive' => true, 'respond' => true, 'persist' => true, + 'identified_by' => null, ], RequestAttributesExtractor::extractAttributes($request) ); @@ -175,6 +185,24 @@ public function testExtractPersist() 'receive' => true, 'respond' => true, 'persist' => true, + 'identified_by' => null, + ], + RequestAttributesExtractor::extractAttributes($request) + ); + } + + public function testExtractIdentifiedBy() + { + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_identified_by' => ['test']]); + + $this->assertEquals( + [ + 'resource_class' => 'Foo', + 'item_operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'identified_by' => ['test'], ], RequestAttributesExtractor::extractAttributes($request) );