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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
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
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
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
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
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
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
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
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
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
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
Expand Up @@ -25,4 +25,6 @@
hydra_context:
'@type': 'hydra:Operation'
'@hydra:title': 'File config Dummy'
stateless: true
iri: 'someirischema'
stateless: true