Skip to content

[Proposal] Add Stages #2978

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

Closed
wants to merge 1 commit into from
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
16 changes: 11 additions & 5 deletions src/Bridge/Symfony/Bundle/Resources/config/api.xml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@
<service id="api_platform.path_segment_name_generator.underscore" class="ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator" public="false" />
<service id="api_platform.path_segment_name_generator.dash" class="ApiPlatform\Core\Operation\DashPathSegmentNameGenerator" public="false" />

<!-- Stages -->

<service id="api_platform.stage.read" class="ApiPlatform\Core\Stage\ReadStage">
<argument type="service" id="api_platform.collection_data_provider" />
<argument type="service" id="api_platform.item_data_provider" />
<argument type="service" id="api_platform.subresource_data_provider" />
<argument type="service" id="api_platform.identifier.converter" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
</service>

<!-- Event listeners -->

<!-- kernel.request priority must be < 8 to be executed after the Firewall -->
Expand All @@ -165,12 +175,8 @@
</service>

<service id="api_platform.listener.request.read" class="ApiPlatform\Core\EventListener\ReadListener">
<argument type="service" id="api_platform.collection_data_provider" />
<argument type="service" id="api_platform.item_data_provider" />
<argument type="service" id="api_platform.subresource_data_provider" />
<argument type="service" id="api_platform.stage.read" />
<argument type="service" id="api_platform.serializer.context_builder" />
<argument type="service" id="api_platform.identifier.converter" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />

<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="4" />
</service>
Expand Down
91 changes: 35 additions & 56 deletions src/EventListener/ReadListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,13 @@

use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
use ApiPlatform\Core\DataProvider\OperationDataProviderTrait;
use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface;
use ApiPlatform\Core\Exception\InvalidIdentifierException;
use ApiPlatform\Core\Exception\RuntimeException;
use ApiPlatform\Core\Exception\NotFoundException;
use ApiPlatform\Core\Identifier\IdentifierConverterInterface;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ToggleableOperationAttributeTrait;
use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface;
use ApiPlatform\Core\Stage\ReadStage;
use ApiPlatform\Core\Stage\ReadStageInterface;
use ApiPlatform\Core\Util\RequestAttributesExtractor;
use ApiPlatform\Core\Util\RequestParser;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
Expand All @@ -35,21 +34,29 @@
*/
final class ReadListener
{
use OperationDataProviderTrait;
use ToggleableOperationAttributeTrait;

public const OPERATION_ATTRIBUTE_KEY = 'read';

private $readStage;
private $serializerContextBuilder;

public function __construct(CollectionDataProviderInterface $collectionDataProvider, ItemDataProviderInterface $itemDataProvider, SubresourceDataProviderInterface $subresourceDataProvider = null, SerializerContextBuilderInterface $serializerContextBuilder = null, IdentifierConverterInterface $identifierConverter = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null)
public function __construct(/*CollectionDataProviderInterface */$readStage, /*ItemDataProviderInterface */$serializerContextBuilder/*, SubresourceDataProviderInterface $subresourceDataProvider = null, SerializerContextBuilderInterface $serializerContextBuilder = null, IdentifierConverterInterface $identifierConverter = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null*/)
{
$this->collectionDataProvider = $collectionDataProvider;
$this->itemDataProvider = $itemDataProvider;
$this->subresourceDataProvider = $subresourceDataProvider;
$this->readStage = $readStage;
$this->serializerContextBuilder = $serializerContextBuilder;
$this->identifierConverter = $identifierConverter;
$this->resourceMetadataFactory = $resourceMetadataFactory;

if (\func_num_args() > 2) {
@trigger_error(sprintf('Not injecting only "%s" and "%s" is deprecated since API Platform 2.5 and will not be possible anymore in API Platform 3', ReadStageInterface::class, SerializerContextBuilderInterface::class), E_USER_DEPRECATED);

$collectionDataProvider = $readStage;
$itemDataProvider = $serializerContextBuilder;
$subresourceDataProvider = func_get_arg(2);
$serializerContextBuilder = func_get_arg(3);
$identifierConverter = func_get_arg(4);
$resourceMetadataFactory = func_get_arg(5);

$this->readStage = new ReadStage($collectionDataProvider, $itemDataProvider, $subresourceDataProvider, $identifierConverter, $resourceMetadataFactory);
$this->serializerContextBuilder = $serializerContextBuilder;
}
}

/**
Expand All @@ -60,61 +67,33 @@ public function __construct(CollectionDataProviderInterface $collectionDataProvi
public function onKernelRequest(GetResponseEvent $event): void
{
$request = $event->getRequest();
if (
!($attributes = RequestAttributesExtractor::extractAttributes($request))
|| !$attributes['receive']
|| $request->isMethod('POST') && isset($attributes['collection_operation_name'])
|| $this->isOperationAttributeDisabled($attributes, self::OPERATION_ATTRIBUTE_KEY)
) {
return;
}

if (null === $filters = $request->attributes->get('_api_filters')) {
$attributes = RequestAttributesExtractor::extractAttributes($request);
$parameters = $request->attributes->all();
if (null === $filters = ($parameters['_api_filters'] ?? null)) {
$queryString = RequestParser::getQueryString($request);
$filters = $queryString ? RequestParser::parseRequestParams($queryString) : null;
}

$context = null === $filters ? [] : ['filters' => $filters];
$normalizationContext = [];
if ($this->serializerContextBuilder) {
// Builtin data providers are able to use the serialization context to automatically add join clauses
$context += $normalizationContext = $this->serializerContextBuilder->createFromRequest($request, true, $attributes);
$request->attributes->set('_api_normalization_context', $normalizationContext);
}

if (isset($attributes['collection_operation_name'])) {
$request->attributes->set('data', $this->getCollectionData($attributes, $context));

return;
}

$data = [];

if ($this->identifierConverter) {
$context[IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER] = true;
$normalizationContext = $this->serializerContextBuilder->createFromRequest($request, true, $attributes);
}

try {
$identifiers = $this->extractIdentifiers($request->attributes->all(), $attributes);

if (isset($attributes['item_operation_name'])) {
$data = $this->getItemData($identifiers, $attributes, $context);
} elseif (isset($attributes['subresource_operation_name'])) {
// Legacy
if (null === $this->subresourceDataProvider) {
throw new RuntimeException('No subresource data provider.');
}

$data = $this->getSubresourceData($identifiers, $attributes, $context);
}
} catch (InvalidIdentifierException $e) {
throw new NotFoundHttpException('Not found, because of an invalid identifier configuration', $e);
$data = $this->readStage->apply($attributes, $parameters, $filters, $request->getMethod(), $normalizationContext);
} catch (NotFoundException $e) {
throw new NotFoundHttpException($e->getMessage(), $e->getPrevious());
}

if (null === $data) {
throw new NotFoundHttpException('Not Found');
return;
}

if ($normalizationContext) {
// Builtin data providers are able to use the serialization context to automatically add join clauses
$request->attributes->set('_api_normalization_context', $normalizationContext);
}

$request->attributes->set('data', $data);
$request->attributes->set('previous_data', \is_object($data) ? clone $data : $data);
$request->attributes->set('previous_data', \is_object($data) && (new \ReflectionClass(\get_class($data)))->isCloneable() ? clone $data : $data);
Copy link
Member

Choose a reason for hiding this comment

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

This should be backported on 2.4 as a bugfix.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is not really a bugfix. It's because now this method is always called whereas before it was not called if the operation was a collection.

Copy link
Member

Choose a reason for hiding this comment

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

An issue has been reported for non-cloneable objects IIRC

Copy link
Member Author

Choose a reason for hiding this comment

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

In this case, without this change, the tests wouldn't pass.

}
}
21 changes: 21 additions & 0 deletions src/Exception/NotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\Exception;

/**
* @author Alan Poulain <[email protected]>
*/
final class NotFoundException extends \Exception implements ExceptionInterface
{
}
4 changes: 4 additions & 0 deletions src/Serializer/SerializerContextBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ public function createFromRequest(Request $request, bool $normalization, array $
throw new RuntimeException('Request attributes are not valid.');
}

if (!isset($attributes['resource_class'])) {
return [];
}

$resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']);
$key = $normalization ? 'normalization_context' : 'denormalization_context';
if (isset($attributes['collection_operation_name'])) {
Expand Down
4 changes: 4 additions & 0 deletions src/Serializer/SerializerFilterContextBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ public function createFromRequest(Request $request, bool $normalization, array $
throw new RuntimeException('Request attributes are not valid.');
}

if (!isset($attributes['resource_class'])) {
return [];
}

$context = $this->decorated->createFromRequest($request, $normalization, $attributes);
$resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']);

Expand Down
94 changes: 94 additions & 0 deletions src/Stage/ReadStage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\Stage;

use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
use ApiPlatform\Core\DataProvider\OperationDataProviderTrait;
use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface;
use ApiPlatform\Core\Exception\InvalidIdentifierException;
use ApiPlatform\Core\Exception\NotFoundException;
use ApiPlatform\Core\Exception\RuntimeException;
use ApiPlatform\Core\Identifier\IdentifierConverterInterface;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ToggleableOperationAttributeTrait;

/**
* Retrieves data from the applicable data provider.
*
* @author Alan Poulain <[email protected]>
*/
final class ReadStage implements ReadStageInterface
Copy link
Member

Choose a reason for hiding this comment

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

Why isn't this class also used by the GraphQL resolver?

Copy link
Member Author

Choose a reason for hiding this comment

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

I would like to merge both too but sadly they are very different. I don't think it will be doable easily.

Copy link
Member

Choose a reason for hiding this comment

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

But then it doesn’t fix the issue of having good extensions points working both for REST and GraphQL. Which is one of the goals.

Copy link
Member Author

Choose a reason for hiding this comment

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

Why not having different ones? The need to use both REST and GraphQL is not common. It would just mean having two different service names to use.

Copy link

Choose a reason for hiding this comment

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

To me it's very common, implementing both. That's actually what drove me to API Platform in the first place, I thought it would provide a(nother) way to DRY my Controllers and Resolvers. If you feel it's too much work for this MR, that's of course your prerogative, but please don't sacrifice forever the goal of unification. Also, Stage feels okay ; it fits, and I can't find anything I deem more suitable.

Copy link
Member Author

Choose a reason for hiding this comment

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

We could introduce stage for both REST and GraphQL, but the service will be an empty shell with practically no code IMO. It will be just there to be decorated.

{
use OperationDataProviderTrait;
use ToggleableOperationAttributeTrait;

public function __construct(CollectionDataProviderInterface $collectionDataProvider, ItemDataProviderInterface $itemDataProvider, SubresourceDataProviderInterface $subresourceDataProvider = null, IdentifierConverterInterface $identifierConverter = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null)
{
$this->collectionDataProvider = $collectionDataProvider;
$this->itemDataProvider = $itemDataProvider;
$this->subresourceDataProvider = $subresourceDataProvider;
$this->identifierConverter = $identifierConverter;
$this->resourceMetadataFactory = $resourceMetadataFactory;
}

/**
* {@inheritdoc}
*/
public function apply(array $attributes, array $parameters, ?array $filters, string $method, array $normalizationContext)
Copy link
Member

Choose a reason for hiding this comment

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

Instead of this long list of unstructured and untyped parameters, shouldn't we introduce a new ApiRequest or ApiQuery/ApiCommand class(es), that we'll construct from the (Symfony/Laravel/PSR-7) HTTP request, or from the GraphQL query, or from any other source of request regarless of the protocol. It will allow to have a more strict typing, and will improve the evolvability of API Platform (in case we need to add more data over the time).

Basically, the responsibility of the Symfony Event Listener, or of the Laravel middleware, or of the GraphQL resolver would be to create this ApiRequest instance.

Copy link
Member Author

Choose a reason for hiding this comment

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

I've always wondered if it would be interesting to introduce this kind of class instead of having a lot of parameters and a context array with more things inside 😅
I'm 👍 for it.

{
if (!isset($attributes['resource_class'])
|| !$attributes['receive']
|| ('POST' === $method && isset($attributes['collection_operation_name']))
|| $this->isOperationAttributeDisabled($attributes, self::OPERATION_ATTRIBUTE_KEY)
) {
return null;
}

$context = null === $filters ? [] : ['filters' => $filters];
$context += $normalizationContext ?? [];

if (isset($attributes['collection_operation_name'])) {
return $this->getCollectionData($attributes, $context);
}

$data = [];

if ($this->identifierConverter) {
$context[IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER] = true;
}

try {
$identifiers = $this->extractIdentifiers($parameters, $attributes);

if (isset($attributes['item_operation_name'])) {
$data = $this->getItemData($identifiers, $attributes, $context);
} elseif (isset($attributes['subresource_operation_name'])) {
if (null === $this->subresourceDataProvider) {
throw new RuntimeException('No subresource data provider.');
}

$data = $this->getSubresourceData($identifiers, $attributes, $context);
}
} catch (InvalidIdentifierException $e) {
throw new NotFoundException('Not found, because of an invalid identifier configuration', 0, $e);
}

if (null === $data) {
throw new NotFoundException('Not Found');
}

return $data;
}
}
29 changes: 29 additions & 0 deletions src/Stage/ReadStageInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\Stage;

/**
* Retrieves data from the applicable data provider.
*
* @author Alan Poulain <[email protected]>
*/
interface ReadStageInterface
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if this new concept is really necessary. Does this logic belong to the DataProvider? Or should we create a ApiRequestFactory class instead (see my previous comment?). I'm not sure yet, I need to think about it, but I've the feeling that we're introducing unnecessary indirection layers.

Copy link
Member Author

@alanpoulain alanpoulain Aug 10, 2019

Choose a reason for hiding this comment

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

I think their responsibility is clear:

  • allow or not to apply the stage
  • extract, collect and normalize the right parameters (mainly extract the identifiers and build the (de)normalization context)
  • call the good "solver"

We could add the second point inside an ApiRequestFactory. But IMO it will not necessarily be a good thing. It will have a lot of conditional clauses and could quickly become a monster.

And I don't know where we could put the other parts. Maybe having a ChainDataProvider and modify the ChainDataPersister to choose the right method (persist or remove to call based on the ApiRequest)?

Copy link
Member Author

Choose a reason for hiding this comment

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

Also IMHO, it's easier for the user to understand and decorate this kind of service than to use the data provider / persister and the serializer.

Copy link
Member

Choose a reason for hiding this comment

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

I’m really not sure about the last point! It’s more concepts to learn with no clear responsibilities.

Copy link
Member Author

Choose a reason for hiding this comment

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

I guess it depends of the developer and their way to think.
But a lot of developers are using the events already to add more logic so the need is there IMO.

{
public const OPERATION_ATTRIBUTE_KEY = 'read';

/**
* @return object|iterable|null
*/
public function apply(array $attributes, array $parameters, ?array $filters, string $method, array $normalizationContext);
Copy link
Member

Choose a reason for hiding this comment

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

apply isn't very descriptive (I'm not a fond of "stage" too btw :D). Can't we more explicit names?

Copy link
Member Author

Choose a reason for hiding this comment

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

Which one do you have in mind? Something like read?
I know you would say that 😄 Naming is really hard and subjective. "Stage" was interesting because it was not used and its meaning was alright: it's a "step in a process".

Copy link
Member

Choose a reason for hiding this comment

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

Stage sounds like “util” or “service” to me: too generic and not very descriptive

Copy link
Member Author

Choose a reason for hiding this comment

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

But it is something kind of generic. It's a step or an operation. How would you call the read, serialize, validate, etc. things?

Copy link
Contributor

Choose a reason for hiding this comment

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

Make it a callable? __invoke?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good idea! WDYT @dunglas?

}
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,7 @@ private function getPartialContainerBuilderProphecy()
'api_platform.identifier.integer',
'api_platform.identifier.uuid_normalizer',
'api_platform.item_data_provider',
'api_platform.stage.read',
'api_platform.listener.exception',
'api_platform.listener.exception.validation',
'api_platform.listener.request.add_format',
Expand Down