Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

Create the DTO System of your Dreams: stateOpti...

weaverryan
September 22, 2023

Create the DTO System of your Dreams: stateOptions + entityClass

One of the best features of API Platform is the ability to add #[ApiResource] above an entity and… bam! You have a fully-functional API! Though, if you want to have full control and peak clarity, nothing beats creating a dedicated DTO class.

But, creating a DTO class - especially when the data comes from Doctrine - feelslike reinventing the wheel! Suddenly you need to create a state provider, stateprocessor and filters… which all do the same thing that API Platform does automatically for entities.

No more! In this talk, we’ll explore a new feature called “state options” that gives you the flexibility of a DTO class, but the convenience of an entity. We’ll explore how this works & exactly what you need (e.g. a mapper system) to create a DTO class and have it “just work”.

weaverryan

September 22, 2023
Tweet

More Decks by weaverryan

Other Decks in Technology

Transcript

  1. @weaverryan With your friend Ryan Weaver THE DTO SYSTEM OF

    YOUR DREAMS: stateOptions + entityClass
  2. > Member of the Symfony core team > Husband of

    the talented and beloved @leannapelham symfonycasts.com twitter.com/weaverryan Howdy there!! I’m Ryan! > Father to my much more charming son, Beckett > Author person at SymfonyCasts.com (and probably not Mickey Mouse)
  3. #[ApiResource(shortName: 'Hobby')] class WebbyHobbyApi { public function __construct( public ?int

    $id = null, public ?string $name = null, public bool $involvesEatingFlies = false, public int $difficultyLevel = 0, ) {} } src/ApiResource/WebbyHobbyApi.php What does that *really* give us?
  4. Routes! _api_/hobbies/{id}_get GET /api/hobbies/{id} _api_/hobbies_get_collection GET /api/hobbies _api_/hobbies_post POST /api/hobbies

    _api_/hobbies/{id}_put PUT /api/hobbies/{id} _api_/hobbies/{id}_patch PATCH /api/hobbies/{id} _api_/hobbies/{id}_delete DELETE /api/hobbies/{id} … which all do NOT work
  5. GET /api/hobbies/5 1) State provider "loads" the WebbyHobbyApi object Central

    object: WebbyHobbyApi 2) Serializer serializes WebbyHobbyApi -> JSON (no state processor)
  6. POST /api/hobbies 1) Serializer deserializers JSON -> WebbyHobbyApi object Central

    object: WebbyHobbyApi 2) State processor "saves" WebbyHobbyApi (no state provider) 3) Serializer serializes WebbyHobbyApi -> JSON
  7. PATCH /api/hobbies/5 2) Serializer deserializers JSON -> WebbyHobbyApi object Central

    object: WebbyHobbyApi 3) State processor "saves" WebbyHobbyApi 4) Serializer serializes WebbyHobbyApi -> JSON 1) State provider "loads" the WebbyHobbyApi object
  8. #[ORM\Entity()] class WebbyHobby { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id

    = null; #[ORM\Column(length: 255)] private ?string $name = null; // ... } src/Entity/WebbyHobby.php What's di ff erent when #[ApiResource] is above an entity? #[ApiResource(shortName: 'Hobby')]
  9. #[ApiResource( operations: [ new Get(provider: ItemProvider::class), ] )] #[ORM\Entity()] class

    WebbyHobby new GetCollection(provider: CollectionProvider::class), new Put( provider: ItemProvider::class, processor: PersistProcessor::class, ), new Delete( provider: ItemProvider::class, processor: RemoveProcessor::class ), new Post(processor: PersistProcessor::class),
  10. Clean Custom Fields ✅ Avoid serialization groups ❌ Need to

    implement providers and processors from manually Doctrine fi lters & pagination gone ✅ Separation of concerns ✅ ❌
  11. GET /api/hobbies { "@context": "\/api\/contexts\/Hobby", "hydra:member": [ { "@type": "WebbyHobby",

    "@id": "\/api\/hobbies\/1", "id": 1, "name": "WebbyHobby 0", "involvesEatingFlies": true, "difficultyLevel": 10 }, ... ] }
  12. stateOptions: new Options(entityClass: WebbyHobby::class) 1) Automatically sets providers and processors

    back to the core Doctrine ORM providers and processors. 2) Doctrine Item/CollectionProvider read entityClass and query from that entity** ** yes, pagination & fi lters still work!
  13. GET /api/hobbies/5 1) Doctrine ItemProvider "loads" the WebbyHobby entity object

    Central object: WebbyHobby entity 2) Serializer serializes WebbyHobby -> WebHobbyApi -> JSON
  14. Custom fi elds impossible ❌ State Processors don't work ❌

    public string $fieldOnlyOnDto; ❌ WRITE operations have a different central object! // WebbyHobbyApi.php
  15. PATCH /api/hobbies/5 2) Deserializes JSON -> WebHobby entity -> WebHobbyApi

    Central object: WebbyHobby entity 3) State processor tries to save WebbyHobbyApi 4) Serializer serializes WebbyHobbyApi -> JSON 1) Doctrine state provider "loads" the WebbyHobby entity object Central object: WebbyHobbyApi
  16. PATCH /api/hobbies/5 2) Custom provider "transforms" WebbyHobby -> WebbyHobbyApi 1)

    Custom provider calls the core Doctrine ItemProvider, which returns a WebbyHobby entity. Central object: WebbyHobbyApi 3) JSON deserialized onto WebbyHobbyApi
  17. PATCH /api/hobbies/5 4) Custom processor receives WebbyHobbyApi and transforms it

    into a WebbyHobby entity. Central object: WebbyHobbyApi 5) Custom processor passes WebbyHobby to core Doctrine processor to save. 6) WebbyHobbyApi serialized -> JSON
  18. use ApiPlatform\Doctrine\Orm\State\CollectionProvider; class EntityToApiStateProvider implements ProviderInterface { public function __construct(

    #[Autowire(service: CollectionProvider::class)] private ProviderInterface $collectionProvider, ) { } // ... }
  19. public function provide(Operation $operation, …) { /** @var WebbyHobby[] $entities

    */ $entities = $this->collectionProvider->provide(…); $dtos = []; foreach ($entities as $entity) { $dtos[] = new WebbyHobbyApi( $entity->getId(), $entity->getName(), $entity->isInvolvesEatingFlies(), $entity->getDifficultyLevel(), ); } return $dtos; }
  20. { "@context": "\/api\/contexts\/Hobby", "@id": "\/api\/hobbies", "@type": "hydra:Collection", "hydra:totalItems": 30, "hydra:member":

    [ { "@id": "\/api\/hobbies\/1", "@type": "Hobby", "id": 1, "name": "WebbyHobby 0", "involvesEatingFlies": true, "difficultyLevel": 10 }, // ... ] }
  21. -Clean Custom Fields ✅ -Avoid serialization groups -NO Need to

    implement providers and processors from manually -Doctrine fi lters & pagination NOT gone ✅ -Separation of concerns ✅ ✅ ✅
  22. class ApiToEntityStateProcessor implements ProcessorInterface { private WebbyHobbyRepository $webbyHobbyRepository; public function

    process(mixed $data, Operation $operation, …) { assert($data instanceof WebbyHobbyApi); if ($data->id) { $entity = $this->webbyHobbyRepository->find($data->id); } else { $entity = new WebbyHobby(); } // … } }
  23. class ApiToEntityStateProcessor implements ProcessorInterface { public function __construct( #[Autowire(service: PersistProcessor::class)]

    private ProcessorInterface $persistProcessor, ) {} public function process(mixed $data, Operation $operation, …) { // … return $data; // return the DTO } } $entity->setName($data->name); $entity->setInvolvesEatingFlies($data->involvesEatingFlies); $entity->setDifficultyLevel($data->difficultyLevel); $this->persistProcessor->process($entity, $operation, $uriVariables); $data->id = $entity->getId();
  24. Entity -> API Mapper #[AsMapper( from: WebbyHobby::class, to: WebbyHobbyApi::class, )]

    class WebbyHobbyEntityToApiMapper implements MapperInterface { public function load(object $from, string $toClass, array $context) { $entity = $from; return new WebbyHobbyApi($entity->getId()); } public function populate(object $from, object $to, array $context) { $entity = $from; $api = $to; $api->name = $entity->getName(); $api->involvesEatingFlies = $entity->isInvolvesEatingFlies(); $api->difficultyLevel = $entity->getDifficultyLevel(); return $api; } }
  25. class EntityToApiStateProvider implements ProviderInterface { public function __construct( #[Autowire(service: CollectionProvider::class)]

    private ProviderInterface $collectionProvider, private MicroMapperInterface $microMapper, ) { } public function provide(Operation $operation, …) { /** @var WebbyHobby[] $entities */ $entities = $this->collectionProvider->provide($operation, …); $dtos = []; foreach ($entities as $entity) { $dtos[] = $this->microMapper ->map($entity, $operation->getClass()); } return $dtos; } }
  26. Thanks to stateOptions, we can call the core Doctrine provider

    & processor @weaverryan $entities = $this->collectionProvider ->provide($operation, …);
  27. We work with the entity only internally, within our state

    provide & processor. The entity is *never* the "central" object.
  28. $entities = $this->collectionProvider ->provide($operation, …); $dtos = []; foreach ($entities

    as $entity) { $dtos[] = $this->microMapper ->map($entity, $resourceClass); } With a "mapping" system, this can all become reusable @weaverryan