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

Add stateless ApiResource attribute #3436

Merged
merged 1 commit into from
Sep 25, 2020
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* Subresources: subresource resourceClass can now be defined as a container parameter in XML and Yaml definitions
* IriConverter: Fix IRI url double encoding - may cause breaking change as some characters no longer encoded in output (#3552)
* OpenAPI: **BC** Replace all characters other than `[a-zA-Z0-9\.\-_]` to `.` in definition names to be compliant with OpenAPI 3.0 (#3669)
* Add stateless ApiResource attribute

## 2.5.7

Expand Down
9 changes: 9 additions & 0 deletions src/Annotation/ApiResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
* @Attribute("securityPostDenormalize", type="string"),
* @Attribute("securityPostDenormalizeMessage", type="string"),
* @Attribute("shortName", type="string"),
* @Attribute("stateless", type="bool"),
* @Attribute("subresourceOperations", type="array"),
* @Attribute("sunset", type="string"),
* @Attribute("swaggerContext", type="array"),
Expand Down Expand Up @@ -118,6 +119,7 @@ final class ApiResource
'paginationPartial',
'paginationViaCursor',
'routePrefix',
'stateless',
'sunset',
'swaggerContext',
'urlGenerationStrategy',
Expand Down Expand Up @@ -414,6 +416,13 @@ final class ApiResource
*/
private $securityPostDenormalizeMessage;

/**
* @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112
*
* @var bool
*/
private $stateless;

/**
* @see https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed
* @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,11 @@ private function normalizeDefaults(array $defaults): array
}
}

if (!\array_key_exists('stateless', $defaults)) {
@trigger_error('Not setting the "api_platform.defaults.stateless" configuration is deprecated since API Platform 2.6 and it will default to `true` in 3.0. You can override this at the operation level if you have stateful operations (highly not recommended).', E_USER_DEPRECATED);
mtarld marked this conversation as resolved.
Show resolved Hide resolved
$normalizedDefaults['attributes']['stateless'] = false;
}

return $normalizedDefaults;
}

Expand Down
2 changes: 2 additions & 0 deletions src/Bridge/Symfony/Routing/ApiLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ public function load($data, $type = null): RouteCollection
[
'_controller' => $controller,
'_format' => null,
'_stateless' => $operation['stateless'] ?? $resourceMetadata->getAttribute('stateless'),
'_api_resource_class' => $operation['resource_class'],
'_api_subresource_operation_name' => $operation['route_name'],
'_api_subresource_context' => [
Expand Down Expand Up @@ -229,6 +230,7 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas
[
'_controller' => $controller,
'_format' => null,
'_stateless' => $operation['stateless'],
'_api_resource_class' => $resourceClass,
sprintf('_api_%s_operation_name', $operationType) => $operationName,
] + ($operation['defaults'] ?? []),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public function create(string $resourceClass): ResourceMetadata

$collectionOperations = $resourceMetadata->getCollectionOperations();
if (null === $collectionOperations) {
$resourceMetadata = $resourceMetadata->withCollectionOperations($this->createOperations($isAbstract ? ['GET'] : ['GET', 'POST']));
$resourceMetadata = $resourceMetadata->withCollectionOperations($this->createOperations($isAbstract ? ['GET'] : ['GET', 'POST'], $resourceMetadata));
} else {
$resourceMetadata = $this->normalize(true, $resourceClass, $resourceMetadata, $collectionOperations);
}
Expand All @@ -76,7 +76,7 @@ public function create(string $resourceClass): ResourceMetadata
}
}

$resourceMetadata = $resourceMetadata->withItemOperations($this->createOperations($methods));
$resourceMetadata = $resourceMetadata->withItemOperations($this->createOperations($methods, $resourceMetadata));
} else {
$resourceMetadata = $this->normalize(false, $resourceClass, $resourceMetadata, $itemOperations);
}
Expand All @@ -91,11 +91,11 @@ public function create(string $resourceClass): ResourceMetadata
return $resourceMetadata;
}

private function createOperations(array $methods): array
private function createOperations(array $methods, ResourceMetadata $resourceMetadata): array
{
$operations = [];
foreach ($methods as $method) {
$operations[strtolower($method)] = ['method' => $method];
$operations[strtolower($method)] = ['method' => $method, 'stateless' => $resourceMetadata->getAttribute('stateless')];
}

return $operations;
Expand Down Expand Up @@ -131,6 +131,8 @@ private function normalize(bool $collection, string $resourceClass, ResourceMeta
$operation['method'] = strtoupper($operation['method']);
}

$operation['stateless'] = $operation['stateless'] ?? $resourceMetadata->getAttribute('stateless');

$newOperations[$operationName] = $operation;
}

Expand Down
2 changes: 1 addition & 1 deletion src/Operation/Factory/SubresourceOperationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ final class SubresourceOperationFactory implements SubresourceOperationFactoryIn
{
public const SUBRESOURCE_SUFFIX = '_subresource';
public const FORMAT_SUFFIX = '.{_format}';
public const ROUTE_OPTIONS = ['defaults' => [], 'requirements' => [], 'options' => [], 'host' => '', 'schemes' => [], 'condition' => '', 'controller' => null];
public const ROUTE_OPTIONS = ['defaults' => [], 'requirements' => [], 'options' => [], 'host' => '', 'schemes' => [], 'condition' => '', 'controller' => null, 'stateless' => null];

private $resourceMetadataFactory;
private $propertyNameCollectionFactory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ class ApiPlatformExtensionTest extends TestCase
],
'defaults' => [
'attributes' => [],
'stateless' => true,
],
]];

Expand Down Expand Up @@ -720,7 +721,7 @@ public function testEnableElasticsearch()
$containerBuilderProphecy->registerForAutoconfiguration(RequestBodySearchCollectionExtensionInterface::class)->willReturn($this->childDefinitionProphecy)->shouldBeCalled();
$containerBuilderProphecy->setParameter('api_platform.elasticsearch.hosts', ['http://elasticsearch:9200'])->shouldBeCalled();
$containerBuilderProphecy->setParameter('api_platform.elasticsearch.mapping', [])->shouldBeCalled();
$containerBuilderProphecy->setParameter('api_platform.defaults', ['attributes' => []])->shouldBeCalled();
$containerBuilderProphecy->setParameter('api_platform.defaults', ['attributes' => ['stateless' => true]])->shouldBeCalled();

$config = self::DEFAULT_CONFIG;
$config['api_platform']['elasticsearch'] = [
Expand Down Expand Up @@ -871,7 +872,7 @@ private function getPartialContainerBuilderProphecy($configuration = null)
'api_platform.http_cache.vary' => ['Accept'],
'api_platform.http_cache.public' => null,
'api_platform.http_cache.invalidation.max_header_length' => 7500,
'api_platform.defaults' => ['attributes' => []],
'api_platform.defaults' => ['attributes' => ['stateless' => true]],
'api_platform.enable_entrypoint' => true,
'api_platform.enable_docs' => true,
'api_platform.url_generation_strategy' => 1,
Expand Down Expand Up @@ -1175,7 +1176,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo
'api_platform.resource_class_directories' => Argument::type('array'),
'api_platform.validator.serialize_payload_fields' => [],
'api_platform.elasticsearch.enabled' => false,
'api_platform.defaults' => ['attributes' => []],
'api_platform.defaults' => ['attributes' => ['stateless' => true]],
];

if ($hasSwagger) {
Expand Down
61 changes: 35 additions & 26 deletions tests/Bridge/Symfony/Routing/ApiLoaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,21 +52,25 @@ public function testApiLoader()
$resourceMetadata = $resourceMetadata->withShortName('dummy');
//default operation based on OperationResourceMetadataFactory
$resourceMetadata = $resourceMetadata->withItemOperations([
'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden']],
'put' => ['method' => 'PUT'],
'delete' => ['method' => 'DELETE'],
'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden'], 'stateless' => null],
'put' => ['method' => 'PUT', 'stateless' => null],
'delete' => ['method' => 'DELETE', 'stateless' => null],
]);
//custom operations
$resourceMetadata = $resourceMetadata->withCollectionOperations([
'my_op' => ['method' => 'GET', 'controller' => 'some.service.name', 'requirements' => ['_format' => 'a valid format'], 'defaults' => ['my_default' => 'default_value'], 'condition' => "request.headers.get('User-Agent') matches '/firefox/i'"], //with controller
'my_second_op' => ['method' => 'POST', 'options' => ['option' => 'option_value'], 'host' => '{subdomain}.api-platform.com', 'schemes' => ['https']], //without controller, takes the default one
'my_path_op' => ['method' => 'GET', 'path' => 'some/custom/path'], //custom path
'my_op' => ['method' => 'GET', 'controller' => 'some.service.name', 'requirements' => ['_format' => 'a valid format'], 'defaults' => ['my_default' => 'default_value'], 'condition' => "request.headers.get('User-Agent') matches '/firefox/i'", 'stateless' => null], //with controller
'my_second_op' => ['method' => 'POST', 'options' => ['option' => 'option_value'], 'host' => '{subdomain}.api-platform.com', 'schemes' => ['https'], 'stateless' => null], //without controller, takes the default one
'my_path_op' => ['method' => 'GET', 'path' => 'some/custom/path', 'stateless' => null], //custom path
'my_stateless_op' => ['method' => 'GET', 'stateless' => true],
]);
$resourceMetadata = $resourceMetadata->withSubresourceOperations([
'subresources_get_subresource' => ['stateless' => true],
]);

$routeCollection = $this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null);

$this->assertEquals(
$this->getRoute('/dummies/{id}.{_format}', 'api_platform.action.get_item', DummyEntity::class, 'get', ['GET'], false, ['id' => '\d+'], ['my_default' => 'default_value']),
$this->getRoute('/dummies/{id}.{_format}', 'api_platform.action.get_item', DummyEntity::class, 'get', ['GET'], false, ['id' => '\d+'], ['my_default' => 'default_value', '_stateless' => null]),
$routeCollection->get('api_dummies_get_item')
);

Expand All @@ -81,12 +85,12 @@ public function testApiLoader()
);

$this->assertEquals(
$this->getRoute('/dummies.{_format}', 'some.service.name', DummyEntity::class, 'my_op', ['GET'], true, ['_format' => 'a valid format'], ['my_default' => 'default_value'], [], '', [], "request.headers.get('User-Agent') matches '/firefox/i'"),
$this->getRoute('/dummies.{_format}', 'some.service.name', DummyEntity::class, 'my_op', ['GET'], true, ['_format' => 'a valid format'], ['my_default' => 'default_value', '_stateless' => null], [], '', [], "request.headers.get('User-Agent') matches '/firefox/i'"),
$routeCollection->get('api_dummies_my_op_collection')
);

$this->assertEquals(
$this->getRoute('/dummies.{_format}', 'api_platform.action.post_collection', DummyEntity::class, 'my_second_op', ['POST'], true, [], [], ['option' => 'option_value'], '{subdomain}.api-platform.com', ['https']),
$this->getRoute('/dummies.{_format}', 'api_platform.action.post_collection', DummyEntity::class, 'my_second_op', ['POST'], true, [], ['_stateless' => null], ['option' => 'option_value'], '{subdomain}.api-platform.com', ['https']),
$routeCollection->get('api_dummies_my_second_op_collection')
);

Expand All @@ -96,7 +100,12 @@ public function testApiLoader()
);

$this->assertEquals(
$this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', DummyEntity::class, true]], 'collection' => true, 'operationId' => 'api_dummies_subresources_get_subresource']),
$this->getRoute('/dummies.{_format}', 'api_platform.action.get_collection', DummyEntity::class, 'my_stateless_op', ['GET'], true, [], ['_stateless' => true]),
$routeCollection->get('api_dummies_my_stateless_op_collection')
);

$this->assertEquals(
$this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', DummyEntity::class, true]], 'collection' => true, 'operationId' => 'api_dummies_subresources_get_subresource'], [], ['_stateless' => true]),
$routeCollection->get('api_dummies_subresources_get_subresource')
);
}
Expand All @@ -106,16 +115,16 @@ public function testApiLoaderWithPrefix()
$resourceMetadata = new ResourceMetadata();
$resourceMetadata = $resourceMetadata->withShortName('dummy');
$resourceMetadata = $resourceMetadata->withItemOperations([
'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden']],
'put' => ['method' => 'PUT'],
'delete' => ['method' => 'DELETE'],
'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden'], 'stateless' => null],
'put' => ['method' => 'PUT', 'stateless' => null],
'delete' => ['method' => 'DELETE', 'stateless' => null],
]);
$resourceMetadata = $resourceMetadata->withAttributes(['route_prefix' => '/foobar-prefix']);

$routeCollection = $this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null);

$this->assertEquals(
$this->getRoute('/foobar-prefix/dummies/{id}.{_format}', 'api_platform.action.get_item', DummyEntity::class, 'get', ['GET'], false, ['id' => '\d+'], ['my_default' => 'default_value']),
$this->getRoute('/foobar-prefix/dummies/{id}.{_format}', 'api_platform.action.get_item', DummyEntity::class, 'get', ['GET'], false, ['id' => '\d+'], ['my_default' => 'default_value', '_stateless' => null]),
$routeCollection->get('api_dummies_get_item')
);

Expand All @@ -142,7 +151,7 @@ public function testNoMethodApiLoader()
]);

$resourceMetadata = $resourceMetadata->withCollectionOperations([
'get' => ['method' => 'GET'],
'get' => ['method' => 'GET', 'stateless' => null],
]);

$this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null);
Expand All @@ -156,11 +165,11 @@ public function testWrongMethodApiLoader()
$resourceMetadata = $resourceMetadata->withShortName('dummy');

$resourceMetadata = $resourceMetadata->withItemOperations([
'post' => ['method' => 'POST'],
'post' => ['method' => 'POST', 'stateless' => null],
]);

$resourceMetadata = $resourceMetadata->withCollectionOperations([
'get' => ['method' => 'GET'],
'get' => ['method' => 'GET', 'stateless' => null],
]);

$this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null);
Expand All @@ -178,14 +187,14 @@ public function testRecursiveSubresource()
$resourceMetadata = new ResourceMetadata();
$resourceMetadata = $resourceMetadata->withShortName('dummy');
$resourceMetadata = $resourceMetadata->withItemOperations([
'get' => ['method' => 'GET'],
'put' => ['method' => 'PUT'],
'delete' => ['method' => 'DELETE'],
'get' => ['method' => 'GET', 'stateless' => null],
'put' => ['method' => 'PUT', 'stateless' => null],
'delete' => ['method' => 'DELETE', 'stateless' => null],
]);
$resourceMetadata = $resourceMetadata->withCollectionOperations([
'my_op' => ['method' => 'GET', 'controller' => 'some.service.name'], //with controller
'my_second_op' => ['method' => 'POST'], //without controller, takes the default one
'my_path_op' => ['method' => 'GET', 'path' => 'some/custom/path'], //custom path
'my_op' => ['method' => 'GET', 'controller' => 'some.service.name', 'stateless' => null], //with controller
'my_second_op' => ['method' => 'POST', 'stateless' => null], //without controller, takes the default one
'my_path_op' => ['method' => 'GET', 'path' => 'some/custom/path', 'stateless' => null], //custom path
]);

$routeCollection = $this->getApiLoaderWithResourceMetadata($resourceMetadata, true)->load(null);
Expand Down Expand Up @@ -297,7 +306,7 @@ private function getApiLoaderWithResourceMetadata(ResourceMetadata $resourceMeta
return new ApiLoader($kernelProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactory, $operationPathResolver, $containerProphecy->reveal(), ['jsonld' => ['application/ld+json']], [], $subresourceOperationFactory, false, true, true, false, false);
}

private function getRoute(string $path, string $controller, string $resourceClass, string $operationName, array $methods, bool $collection = false, array $requirements = [], array $extraDefaults = [], array $options = [], string $host = '', array $schemes = [], string $condition = ''): Route
private function getRoute(string $path, string $controller, string $resourceClass, string $operationName, array $methods, bool $collection = false, array $requirements = [], array $extraDefaults = ['_stateless' => null], array $options = [], string $host = '', array $schemes = [], string $condition = ''): Route
{
return new Route(
$path,
Expand All @@ -316,7 +325,7 @@ private function getRoute(string $path, string $controller, string $resourceClas
);
}

private function getSubresourceRoute(string $path, string $controller, string $resourceClass, string $operationName, array $context, array $requirements = []): Route
private function getSubresourceRoute(string $path, string $controller, string $resourceClass, string $operationName, array $context, array $requirements = [], array $extraDefaults = ['_stateless' => null]): Route
{
return new Route(
$path,
Expand All @@ -326,7 +335,7 @@ private function getSubresourceRoute(string $path, string $controller, string $r
'_api_resource_class' => $resourceClass,
'_api_subresource_operation_name' => $operationName,
'_api_subresource_context' => $context,
],
] + $extraDefaults,
$requirements,
[],
'',
Expand Down
1 change: 1 addition & 0 deletions tests/Fixtures/FileConfigurations/resources.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
<attribute name="@type">hydra:Operation</attribute>
<attribute name="@hydra:title">File config Dummy</attribute>
</attribute>
<attribute name="stateless">true</attribute>

<property
name="foo"
Expand Down
1 change: 1 addition & 0 deletions tests/Fixtures/FileConfigurations/resources.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ resources:
hydra_context:
'@type': 'hydra:Operation'
'@hydra:title': !php/const ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FileConfigDummy::HYDRA_TITLE
stateless: true
iri: 'someirischema'
properties:
'foo':
Expand Down
2 changes: 2 additions & 0 deletions tests/Fixtures/FileConfigurations/single_resource.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@
hydra_context:
'@type': 'hydra:Operation'
'@hydra:title': 'File config Dummy'
stateless: true
iri: 'someirischema'
stateless: true
Loading