Slide 1

Slide 1 text

@weaverryan With your friend Ryan Weaver THE DTO SYSTEM OF YOUR DREAMS: stateOptions + entityClass

Slide 2

Slide 2 text

> 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)

Slide 3

Slide 3 text

Provider, Processor & "The Central Object" @weaverryan

Slide 4

Slide 4 text

#[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?

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

Your #[ApiResource] Lives and Dies by its state provider and processors And we have none!

Slide 7

Slide 7 text

GET /api/hobbies/5 1) State provider "loads" the WebbyHobbyApi object Central object: WebbyHobbyApi 2) Serializer serializes WebbyHobbyApi -> JSON (no state processor)

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

@weaverryan State Providers and Processors Without them === you have no API With them === your API works

Slide 11

Slide 11 text

Our job: To build these!

Slide 12

Slide 12 text

But wait… Doctrine is automatic? @weaverryan

Slide 13

Slide 13 text

#[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')]

Slide 14

Slide 14 text

#[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),

Slide 15

Slide 15 text

@weaverryan Free State Providers & Processors (CollectionProvider gives you fi lters & pagination)

Slide 16

Slide 16 text

DTO Class Instead? @weaverryan

Slide 17

Slide 17 text

#[ApiResource( shortName: 'Hobby', )] class WebbyHobbyApi src/ApiResource/WebbyHobbyApi.php

Slide 18

Slide 18 text

Clean Custom Fields ✅ Avoid serialization groups ❌ Need to implement providers and processors from manually Doctrine fi lters & pagination gone ✅ Separation of concerns ✅ ❌

Slide 19

Slide 19 text

stateOptions? @weaverryan dunglas? FrankenPHP

Slide 20

Slide 20 text

#[ApiResource( shortName: 'Hobby', )] class WebbyHobbyApi src/ApiResource/WebbyHobbyApi.php stateOptions: new Options( entityClass: WebbyHobby::class ),

Slide 21

Slide 21 text

GET /api/hobbies { "@context": "\/api\/contexts\/Hobby", "hydra:member": [ { "@type": "WebbyHobby", "@id": "\/api\/hobbies\/1", "id": 1, "name": "WebbyHobby 0", "involvesEatingFlies": true, "difficultyLevel": 10 }, ... ] }

Slide 22

Slide 22 text

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!

Slide 23

Slide 23 text

GET /api/hobbies/5 1) Doctrine ItemProvider "loads" the WebbyHobby entity object Central object: WebbyHobby entity 2) Serializer serializes WebbyHobby -> WebHobbyApi -> JSON

Slide 24

Slide 24 text

Perfect Solution? @weaverryan

Slide 25

Slide 25 text

Custom fi elds impossible ❌ State Processors don't work ❌ public string $fieldOnlyOnDto; ❌ WRITE operations have a different central object! // WebbyHobbyApi.php

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

Am I dead in the water? @weaverryan

Slide 28

Slide 28 text

DTO as the Central Object! @weaverryan

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

Start with the Provider @weaverryan

Slide 32

Slide 32 text

class EntityToApiStateProvider implements ProviderInterface { public function provide(Operation $operation, …) { return [ new WebbyHobbyApi(), new WebbyHobbyApi(), ]; } }

Slide 33

Slide 33 text

#[ApiResource( shortName: 'Hobby', provider: EntityToApiStateProvider::class, stateOptions: new Options(entityClass: WebbyHobby::class), )] class WebbyHobbyApi

Slide 34

Slide 34 text

use ApiPlatform\Doctrine\Orm\State\CollectionProvider; class EntityToApiStateProvider implements ProviderInterface { public function __construct( #[Autowire(service: CollectionProvider::class)] private ProviderInterface $collectionProvider, ) { } // ... }

Slide 35

Slide 35 text

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; }

Slide 36

Slide 36 text

{ "@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 }, // ... ] }

Slide 37

Slide 37 text

-Clean Custom Fields ✅ -Avoid serialization groups -NO Need to implement providers and processors from manually -Doctrine fi lters & pagination NOT gone ✅ -Separation of concerns ✅ ✅ ✅

Slide 38

Slide 38 text

#[ApiResource( shortName: 'Hobby', provider: EntityToApiStateProvider::class, stateOptions: new Options(entityClass: WebbyHobby::class), )] class WebbyHobbyApi { #[ApiFilter(SearchFilter::class)] public ?string $name = null; }

Slide 39

Slide 39 text

Reverse in the processor @weaverryan

Slide 40

Slide 40 text

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(); } // … } }

Slide 41

Slide 41 text

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();

Slide 42

Slide 42 text

It's ALIVE! @weaverryan

Slide 43

Slide 43 text

Keep pagination docs [ ] Item provider Remove processor [ ] [ ] Some minor TODOs

Slide 44

Slide 44 text

I need a Fully Reusable System @weaverryan

Slide 45

Slide 45 text

The state provider and processor are 100% generic except for… @weaverryan the entity <=> DTO mapping

Slide 46

Slide 46 text

Symfony serializer ? Jane AutoMapper Homegrown ? ? Data Mapping Library?

Slide 47

Slide 47 text

$ composer require symfonycasts/micro-mapper

Slide 48

Slide 48 text

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; } }

Slide 49

Slide 49 text

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; } }

Slide 50

Slide 50 text

Full Control ✅ Easy to Understand ✅ Super Manual! ❌ symfonycasts/micro-mapper

Slide 51

Slide 51 text

The View From Space @weaverryan

Slide 52

Slide 52 text

DTOs are AWESOME for Flexibility @weaverryan public ?string $totallyCrazyCustomField = null;

Slide 53

Slide 53 text

But they DO require a custom state provider & processor @weaverryan

Slide 54

Slide 54 text

Thanks to stateOptions, we can call the core Doctrine provider & processor @weaverryan $entities = $this->collectionProvider ->provide($operation, …);

Slide 55

Slide 55 text

We work with the entity only internally, within our state provide & processor. The entity is *never* the "central" object.

Slide 56

Slide 56 text

$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

Slide 57

Slide 57 text

Thank you! @weaverryan API Platform DTO Tutorial symfonycasts.com/api-platform-extending