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

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