Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

2

Slide 3

Slide 3 text

https://speakerdeck.com/hhamon 3

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

https://localhost/api 7

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

Let’s Get Started! 9

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

12

Slide 13

Slide 13 text

13

Slide 14

Slide 14 text

14

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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”

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

19

Slide 20

Slide 20 text

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”

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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”

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

// ... #[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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

Serializing Organizations with Serialization Groups 40

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

Appending Data with Custom Object Normalizers 45

Slide 46

Slide 46 text

# 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

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

Controlling Object Normalization on an Operation Level Basis 50

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

Controlling the List of Enabled Operations on a Resource 55

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

Controlling Object Denormalization on Write 58

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

61 Organization create payload is pregenerated from JSON schema.

Slide 62

Slide 62 text

62

Slide 63

Slide 63 text

63

Slide 64

Slide 64 text

64

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

67

Slide 68

Slide 68 text

68

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

Embedding Nested Objects and Relations 70

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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" ] }

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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" } ] }

Slide 76

Slide 76 text

Embedding Subresources 76

Slide 77

Slide 77 text

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 { // ... }

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

80

Slide 81

Slide 81 text

81

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

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

Slide 85

Slide 85 text

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

Slide 86

Slide 86 text

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

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

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

Slide 89

Slide 89 text

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

Slide 90

Slide 90 text

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

Slide 91

Slide 91 text

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

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

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

Slide 94

Slide 94 text

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

Slide 95

Slide 95 text

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/

Slide 96

Slide 96 text

96