$30 off During Our Annual Pro Sale. View Details »

Create the DTO System of your Dreams: stateOptions + entityClass

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

    View Slide

  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)

    View Slide

  3. Provider, Processor &


    "The Central Object"
    @weaverryan

    View Slide

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

    View Slide

  5. 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

    View Slide

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

    View Slide

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

    View Slide

  8. 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

    View Slide

  9. 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

    View Slide

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

    View Slide

  11. Our job: To build these!

    View Slide

  12. But wait… Doctrine


    is automatic?
    @weaverryan

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  16. DTO Class Instead?
    @weaverryan

    View Slide

  17. #[ApiResource(


    shortName: 'Hobby',


    )]


    class WebbyHobbyApi
    src/ApiResource/WebbyHobbyApi.php

    View Slide

  18. Clean Custom Fields

    Avoid serialization groups

    Need to implement providers


    and processors from manually
    Doctrine
    fi
    lters & pagination gone

    Separation of concerns


    View Slide

  19. stateOptions?
    @weaverryan
    dunglas?
    FrankenPHP

    View Slide

  20. #[ApiResource(


    shortName: 'Hobby',


    )]


    class WebbyHobbyApi
    src/ApiResource/WebbyHobbyApi.php
    stateOptions: new Options(


    entityClass: WebbyHobby::class


    ),

    View Slide

  21. GET /api/hobbies
    {


    "@context": "\/api\/contexts\/Hobby",


    "hydra:member": [


    {


    "@type": "WebbyHobby",


    "@id": "\/api\/hobbies\/1",


    "id": 1,


    "name": "WebbyHobby 0",


    "involvesEatingFlies": true,


    "difficultyLevel": 10


    },


    ...


    ]


    }


    View Slide

  22. 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!

    View Slide

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

    View Slide

  24. Perfect Solution?
    @weaverryan

    View Slide

  25. Custom
    fi
    elds impossible

    State Processors don't work

    public string $fieldOnlyOnDto;

    WRITE operations have a


    different central object!
    // WebbyHobbyApi.php

    View Slide

  26. 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

    View Slide

  27. Am I dead in the water?
    @weaverryan

    View Slide

  28. DTO as the


    Central Object!
    @weaverryan

    View Slide

  29. 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

    View Slide

  30. 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

    View Slide

  31. Start with the Provider
    @weaverryan

    View Slide

  32. class EntityToApiStateProvider implements ProviderInterface


    {


    public function provide(Operation $operation, …)


    {


    return [


    new WebbyHobbyApi(),


    new WebbyHobbyApi(),


    ];


    }


    }

    View Slide

  33. #[ApiResource(


    shortName: 'Hobby',


    provider: EntityToApiStateProvider::class,


    stateOptions: new Options(entityClass: WebbyHobby::class),


    )]


    class WebbyHobbyApi

    View Slide

  34. use ApiPlatform\Doctrine\Orm\State\CollectionProvider;


    class EntityToApiStateProvider implements ProviderInterface


    {


    public function __construct(


    #[Autowire(service: CollectionProvider::class)]


    private ProviderInterface $collectionProvider,


    )


    {


    }


    // ...


    }

    View Slide

  35. 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;


    }

    View Slide

  36. {


    "@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


    },


    // ...


    ]


    }


    View Slide

  37. -Clean Custom Fields

    -Avoid serialization groups
    -NO Need to implement providers


    and processors from manually
    -Doctrine
    fi
    lters & pagination


    NOT gone

    -Separation of concerns



    View Slide

  38. #[ApiResource(


    shortName: 'Hobby',


    provider: EntityToApiStateProvider::class,


    stateOptions: new Options(entityClass: WebbyHobby::class),


    )]


    class WebbyHobbyApi
    {


    #[ApiFilter(SearchFilter::class)]


    public ?string $name = null;


    }


    View Slide

  39. Reverse in the processor
    @weaverryan

    View Slide

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


    }


    // …


    }


    }

    View Slide

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

    View Slide

  42. It's ALIVE!
    @weaverryan

    View Slide

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

    View Slide

  44. I need a
    Fully


    Reusable
    System
    @weaverryan

    View Slide

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

    View Slide

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

    View Slide

  47. $ composer require symfonycasts/micro-mapper

    View Slide

  48. 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;


    }


    }


    View Slide

  49. 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;


    }


    }

    View Slide

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

    View Slide

  51. The View
    From Space
    @weaverryan

    View Slide

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

    View Slide

  53. But they DO require a custom


    state provider & processor
    @weaverryan

    View Slide

  54. Thanks to stateOptions, we can
    call the core Doctrine provider &
    processor
    @weaverryan
    $entities = $this->collectionProvider


    ->provide($operation, …);

    View Slide

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

    View Slide

  56. $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

    View Slide

  57. Thank you!
    @weaverryan
    API Platform DTO Tutorial


    symfonycasts.com/api-platform-extending

    View Slide