Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Build your next REST API with Symfony & API Pl...

Avatar for Hugo Hamon 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

Avatar for Hugo Hamon

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

  3. 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
  4. 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
  5. 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
  6. Application Example Manage a directory of Organization resources where each

    organization entity can enrol User resources as Staff Members. 8
  7. #[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
  8. 12

  9. 13

  10. 14

  11. { "@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
  12. 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”
  13. #[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
  14. 19

  15. 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”
  16. 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
  17. { "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
  18. 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”
  19. { "@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
  20. 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
  21. 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
  22. { "@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
  23. 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
  24. // ... #[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
  25. 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
  26. 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
  27. { "@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
  28. 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
  29. interface ContextAwareQueryCollectionExtensionInterface extends QueryCollectionExtensionInterface { public function applyToCollection( QueryBuilder $queryBuilder,

    QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null, array $context = [] ); } 36
  30. 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); } } }
  31. 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
  32. 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
  33. 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
  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", "foo": "bar" }, { '_': '...'} ], "hydra:totalItems": 13, } 42 🤡
  35. // ... 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
  36. { "@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
  37. 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
  38. 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
  39. { "@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
  40. 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; }
  41. 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); } // ... }
  42. { "@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
  43. { "@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" }
  44. #[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
  45. #[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
  46. 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
  47. 62

  48. 63

  49. 64

  50. 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
  51. 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
  52. 67

  53. 68

  54. 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
  55. #[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
  56. class Organization implements \Stringable, Timestampable { // ... /** @var

    Collection<int, ActivityField> */ #[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
  57. 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" ] }
  58. 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 { }
  59. 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" } ] }
  60. 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 { // ... }
  61. 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; // ... }
  62. 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<int, StaffMember> */ #[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; // ... }
  63. 80

  64. 81

  65. 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
  66. 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; }
  67. 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(); } }
  68. 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 { }
  69. 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); } }
  70. 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
  71. 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
  72. 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
  73. 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
  74. 91 final class StaffMemberEnrollmentDataTransformer implements DataTransformerInterface { /** * @param

    StaffMemberEnrollment $data * @param array<string, mixed> $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); } }
  75. 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; } }
  76. 93 final class StaffMemberDataPersister implements ContextAwareDataPersisterInterface { // ... /**

    * @param StaffMember $data * @param array<string, mixed> $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(); } }
  77. 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/
  78. 96