Skip to content

Allow abstract resource #311

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

Merged
merged 1 commit into from
Nov 18, 2015
Merged
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
7 changes: 7 additions & 0 deletions Api/ResourceResolverTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ public function guessResource($object, array $context = null, $strict = false)
}

if ($strict && isset($isObject) && $resource->getEntityClass() !== $type) {
if (is_subclass_of($type, $resource->getEntityClass())) {
$resource = $this->resourceCollection->getResourceForEntity($type);
if (null !== $resource) {
return $resource;
}
}

throw new InvalidArgumentException(
sprintf('No resource found for object of type "%s"', $type)
);
Expand Down
48 changes: 39 additions & 9 deletions DependencyInjection/Compiler/ResourcePass.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,25 +42,41 @@ public function process(ContainerBuilder $container)
continue;
}

$isAbstract = $this->isAbstract($resourceDefinition->getArgument(0));
if (!$resourceDefinition->hasMethodCall('initItemOperations')) {
$resourceDefinition->addMethodCall('initItemOperations', [[
$this->createOperation($container, $serviceId, 'GET', false),
$this->createOperation($container, $serviceId, 'PUT', false),
$this->createOperation($container, $serviceId, 'DELETE', false),
]]);
$methods = $isAbstract ? ['GET', 'DELETE'] : ['GET', 'PUT', 'DELETE'];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Methods can be used as keys to avoid the array_map call and reduce the complexity O(1) instead of O(n).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the point: Methods used as keys.. of what?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forget about that it has no interest here.

$resourceDefinition->addMethodCall('initItemOperations', [$this->createOperations($container, $serviceId, $methods, false)]);
}

if (!$resourceDefinition->hasMethodCall('initCollectionOperations')) {
$resourceDefinition->addMethodCall('initCollectionOperations', [[
$this->createOperation($container, $serviceId, 'GET', true),
$this->createOperation($container, $serviceId, 'POST', true),
]]);
$methods = $isAbstract ? ['GET'] : ['GET', 'POST'];
$resourceDefinition->addMethodCall('initCollectionOperations', [$this->createOperations($container, $serviceId, $methods, true)]);
}
}

$resourceCollectionDefinition->addMethodCall('init', [$resourceReferences]);
}

/**
* Adds a list of operations.
*
* @param ContainerBuilder $container
* @param string $serviceId
* @param array $methods
* @param bool $collection
*
* @return Reference[]
*/
private function createOperations(ContainerBuilder $container, $serviceId, $methods, $collection)
{
$operations = [];
foreach ($methods as $method) {
$operations[] = $this->createOperation($container, $serviceId, $method, $collection);
}

return $operations;
}

/**
* Adds an operation.
*
Expand Down Expand Up @@ -94,6 +110,20 @@ private function createOperation(ContainerBuilder $container, $serviceId, $metho
return new Reference($operationId);
}

/**
* Returns if the given class is abstract.
*
* @param string $instanceClass
*
* @return bool
*/
private function isAbstract($instanceClass)
{
$reflectionClass = new \ReflectionClass($instanceClass);

return $reflectionClass->isAbstract();
}

/**
* Gets class of the given definition.
*
Expand Down
15 changes: 12 additions & 3 deletions JsonLd/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,6 @@ public function denormalize($data, $class, $format = null, array $context = [])
}
}

$reflectionClass = new \ReflectionClass($class);

if (isset($data['@id']) && !isset($context['object_to_populate'])) {
$context['object_to_populate'] = $this->iriConverter->getItemFromIri($data['@id']);

Expand All @@ -210,9 +208,20 @@ public function denormalize($data, $class, $format = null, array $context = [])
$overrideClass = false;
}

$instanceClass = $overrideClass ? get_class($context['object_to_populate']) : $class;
$reflectionClass = new \ReflectionClass($instanceClass);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using reflection at runtime looks like a bad idea. It will considerably decrease performances.
We should store if the class is abstract or not in the ClassMetadata so it will be handled by the cache warmer and it will not have a negative impact at runtime.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflection's Instance is necessary to instantiateObject.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok missed that... We probably have a vector to optimize performance here too.

if ($reflectionClass->isAbstract()) {
throw new InvalidArgumentException(
sprintf(
'Cannot create an instance of %s from serialized data because it is an abstract resource',
$instanceClass
)
);
}

$object = $this->instantiateObject(
$normalizedData,
$overrideClass ? get_class($context['object_to_populate']) : $class,
$instanceClass,
$context,
$reflectionClass,
$allowedAttributes
Expand Down
61 changes: 61 additions & 0 deletions Tests/Behat/TestBundle/Entity/AbstractDummy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

/*
* This file is part of the DunglasApiBundle package.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Dunglas\ApiBundle\Tests\Behat\TestBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Dunglas\ApiBundle\Annotation\Iri;
use Symfony\Component\Validator\Constraints as Assert;

/**
* AbstractDummy.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*
* @ORM\Entity
* @ORM\InheritanceType("SINGLE_TABLE")
* @ORM\DiscriminatorColumn(name="discr", type="string", length=16)
* @ORM\DiscriminatorMap({"concrete" = "ConcreteDummy"})
*/
abstract class AbstractDummy
{
/**
* @var int The id.
*
* @ORM\Column(type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* @var string The dummy name.
*
* @ORM\Column
* @Assert\NotBlank
* @Iri("http://schema.org/name")
*/
private $name;

public function getId()
{
return $this->id;
}

public function setName($name)
{
$this->name = $name;
}

public function getName()
{
return $this->name;
}
}
43 changes: 43 additions & 0 deletions Tests/Behat/TestBundle/Entity/ConcreteDummy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/*
* This file is part of the DunglasApiBundle package.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Dunglas\ApiBundle\Tests\Behat\TestBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
* ConcreteDummy.
*
* @author Jérémy Derusse <jeremy@derusse.com>
*
* @ORM\Entity
*/
class ConcreteDummy extends AbstractDummy
{
/**
* @var string a concrete thing
*
* @ORM\Column
* @Assert\NotBlank
*/
private $instance;

public function setInstance($instance)
{
$this->instance = $instance;
}

public function getInstance()
{
return $this->instance;
}
}
2 changes: 2 additions & 0 deletions Tests/DependencyInjection/Compiler/ResourcePassTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public function testProcess()

$builtinResourceDefinitionProphecy = $this->prophesize('Symfony\Component\DependencyInjection\Definition');
$builtinResourceDefinitionProphecy->getClass()->willReturn('Dunglas\ApiBundle\Api\Resource')->shouldBeCalled();
$builtinResourceDefinitionProphecy->getArgument(0)->willReturn('stdClass')->shouldBeCalled();
$builtinResourceDefinitionProphecy->hasMethodCall('initItemOperations')->willReturn(true)->shouldBeCalled();
$builtinResourceDefinitionProphecy->addMethodCall('initItemOperations')->shouldNotBeCalled();
$builtinResourceDefinitionProphecy->hasMethodCall('initCollectionOperations', Argument::any())->willReturn(true)->shouldBeCalled();
Expand All @@ -50,6 +51,7 @@ public function testProcess()
$decoratedResourceDefinitionProphecy = $this->prophesize('Symfony\Component\DependencyInjection\DefinitionDecorator');
$decoratedResourceDefinitionProphecy->getClass()->willReturn(false)->shouldBeCalled();
$decoratedResourceDefinitionProphecy->getParent()->willReturn('inner_resource')->shouldBeCalled();
$decoratedResourceDefinitionProphecy->getArgument(0)->willReturn('stdClass')->shouldBeCalled();
$decoratedResourceDefinitionProphecy->hasMethodCall('initItemOperations')->willReturn(false)->shouldBeCalled();
$decoratedResourceDefinitionProphecy->addMethodCall('initItemOperations', Argument::type('array'))->shouldBeCalled();
$decoratedResourceDefinitionProphecy->hasMethodCall('initCollectionOperations')->willReturn(false)->shouldBeCalled();
Expand Down
129 changes: 129 additions & 0 deletions features/crud_abstract.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
Feature: Create-Retrieve-Update-Delete on abstract resource
In order to use an hypermedia API
As a client software developer
I need to be able to retrieve, create, update and delete JSON-LD encoded resources even if they are abstract.

@createSchema
Scenario: Create a concrete resource
When I send a "POST" request to "/concrete_dummies" with body:
"""
{
"instance": "Concrete",
"name": "My Dummy"
}
"""
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"
And the JSON should be equal to:
"""
{
"@context": "/contexts/ConcreteDummy",
"@id": "/concrete_dummies/1",
"@type": "ConcreteDummy",
"instance": "Concrete",
"name": "My Dummy"
}
"""

Scenario: Get a resource
When I send a "GET" request to "/abstract_dummies/1"
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"
And the JSON should be equal to:
"""
{
"@context": "/contexts/ConcreteDummy",
"@id": "/concrete_dummies/1",
"@type": "ConcreteDummy",
"instance": "Concrete",
"name": "My Dummy"
}
"""

Scenario: Get a collection
When I send a "GET" request to "/abstract_dummies"
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"
And the JSON should be equal to:
"""
{
"@context": "/contexts/AbstractDummy",
"@id": "/abstract_dummies",
"@type": "hydra:PagedCollection",
"hydra:totalItems": 1,
"hydra:itemsPerPage": 3,
"hydra:firstPage": "/abstract_dummies",
"hydra:lastPage": "/abstract_dummies",
"hydra:member": [
{
"@id":"/concrete_dummies/1",
"@type":"ConcreteDummy",
"instance": "Concrete",
"name": "My Dummy"
}
],
"hydra:search": {
"@type": "hydra:IriTemplate",
"hydra:template": "\/abstract_dummies{?id,name,order[id],order[name]}",
"hydra:variableRepresentation": "BasicRepresentation",
"hydra:mapping": [
{
"@type": "IriTemplateMapping",
"variable": "id",
"property": "id",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "name",
"property": "name",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "order[id]",
"property": "id",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "order[name]",
"property": "name",
"required": false
}
]
}
}
"""

Scenario: Update a concrete resource
When I send a "PUT" request to "/concrete_dummies/1" with body:
"""
{
"@id": "/concrete_dummies/1",
"instance": "Become real",
"name": "A nice dummy"
}
"""
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"
And the JSON should be equal to:
"""
{
"@context": "/contexts/ConcreteDummy",
"@id": "/concrete_dummies/1",
"@type": "ConcreteDummy",
"instance": "Become real",
"name": "A nice dummy"
}
"""

@dropSchema
Scenario: Delete a resource
When I send a "DELETE" request to "/abstract_dummies/1"
Then the response status code should be 204
And the response should be empty
13 changes: 13 additions & 0 deletions features/fixtures/TestApp/config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@ services:
- "@my_relation_embedder_resource.item_operation.custom_get"
tags: [ { name: "api.resource" } ]

my_abstract_dummy_resource:
parent: "api.resource"
arguments: [ "Dunglas\ApiBundle\Tests\Behat\TestBundle\Entity\AbstractDummy" ]
calls:
- method: "initFilters"
arguments: [ [ "@my_dummy_resource.search_filter", "@my_dummy_resource.order_filter", "@my_dummy_resource.date_filter" ] ]
tags: [ { name: "api.resource" } ]

my_concrete_dummy_resource:
parent: "api.resource"
arguments: [ "Dunglas\ApiBundle\Tests\Behat\TestBundle\Entity\ConcreteDummy" ]
tags: [ { name: "api.resource" } ]

custom_resource:
parent: "api.resource"
class: "Dunglas\ApiBundle\Tests\Behat\TestBundle\Api\CustomResource"
Expand Down
Loading