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

Build your next REST API with Symfony & API Platform

Hugo Hamon
February 24, 2022

Build your next REST API with Symfony & API Platform

API Platform is a Symfony add-on that helps you automate the creation and personalization of a REST API. In this talk, you'll learn how to get started with API Platform. From a Doctrine data model, we'll generate a full featured series of CRUD API endpoints to read & write your model. We'll also cover more advanced use cases such as search filters, serialization contexts, authentication & authorizations, custom API endpoints, and extension points

Hugo Hamon

February 24, 2022
Tweet

More Decks by Hugo Hamon

Other Decks in Programming

Transcript

  1. Build your next REST API
    with Symfony & API Platform
    Hugo Hamon / Confoo 2022 / Feb. 24th
    1

    View Slide

  2. 2

    View Slide

  3. https://speakerdeck.com/hhamon
    3

    View Slide

  4. API Platform
    API Platform is an API first framework that
    provides tools to automate the generation of REST &
    GraphQL APIs. It’s architectured on top of Symfony in
    a way to be make it easy for developers to extend it,
    configure it and develop custom code on top if it.
    4

    View Slide

  5. Features Set
    What’s in the box?
    ★ CRUD RESTful operations
    ★ Custom API endpoint
    ★ OpenAPI / Swagger support
    ★ JSON Schema support
    ★ GraphQL support
    ★ Doctrine support
    ★ Mongodb & Elasticsearch support
    ★ Pagination
    ★ Filtering
    ★ Sorting
    ★ Validation
    ★ Content Negotiation
    ★ File upload
    ★ Security (permissions)
    ★ JWT authentication
    ★ Errors handling
    ★ Push & pull notifications
    ★ … and more
    5

    View Slide

  6. Install
    $ cd my_project/
    $ composer require api
    Configure
    # app/config/packages/api_platform.yaml
    api_platform:
    mapping:
    paths: ['%kernel.project_dir%/src/Entity' ]
    patch_formats:
    json: ['application/merge-patch+json' ]
    swagger:
    versions: [3]
    # app/config/routes/api_platform.yaml
    api_platform:
    resource: .
    type: api_platform
    prefix: /api
    6

    View Slide

  7. https://localhost/api
    7

    View Slide

  8. Application Example
    Manage a directory of Organization resources where each
    organization entity can enrol User resources as Staff Members.
    8

    View Slide

  9. Let’s Get Started!
    9

    View Slide

  10. #[ORM\Entity(repositoryClass: OrganizationRepository::class)]
    #[ORM\UniqueConstraint(name: 'organization_slug_unique', columns: ['slug'])]
    class Organization implements \Stringable
    {
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private Uuid $id;
    #[ORM\Column(type: Types::STRING, length: 64)]
    private string $name;
    #[ORM\Column(type: Types::STRING, length: 64)]
    #[Gedmo\Slug(fields: ['name'])]
    private ?string $slug = null;
    #[ORM\Column(type: Types::STRING, length: 2, options: ['default' => 'FR'])]
    private string $country = 'FR';
    #[ORM\Column(type: Types::STRING, nullable: true)]
    private ?string $logo = null;
    // ...
    }
    Doctrine Entity Model
    10

    View Slide

  11. namespace App\Entity;
    use ApiPlatform\Core\Annotation\ApiResource;
    // ...
    #[ORM\ApiResource]
    class Organization implements \Stringable
    {
    // ...
    }
    Expose an entity class as an API resource
    11

    View Slide

  12. 12

    View Slide

  13. 13

    View Slide

  14. 14

    View Slide

  15. {
    "@context": "/api/contexts/Organization" ,
    "@id": "/api/organizations" ,
    "@type": "hydra:Collection" ,
    "hydra:member" : [
    {
    "@id": "/api/organizations/008610ff-f204-3d41-8d3b-d7b691f77575" ,
    "@type": "Organization" ,
    "id": "008610ff-f204-3d41-8d3b-d7b691f77575" ,
    "name": "McLaughlin Inc" ,
    "slug": "mclaughlin-inc" ,
    "country": "TN"
    }
    // ...
    ],
    "hydra:totalItems" : 130,
    "hydra:view" : {
    "@id": "/api/organizations?page=1" ,
    "@type": "hydra:PartialCollectionView" ,
    "hydra:first" : "/api/organizations?page=1" ,
    "hydra:last" : "/api/organizations?page=5" ,
    "hydra:next" : "/api/organizations?page=2"
    }
    } JSON LD HTTP Response 15

    View Slide

  16. Documenting
    ★ Swagger / Open API
    ★ Resources
    ★ Collections
    ★ Properties
    ★ HTTP requests
    ○ Path parameters
    ○ Query parameters
    ○ JSON payloads
    ○ Examples
    ★ HTTP responses
    ○ Status codes
    ○ JSON payloads
    ○ Examples
    ★ Deprecations
    ★ etc.
    16
    “Organization API resource
    must be self documented
    and easy to consume for a
    client application”

    View Slide

  17. https://localhost/api/docs.json
    17

    View Slide

  18. #[ApiResource(
    description: 'This collection of endpoints enables to handle `Organization` resources.'
    )]
    class Organization implements \Stringable
    {
    /** The main organization identifier */
    #[ApiProperty(writable: false, example: 'b3a49e06-2415-4eb5-ba01-ab24096ce2b9')]
    private Uuid $id;
    /** The organization name */
    #[ApiProperty(example: 'DELL EMEA')]
    private string $name;
    /** The organization slug */
    #[ApiProperty(writable: false, example: 'dell-emea')]
    private ?string $slug = null;
    /** The organization ISO2 country code */
    private string $country = 'FR';
    /** The organization logo image file */
    #[ApiProperty(writable: false, example: 'logo_dell-emea.png')]
    private ?string $logo = null;
    }
    18

    View Slide

  19. 19

    View Slide

  20. Sorting ★ Default sorting
    ★ List of sortable fields
    ★ Configuring sort direction
    ★ Sort on nested resources
    20
    “Collection of
    organizations must be
    sortable using predefined
    allowed fields”

    View Slide

  21. use ApiPlatform\Core\Annotation\ApiFilter;
    use ApiPlatform\Core\Annotation\ApiResource;
    use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\OrderFilter;
    // …
    #[ApiResource(
    description: 'This collection of endpoints ...',
    order: ['name' => 'ASC']
    )]
    #[ApiFilter(
    OrderFilter::class,
    properties: ['name', 'country'],
    arguments: ['orderParameterName' => 'order']
    )]
    class Organization implements \Stringable
    {
    }
    21

    View Slide

  22. {
    "hydra:member" : [
    {
    "@id": "/api/organizations/3f9dfa23-d67c-316b-974f-737116690ec6" ,
    "@type": "Organization" ,
    "id": "3f9dfa23-d67c-316b-974f-737116690ec6" ,
    "name": "Abshire, Konopelski and Grimes",
    "slug": "abshire-konopelski-and-grimes" ,
    "country": "LS"
    },
    {
    "@id": "/api/organizations/22c8e295-da54-38e7-af8c-82ba0718803c" ,
    "@type": "Organization" ,
    "id": "22c8e295-da54-38e7-af8c-82ba0718803c" ,
    "name": "Abshire-Will",
    "slug": "abshire-will" ,
    "country": "VE"
    },
    ...
    {
    "@id": "/api/organizations/311be492-89ec-3bff-915c-18c5e6358c49" ,
    "@type": "Organization" ,
    "id": "311be492-89ec-3bff-915c-18c5e6358c49" ,
    "name": "Dickinson, O'Keefe and Olson",
    "slug": "dickinson-okeefe-and-olson" ,
    "country": "CG"
    }
    ]
    }
    22

    View Slide

  23. 23
    https://localhost/api/organizations?order[name]=asc
    https://localhost/api/organizations?order[name]=desc
    https://localhost/api/organizations?order[country]=asc
    https://localhost/api/organizations?order[country]=desc
    https://localhost/api/organizations?order[name]=asc&order[country]=asc
    https://localhost/api/organizations?order[name]=asc&order[country]=desc
    https://localhost/api/organizations?order[name]=desc&order[country]=asc
    https://localhost/api/organizations?order[name]=desc&order[country]=desc

    View Slide

  24. Pagination
    ★ Pagination support
    ○ Enable / disable globally
    ○ Enable / disable per resource
    ★ Pagination control at client level
    ○ Allowing
    ○ Disabling
    ★ Controlling items per page
    ○ At global scale
    ○ Per API resource
    ★ Partial Pagination
    ○ At global scale
    ○ Per API resource
    ★ Cursor Based Pagination
    ○ Based on “ids” values
    ○ Per API resource
    24
    “Collection of
    organizations must be
    paginated to prevent
    loading too many data”

    View Slide

  25. #[ApiResource(
    ...,
    paginationClientEnabled: true,
    paginationItemsPerPage: 50,
    paginationMaximumItemsPerPage: 120
    )]
    // ...
    class Organization implements \Stringable
    {
    }
    25

    View Slide

  26. {
    "@context": "\/api\/contexts\/Organization",
    "@id": "\/api\/organizations",
    "@type": "hydra:Collection",
    "hydra:member": [
    {
    "@id": "\/api\/organizations\/3f9dfa23-d67c-316b-974f-737116690ec6",
    "@type": "Organization",
    "id": "3f9dfa23-d67c-316b-974f-737116690ec6",
    "name": "Abshire, Konopelski and Grimes",
    "slug": "abshire-konopelski-and-grimes",
    "country": "LS"
    },
    ...
    ],
    "hydra:totalItems": 131,
    "hydra:view": {
    "@id": "\/api\/organizations?pagination=true\u0026itemsPerPage=64\u0026page=1",
    "@type": "hydra:PartialCollectionView",
    "hydra:first": "\/api\/organizations?pagination=true\u0026itemsPerPage=64\u0026page=1",
    "hydra:last": "\/api\/organizations?pagination=true\u0026itemsPerPage=64\u0026page=3",
    "hydra:next": "\/api\/organizations?pagination=true\u0026itemsPerPage=64\u0026page=2"
    }
    }
    26

    View Slide

  27. Filtering
    “Organizations must be
    filterable with some
    predefined allowed
    criteria”
    ★ Doctrine ORM filters
    ○ Order
    ○ Search
    ○ Date
    ○ Range
    ○ Boolean
    ○ Numeric
    ○ Existence
    ★ Serializer filters
    ○ Group
    ○ Property
    ★ Mongodb & Elasticsearch filters
    ○ Basic support
    ★ Custom filters
    ○ Write your own :)
    ★ Automatic documentation
    ○ Filters in JSON Schema
    27

    View Slide

  28. use ApiPlatform\Core\Annotation\ApiFilter;
    use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
    // ...
    #[ApiFilter(
    SearchFilter::class,
    properties: [
    'id' => 'exact',
    'name' => 'ipartial',
    'country' => 'iexact'
    ]
    )]
    class Organization implements \Stringable
    {
    // ...
    }
    28

    View Slide

  29. {
    "@context": "/api/contexts/Organization",
    "@id": "/api/organizations",
    "@type": "hydra:Collection",
    "hydra:member": [
    {
    "@id": "/api/organizations/f13d9eb6-2d0c-39f2-9ce9-896f936b8165",
    "@type": "Organization",
    "id": "f13d9eb6-2d0c-39f2-9ce9-896f936b8165",
    "name": "Carroll-Schumm",
    "slug": "carroll-schumm",
    "country": "ES"
    }
    ],
    "hydra:totalItems": 1,
    "hydra:view": {
    "@id":
    "/api/organizations?name=car&country%5B%5D=FR&country%5B%5D=DE&country%5B%5D=es&country%5B%5D=it",
    "@type": "hydra:PartialCollectionView"
    }
    }
    29
    https://localhost/api/organizations?name=car&country[]=FR&country[]=DE&country[]=es&country[]=it

    View Slide

  30. Query
    Extensions
    “Private organizations and
    organizations with expired
    subscriptions must be
    filtered out.”
    ★ Accessing API context
    ○ Filters
    ○ Resource class
    ○ Operation type
    ○ Serialization groups
    ★ Collection & Item extensions
    ○ Hack the SQL query
    ○ Add custom WHERE clauses
    30

    View Slide

  31. // ...
    #[ORM\Index(
    columns: ['subscription_starting_at', 'subscription_ending_at', 'is_private'],
    name: 'organization_active_idx'
    )]
    class Organization implements \Stringable
    {
    // ...
    /** The organization subscription starting date */
    #[ApiProperty(writable: false, example: '2021-09-01')]
    #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
    private ?\DateTimeImmutable $subscriptionStartingAt = null;
    /** The organization subscription ending date */
    #[ApiProperty(writable: false, example: '2022-08-31')]
    #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
    private ?\DateTimeImmutable $subscriptionEndingAt = null;
    /** Whether this organization should be considered private & hidden from public feeds */
    #[ApiProperty(writable: false, example: false)]
    #[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
    private bool $isPrivate = false;
    // ...
    }
    31

    View Slide

  32. class OrganizationRepository extends ServiceEntityRepository
    {
    // ...
    public function addFilterInactiveOrganizationCriterion(QueryBuilder $queryBuilder): static
    {
    $a = $queryBuilder->getRootAliases()[0];
    $queryBuilder
    ->andWhere($queryBuilder->expr()->eq($a . '.isPrivate', ':private'))
    ->andWhere($queryBuilder->expr()->isNotNull($a . '.subscriptionStartingAt'))
    ->andWhere($queryBuilder->expr()->gte($a . '.subscriptionEndingAt', ':subscription_ending'))
    ->setParameter('private', false)
    ->setParameter('subscription_ending', Chronos::now()->format('Y-m-d'))
    ;
    return $this;
    }
    }
    32

    View Slide

  33. use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
    use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
    use App\Entity\Organization;
    use App\Repository\OrganizationRepository;
    use Doctrine\ORM\QueryBuilder;
    final class FilterInactiveOrganizationExtension implements QueryCollectionExtensionInterface
    {
    private OrganizationRepository $organizationRepository;
    public function __construct(OrganizationRepository $organizationRepository)
    {
    $this->organizationRepository = $organizationRepository;
    }
    public function applyToCollection(
    QueryBuilder $queryBuilder,
    QueryNameGeneratorInterface $queryNameGenerator,
    string $resourceClass,
    string $operationName = null
    ): void {
    if ([Organization::class, 'get'] === [$resourceClass, $operationName]) {
    $this->organizationRepository->addFilterInactiveOrganizationCriterion($queryBuilder);
    }
    }
    }
    33

    View Slide

  34. {
    "@context": "/api/contexts/Organization",
    "@id": "/api/organizations",
    "@type": "hydra:Collection",
    "hydra:member": [
    {
    "@id": "/api/organizations/ccfb68da-ed42-3dbb-a159-b2108745df4a",
    "@type": "Organization",
    "id": "ccfb68da-ed42-3dbb-a159-b2108745df4a",
    "name": "Barton, Schmeler and Buckridge",
    "slug": "barton-schmeler-and-buckridge",
    "country": "SI",
    "subscriptionStartingAt": "2021-11-03T00:00:00+00:00",
    "subscriptionEndingAt": "2022-08-30T00:00:00+00:00",
    "private": false
    },
    { '_': '...'}
    ],
    "hydra:totalItems": 13,
    } 34

    View Slide

  35. SELECT o0_.id AS id_0,
    o0_.name AS name_1,
    o0_.slug AS slug_2,
    o0_.country AS country_3,
    o0_.logo AS logo_4,
    o0_.subscription_starting_at AS subscription_starting_at_5,
    o0_.subscription_ending_at AS subscription_ending_at_6,
    o0_.is_private AS is_private_7
    FROM organization o0_
    WHERE o0_.is_private = 0
    AND o0_.subscription_starting_at IS NOT NULL
    AND o0_.subscription_ending_at >= '2022-02-21'
    ORDER BY o0_.name ASC
    LIMIT 50;
    35
    GET https://localhost/api/organizations

    View Slide

  36. interface ContextAwareQueryCollectionExtensionInterface
    extends QueryCollectionExtensionInterface
    {
    public function applyToCollection(
    QueryBuilder $queryBuilder,
    QueryNameGeneratorInterface $queryNameGenerator,
    string $resourceClass,
    string $operationName = null,
    array $context = []
    );
    }
    36

    View Slide

  37. 37
    use ApiPlatform \Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface ;
    use ApiPlatform \Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface ;
    use App\Entity\Organization ;
    use App\Repository\OrganizationRepository ;
    use Doctrine\ORM\QueryBuilder ;
    final class FilterInactiveOrganizationExtension
    implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
    {
    // ...
    public function applyToItem(
    QueryBuilder $queryBuilder,
    QueryNameGeneratorInterface $queryNameGenerator,
    string $resourceClass,
    array $identifiers,
    string $operationName = null,
    array $context = []
    ): void {
    if (Organization::class === $resourceClass) {
    $this->organizationRepository->addFilterInactiveOrganizationCriterion($queryBuilder);
    }
    }
    }

    View Slide

  38. SELECT o0_.id AS id_0,
    o0_.name AS name_1,
    o0_.slug AS slug_2,
    o0_.country AS country_3,
    o0_.logo AS logo_4,
    o0_.subscription_starting_at AS subscription_starting_at_5,
    o0_.subscription_ending_at AS subscription_ending_at_6,
    o0_.is_private AS is_private_7
    FROM organization o0_
    WHERE o0_.id = 'ccfb68da-ed42-3dbb-a159-b2108745df4a
    '
    AND o0_.is_private = 0
    AND o0_.subscription_starting_at IS NOT NULL
    AND o0_.subscription_ending_at >= '2022-02-21';
    38
    GET https://localhost/api/organizations/ccfb68da-ed42-3dbb-a159-b2108745df4a

    View Slide

  39. Serialization
    “Organizations’ private &
    subscription attributes
    must not be exposed. Thus,
    logo should be exposed as
    an absolute URI.”
    ★ Control denormalization on read
    ○ Choose attributes to expose
    ○ Append virtual attributes
    ○ Add custom normalization logic
    ★ Control denormalization on write
    ○ Choose attributes to expose
    ○ Choose attributes to ignore
    ○ Add custom denormalization logic
    ★ Add access control on attributes
    ○ Users’ roles based exposure
    ○ Security voters based exposure
    39

    View Slide

  40. Serializing
    Organizations with
    Serialization Groups
    40

    View Slide

  41. use Symfony\Component\Serializer\Annotation\Ignore;
    // ...
    class Organization implements \Stringable
    {
    // ...
    #[Ignore]
    private ?\DateTimeImmutable $subscriptionStartingAt = null;
    // ...
    #[Ignore]
    private ?\DateTimeImmutable $subscriptionEndingAt = null;
    // ...
    #[Ignore]
    private bool $isPrivate = false;
    // ...
    public function getFoo(): string
    {
    return 'bar';
    }
    #[Ignore()]
    public function isPrivate(): bool
    {
    return $this->isPrivate;
    }
    } 41

    View Slide

  42. {
    "@context": "/api/contexts/Organization",
    "@id": "/api/organizations",
    "@type": "hydra:Collection",
    "hydra:member": [
    {
    "@id": "/api/organizations/ccfb68da-ed42-3dbb-a159-b2108745df4a",
    "@type": "Organization",
    "id": "ccfb68da-ed42-3dbb-a159-b2108745df4a",
    "name": "Barton, Schmeler and Buckridge",
    "slug": "barton-schmeler-and-buckridge",
    "country": "SI",
    "foo": "bar"
    },
    { '_': '...'}
    ],
    "hydra:totalItems": 13,
    }
    42
    🤡

    View Slide

  43. // ...
    use Symfony\Component\Serializer\Annotation\Groups;
    #[ApiResource(
    description: 'This collection of endpoints enables to handle `Organization` resources.'
    ,
    normalizationContext: [
    'groups' => ['organization:read'],
    'skip_null_values' => false,
    ],
    ...
    )]
    class Organization implements \Stringable
    {
    // ...
    #[Groups(['organization:read'])]
    private Uuid $id;
    // ...
    #[Groups(['organization:read'])]
    private string $name;
    // ...
    #[Groups(['organization:read'])]
    private ?string $slug = null;
    // ...
    #[Groups(['organization:read'])]
    private string $country = 'FR';
    // ...
    #[Groups(['organization:read'])]
    private ?string $logo = null;
    // ...
    }
    43

    View Slide

  44. {
    "@context": "/api/contexts/Organization",
    "@id": "/api/organizations",
    "@type": "hydra:Collection",
    "hydra:member": [
    {
    "@id": "/api/organizations/fe502b36-18dc-3646-8ee5-b9987fb2e092",
    "@type": "Organization",
    "id": "fe502b36-18dc-3646-8ee5-b9987fb2e092",
    "name": "Corwin, Swift and Kerluke",
    "slug": "corwin-swift-and-kerluke",
    "country": "MW",
    "logo": "416298a73fe3273413dea7d726783179918dead2.png"
    },
    {
    "@id": "/api/organizations/d144e045-7ce6-3360-857f-3c35a155762b",
    "@type": "Organization",
    "id": "d144e045-7ce6-3360-857f-3c35a155762b",
    "name": "Cummerata-Hahn",
    "slug": "cummerata-hahn",
    "country": "DO",
    "logo": null
    }
    }
    44

    View Slide

  45. Appending Data with
    Custom Object Normalizers
    45

    View Slide

  46. # app/config/assets.yaml
    parameters:
    env(CDN_BASE_URL): 'https://static.xyz.tld'
    framework:
    assets:
    packages:
    organization_logo:
    base_url: '%env(string:CDN_BASE_URL)%/images/orgs'
    46

    View Slide

  47. namespace App\API\Serializer\Normalizer;
    use App\Entity\Organization;
    use Symfony\Component\Asset\Packages;
    use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface
    ;
    use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface
    ;
    use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait
    ;
    final class OrganizationNormalizer implements ContextAwareNormalizerInterface, NormalizerAwareInterface
    {
    use NormalizerAwareTrait
    ;
    private const ALREADY_CALLED = __CLASS__ . '.called';
    private Packages $packages; // injected in constructor
    // ...
    public function supportsNormalization
    (mixed $data, string $format = null, array $context = []):
    bool
    {
    if ($context[self::ALREADY_CALLED] ?? false) {
    return false;
    }
    return $data instanceof Organization;
    }
    } 47

    View Slide

  48. final class OrganizationNormalizer
    implements ContextAwareNormalizerInterface, NormalizerAwareInterface
    {
    // ...
    public function normalize(mixed $object, string $format = null, array $context = []): array
    {
    \assert($object instanceof Organization);
    $context[ self::ALREADY_CALLED] = true;
    $data = $this->normalizer->normalize($object, $format, $context);
    \assert(\is_array($data));
    $logo = $object->getLogo() ?: 'default.png' ;
    return \array_merge($data, [
    'logo' => $this->packages->getUrl($logo, 'organization_logo'),
    ]);
    }
    }
    48

    View Slide

  49. {
    "@context": "/api/contexts/Organization" ,
    "@id": "/api/organizations" ,
    "@type": "hydra:Collection" ,
    "hydra:member" : [
    {
    "@id": "/api/organizations/fe502b36-18dc-3646-8ee5-b9987fb2e092" ,
    "@type": "Organization" ,
    "id": "fe502b36-18dc-3646-8ee5-b9987fb2e092" ,
    "name": "Corwin, Swift and Kerluke" ,
    "slug": "corwin-swift-and-kerluke" ,
    "country": "MW",
    "logo": "https://static.xyz.tld/images/orgs/416298a73fe3273413dea7d726783179918dead2.png"
    },
    {
    "@id": "/api/organizations/d144e045-7ce6-3360-857f-3c35a155762b" ,
    "@type": "Organization" ,
    "id": "d144e045-7ce6-3360-857f-3c35a155762b" ,
    "name": "Cummerata-Hahn" ,
    "slug": "cummerata-hahn" ,
    "country": "DO",
    "logo": "https://static.xyz.tld/images/orgs/default.png"
    }
    ]
    } 49

    View Slide

  50. Controlling Object
    Normalization on an
    Operation Level Basis
    50

    View Slide

  51. 51
    #[ApiResource(
    description: 'This collection of endpoints enables to handle `Organization` resources.'
    ,
    itemOperations: [
    'get' => [
    'normalization_context' => [
    'groups' => [
    'organization:read',
    'organization:read:item',
    ],
    ],
    ],
    'patch',
    'delete',
    ],
    normalizationContext: [
    'groups' => ['organization:read'],
    'skip_null_values' => false,
    ],
    // ...
    )]
    // ...
    class Organization implements \Stringable
    {
    // ...
    #[Groups(['organization:read:item'])]
    private string $country = 'FR';
    // ...
    #[Groups(['organization:read:item'])]
    private ?string $logo = null;
    }

    View Slide

  52. 52
    final class OrganizationNormalizer
    implements ContextAwareNormalizerInterface, NormalizerAwareInterface
    {
    // ...
    public function supportsNormalization(..., array $context = []): bool
    {
    if ($context[self::ALREADY_CALLED] ?? false) {
    return false;
    }
    return $data instanceof Organization
    && 'item' === ($context['operation_type'] ?? null);
    }
    // ...
    }

    View Slide

  53. {
    "@context": "/api/contexts/Organization"
    ,
    "@id": "/api/organizations"
    ,
    "@type": "hydra:Collection"
    ,
    "hydra:member": [
    {
    "@id": "/api/organizations/fe502b36-18dc-3646-8ee5-b9987fb2e092"
    ,
    "@type": "Organization",
    "id": "fe502b36-18dc-3646-8ee5-b9987fb2e092"
    ,
    "name": "Corwin, Swift and Kerluke"
    ,
    "slug": "corwin-swift-and-kerluke"
    ,
    },
    {
    "@id": "/api/organizations/d144e045-7ce6-3360-857f-3c35a155762b"
    ,
    "@type": "Organization",
    "id": "d144e045-7ce6-3360-857f-3c35a155762b"
    ,
    "name": "Cummerata-Hahn",
    "slug": "cummerata-hahn",
    }
    ]
    } 53

    View Slide

  54. {
    "@context": "/api/contexts/Organization",
    "@id": "/api/organizations/fe502b36-18dc-3646-8ee5-b9987fb2e092",
    "@type": "Organization",
    "id": "fe502b36-18dc-3646-8ee5-b9987fb2e092",
    "name": "Corwin, Swift and Kerluke",
    "slug": "corwin-swift-and-kerluke",
    "country": "MW",
    "logo": "https://static.xyz.tld/images/orgs/416298a73fe3273413dea7d726783179918dead2.png"
    }
    54
    {
    "@context": "/api/contexts/Organization",
    "@id": "/api/organizations/d144e045-7ce6-3360-857f-3c35a155762b",
    "@type": "Organization",
    "id": "d144e045-7ce6-3360-857f-3c35a155762b",
    "name": "Cummerata-Hahn",
    "slug": "cummerata-hahn",
    "country": "DO",
    "logo": "https://static.xyz.tld/images/orgs/default.png"
    }

    View Slide

  55. Controlling the List of
    Enabled Operations on a
    Resource
    55

    View Slide

  56. #[ApiResource(
    description: 'This collection of endpoints enables to handle `Organization` resources.',
    collectionOperations: [
    'get',
    'post' => [
    'openapi_context' => [
    'summary' => 'Onboard a new organization in the application.',
    'description' => "This endpoint enables to create a new organization resource...",
    ],
    ],
    ],
    itemOperations: [
    'get' => [
    'normalization_context' => [
    'groups' => [
    'organization:read',
    'organization:read:item',
    ],
    ],
    ],
    'patch' => [
    'openapi_context' => [
    'summary' => 'Update an existing organization.',
    'description' => "This endpoint enables to update the basic details of an organization...",
    ],
    ],
    ],
    // ...
    )]
    56

    View Slide

  57. 57
    DELETE operation is gone and operations titles have been updated!

    View Slide

  58. Controlling Object
    Denormalization on Write
    58

    View Slide

  59. #[ApiResource(
    // ...
    collectionOperations: [
    'get',
    'post' => [
    // ...
    'denormalization_context' => [
    'groups' => ['organization:write, 'organization:write:create'],
    ],
    ],
    ],
    itemOperations: [
    // ...
    'patch' => [
    // ...
    'denormalization_context' => [
    'groups' => ['organization:write, 'organization:write:update'],
    ],
    ],
    ],
    denormalizationContext: [
    'groups' => ['organization:write'],
    'skip_null_values' => false,
    ],
    )]
    59

    View Slide

  60. class Organization implements \Stringable
    {
    #[ApiProperty(example: 'DELL EMEA')]
    #[Groups([
    'organization:write:update'
    , 'organization:write'])]
    private string $name;
    #[Groups([
    'organization:read:item'
    , 'organization:write'])]
    private string $country = 'FR';
    #[Groups(['organization:read:item'
    , 'organization:write:update'])]
    private ?string $logo = null;
    #[ApiProperty(example: '2021-09-01', security: "is_granted('ROLE_ADMIN')")]
    #[Groups(['organization:write:update'])]
    private ?\DateTimeImmutable $subscriptionStartingAt =
    null;
    #[ApiProperty(example: '2022-08-31', security: "is_granted('ROLE_ADMIN')")]
    #[Groups(['organization:write:update'])]
    private ?\DateTimeImmutable $subscriptionEndingAt =
    null;
    #[ApiProperty(example: true, security: "is_granted('ROLE_ADMIN')")]
    #[Groups(['organization:write:update'])]
    private bool $isPrivate = false;
    // ...
    }
    60

    View Slide

  61. 61
    Organization create payload is pregenerated from JSON schema.

    View Slide

  62. 62

    View Slide

  63. 63

    View Slide

  64. 64

    View Slide

  65. Validation
    Validate incoming HTTP
    request JSON payloads
    automatically.
    ★ Validate Input Data
    ○ Before denormalization
    ○ After denormalization
    ★ Based on Symfony Validator
    ○ Reuse Validator constraints
    ○ Write custom validators
    ○ Leverage validation groups
    ○ Leverage translations
    ★ Generate JSONLD Error Response
    ○ Standard JSONLD for Errors
    ○ 400 / 422 / 500 status codes
    65

    View Slide

  66. use Symfony\Bridge\Doctrine\Validator\Constraints \UniqueEntity ;
    use Symfony\Component\Validator\Constraints \Country;
    use Symfony\Component\Validator\Constraints \Image;
    use Symfony\Component\Validator\Constraints \Length;
    use Symfony\Component\Validator\Constraints \NotBlank;
    #[NotBlank(fields: ['name'], message: 'An organization with the same name already exists.')]
    class Organization implements \Stringable
    {
    // ...
    #[NotBlank(message: 'Organization name is required.')]
    #[Length(min: 2, max: 64)]
    private string $name;
    #[NotBlank(message: 'Organization country is required.')]
    #[Country]
    private string $country = 'FR';
    #[Image(maxSize: '1M', mimeTypes: ['images/png'])]
    private ?string $logo = null;
    // ...
    }
    66

    View Slide

  67. 67

    View Slide

  68. 68

    View Slide

  69. Nested
    Resources &
    Subresources
    “Organizations can be
    linked to zero or many
    activity fields.”
    ★ Embed nested objects
    ○ When normalizing
    ○ When denormalizing
    ★ Embed subresources
    ○ When normalizing only
    ★ Exposition mode
    ○ As IRIs
    ○ As Objects
    69

    View Slide

  70. Embedding Nested Objects
    and Relations
    70

    View Slide

  71. #[ORM\Entity(repositoryClass: ActivityFieldRepository::class)]
    #[ORM\UniqueConstraint(name: 'activity_field_slug_unique', columns: ['slug'])]
    #[ApiResource(
    description: 'This collection of endpoints enables to read `ActivityField` resources.',
    collectionOperations: ['get'],
    itemOperations: ['get'],
    normalizationContext: ['groups' => ['activity_field:read']],
    order: ['name' => 'ASC']
    )]
    class ActivityField implements \Stringable, Timestampable
    {
    #[ApiProperty(example: '98c276e1-bc20-4f9e-8027-cac10bb2100c')]
    #[Groups(['activity_field:read'])]
    private Uuid $id;
    #[ApiProperty(example: 'IT Engineering')]
    #[Groups(['activity_field:read', 'activity_field:write'])]
    private string $name;
    #[ApiProperty(example: 'it-engineering')]
    #[Groups(['activity_field:read'])]
    private ?string $slug = null;
    // ...
    }
    71

    View Slide

  72. class Organization implements \Stringable, Timestampable
    {
    // ...
    /** @var Collection */
    #[ORM\ManyToMany(targetEntity: ActivityField::class)]
    #[ORM\JoinTable(name: 'organization_has_activity_field')]
    #[ORM\JoinColumn(onDelete: 'CASCADE')]
    #[ORM\InverseJoinColumn(onDelete: 'CASCADE')]
    #[ORM\OrderBy(['name' => 'ASC'])]
    #[Groups(['organization:read:item'])]
    private Collection $activityFields;
    public function getActivityFields(): Collection { ... }
    public function addActivityField(ActivityField $activityField): void { ... }
    public function removeActivityField(ActivityField $activityField): void { ... }
    }
    72

    View Slide

  73. 73
    {
    "@context": "/api/contexts/Organization",
    "@id": "/api/organizations/bd1ef147-0214-4b0e-849b-1d40fb233cd8",
    "@type": "Organization",
    "id": "bd1ef147-0214-4b0e-849b-1d40fb233cd8",
    "name": "TESLA",
    "slug": "tesla",
    "country": "US",
    "logo": "https://localhost/images/orgs/62063…d8a40b24.png",
    "activityFields": [
    "/api/activity_fields/c827ad31-6e52-4d0f-9262-9f7910e61183",
    "/api/activity_fields/856fef05-e245-4231-954f-e2ebf132e6c5"
    ]
    }

    View Slide

  74. 74
    #[ApiResource(
    description: 'This collection of endpoints enables to handle `Organization` resources.'
    ,
    // ...
    itemOperations: [
    'get' => [
    'normalization_context' => [
    'groups' => [
    'activity_field:read',
    'organization:read'
    ,
    'organization:read:item'
    ,
    ],
    ],
    ],
    // ...
    )]
    class Organization implements \Stringable, Timestampable
    {
    }

    View Slide

  75. 75
    {
    "@context": "/api/contexts/Organization",
    "@id": "/api/organizations/bd1ef147-0214-4b0e-849b-1d40fb233cd8",
    "@type": "Organization",
    "id": "bd1ef147-0214-4b0e-849b-1d40fb233cd8",
    "name": "TESLA",
    ...
    "activityFields": [
    {
    "@id": "/api/activity_fields/c827ad31-6e52-4d0f-9262-9f7910e61183",
    "@type": "ActivityField",
    "id": "c827ad31-6e52-4d0f-9262-9f7910e61183",
    "name": "Cars Manufacturing",
    "slug": "cars-manufacturing"
    },
    {
    "@id": "/api/activity_fields/856fef05-e245-4231-954f-e2ebf132e6c5",
    "@type": "ActivityField",
    "id": "856fef05-e245-4231-954f-e2ebf132e6c5",
    "name": "Luxury Goods & Fashion",
    "slug": "luxury-goods-fashion"
    }
    ]
    }

    View Slide

  76. Embedding Subresources
    76

    View Slide

  77. 77
    #[ApiResource(
    description: 'This collection of endpoints enables … as collaborators.' ,
    subresourceOperations: [
    'api_organizations_staff_members_get_subresource' => [
    'normalization_context' => [
    'groups' => ['staff_member:read', 'user:read:as_staff_member'],
    ],
    ],
    ],
    denormalizationContext: [
    'groups' => ['staff_member:write' ],
    'skip_null_values' => false,
    ],
    normalizationContext: [
    'groups' => ['staff_member:read' ],
    'skip_null_values' => false,
    ],
    )]
    #[ORM\Entity(repositoryClass: StaffMemberRepository ::class)]
    class StaffMember
    {
    // ...
    }

    View Slide

  78. 78
    #[ORM\Entity(repositoryClass: StaffMemberRepository ::class)]
    class StaffMember
    {
    use TimestampableTrait ;
    #[ApiProperty (example: '13a5b1f6-ce97-4823-ac4e-1a79f8e3cb66')]
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    #[Groups(['staff_member:read'])]
    private Uuid $id;
    #[ORM\ManyToOne(targetEntity: Organization ::class, inversedBy : 'staffMember s')]
    #[ORM\JoinColumn (nullable: false, onDelete: 'CASCADE')]
    #[Groups(['staff_member:write:create'])]
    private Organization $organization;
    #[ORM\ManyToOne(targetEntity: User::class, inversedBy : 'collaboration s')]
    #[ORM\JoinColumn (referencedColumnName: 'identifie r', nullable: false, onDelete: 'CASCADE')]
    #[Groups(['staff_member:read', 'staff_member:write:create'])]
    private User $person;
    #[ORM\Column(type: 'StaffMemberRoleTyp e', nullable: true)]
    #[Groups(['staff_member:read', 'staff_member:write'])]
    private ?string $role;
    // ...
    }

    View Slide

  79. 79
    #[ApiResource(
    // ...
    itemOperations: [
    'get' => [
    'normalization_context' => [
    'groups' => ['....', 'staff_member:read', 'user:read:as_staff_member'],
    ],
    ],
    // ...
    ],
    subresourceOperations: [
    'staff_members_get_subresource' => [
    'method' => 'GET',
    'path' => '/organizations/{id}/staff-members',
    ],
    ],
    // ...
    )]
    class Organization implements \Stringable, Timestampable
    {
    /** @var Collection */
    #[ApiProperty(description: 'The list of organization staff member users with their roles')]
    #[ApiSubresource]
    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: StaffMember::class)]
    #[ORM\OrderBy(['createdAt' => 'ASC'])]
    #[Groups(['organization:read:item'])]
    private Collection $staffMembers;
    // ...
    }

    View Slide

  80. 80

    View Slide

  81. 81

    View Slide

  82. Data Transfer Objects
    Custom Operations
    “Enable organizations to
    onboard new staff
    members.”
    ★ Define more complex data model
    ○ Decouple from database model
    ○ Create domain centric model
    ★ Combine with custom operations
    82

    View Slide

  83. 83
    final class StaffMemberEnrollment
    {
    #[Groups(['staff_member:write:create'])]
    public ?Organization $organization = null;
    #[Groups(['staff_member:write:create'])]
    public ?User $user = null;
    #[Groups(['staff_member:write:create'])]
    public ?string $role = null;
    #[Groups(['staff_member:write:create'])]
    public ?string $welcomeMessage = null;
    }

    View Slide

  84. 84
    #[GroupSequence(['StaffMemberEnrollment', 'Consistency'])]
    final class StaffMemberEnrollment
    {
    #[NotBlank(message: 'Organization IRI is required.')]
    public ?Organization $organization = null;
    #[NotBlank(message: 'User IRI is required.')]
    public ?User $user = null;
    #[Choice(choices: StaffMemberRoleType::ALLOWED_ENROLLMENT_ROLES, message: 'Given role is not a valid staff member role.')]
    public ?string $role = null;
    #[NotBlank(message: 'Welcome message is required')]
    public ?string $welcomeMessage = null;
    #[Callback(groups: ['Consistency'])]
    public function preventDoubleEnrollment(ExecutionContextInterface $context): void
    {
    if (! $this->organization->hasStaffMember(
    $this->user)) {
    return;
    }
    $context->buildViolation(
    'User is already an enrolled staff member of this organization.'
    )
    ->atPath(
    'user')
    ->setCause(
    $this->user)
    ->setInvalidValue(
    $this->user)
    ->addViolation();
    }
    }

    View Slide

  85. 85
    #[ApiResource(
    description: 'This collection of endpoints enables to enroll users ...',
    collectionOperations: [
    'get',
    'enroll_staff_member' => [
    'openapi_context' => [
    'summary' => 'Enrolls a single user as a staff member …',
    'description' => 'This endpoint enables to enroll a user …',
    ],
    'method' => 'POST',
    'input' => StaffMemberEnrollment::class,
    'denormalization_context' => ['staff_member:write:create'],
    ],
    ],
    // ...
    )]
    class StaffMember
    {
    }

    View Slide

  86. 86
    namespace App\API\Organization ;
    use ApiPlatform \Core\DataTransformer \DataTransformerInterface ;
    use ApiPlatform \Core\Validator\ValidatorInterface ;
    use App\Entity\StaffMember ;
    final class StaffMemberEnrollmentDataTransformer implements DataTransformerInterface
    {
    private ValidatorInterface $validator;
    // ...
    public function transform($data, string $to, array $context = []): StaffMember
    {
    $this->validator->validate($data);
    return new StaffMember($data->organization, $data->user, $data->role);
    }
    public function supportsTransformation($data, string $to, array $context = []): bool
    {
    if ($data instanceof StaffMember) {
    return false;
    }
    return StaffMember::class === $to
    && StaffMemberEnrollment::class === ($context[ 'input']['class'] ?? null);
    }
    }

    View Slide

  87. 87
    {
    "organization" : "/api/organizations/b3a49e06-2415-4eb5-ba01-ab24096ce2b9" ,
    "user": "/api/users/2BGI9DK6" ,
    "role": "ROLE_ORGANIZATION_OWNER",
    "welcomeMessage" : "Hey! Welcome in our organization :)"
    }
    {
    "@context": "/api/contexts/ConstraintViolationList" ,
    "@type": "ConstraintViolationList" ,
    "hydra:title" : "An error occurred" ,
    "hydra:description" : "role: Given role is not a valid staff member role." ,
    "violations": [
    {
    "propertyPath": "role",
    "message": "Given role is not a valid staff member role.",
    "code": "8e179f1b-97aa-4560-a02f-2a8b42e49df7"
    }
    ]
    }
    422

    View Slide

  88. 88
    {
    "organization" : "/api/organizations/b3a49e06-2415-4eb5-ba01-ab24096ce2b9" ,
    "user": "/api/users/2BGI9DK6" ,
    "role": "ROLE_FIELD_TECHNICIAN" ,
    "welcomeMessage" : "Hey! Welcome in our organization :)"
    }
    {
    "@context": "/api/contexts/ConstraintViolationList" ,
    "@type": "ConstraintViolationList" ,
    "hydra:title" : "An error occurred" ,
    "hydra:description" : "user: User is already an enrolled … this organization." ,
    "violations": [
    {
    "propertyPath": "user",
    "message": "User is already an enrolled staff member of this organization.",
    "code": null
    }
    ]
    }
    422

    View Slide

  89. 89
    {
    "organization": "/api/organizations/b3a49e06-2415-4eb5-ba01-ab24096ce2b9"
    ,
    "user": "/api/users/2DRWZS56"
    ,
    "role": "ROLE_FIELD_TECHNICIAN"
    ,
    "welcomeMessage"
    : "Hey! Welcome in our organization :)"
    }
    {
    "@context": "/api/contexts/StaffMember" ,
    "@id": "/api/staff_members/72b60988-a6d6-423d-9973-73a58929c1cf" ,
    "@type": "StaffMember" ,
    "id": "72b60988-a6d6-423d-9973-73a58929c1cf" ,
    "person": "/api/users/2DRWZS56" ,
    "role": "ROLE_FIELD_TECHNICIAN"
    }
    200

    View Slide

  90. Data
    Persistence
    “When a user is onboarded
    in an organization, the
    system sends a welcome
    email”
    ★ Trigger custom logic on persistence
    ○ Before a resource is persisted
    ○ After a resource is persisted
    ★ Write custom data persisters
    ○ Handle persistence logic
    yourself
    90

    View Slide

  91. 91
    final class StaffMemberEnrollmentDataTransformer implements DataTransformerInterface
    {
    /**
    * @param StaffMemberEnrollment $data
    * @param array $context
    */
    public function transform($data, string $to, array $context = []): StaffMember
    {
    $this->validator->validate($data);
    $staff = new StaffMember($data->organization, $data->user, $data->role);
    $staff->setWelcomeEmail($this->createWelcomeEmail($data));
    return $staff;
    }
    private function createWelcomeEmail
    (StaffMemberEnrollment $data): Email
    {
    return (new Email())
    ->to(
    new Address($data->user->getEmailAddress(), $data->user->getFullName()))
    ->subject(\sprintf(
    'You have been enrolled at %s!'
    , $data->organization->getName()))
    ->text($data->welcomeMessage);
    }
    }

    View Slide

  92. 92
    namespace App\API\Organization;
    use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
    use App\Entity\StaffMember;
    use Doctrine\ORM\EntityManagerInterface;
    use Symfony\Component\Mailer\MailerInterface;
    final class StaffMemberDataPersister implements ContextAwareDataPersisterInterface
    {
    private EntityManagerInterface $entityManager;
    private MailerInterface $mailer;
    public function __construct(
    EntityManagerInterface $entityManager,
    MailerInterface $mailer,
    ) {
    $this->entityManager = $entityManager;
    $this->mailer = $mailer;
    }
    public function supports($data, array $context = []): bool
    {
    return $data instanceof StaffMember;
    }
    }

    View Slide

  93. 93
    final class StaffMemberDataPersister implements ContextAwareDataPersisterInterface
    {
    // ...
    /**
    * @param StaffMember $data
    * @param array $context
    */
    public function persist($data, array $context = []): void
    {
    $this->entityManager->persist($data);
    $this->entityManager->flush();
    if ($welcomeEmail = $data->getWelcomeEmail()) {
    $this->mailer->send($welcomeEmail);
    }
    }
    public function remove($data, array $context = []): void
    {
    $this->entityManager->remove($data);
    $this->entityManager->flush();
    }
    }

    View Slide

  94. 94
    {
    "organization": "/api/organizations/b3a49e06-2415-4eb5-ba01-ab24096ce2b9"
    ,
    "user": "/api/users/2DRWZS56"
    ,
    "role": "ROLE_FIELD_TECHNICIAN"
    ,
    "welcomeMessage"
    : "Hey! Welcome in our organization :)"
    }

    View Slide

  95. Going Further?
    ★ Custom context builder
    ○ Dynamic API context update
    ★ Authentication
    ○ HTTP Basic
    ○ JWT
    ○ SSO
    ○ …
    ★ Authorization
    ○ Validate users permissions
    ○ Leverage Symfony roles
    ○ Leverage Symfony voters
    ★ Functional testing
    ○ Panther
    ○ Symfony Test Client
    ★ GraphQL / Mongodb / Elasticsearch
    ★ Push with Mercure & Vulcain
    ★ …
    95
    https://api-platform.com/docs/core/

    View Slide

  96. 96

    View Slide