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

Improve Input/Output with normalizers #2495

Merged
merged 2 commits into from
Feb 19, 2019

Conversation

soyuka
Copy link
Member

@soyuka soyuka commented Feb 5, 2019

Q A
Bug fix? yes
New feature? no
BC breaks? no
Deprecations? no
Tests pass? yes
Fixed tickets #2488
License MIT
Doc PR todo

Input/Output through normalizers.
Outputs can be used in collections.
Functional tests through behat with input/output specifications by operation and on the resource.
Tests with relations on DTO classes, the Json-ld normalizer now works with any class (it doesn't have to be a ResourceClass anymore).
When a class is not a resource class but gets denormalized, its ResourceMetadata defaults to a shortname based on the DTO classname.
When an IRI is needed, the blank node identifier is set to : _:md5(serialize($object)).

@soyuka soyuka force-pushed the test-input-output branch from 161f236 to cd5067c Compare February 5, 2019 09:19
src/EventListener/DeserializeListener.php Outdated Show resolved Hide resolved
src/EventListener/SerializeListener.php Outdated Show resolved Hide resolved
src/Hydra/Serializer/CollectionFiltersNormalizer.php Outdated Show resolved Hide resolved
@soyuka soyuka force-pushed the test-input-output branch 2 times, most recently from 1fba45a to 3408ca9 Compare February 5, 2019 13:49
{
return RecoverPasswordInput::class === ($context['input_class'] ?? null);
}
}
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 example will be used in the documentation. @ragboyjr maybe that you have remarks about this?

Copy link
Contributor

Choose a reason for hiding this comment

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

Is part of the new requirements of input/output classes are that we need to create our own normalizers/denormalizers?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I'm working on a graph to explain how things works but it's definitely better then using persisters and providers.

Copy link
Contributor

Choose a reason for hiding this comment

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

Interesting, I can't speak for everyone, but my use case with input/output classes would be that I'm creating a custom handler for a specific endpoint. So instead of allowing ApiP to use their defaults, I'm intercepting it.

With messenger enabled, this is incredibly elegant, you set your input class, it gets routed to messenger which then routes to a service tagged as a message handler which is responsible for handling the input dto, creating the resource, persist it, then return it.

Without messenger, I feel like the next best solution is you'd set the input class, create a custom controller for the specific operation, and set api_persist=false since your controller will handle it.

With these new changes, that flow gets a lot more complicated since we'd know need to setup a new normalizer/denormalizer to bring our array data into the input dto, and then route it into the system. I'm not sure business logic should be put into data persisters/providers or serializers.

My goal would be that input/output classes get automatically normalized/denormalized according to the custom services created via messenger or via a custom controller.

Copy link
Member Author

Choose a reason for hiding this comment

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

Without messenger, I feel like the next best solution is you'd set the input class, create a custom controller for the specific operation, and set api_persist=false since your controller will handle it.

About this, we talked about deprecating api_persist and things like this in favor of input_class=false (does the same thing).

My goal would be that input/output classes get automatically normalized/denormalized according to the custom services created via messenger or via a custom controller.

I think that this is definitely feasible.

Copy link
Contributor

Choose a reason for hiding this comment

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

So my understanding with api_persist=false was that it disables the write listener. Why would input_class=false disable that? Also input_class=null which is the default means use resource_class for input, input_class=SomeClass means use that for input, input_class=false means disable the write listener?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's a shortcut introduced by @dunglas in e10d658#diff-0170cc702a42034d94c5d83ccadad1deR81. It totally makes sense when you say "output_class=false" it means that you don't want any output, the response will therefore be empty. With input_class=false it says "don't read my data" (skips the deserialization).

Copy link
Contributor

Choose a reason for hiding this comment

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

Gotcha, that makes sense, I think you meant output_class=class is the same as api_persist=false not input_class=false.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes sorry!

@soyuka soyuka force-pushed the test-input-output branch from 3408ca9 to 89dfb20 Compare February 5, 2019 14:04
@soyuka
Copy link
Member Author

soyuka commented Feb 5, 2019

I broke a lot of tests, I'll fix them when/if we validate the changes.

@soyuka soyuka force-pushed the test-input-output branch 7 times, most recently from 8e6b043 to c849588 Compare February 8, 2019 13:45
@ragboyjr
Copy link
Contributor

ragboyjr commented Feb 8, 2019

@soyuka are you still moving forward with the normalizer/denormalizer design for handling input/output classes?

@soyuka soyuka force-pushed the test-input-output branch from c849588 to dfbde56 Compare February 8, 2019 15:50
@soyuka
Copy link
Member Author

soyuka commented Feb 8, 2019

Yes it's almost RFR waiting for tests to pass!

@ragboyjr
Copy link
Contributor

ragboyjr commented Feb 8, 2019

@soyuka Can you provide an example of you foresee the new system working with normalizers? It looks as if this PR expects us to put business logic in normalizers and treat them more like factories or something, was just a bit concerned on that point.

@soyuka
Copy link
Member Author

soyuka commented Feb 10, 2019

I've put my example in a separated commit for you: ae5ab60

I want to use these for the docs after.

@ragboyjr
Copy link
Contributor

Thanks @soyuka

Going off of the examples you provided in that commit, I don't think using normalizers to utilize input/output classes is the best way to integrate these features for a few reasons:

  1. Normalizers are library code responsible for transforming objects into arrays and vice versa. Extending that to also perform business logic seems like it would break Single Responsibility Principal i.e. we are both normalizing an array of objects AND performing critical business logic.

  2. Normalizing is meant to be more of "pure" process meaning no IO or side affects should occur during it. Creating a normalizer that performs side affects could definitely cause issues related to the Liskov Substitution Principle i.e. expecting a normalization to be pure operation that can be done over and over may no longer work when embedding business logic inside of the process.

  3. In the context of implementing CQRS (which is the main reason IMHO input/output classes are used), creating a normalizer or data persister feels like the wrong place to be making these hook points for writing commands into your domain model. It's not very self evident that normalizing an array into a CreateBookRequest (input class) would actually perform business logic for persisting into the system. One would expect to just return the CreateBookRequest object.

    Additionally, when using normalizers/data persisters, In both cases you need to create a class which implements unused methods in order to take control of the write model. Message handlers or a custom controller action would provide a nice explicit API that can be used outside of the context of ApiP and better the fit the mentality for writing commands into system.

If I may offer an alternative solution (I'd also be very willing to code up this feature and submit a PR), I suggest we should focus on streamlining the configuration for using custom controller actions and/or messenger handlers for the standard ways of utilizing input/output classes per resource operations.

Message Handlers, like Custom Controllers are very simple to make and autowire with SF out of the box. Controllers and Message Handlers can easily wrap a domain or application service, and for dx, we'd even be able to expose application services as Controllers or Message Handlers.

The moment a user wants to use a custom input class, they are already relinquishing the automation from API Platform in favor of an explicit hand written write model (while not sacrificing features with read model). So utilizing a custom controller action or message handler would better fit with expectations on implementing custom write model.

@soyuka
Copy link
Member Author

soyuka commented Feb 11, 2019

Normalizers are library code responsible for transforming objects into arrays and vice versa.

Not necessarily, IMO we can extend that (de)normalization principle to something wider, take something in input and give back something else on output. We're already doing this with identifiers for example.

Actually, let me illustrate the more complex case we're facing today with input/output classes. Say we have a PUT request, where the developer wants:

  1. A custom input
  2. Re-use the object that the Data Provider read from the :id, the object to update
  3. A custom output

We draw multiple diagrams with @dunglas and we ended up with the following:

api-platform-put-i-o

Note that we use every elements of our current chain (validation, persister, provider) without changing them.
From that on, GET, POST operations can be easily deduced and are easy to set up.

It's not very self evident that normalizing an array into a CreateBookRequest (input class) would actually perform business logic for persisting into the system. One would expect to just return the CreateBookRequest object.

There is no persisting logic actually, and indeed the normalization would just return the Input or Output class regarding which side we're on. Or maybe I missed something.
What's also great about using a normalizer is that you'd be able to use ld+json with classes that aren't resource classes, for example sending the following Input:

{ "user": "/users/1", "recover": "foo@example.com" }

About controller's and messenger handler's, they could be a good alternative but in my above example they can still be added to the stack and Api Platform doesn't depend on them. We need to think a bit outside the symfony's box here because we also don't want to be to tight to symfony, for example Api Platform needs to work with laravel at some point (joins the #2506 event simplification proposal).

@@ -257,8 +257,8 @@ private function getHydraOperation(string $resourceClass, ResourceMetadata $reso
}

$shortName = $resourceMetadata->getShortName();
$inputClass = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input_class', null, true);
$outputClass = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output_class', null, true);
$inputClass = $resourceMetadata->getTypedOperationIOClass($operationType, $operationName, 'input', false);
Copy link
Member

Choose a reason for hiding this comment

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

We could maybe use constants for input / output?

@@ -42,9 +42,9 @@ final class ItemNormalizer extends AbstractItemNormalizer
private $componentsCache = [];
private $resourceMetadataFactory;

public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [])
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], bool $allowDtoClass = false)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], bool $allowDtoClass = false)
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], bool $allowIOClass = false)

{
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext);
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $allowDtoClass);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $allowDtoClass);
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $allowIOClass);

/**
* {@inheritdoc}
*/
public function getIOResourceContext($object, array $context, int $referenceType = UrlGeneratorInterface::ABS_PATH): array
Copy link
Member

Choose a reason for hiding this comment

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

Maybe could we drop the IO part in the name? It works for any object actually

{
/**
* Creates a JSON-LD context based on the given object.
* Usually this is used with an Input or Output DTO object.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* Usually this is used with an Input or Output DTO object.
* Usually this is used with an Input or Output objects.

*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
interface IOContextBuilderInterface extends ContextBuilderInterface
Copy link
Member

Choose a reason for hiding this comment

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

Anonymous instead of IO? It can be used in a generic way to serialize any object in JSON-LD.

{
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, null, false, $defaultContext);
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, null, false, $defaultContext, $allowDtoClass);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, null, false, $defaultContext, $allowDtoClass);
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, null, false, $defaultContext, $allowIOClass);

@@ -41,9 +41,9 @@ final class ItemNormalizer extends AbstractItemNormalizer
private $resourceMetadataFactory;
private $contextBuilder;

public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [])
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], bool $allowDtoClass = false)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], bool $allowDtoClass = false)
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], bool $allowIOClass = false)

return ['class' => null];
}

if (null === $ioAttribute) {
Copy link
Member

Choose a reason for hiding this comment

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

Should probably be the first test (nitpicking)

$ioAttribute = ['class' => $ioAttribute];
}

if (!isset($ioAttribute['name'])) {
Copy link
Member

Choose a reason for hiding this comment

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

Can't we use the reflection instead? getShortName()?

Copy link
Member

Choose a reason for hiding this comment

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

By the way, it should be done in the factory to allow caching, instead of directly here.

/**
* Get the resource I/O class if any.
*/
public function getTypedResourceIOClass(string $ioType, $fallback = null)
Copy link
Member

Choose a reason for hiding this comment

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

We should remove this method and normalize the data in the factory to always populate an array?

@@ -119,7 +137,15 @@ public function normalize($object, $format = null, array $context = [])
*/
public function supportsDenormalization($data, $type, $format = null)
{
return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type);
if ('elasticsearch' === $format) {
Copy link
Member

Choose a reason for hiding this comment

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

It's weird isn't it?

Copy link
Member Author

Choose a reason for hiding this comment

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

change context

Copy link
Contributor

Choose a reason for hiding this comment

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

 if (ApiPlatform\Core\Bridge\Elasticsearch\Serializer\ItemNormalizer::FORMAT === $format)

@dunglas
Copy link
Member

dunglas commented Feb 11, 2019

The problem with our approach is that we breaks the signature of the Symfony normalizer: @return array|string|int|float|bool (https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Serializer/Normalizer/NormalizerInterface.php#L33).

What we could maybe do is:

  1. Create new InputToResourceTransformerInterface and ResourceToOutputTransformerInterface
  2. Directly in ItemNormalizer, if output or input is defined, call the transformer

I think it will also address @ragboyjr concerns and it's slight change. WDYT?

@ragboyjr
Copy link
Contributor

OK, @soyuka thank you for the flow chart, it helps me understand a lot more where you guys are coming from.

First, @dunglas using transformers to go from DTO -> resource after the denormalization step makes a LOT more sense IMHO. Users wouldn't have to make denormalizers, they could just make transformers, and those could probably setup as simply static methods on the resources to provide that transformation as well.

Regrading the flow chart, I think understand why we have the disconnect. From my limited perspective, input classes served as an escape hatch from ApiP for supporting CQRS, the idea being, if you want to create your own service to handle persistence of a resource from an input class, you could.

Whereas It looks like you guys are trying to automate a lot of that process with the idea of transformers.

The way I envisioned is like the following:

screen shot 2019-02-11 at 8 53 57 am

The parts in yellow showing the custom code a user would write.

ApiP already has the concept of readable/writeable properties which allow for different schemas to be created vs read. I'm not sure how much use input/output transformers would get since this PR doesn't seem to address the use case of making CQRS integration more seamless for the end user.

@@ -0,0 +1,212 @@
Feature: DTO input and output
In order to use an hypermedia API
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
In order to use an hypermedia API
In order to use a hypermedia API

@soyuka soyuka mentioned this pull request Feb 15, 2019
2 tasks
} catch (InvalidArgumentException $e) {
$context = $this->initContext(\get_class($object), $context);
$rawData = parent::normalize($object, $format, $context);
if (!\is_array($rawData)) {
Copy link
Member

Choose a reason for hiding this comment

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

Useless? You return $rawData in all cases.

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess that if $rawData isn't an array it can't be merged with $data and must be returned directly so might not be useless

$resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true);
} catch (InvalidArgumentException $e) {
$rawData = parent::normalize($object, $format, $context);
if (!\is_array($rawData)) {
Copy link
Member

Choose a reason for hiding this comment

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

Useless?

*/
public function getAnonymousResourceContext($object, array $context, int $referenceType = UrlGeneratorInterface::ABS_PATH): array
{
$id = $context['iri'] ?? '_:'.spl_object_id($object);
Copy link
Member

Choose a reason for hiding this comment

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

Using spl_object_id() drops support for PHP 7.1. Otherwise you have to add symfony/polyfill-php72 as dependency...

I need to be able to use DTOs on my resources as Input or Output objects.

@createSchema
Scenario: Create a resource with a custom Input.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Scenario: Create a resource with a custom Input.
Scenario: Create a resource with a custom Input

"bat": "test"
}
"""
Then I add "Content-Type" header equal to "application/ld+json"
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Then I add "Content-Type" header equal to "application/ld+json"
When I add "Content-Type" header equal to "application/ld+json"

}
"""
Then the response status code should be 201
Then I add "Content-Type" header equal to "application/ld+json"
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Then I add "Content-Type" header equal to "application/ld+json"
When I add "Content-Type" header equal to "application/ld+json"

} catch (InvalidArgumentException $e) {
$context = $this->initContext(\get_class($object), $context);
$rawData = parent::normalize($object, $format, $context);
if (!\is_array($rawData)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess that if $rawData isn't an array it can't be merged with $data and must be returned directly so might not be useless

{
return [
// no input class defined
[[], null],
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe those comments could have been used to give more descriptive errors
'no input class defined' => [[], null],

Copy link
Member Author

Choose a reason for hiding this comment

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

it's not necessary an error, more that the metadata will be defined like this.

Move data transform to normalizers
@soyuka soyuka merged commit eac03c2 into api-platform:2.4 Feb 19, 2019
@soyuka
Copy link
Member Author

soyuka commented Feb 19, 2019

Merging this, please don't hesitate to leave more comments we can still come back to them before a stable release!

@Devristo
Copy link

Devristo commented Mar 7, 2019

@soyuka thanks for your work on this PR, it looks like it was quite a challenge!

Even though I tried to follow the discussion with @ragboyjr and @dunglas above. It's still not clear how an input_class DTO can be handled by the messenger persister.

Let's say I have a Todo resource. I would like the GET, DELETE handled by the API-platform. However for POSTing a new Todo I would like to use a CreateTodoDto and the Symfony Messenger integration instead. For example to do this asynchronously.

I assumed I could set the POST input class to CreateTodoDto (is not a resource) and add messenger=true to the Todo resource. However it seems that now the AbstractItemNormalizer enforces me to deserialize the request body to a Todo resource instead?

Before this MR was merged I was able to get this to work, albeit a minor workaround which I no longer can recall.

In my understanding after this MR I need to create two resources to work around this. A CreateTodoDto resource which is 'persisted' by the messenger and the normal Todo resource. By default this means different URLs, however this URL can be altered to lets say POST /todos.

This feels dirty to me, am I missing the correct approach?

@soyuka
Copy link
Member Author

soyuka commented Mar 8, 2019

In fact nothing changed in how resources are persisted.

Say we leave the messenger on the side for now. When you send a different representation of a class (eg use an input class) through a POST it does:

=> POST /todos `{ Input }`
=> Input transformed to Todo
=> Persist Todo

When you add the messenger flag, instead of using doctrine to persist it'll just send the resource through the messenger instead:

=> POST /todos `{ Input }`
=> Input transformed to Todo
=> Handle Todo message

Can you show me the difference between your input and your resource?

@Devristo
Copy link

Devristo commented Mar 8, 2019

Thank you for your reply @soyka.

In my 'CQRS' scenario I would like to handle an instance of the input class as a message. Such that a PUT {INPUT} and POST {DIFFERENT_INPUT} end up in two separate messages and can be handled accordingly.

Now I can set different input classes for both PUT and POST, however in the end the message handler will always receive a Todo. At this point I no longer know have the input data I need for a PUT or a POST. Basically I cannot do CQRS like this, since I lost the domain event.

For now I have created separate CreateTodo and UpdateTodo resources, such that I can handle / persist these as separate messages. But in my opinion conceptually they should be operations of the same Todo resource.

I think the approach @ragboyjr would make much more sense in CQRS-setup. Although I am fully aware that the scope of this project is much wider, it would be nice if there was a proper configuration for it.

@soyuka soyuka mentioned this pull request Mar 8, 2019
@soyuka
Copy link
Member Author

soyuka commented Mar 8, 2019

I've found a good solution, it just needs a quick fix see #2590.
Take a look at the following gist: https://gist.github.com/soyuka/312526207073dc0edb6add81d15f6249

@Devristo
Copy link

Devristo commented Mar 8, 2019

This seems to work!

Other users who want to have a similar approach could even alter DataTransformer to handle all input-classes of a specific interface. Then I would not have to write one for each specific message.

<?php
namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Contract\DomainMessage;

class DomainMessageTransformer implements DataTransformerInterface {
    public function transform($object, string $to, array $context = [])
    {
        return $object;
    }

    public function supportsTransformation($object, string $to, array $context = []): bool
    {
        if (\is_object($object)) {
            return false;
        }

        $className = $context['input']['class'] ?? null;

        if (is_null($className)) {
            return false;
        }

        return is_subclass_of($className, DomainMessage::class);
    }
}

@soyuka
Copy link
Member Author

soyuka commented Mar 8, 2019

Exactly!

@kiler129
Copy link

It seems (correct me if I'm wrong) this PR introduced a dangerous assumption - AbstractItemNormalizer now "owns" serialization of anything in the application, even if it's not an API context at all.

Was this change intentional that now ItemNormalizer is used for every serialization? Before it was only used for resource classes, but now it checks allowUnmappedClass (which is effectively true with immediate deprecation of false) and claims serialization of anything.
Also since it has a priority higher than default object normalizer (-923 vs -1000) it will always be checked first.

@teohhanhui
Copy link
Contributor

teohhanhui commented Mar 22, 2019

@kiler129 Don't worry. We've changed it so that it only handles API resources (but also non-resources reachable from a resource).

Please try v2.4.0 and let us know if you still have any issues with that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants