Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] [WIP] Offer post, delete and put subresource #2598

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions features/bootstrap/DoctrineContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy;
use Behat\Behat\Context\Context;
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\Common\Persistence\Mapping\ClassMetadata;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

/**
* Defines application features from the specific context.
Expand Down Expand Up @@ -944,6 +946,48 @@ public function thereIsAnAnswerToTheQuestion(string $a, string $q)
$this->manager->clear();
}

/**
* @Then the expression :expression on the object of the entity :shortName with id :id should return :expectedResult
*/
public function theExpressionOnTheObjectOfTheEntityWithIdShouldReturn(string $expression, string $shortName, string $id, string $expectedResult)
{
/** @var \Doctrine\ORM\Mapping\ClassMetadata|\Doctrine\ODM\MongoDB\Mapping\ClassMetadata $class */
$class = $this->getClass($shortName);

$repository = $this->manager->getRepository($class->getName());

$entity = $repository->find($id);

$expressionLanguage = new ExpressionLanguage();
$actualResult = $expressionLanguage->evaluate($expression, ['object' => $entity]);

if ($expectedResult !== ((string) $actualResult)) {
throw new \Exception(sprintf('The expected value "%s" is not equal to the actual value "%s"', $expectedResult, $actualResult));
}

$this->manager->clear();
}

/**
* @Then the count of entity :shortName with attribute :attribute equal to :attributeValue should be equal to :expectedCount
*/
public function theCountOfEntityWithAttributeEqualToShouldBeEqualTo(string $shortName, string $attribute, string $attributeValue, string $expectedCount)
{
$class = $this->getClass($shortName);

$repository = $this->manager->getRepository($class->getName());

$entities = $repository->findBy([
$attribute => $attributeValue,
]);

if ($expectedCount !== ((string) $actualCount = count($entities))) {
throw new \Exception(sprintf('The expected count "%s" is not equals to the actual count "%s"', $expectedCount, $actualCount));
}

$this->manager->clear();
}

/**
* @Given there are :nb nodes in a container :uuid
*/
Expand Down Expand Up @@ -1571,4 +1615,22 @@ private function buildThirdLevel()
{
return $this->isOrm() ? new ThirdLevel() : new ThirdLevelDocument();
}

private function getClass(string $shortName): ClassMetadata
{
/** @var ClassMetadata $class */
$class = current(array_filter($this->classes, function (ClassMetadata $class) use ($shortName) {
$fullName = '\\'.$class->getName();
$shortName = '\\'.$shortName;

return strrpos($fullName, $shortName) === strlen($fullName) - strlen($shortName);
}));

if (false === $class) {
throw new \Exception(sprintf('Given entity class name "%s" does not exist. Please make sure it is defined',
$shortName));
}

return $class;
}
}
102 changes: 102 additions & 0 deletions features/main/subresource.feature
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,38 @@ Feature: Subresource support
}
"""

Scenario: Put subresource one to one relation
Given there is an answer "42" to the question "What's the answer to the Ultimate Question of Life, the Universe and Everything?"
When I add "Content-Type" header equal to "application/ld+json"
And I send a "PUT" request to "/questions/1/answer" with body:
"""
{
"@id": "/answers/1",
"content": "43",
"question": "/questions/1",
"relatedQuestions": [
"/questions/1"
]
}
"""
Then the response status code should be 200
And the response should be in JSON
And the JSON should be equal to:
"""
{
"@context": "/contexts/Answer",
"@id": "/answers/1",
"@type": "Answer",
"id": 1,
"content": "43",
"question": "/questions/1",
"relatedQuestions": [
"/questions/1"
]
}
"""
And the expression "object.getContent()" on the object of the entity "Answer" with id "1" should return "43"

Scenario: Get a non existant subresource
Given there is an answer "42" to the question "What's the answer to the Ultimate Question of Life, the Universe and Everything?"
When I send a "GET" request to "/questions/999999/answer"
Expand Down Expand Up @@ -193,6 +225,58 @@ Feature: Subresource support
}
"""

Scenario: Post the subresource relation item
When I add "Content-Type" header equal to "application/ld+json"
And I send a "POST" request to "/dummies/1/related_dummies" with body:
"""
{
"@context": "/contexts/RelatedDummy",
"@type": "https://schema.org/Product",
"name": null,
"symfony": "symfony",
"dummyDate": "2019-01-01",
"thirdLevel": "/third_levels/1",
"relatedToDummyFriend": [],
"dummyBoolean": null,
"embeddedDummy": [],
"age": null
}
"""
Then the response status code should be 201
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/RelatedDummy",
"@id": "/related_dummies/3",
"@type": "https://schema.org/Product",
"id": 3,
"name": null,
"symfony": "symfony",
"dummyDate": "2019-01-01T00:00:00+00:00",
"thirdLevel": {
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"fourthLevel": "/fourth_levels/1"
},
"relatedToDummyFriend": [],
"dummyBoolean": null,
"embeddedDummy": [],
"age": null
}
"""
And the count of entity "RelatedDummy" with attribute "id" equal to "3" should be equal to "1"
And the expression "object.getRelatedDummies().count()" on the object of the entity "Dummy" with id "1" should return "3"
And the expression "object.getRelatedDummies()[2].getId()" on the object of the entity "Dummy" with id "1" should return "3"

Scenario: Delete the subresource relation item
Given the count of entity "RelatedDummy" with attribute "id" equal to "3" should be equal to "1"
When I add "Content-Type" header equal to "application/ld+json"
And I send a "DELETE" request to "/dummies/1/related_dummies/3"
Then the response status code should be 204
And the count of entity "RelatedDummy" with attribute "id" equal to "3" should be equal to "0"

Scenario: Get the subresource relation item
When I send a "GET" request to "/dummies/1/related_dummies/2"
And the response status code should be 200
Expand Down Expand Up @@ -269,6 +353,24 @@ Feature: Subresource support
}
"""

Scenario: Get single offer subresource from aggregate offers subresource
Given I have a product with offers
When I send a "GET" request to "/dummy_products/2/offers/1/offers/1"
And 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/DummyOffer",
"@id": "/dummy_offers/1",
"@type": "DummyOffer",
"id": 1,
"value": 2,
"aggregate": "/dummy_aggregate_offers/1"
}
"""

Scenario: Get offers subresource from aggregate offers subresource
Given I have a product with offers
When I send a "GET" request to "/dummy_products/2/offers/1/offers"
Expand Down
5 changes: 4 additions & 1 deletion src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ private function buildAggregation(array $identifiers, array $context, Builder $p
foreach ($normalizedIdentifiers as $key => $value) {
$aggregation->match()->field($key)->equals($value);
}
} elseif ($classMetadata->isIdentifier($previousAssociationProperty)) {
} elseif (
// TODO: next major: remove support of identifier being subresource
$classMetadata->isIdentifier($previousAssociationProperty)
) {
foreach ($normalizedIdentifiers as $key => $value) {
$aggregation->match()->field($key)->equals($value);
}
Expand Down
8 changes: 7 additions & 1 deletion src/Bridge/Doctrine/Orm/SubresourceDataProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,13 @@ private function buildQuery(array $identifiers, array $context, QueryNameGenerat
$qb->select("IDENTITY($alias.$previousAssociationProperty)")
->from($identifierResourceClass, $alias);
}
} elseif ($classMetadata->isIdentifier($previousAssociationProperty)) {
} elseif (
// TODO: next major: remove support of item subresource workaround which enabled by id property as a subresource
$classMetadata->isIdentifier($previousAssociationProperty)
) {
$qb->select($alias)
->from($identifierResourceClass, $alias);
} else {
$qb->select($alias)
->from($identifierResourceClass, $alias);
}
Expand Down
11 changes: 11 additions & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/api.xml
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@
<tag name="kernel.event_listener" event="kernel.view" method="onKernelView" priority="32" />
</service>

<service id="api_platform.listener.request.associate" class="ApiPlatform\Core\EventListener\AssociateListener">
<argument type="service" id="api_platform.property_accessor" />

<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="1" />
</service>

<service id="api_platform.listener.request.deserialize" class="ApiPlatform\Core\EventListener\DeserializeListener">
<argument type="service" id="api_platform.serializer" />
<argument type="service" id="api_platform.serializer.context_builder" />
Expand Down Expand Up @@ -236,6 +242,10 @@
<service id="api_platform.action.put_item" alias="api_platform.action.placeholder" public="true" />
<service id="api_platform.action.delete_item" alias="api_platform.action.placeholder" public="true" />
<service id="api_platform.action.get_subresource" alias="api_platform.action.placeholder" public="true" />
<service id="api_platform.action.post_subresource" alias="api_platform.action.placeholder" public="true" />
<service id="api_platform.action.put_subresource" alias="api_platform.action.placeholder" public="true" />
<service id="api_platform.action.delete_subresource" alias="api_platform.action.placeholder" public="true" />
<service id="api_platform.action.patch_subresource" alias="api_platform.action.placeholder" public="true" />

<service id="api_platform.action.entrypoint" class="ApiPlatform\Core\Action\EntrypointAction" public="true">
<argument type="service" id="api_platform.metadata.resource.name_collection_factory" />
Expand Down Expand Up @@ -292,6 +302,7 @@
<argument type="service" id="api_platform.metadata.property.name_collection_factory" />
<argument type="service" id="api_platform.metadata.property.metadata_factory" />
<argument type="service" id="api_platform.path_segment_name_generator" />
<argument>%api_platform.formats%</argument>
</service>

<service id="api_platform.subresource_operation_factory.cached" class="ApiPlatform\Core\Operation\Factory\CachedSubresourceOperationFactory" decorates="api_platform.subresource_operation_factory" decoration-priority="-10" public="false">
Expand Down
42 changes: 25 additions & 17 deletions src/Bridge/Symfony/Routing/ApiLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,9 @@ public function load($data, $type = null): RouteCollection
}

foreach ($this->subresourceOperationFactory->create($resourceClass) as $operationId => $operation) {
if (null === $controller = $operation['controller'] ?? null) {
$controller = self::DEFAULT_ACTION_PATTERN.'get_subresource';
$this->assertOperationMethod($resourceClass, $operationId, $operation);

if (!$this->container->has($controller)) {
throw new RuntimeException(sprintf('There is no builtin action for the %s %s operation. You need to define the controller yourself.', OperationType::SUBRESOURCE, 'GET'));
}
}
$controller = $this->resolveOperationController($operation, OperationType::SUBRESOURCE);

$routeCollection->add($operation['route_name'], new Route(
$operation['path'],
Expand All @@ -135,7 +131,7 @@ public function load($data, $type = null): RouteCollection
$operation['options'] ?? [],
$operation['host'] ?? '',
$operation['schemes'] ?? [],
['GET'],
[$operation['method']],
$operation['condition'] ?? ''
));
}
Expand Down Expand Up @@ -189,17 +185,9 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas
return;
}

if (!isset($operation['method'])) {
throw new RuntimeException(sprintf('Either a "route_name" or a "method" operation attribute must exist for the operation "%s" of the resource "%s".', $operationName, $resourceClass));
}

if (null === $controller = $operation['controller'] ?? null) {
$controller = sprintf('%s%s_%s', self::DEFAULT_ACTION_PATTERN, strtolower($operation['method']), $operationType);
$this->assertOperationMethod($resourceClass, $operationName, $operation);

if (!$this->container->has($controller)) {
throw new RuntimeException(sprintf('There is no builtin action for the %s %s operation. You need to define the controller yourself.', $operationType, $operation['method']));
}
}
$controller = $this->resolveOperationController($operation, $operationType);

$path = trim(trim($resourceMetadata->getAttribute('route_prefix', '')), '/');
$path .= $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName);
Expand All @@ -222,4 +210,24 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas

$routeCollection->add(RouteNameGenerator::generate($operationName, $resourceShortName, $operationType), $route);
}

private function assertOperationMethod(string $resourceClass, string $operationName, array $operation)
{
if (!isset($operation['method'])) {
throw new RuntimeException(sprintf('Either a "route_name" or a "method" operation attribute must exist for the operation "%s" of the resource "%s".', $operationName, $resourceClass));
}
}

private function resolveOperationController(array $operation, string $operationType): string
{
if (null === $controller = $operation['controller'] ?? null) {
$controller = sprintf('%s%s_%s', self::DEFAULT_ACTION_PATTERN, strtolower($operation['method']), $operationType);

if (!$this->container->has($controller)) {
throw new RuntimeException(sprintf('There is no builtin action for the %s %s operation. You need to define the controller yourself.', $operationType, $operation['method']));
}
}

return $controller;
}
}
10 changes: 7 additions & 3 deletions src/DataProvider/OperationDataProviderTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,19 @@ private function extractIdentifiers(array $parameters, array $attributes)

$identifiers = [];

foreach ($attributes['subresource_context']['identifiers'] as $key => [$id, $resourceClass, $hasIdentifier]) {
/**
* Subresource can potentially result in $property collision. An $id is in place as a route id to avoid collision.
* On data resolution, a genuine $property is used.
*/
foreach ($attributes['subresource_context']['identifiers'] as $key => [$property, $resourceClass, $hasIdentifier, $id]) {
if (false === $hasIdentifier) {
continue;
}

$identifiers[$id] = $parameters[$id];
$identifiers[$property] = $parameters[$id];

if (null !== $this->identifierConverter) {
$identifiers[$id] = $this->identifierConverter->convert((string) $identifiers[$id], $resourceClass);
$identifiers[$property] = $this->identifierConverter->convert((string) $identifiers[$property], $resourceClass);
}
}

Expand Down
Loading