Using API platform to build ticketing system Antonio Perić-Mažar, Locastic 22.11.2019. - #SymfonyCon, Amsterdam
Antonio Perić-Mažar CEO @ Locastic Co-founder @ Litto Co-founder @ Tinel Meetup t: @antonioperic m: antonio@locastic.com
Locastic Helping clients create web and mobile apps since 2011 • UX/UI • Mobile apps • Web apps • Training & Consulting www.locastic.com @locastic
• API Platform & Symfony • Ticketing platform: GFNY (franchise business) • ~ year and half in production • ~ 60 000 tickets released & race results stored in DB • ~ 20 000 users/racers, • ~ 60 users with admin roles • 48 events in 26 countries, users from 82 countries • 8 different languages including Hebrew and Indonesian Context & API Platform Experience
• Social network • chat based • matching similar to Tinder :) • few CRM/ERP applications Context & API Platform Experience
What is API platform ?
–Fabien Potencier (creator of Symfony), SymfonyCon 2017 “API Platform is the most advanced API platform, in any framework or language.”
• full stack framework dedicated to API-Driven projects • contains a PHP library to create a fully featured APIs supporting industry standards (JSON-LD, Hydra, GraphQL, OpenAPI…) • provides ambitious Javascript tooling to consume APIs in a snap • Symfony official API stack (instead of FOSRestBundle) • shipped with Docker and Kubernetes integration API Platform
• creating, retrieving, updating and deleting (CRUD) resources • data validation • pagination • filtering • sorting • hypermedia/HATEOAS and content negotiation support (JSON-LD and Hydra, JSON:API, HAL…) API Platform built-in features:
• GraphQL support • Nice UI and machine-readable documentations (Swagger UI/ OpenAPI, GraphiQL…) • authentication (Basic HTP, cookies as well as JWT and OAuth through extensions) • CORS headers • security checks and headers (tested against OWASP recommendations) API Platform built-in features:
• invalidation-based HTTP caching • and basically everything needed to build modern APIs. API Platform built-in features:
Creating Simple CRUD in a minute
Create Entity Step One <?php // src/Entity/Greeting.php namespace AppEntity; class Greeting { private $id; public $name = ''; public function getId(): int { return $this->id; } }
Create Mapping Step Two # config/doctrine/Greeting.orm.yml AppEntityGreeting: type: entity table: greeting id: id: type: integer generator: { strategy: AUTO } fields: name: type: string length: 100
Add Validation Step Three # config/validator/greeting.yaml AppEntityGreeting: properties: name: - NotBlank: ~
Expose Resource Step Four # config/api_platform/resources.yaml resources: AppEntityGreeting: ~
Serialization Groups Read Write
Use YAML Configuration advice
User Management & Security
• Avoid using FOSUserBundle • not well suited with API • to much overhead and not needed complexity • Use Doctrine User Provider • simple and easy to integrate • bin/console maker:user User management
// src/Entity/User.php namespace AppEntity; use SymfonyComponentSecurityCoreUserUserInterface; use SymfonyComponentSerializerAnnotationGroups; class User implements UserInterface { /** * @Groups({"user-read"}) */ private $id; /** * @Groups({"user-write", "user-read"}) */ private $email; /** * @Groups({"user-read"}) */ private $roles = []; /** * @Groups({"user-write"}) */ private $plainPassword; private $password; … getters and setters … }
// src/Entity/User.php namespace AppEntity; use SymfonyComponentSecurityCoreUserUserInterface; use SymfonyComponentSerializerAnnotationGroups; class User implements UserInterface { /** * @Groups({"user-read"}) */ private $id; /** * @Groups({"user-write", "user-read"}) */ private $email; /** * @Groups({"user-read"}) */ private $roles = []; /** * @Groups({"user-write"}) */ private $plainPassword; private $password; … getters and setters … } # config/doctrine/User.orm.yml AppEntityUser: type: entity table: users repositoryClass: AppRepositoryUserRepository id: id: type: integer generator: { strategy: AUTO } fields: email: type: string length: 255 password: type: string length: 255 roles: type: array
// src/Entity/User.php namespace AppEntity; use SymfonyComponentSecurityCoreUserUserInterface; use SymfonyComponentSerializerAnnotationGroups; class User implements UserInterface { /** * @Groups({"user-read"}) */ private $id; /** * @Groups({"user-write", "user-read"}) */ private $email; /** * @Groups({"user-read"}) */ private $roles = []; /** * @Groups({"user-write"}) */ private $plainPassword; private $password; … getters and setters … } # config/doctrine/User.orm.yml AppEntityUser: type: entity table: users repositoryClass: AppRepositoryUserRepository id: id: type: integer generator: { strategy: AUTO } fields: email: type: string length: 255 password: type: string length: 255 roles: type: array # config/api_platform/resources.yaml resources: AppEntityUser: attributes: normalization_context: groups: ['user-read'] denormalization_context: groups: ['user-write']
use ApiPlatformCoreDataPersisterContextAwareDataPersisterInterface; use AppEntityUser; use DoctrineORMEntityManagerInterface; use SymfonyComponentSecurityCoreEncoderUserPasswordEncoderInterface; class UserDataPersister implements ContextAwareDataPersisterInterface { private $entityManager; private $userPasswordEncoder; public function __construct(EntityManagerInterface $entityManager, UserPasswordEncoderInterface $userPasswordEncoder) { $this->entityManager = $entityManager; $this->userPasswordEncoder = $userPasswordEncoder; } public function supports($data, array $context = []): bool { return $data instanceof User; } public function persist($data, array $context = []) { /** @var User $data */ if ($data->getPlainPassword()) { $data->setPassword( $this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword()) ); $data->eraseCredentials(); } $this->entityManager->persist($data); $this->entityManager->flush($data); return $data; } public function remove($data, array $context = []) { $this->entityManager->remove($data); $this->entityManager->flush(); }
• Lightweight and simple authentication system • Stateless: token signed and verified server-side then stored client- side and sent with each request in an Authorization header • Store the token in the browser local storage JSON Web Token (JWT)
• API Platform allows to easily add a JWT-based authentication to your API using LexikJWTAuthenticationBundle. • Maybe you want to use a refresh token to renew your JWT. In this case you can check JWTRefreshTokenBundle. User authentication
User security checker Security <?php namespace AppSecurity; use AppExceptionAccountDeletedException; use AppSecurityUser as AppUser; use SymfonyComponentSecurityCoreExceptionAccountExpiredException; use SymfonyComponentSecurityCoreExceptionCustomUserMessageAuthenticat use SymfonyComponentSecurityCoreUserUserCheckerInterface; use SymfonyComponentSecurityCoreUserUserInterface; class UserChecker implements UserCheckerInterface { public function checkPreAuth(UserInterface $user) { if (!$user instanceof AppUser) { return; } // user is deleted, show a generic Account Not Found message. if ($user->isDeleted()) { throw new AccountDeletedException(); } } public function checkPostAuth(UserInterface $user) { if (!$user instanceof AppUser) { return; } // user account is expired, the user may be notified if ($user->isExpired()) { throw new AccountExpiredException('...'); } } }
User security checker Security # config/packages/security.yaml # ... security: firewalls: main: pattern: ^/ user_checker: AppSecurityUserChecker # ...
Resource and operation level Security # api/config/api_platform/resources.yaml AppEntityBook: attributes: security: 'is_granted("ROLE_USER")' collectionOperations: get: ~ post: security: 'is_granted("ROLE_ADMIN")' itemOperations: get: ~ put: security_: 'is_granted("ROLE_ADMIN") or object.owner == user'
Resource and operation level using Voters Security # api/config/api_platform/resources.yaml AppEntityBook: itemOperations: get: security_: 'is_granted('READ', object)' put: security_: 'is_granted('UPDATE', object)'
• A JWT is self-contained, meaning that we can trust into its payload for processing the authentication. In a nutshell, there should be no need for loading the user from the database when authenticating a JWT Token, the database should be hit only once for delivering the token. • It means you will have to fetch the User entity from the database yourself as needed (probably through the Doctrine EntityManager or your custom Provider). JWT tip A database-less user provider
JWT tip A database-less user provider # config/packages/security.yaml security: providers: jwt: lexik_jwt: ~ security: firewalls: api: provider: jwt guard: # ...
Creating multi-language APIs
• Locastic Api Translation Bundle • Translation bundle for ApiPlatform based on Sylius translation • It requires two entities: Translatable & Translation entity • Open source • https://github.com/Locastic/ApiPlatformTranslationBundle • https://locastic.com/blog/having-troubles-with-implementing- translations-in-apiplatform/ Creating multi-language APIs
<?php use LocasticApiPlatformTranslationBundleModelAbstractTranslatable; class Post extends AbstractTranslatable { // ... protected function createTranslation() { return new PostTranslation(); } }
<?php use LocasticApiPlatformTranslationBundleModelAbstractTranslatable; class Post extends AbstractTranslatable { // ... protected function createTranslation() { return new PostTranslation(); } } use LocasticApiPlatformTranslationBundleModelAbstractTranslatable; class Post extends AbstractTranslatable { // ... /** * @Groups({"post_write", "translations"}) */ protected $translations; }
<?php use LocasticApiPlatformTranslationBundleModelAbstractTranslatable; class Post extends AbstractTranslatable { // ... protected function createTranslation() { return new PostTranslation(); } } use LocasticApiPlatformTranslationBundleModelAbstractTranslatable; class Post extends AbstractTranslatable { // ... /** * @Groups({"post_write", "translations"}) */ protected $translations; } <?php use LocasticApiPlatformTranslationBundleModelAbstractTranslatable; use SymfonyComponentSerializerAnnotationGroups; class Post extends AbstractTranslatable { // ... /** * @var string * * @Groups({"post_read"}) */ private $title; public function setTitle($title) { $this->getTranslation()->setTitle($title); return $this; } public function getTitle() { return $this->getTranslation()->getTitle(); } }
<?php use SymfonyComponentSerializerAnnotationGroups; use LocasticApiTranslationBundleModelAbstractTranslation; class PostTranslation extends AbstractTranslation { // ... /** * @var string * * @Groups({"post_read", "post_write", "translations"}) */ private $title; /** * @var string * @Groups({"post_write", "translations"}) */ protected $locale; public function setTitle($title) { $this->title = $title; return $this; } public function getTitle() { return $this->title; } }
<?php use SymfonyComponentSerializerAnnotationGroups; use LocasticApiTranslationBundleModelAbstractTranslation; class PostTranslation extends AbstractTranslation { // ... /** * @var string * * @Groups({"post_read", "post_write", "translations"}) */ private $title; /** * @var string * @Groups({"post_write", "translations"}) */ protected $locale; public function setTitle($title) { $this->title = $title; return $this; } public function getTitle() { return $this->title; } } AppBundleEntityPost: itemOperations: get: method: GET put: method: PUT normalization_context: groups: ['translations'] collectionOperations: get: method: GET post: method: POST normalization_context: groups: ['translations'] attributes: filters: ['translation.groups'] normalization_context: groups: ['post_read'] denormalization_context: groups: ['post_write']
POST translation example Multi-language API { "datetime":"2017-10-10", "translations": { "en":{ "title":"test", "content":"test", "locale":"en" }, "de":{ "title":"test de", "content":"test de", "locale":"de" } } }
Get response by locale GET /api/posts/1?locale=en { "@context": "/api/v1/contexts/Post", "@id": "/api/v1/posts/1')", "@type": "Post", "id": 1, "datetime":"2019-10-10", "title":"Hello world", "content":"Hello from Verona!" }
Get response with all translations GET /api/posts/1?groups[]=translations { "@context": "/api/v1/contexts/Post", "@id": "/api/v1/posts/1')", "@type": "Post", "id": 1, "datetime":"2019-10-10", "translations": { "en":{ "title":"Hello world", "content":"Hello from Verona!", "locale":"en" }, "it":{ "title":"Ciao mondo", "content":"Ciao da Verona!", "locale":"it" } } }
• https://github.com/lexik/LexikTranslationBundle • or you can write your own: • https://locastic.com/blog/symfony-translation-process- automation/ Static translation
Manipulating context and avoding to have / api/admin
Manipulating the Context Context namespace AppEntity; use ApiPlatformCoreAnnotationApiResource; use SymfonyComponentSerializerAnnotationGroups; /** * @ApiResource( * normalizationContext={"groups"={"book:output"}}, * denormalizationContext={"groups"={"book:input"}} * ) */ class Book { // ... /** * This field can be managed only by an admin * * @var bool * * @Groups({"book:output", "admin:input"}) */ public $active = false; /** * This field can be managed by any user * * @var string * * @Groups({"book:output", "book:input"}) */ public $name; // ... }
Manipulating the Context Context # api/config/services.yaml services: # ... 'AppSerializerBookContextBuilder': decorates: 'api_platform.serializer.context_builder' arguments: [ '@AppSerializerBookContextBuilder.inner' ] autoconfigure: false
// api/src/Serializer/BookContextBuilder.php namespace AppSerializer; use ApiPlatformCoreSerializerSerializerContextBuilderInterface; use SymfonyComponentHttpFoundationRequest; use SymfonyComponentSecurityCoreAuthorizationAuthorizationCheckerInterface; use AppEntityBook; final class BookContextBuilder implements SerializerContextBuilderInterface { private $decorated; private $authorizationChecker; public function __construct(SerializerContextBuilderInterface $decorated, AuthorizationCheckerInterface $authorizationChecker) { $this->decorated = $decorated; $this->authorizationChecker = $authorizationChecker; } public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array { $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes); $resourceClass = $context['resource_class'] ?? null; if ($resourceClass === Book::class && isset($context['groups']) && $this->authorizationChecker- >isGranted('ROLE_ADMIN') && false === $normalization) { $context['groups'][] = 'admin:input'; } return $context; } }
Symfony Messenger Component
• The Messenger component helps applications send and receive messages to/from other applications or via message queues. • Easy to implement • Making async easy • Many transports are supported to dispatch messages to async consumers, including RabbitMQ, Apache Kafka, Amazon SQS and Google Pub/Sub. Symfony Messenger
• Allows to implement the Command Query Responsibility Segregation (CQRS) pattern in a convenient way. • It also makes it easy to send messages through the web API that will be consumed asynchronously. • Async import, export, image processing… any heavy work Symfony Messenger & API Platform
CQRS Symfony Messenger & API Platform AppEntityPasswordResetRequest: collectionOperations: post: status: 202 itemOperations: [] attributes: messenger: true output: false
CQRS Symfony Messenger & API Platform <?php namespace AppHandler; use AppEntityPasswordResetRequest; use SymfonyComponentMessengerHandlerMessageHandlerInterfac final class PasswordResetRequestHandler implements MessageHand { public function __invoke(PasswordResetRequest $forgotPassw { // do some heavy things } } <?php namespace AppEntity; final class PasswordResetRequest { public $email; }
<?php namespace AppDataPersister; use ApiPlatformCoreDataPersisterContextAwareDataPersisterInterface; use AppEntityImageMedia; use DoctrineORMEntityManagerInterface; use SymfonyComponentMessengerMessageBusInterface; class ImageMediaDataPersister implements ContextAwareDataPersisterInterface { private $entityManager; private $messageBus; public function __construct(EntityManagerInterface $entityManager, MessageBusInterface $messageBus) { $this->entityManager = $entityManager; $this->messageBus = $messageBus; } public function supports($data, array $context = []): bool { return $data instanceof ImageMedia; } public function persist($data, array $context = []) { $this->entityManager->persist($data); $this->entityManager->flush($data); $this->messageBus->dispatch(new ProcessImageMessage($data->getId())); return $data; } public function remove($data, array $context = []) { $this->entityManager->remove($data); $this->entityManager->flush(); $this->messageBus->dispatch(new DeleteImageMessage($data->getId())); } }
namespace AppEventSubscriber; use ApiPlatformCoreEventListenerEventPriorities; use AppEntityBook; use SymfonyComponentEventDispatcherEventSubscriberInterface; use SymfonyComponentHttpFoundationRequest; use SymfonyComponentHttpKernelEventViewEvent; use SymfonyComponentHttpKernelKernelEvents; use SymfonyComponentMessengerMessageBusInterface; final class BookMailSubscriber implements EventSubscriberInterface { private $messageBus; public function __construct(MessageBusInterface $messageBus) { $this->messageBus = $messageBus; } public static function getSubscribedEvents() { return [ KernelEvents::VIEW => ['sendMail', EventPriorities::POST_WRITE], ]; } public function sendMail(ViewEvent $event) { $book = $event->getControllerResult(); $method = $event->getRequest()->getMethod(); if (!$book instanceof Book || Request::METHOD_POST !== $method) { return; } // send to all users 2M that new book has arrived this->messageBus->dispatch(new SendEmailMessage(‘new-book’, $book->getTitle())); } }
• If same Symfony application is dispatching and consuming messages it works very well • If different Symfony applications are consuming messages, there are issues • If Symfony applications consume some 3-rd party non-Symfony messages, there is huge issue Symfony & Messenger
{ "body":"{"key":"TESTING","variables":{"UserName":"some test username ", "Greetings":"Some test greeting 4faf188d396e99e023a3f77c04a9c06d","Email": "antonio@locastic.com"},"to":"antonio@locastic.com"}", "properties":[ ], "headers":{ "type":"AppMessageCommunicationMessage", "X-Message-Stamp-SymfonyComponentMessengerStamp BusNameStamp":"[{"busName":"command_bus"}]", "X-Message-Stamp-SymfonyComponentMessengerStamp SentStamp":"[{"senderClass":"EnqueueMessengerAdapterQueueInteropTransport", "senderAlias":"communication_sync_amqp"}]", "Content-Type":"application/json" } }
{ "body":"{"key":"TESTING","variables":{"UserName":"some test username ", "Greetings":"Some test greeting 4faf188d396e99e023a3f77c04a9c06d","Email": "antonio@locastic.com"},"to":"antonio@locastic.com"}", "properties":[ ], "headers":{ "type":"AppMessageCommunicationMessage", "X-Message-Stamp-SymfonyComponentMessengerStamp BusNameStamp":"[{"busName":"command_bus"}]", "X-Message-Stamp-SymfonyComponentMessengerStamp SentStamp":"[{"senderClass":"EnqueueMessengerAdapterQueueInteropTransport", "senderAlias":"communication_sync_amqp"}]", "Content-Type":"application/json" } }
{ "body":"{"key":"TESTING","variables":{"UserName":"some test username ", "Greetings":"Some test greeting 4faf188d396e99e023a3f77c04a9c06d","Email": "antonio@locastic.com"},"to":"antonio@locastic.com"}", "properties":[ ], "headers":{ "type":"AppMessageCommunicationMessage", "X-Message-Stamp-SymfonyComponentMessengerStamp BusNameStamp":"[{"busName":"command_bus"}]", "X-Message-Stamp-SymfonyComponentMessengerStamp SentStamp":"[{"senderClass":"EnqueueMessengerAdapterQueueInteropTransport", "senderAlias":"communication_sync_amqp"}]", "Content-Type":"application/json" } } String contains escaped JSON
{ "key": "TESTING", "variables": { "username": "some test userName", "Grettings": "Some test greeting 4faf188d396e99e023a3f77c04a9c06d", "Email:" "antonio@locastic.com" } }
namespace AppMessenger; … class ExternalJsonMessageSerializer implements SerializerInterface { public function decode(array $encodedEnvelope): Envelope { $body = $encodedEnvelope['variables']; $headers = $encodedEnvelope['key']; $data = json_decode($body, true); if (null === $data) { throw new MessageDecodingFailedException('Invalid JSON'); } if (!isset($headers['type'])) { throw new MessageDecodingFailedException('Missing "type" header'); } if ($headers !== 'TESTING') { throw new MessageDecodingFailedException(sprintf('Invalid type "%s"', $headers)); } // in case of redelivery, unserialize any stamps $stamps = []; if (isset($headers['stamps'])) { $stamps = unserialize($headers['stamps']); } $envelope = $this->createMyExternalMessage($data); // in case of redelivery, unserialize any stamps $stamps = []; if (isset($headers['stamps'])) { $stamps = unserialize($headers['stamps']); } $envelope = $envelope->with(... $stamps); return $envelope; } … }
namespace AppMessenger; … class ExternalJsonMessageSerializer implements SerializerInterface { public function decode(array $encodedEnvelope): Envelope { $body = $encodedEnvelope['variables']; $headers = $encodedEnvelope['key']; $data = json_decode($body, true); if (null === $data) { throw new MessageDecodingFailedException('Invalid JSON'); } if (!isset($headers['type'])) { throw new MessageDecodingFailedException('Missing "type" header'); } if ($headers !== 'TESTING') { throw new MessageDecodingFailedException(sprintf('Invalid type "%s"', $headers)); } // in case of redelivery, unserialize any stamps $stamps = []; if (isset($headers['stamps'])) { $stamps = unserialize($headers['stamps']); } $envelope = $this->createMyExternalMessage($data); // in case of redelivery, unserialize any stamps $stamps = []; if (isset($headers['stamps'])) { $stamps = unserialize($headers['stamps']); } $envelope = $envelope->with(... $stamps); return $envelope; } … } private function createMyExternalMessage($data): Envelope { $message = new MyExternalMessage($data); $envelope = new Envelope($message); // needed only if you need this to be sent through the non-default bus $envelope = $envelope->with(new BusNameStamp('command.bus')); return $envelope; }
namespace AppMessenger; … class ExternalJsonMessageSerializer implements SerializerInterface { public function decode(array $encodedEnvelope): Envelope { $body = $encodedEnvelope['variables']; $headers = $encodedEnvelope['key']; $data = json_decode($body, true); if (null === $data) { throw new MessageDecodingFailedException('Invalid JSON'); } if (!isset($headers['type'])) { throw new MessageDecodingFailedException('Missing "type" header'); } if ($headers !== 'TESTING') { throw new MessageDecodingFailedException(sprintf('Invalid type "%s"', $headers)); } // in case of redelivery, unserialize any stamps $stamps = []; if (isset($headers['stamps'])) { $stamps = unserialize($headers['stamps']); } $envelope = $this->createMyExternalMessage($data); // in case of redelivery, unserialize any stamps $stamps = []; if (isset($headers['stamps'])) { $stamps = unserialize($headers['stamps']); } $envelope = $envelope->with(... $stamps); return $envelope; } … } private function createMyExternalMessage($data): Envelope { $message = new MyExternalMessage($data); $envelope = new Envelope($message); // needed only if you need this to be sent through the non-default bus $envelope = $envelope->with(new BusNameStamp('command.bus')); return $envelope; } framework: messenger: ... transports: ... # a transport used consuming messages from an external system # messages are not meant to be *sent* to this transport external_messages: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' serializer: AppMessengerExternalJsonMessageSerializer options: # assume some other system will create this auto_setup: false # exchange config not needed because that's only # for *sending* messages queues: messages_from_external: ~ ...
• https://github.com/Happyr/message-serializer (custom serializer) • http://developer.happyr.com/symfony-messenger-on-aws-lambda • have in mind that Messenger component is similar to ORM • will work in most of cases but in same situation you will need to do custom/your own solution More help:
Handling emails
• Symfony Mailer component • load balancer • /w messenger component can do async in easy way • high availability / failover • Amazon SES, Gmail, Mailchim Mandril, Mailgun, Postmark, Sendgrid Handling emails
Creating reports (exports)
• problem: • different objects from source and in our database • multiple sources of data (3rd party) • DataTransformer transforms from source object to our object (DTO) • exporting to CSV files Using DTOs with import and export
resources: AppEntityOrder: collectionOperations: get: ~ exports: method: POST path: '/orders/export' formats: csv: ['text/csv'] pagination_enabled: false output: "OrderExport::class" normalization_context: groups: ['order-export']
<?php namespace AppDTO; use SymfonyComponentSerializerAnnotationGroups; final class OrderExport { /** * @var string * @Groups({"order-export"}) */ private $orderStatus; /** * @var string * @Groups({"order-export"}) */ private $orderNumber; /** * @var DateTime * @Groups({"order-export"}) */ private $orderDate; /** * @var string * @Groups({"order-export"}) */ private $customerFullName; /** * @var string * @Groups({"order-export"}) */ private $customerEmail; /** * @var string * @Groups({"order-export"})
<?php namespace AppDTO; use SymfonyComponentSerializerAnnotationGroups; final class OrderExport { /** * @var string * @Groups({"order-export"}) */ private $orderStatus; /** * @var string * @Groups({"order-export"}) */ private $orderNumber; /** * @var DateTime * @Groups({"order-export"}) */ private $orderDate; /** * @var string * @Groups({"order-export"}) */ private $customerFullName; /** * @var string * @Groups({"order-export"}) */ private $customerEmail; /** * @var string * @Groups({"order-export"}) namespace AppDataTransformer; use ApiPlatformCoreDataTransformerDataTransformerInterface; … class OrderExportDataTransformer implements DataTransformerInterface { /** * @param Order $data * @param string $to * @param array $context * * @return object|void */ public function transform($data, string $to, array $context = []) { $export = new OrderExport(); $export->setOrderNumber($data->getOrderNumber()); if ($data->getStatus() instanceof Status) { $export->setOrderStatus($data->getStatus()->getStatus()); } $export->setOrderDate($data->getOrderDate()); … … $export->setTotalNumberOfTicketsInOrder($data->getTickets()->count()); $export->setTicketRevenue($data->getTicketsRevenue()->getFormatted()); $export->setDeliveryStatus($data->getDeliveryStatus()->getStatus()); $export->setDeliveryMethodRevenue($data->getDeliveryCost()->getFormatted()); $export->setFeeRevenue($data->getTotalFees()->getFormatted()); $export->setTicketTypes($data->getTicketTypesFormatted()); return $export; } /** * {@inheritdoc} */ public function supportsTransformation($data, string $to, array $context = []): bool { return OrderExport::class === $to && $data instanceof Order; } }
Real-time applications with API platform
• Redis + NodeJS + socket.io • Pusher • ReactPHP • … • but to be honest PHP is not build for realtime :) Real-time applications with API platform
• Fast, written in Go • native browser support, no lib nor SDK required (built on top of HTTP and server-sent events) • compatible with all existing servers, even those who don't support persistent connections (serverless architecture, PHP, FastCGI…) • Automatic HTTP/2 and HTTPS (using Let's Encrypt) support • CORS support, CSRF protection mechanism • Cloud Native, follows the Twelve-Factor App methodology • Open source (AGPL) • … Mercure.rocks
resources: AppEntityGreeting: attributes: mercure: true const eventSource = new EventSource('http://localhost:3000/hub?topic=' + encodeURIComponent('http://example.com/greeting/1')); eventSource.onmessage = event => { // Will be called every time an update is published by the server console.log(JSON.parse(event.data)); }
Testing
• Ask yourself: “Am I sure the code I tested works as it should?” • 100% coverage doesn’t guarantee your code is fully tested and working • Write test first is just one of the approaches • Legacy code: • Start replicating bugs with tests before fixing them • Test at least most important and critical parts Testing tips and tricks
Handy testing tools
• Created for PHPUnit • Manipulates the Symfony HttpKernel directly to simulate HTTP requests and responses • Huge performance boost compared to triggering real network requests • Also good to consider Sylius/ApiTestCase The test Http Client in API Platform
PHP Matcher Library that enables you to check your response against patterns.
PHP Matcher Library that enables you to check your response against patterns.
Faker (/w Alice) Library for generating random data
Postman tests Newman + Postman
Postman tests + Newman Newman + Postman
• Infection - tool for mutation testing • PHPStan - focuses on finding errors in your code without actually running it • Continuous integration (CI) -  enables you to run your tests on git on each commit Tools for checking test quality
Api Platform (Symfony) is awesome! Conclusion
Thank you!
Questions? Antonio Perić-Mažar t: @antonioperic m: antonio@locastic.com

Using API Platform to build ticketing system #symfonycon

  • 1.
    Using API platformto build ticketing system Antonio Perić-Mažar, Locastic 22.11.2019. - #SymfonyCon, Amsterdam
  • 2.
    Antonio Perić-Mažar CEO @ Locastic Co-founder@ Litto Co-founder @ Tinel Meetup t: @antonioperic m: antonio@locastic.com
  • 3.
    Locastic Helping clients createweb and mobile apps since 2011 • UX/UI • Mobile apps • Web apps • Training & Consulting www.locastic.com @locastic
  • 4.
    • API Platform& Symfony • Ticketing platform: GFNY (franchise business) • ~ year and half in production • ~ 60 000 tickets released & race results stored in DB • ~ 20 000 users/racers, • ~ 60 users with admin roles • 48 events in 26 countries, users from 82 countries • 8 different languages including Hebrew and Indonesian Context & API Platform Experience
  • 5.
    • Social network •chat based • matching similar to Tinder :) • few CRM/ERP applications Context & API Platform Experience
  • 6.
    What is APIplatform ?
  • 7.
    –Fabien Potencier (creatorof Symfony), SymfonyCon 2017 “API Platform is the most advanced API platform, in any framework or language.”
  • 8.
    • full stackframework dedicated to API-Driven projects • contains a PHP library to create a fully featured APIs supporting industry standards (JSON-LD, Hydra, GraphQL, OpenAPI…) • provides ambitious Javascript tooling to consume APIs in a snap • Symfony official API stack (instead of FOSRestBundle) • shipped with Docker and Kubernetes integration API Platform
  • 9.
    • creating, retrieving,updating and deleting (CRUD) resources • data validation • pagination • filtering • sorting • hypermedia/HATEOAS and content negotiation support (JSON-LD and Hydra, JSON:API, HAL…) API Platform built-in features:
  • 10.
    • GraphQL support •Nice UI and machine-readable documentations (Swagger UI/ OpenAPI, GraphiQL…) • authentication (Basic HTP, cookies as well as JWT and OAuth through extensions) • CORS headers • security checks and headers (tested against OWASP recommendations) API Platform built-in features:
  • 11.
    • invalidation-based HTTPcaching • and basically everything needed to build modern APIs. API Platform built-in features:
  • 12.
  • 13.
    Create Entity Step One <?php // src/Entity/Greeting.php namespaceAppEntity; class Greeting { private $id; public $name = ''; public function getId(): int { return $this->id; } }
  • 14.
    Create Mapping Step Two # config/doctrine/Greeting.orm.yml AppEntityGreeting: type:entity table: greeting id: id: type: integer generator: { strategy: AUTO } fields: name: type: string length: 100
  • 15.
  • 16.
    Expose Resource Step Four #config/api_platform/resources.yaml resources: AppEntityGreeting: ~
  • 22.
  • 24.
  • 25.
  • 26.
    • Avoid usingFOSUserBundle • not well suited with API • to much overhead and not needed complexity • Use Doctrine User Provider • simple and easy to integrate • bin/console maker:user User management
  • 27.
    // src/Entity/User.php namespace AppEntity; useSymfonyComponentSecurityCoreUserUserInterface; use SymfonyComponentSerializerAnnotationGroups; class User implements UserInterface { /** * @Groups({"user-read"}) */ private $id; /** * @Groups({"user-write", "user-read"}) */ private $email; /** * @Groups({"user-read"}) */ private $roles = []; /** * @Groups({"user-write"}) */ private $plainPassword; private $password; … getters and setters … }
  • 28.
    // src/Entity/User.php namespace AppEntity; useSymfonyComponentSecurityCoreUserUserInterface; use SymfonyComponentSerializerAnnotationGroups; class User implements UserInterface { /** * @Groups({"user-read"}) */ private $id; /** * @Groups({"user-write", "user-read"}) */ private $email; /** * @Groups({"user-read"}) */ private $roles = []; /** * @Groups({"user-write"}) */ private $plainPassword; private $password; … getters and setters … } # config/doctrine/User.orm.yml AppEntityUser: type: entity table: users repositoryClass: AppRepositoryUserRepository id: id: type: integer generator: { strategy: AUTO } fields: email: type: string length: 255 password: type: string length: 255 roles: type: array
  • 29.
    // src/Entity/User.php namespace AppEntity; useSymfonyComponentSecurityCoreUserUserInterface; use SymfonyComponentSerializerAnnotationGroups; class User implements UserInterface { /** * @Groups({"user-read"}) */ private $id; /** * @Groups({"user-write", "user-read"}) */ private $email; /** * @Groups({"user-read"}) */ private $roles = []; /** * @Groups({"user-write"}) */ private $plainPassword; private $password; … getters and setters … } # config/doctrine/User.orm.yml AppEntityUser: type: entity table: users repositoryClass: AppRepositoryUserRepository id: id: type: integer generator: { strategy: AUTO } fields: email: type: string length: 255 password: type: string length: 255 roles: type: array # config/api_platform/resources.yaml resources: AppEntityUser: attributes: normalization_context: groups: ['user-read'] denormalization_context: groups: ['user-write']
  • 30.
    use ApiPlatformCoreDataPersisterContextAwareDataPersisterInterface; use AppEntityUser; useDoctrineORMEntityManagerInterface; use SymfonyComponentSecurityCoreEncoderUserPasswordEncoderInterface; class UserDataPersister implements ContextAwareDataPersisterInterface { private $entityManager; private $userPasswordEncoder; public function __construct(EntityManagerInterface $entityManager, UserPasswordEncoderInterface $userPasswordEncoder) { $this->entityManager = $entityManager; $this->userPasswordEncoder = $userPasswordEncoder; } public function supports($data, array $context = []): bool { return $data instanceof User; } public function persist($data, array $context = []) { /** @var User $data */ if ($data->getPlainPassword()) { $data->setPassword( $this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword()) ); $data->eraseCredentials(); } $this->entityManager->persist($data); $this->entityManager->flush($data); return $data; } public function remove($data, array $context = []) { $this->entityManager->remove($data); $this->entityManager->flush(); }
  • 31.
    • Lightweight andsimple authentication system • Stateless: token signed and verified server-side then stored client- side and sent with each request in an Authorization header • Store the token in the browser local storage JSON Web Token (JWT)
  • 34.
    • API Platformallows to easily add a JWT-based authentication to your API using LexikJWTAuthenticationBundle. • Maybe you want to use a refresh token to renew your JWT. In this case you can check JWTRefreshTokenBundle. User authentication
  • 36.
    User security checker Security <?php namespace AppSecurity; useAppExceptionAccountDeletedException; use AppSecurityUser as AppUser; use SymfonyComponentSecurityCoreExceptionAccountExpiredException; use SymfonyComponentSecurityCoreExceptionCustomUserMessageAuthenticat use SymfonyComponentSecurityCoreUserUserCheckerInterface; use SymfonyComponentSecurityCoreUserUserInterface; class UserChecker implements UserCheckerInterface { public function checkPreAuth(UserInterface $user) { if (!$user instanceof AppUser) { return; } // user is deleted, show a generic Account Not Found message. if ($user->isDeleted()) { throw new AccountDeletedException(); } } public function checkPostAuth(UserInterface $user) { if (!$user instanceof AppUser) { return; } // user account is expired, the user may be notified if ($user->isExpired()) { throw new AccountExpiredException('...'); } } }
  • 37.
    User security checker Security # config/packages/security.yaml #... security: firewalls: main: pattern: ^/ user_checker: AppSecurityUserChecker # ...
  • 38.
    Resource and operation level Security # api/config/api_platform/resources.yaml AppEntityBook: attributes: security:'is_granted("ROLE_USER")' collectionOperations: get: ~ post: security: 'is_granted("ROLE_ADMIN")' itemOperations: get: ~ put: security_: 'is_granted("ROLE_ADMIN") or object.owner == user'
  • 39.
    Resource and operation level using Voters Security #api/config/api_platform/resources.yaml AppEntityBook: itemOperations: get: security_: 'is_granted('READ', object)' put: security_: 'is_granted('UPDATE', object)'
  • 40.
    • A JWTis self-contained, meaning that we can trust into its payload for processing the authentication. In a nutshell, there should be no need for loading the user from the database when authenticating a JWT Token, the database should be hit only once for delivering the token. • It means you will have to fetch the User entity from the database yourself as needed (probably through the Doctrine EntityManager or your custom Provider). JWT tip A database-less user provider
  • 41.
    JWT tip A database-lessuser provider # config/packages/security.yaml security: providers: jwt: lexik_jwt: ~ security: firewalls: api: provider: jwt guard: # ...
  • 42.
  • 43.
    • Locastic ApiTranslation Bundle • Translation bundle for ApiPlatform based on Sylius translation • It requires two entities: Translatable & Translation entity • Open source • https://github.com/Locastic/ApiPlatformTranslationBundle • https://locastic.com/blog/having-troubles-with-implementing- translations-in-apiplatform/ Creating multi-language APIs
  • 44.
    <?php use LocasticApiPlatformTranslationBundleModelAbstractTranslatable; class Postextends AbstractTranslatable { // ... protected function createTranslation() { return new PostTranslation(); } }
  • 45.
    <?php use LocasticApiPlatformTranslationBundleModelAbstractTranslatable; class Postextends AbstractTranslatable { // ... protected function createTranslation() { return new PostTranslation(); } } use LocasticApiPlatformTranslationBundleModelAbstractTranslatable; class Post extends AbstractTranslatable { // ... /** * @Groups({"post_write", "translations"}) */ protected $translations; }
  • 46.
    <?php use LocasticApiPlatformTranslationBundleModelAbstractTranslatable; class Postextends AbstractTranslatable { // ... protected function createTranslation() { return new PostTranslation(); } } use LocasticApiPlatformTranslationBundleModelAbstractTranslatable; class Post extends AbstractTranslatable { // ... /** * @Groups({"post_write", "translations"}) */ protected $translations; } <?php use LocasticApiPlatformTranslationBundleModelAbstractTranslatable; use SymfonyComponentSerializerAnnotationGroups; class Post extends AbstractTranslatable { // ... /** * @var string * * @Groups({"post_read"}) */ private $title; public function setTitle($title) { $this->getTranslation()->setTitle($title); return $this; } public function getTitle() { return $this->getTranslation()->getTitle(); } }
  • 47.
    <?php use SymfonyComponentSerializerAnnotationGroups; use LocasticApiTranslationBundleModelAbstractTranslation; classPostTranslation extends AbstractTranslation { // ... /** * @var string * * @Groups({"post_read", "post_write", "translations"}) */ private $title; /** * @var string * @Groups({"post_write", "translations"}) */ protected $locale; public function setTitle($title) { $this->title = $title; return $this; } public function getTitle() { return $this->title; } }
  • 48.
    <?php use SymfonyComponentSerializerAnnotationGroups; use LocasticApiTranslationBundleModelAbstractTranslation; classPostTranslation extends AbstractTranslation { // ... /** * @var string * * @Groups({"post_read", "post_write", "translations"}) */ private $title; /** * @var string * @Groups({"post_write", "translations"}) */ protected $locale; public function setTitle($title) { $this->title = $title; return $this; } public function getTitle() { return $this->title; } } AppBundleEntityPost: itemOperations: get: method: GET put: method: PUT normalization_context: groups: ['translations'] collectionOperations: get: method: GET post: method: POST normalization_context: groups: ['translations'] attributes: filters: ['translation.groups'] normalization_context: groups: ['post_read'] denormalization_context: groups: ['post_write']
  • 49.
  • 50.
    Get response bylocale GET /api/posts/1?locale=en { "@context": "/api/v1/contexts/Post", "@id": "/api/v1/posts/1')", "@type": "Post", "id": 1, "datetime":"2019-10-10", "title":"Hello world", "content":"Hello from Verona!" }
  • 51.
    Get response withall translations GET /api/posts/1?groups[]=translations { "@context": "/api/v1/contexts/Post", "@id": "/api/v1/posts/1')", "@type": "Post", "id": 1, "datetime":"2019-10-10", "translations": { "en":{ "title":"Hello world", "content":"Hello from Verona!", "locale":"en" }, "it":{ "title":"Ciao mondo", "content":"Ciao da Verona!", "locale":"it" } } }
  • 52.
    • https://github.com/lexik/LexikTranslationBundle • oryou can write your own: • https://locastic.com/blog/symfony-translation-process- automation/ Static translation
  • 53.
  • 54.
    Manipulating the Context Context namespace AppEntity; useApiPlatformCoreAnnotationApiResource; use SymfonyComponentSerializerAnnotationGroups; /** * @ApiResource( * normalizationContext={"groups"={"book:output"}}, * denormalizationContext={"groups"={"book:input"}} * ) */ class Book { // ... /** * This field can be managed only by an admin * * @var bool * * @Groups({"book:output", "admin:input"}) */ public $active = false; /** * This field can be managed by any user * * @var string * * @Groups({"book:output", "book:input"}) */ public $name; // ... }
  • 55.
    Manipulating the Context Context # api/config/services.yaml services: #... 'AppSerializerBookContextBuilder': decorates: 'api_platform.serializer.context_builder' arguments: [ '@AppSerializerBookContextBuilder.inner' ] autoconfigure: false
  • 56.
    // api/src/Serializer/BookContextBuilder.php namespace AppSerializer; useApiPlatformCoreSerializerSerializerContextBuilderInterface; use SymfonyComponentHttpFoundationRequest; use SymfonyComponentSecurityCoreAuthorizationAuthorizationCheckerInterface; use AppEntityBook; final class BookContextBuilder implements SerializerContextBuilderInterface { private $decorated; private $authorizationChecker; public function __construct(SerializerContextBuilderInterface $decorated, AuthorizationCheckerInterface $authorizationChecker) { $this->decorated = $decorated; $this->authorizationChecker = $authorizationChecker; } public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array { $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes); $resourceClass = $context['resource_class'] ?? null; if ($resourceClass === Book::class && isset($context['groups']) && $this->authorizationChecker- >isGranted('ROLE_ADMIN') && false === $normalization) { $context['groups'][] = 'admin:input'; } return $context; } }
  • 57.
  • 58.
    • The Messengercomponent helps applications send and receive messages to/from other applications or via message queues. • Easy to implement • Making async easy • Many transports are supported to dispatch messages to async consumers, including RabbitMQ, Apache Kafka, Amazon SQS and Google Pub/Sub. Symfony Messenger
  • 60.
    • Allows toimplement the Command Query Responsibility Segregation (CQRS) pattern in a convenient way. • It also makes it easy to send messages through the web API that will be consumed asynchronously. • Async import, export, image processing… any heavy work Symfony Messenger & API Platform
  • 61.
    CQRS Symfony Messenger &API Platform AppEntityPasswordResetRequest: collectionOperations: post: status: 202 itemOperations: [] attributes: messenger: true output: false
  • 62.
    CQRS Symfony Messenger &API Platform <?php namespace AppHandler; use AppEntityPasswordResetRequest; use SymfonyComponentMessengerHandlerMessageHandlerInterfac final class PasswordResetRequestHandler implements MessageHand { public function __invoke(PasswordResetRequest $forgotPassw { // do some heavy things } } <?php namespace AppEntity; final class PasswordResetRequest { public $email; }
  • 63.
    <?php namespace AppDataPersister; use ApiPlatformCoreDataPersisterContextAwareDataPersisterInterface; useAppEntityImageMedia; use DoctrineORMEntityManagerInterface; use SymfonyComponentMessengerMessageBusInterface; class ImageMediaDataPersister implements ContextAwareDataPersisterInterface { private $entityManager; private $messageBus; public function __construct(EntityManagerInterface $entityManager, MessageBusInterface $messageBus) { $this->entityManager = $entityManager; $this->messageBus = $messageBus; } public function supports($data, array $context = []): bool { return $data instanceof ImageMedia; } public function persist($data, array $context = []) { $this->entityManager->persist($data); $this->entityManager->flush($data); $this->messageBus->dispatch(new ProcessImageMessage($data->getId())); return $data; } public function remove($data, array $context = []) { $this->entityManager->remove($data); $this->entityManager->flush(); $this->messageBus->dispatch(new DeleteImageMessage($data->getId())); } }
  • 64.
    namespace AppEventSubscriber; use ApiPlatformCoreEventListenerEventPriorities; useAppEntityBook; use SymfonyComponentEventDispatcherEventSubscriberInterface; use SymfonyComponentHttpFoundationRequest; use SymfonyComponentHttpKernelEventViewEvent; use SymfonyComponentHttpKernelKernelEvents; use SymfonyComponentMessengerMessageBusInterface; final class BookMailSubscriber implements EventSubscriberInterface { private $messageBus; public function __construct(MessageBusInterface $messageBus) { $this->messageBus = $messageBus; } public static function getSubscribedEvents() { return [ KernelEvents::VIEW => ['sendMail', EventPriorities::POST_WRITE], ]; } public function sendMail(ViewEvent $event) { $book = $event->getControllerResult(); $method = $event->getRequest()->getMethod(); if (!$book instanceof Book || Request::METHOD_POST !== $method) { return; } // send to all users 2M that new book has arrived this->messageBus->dispatch(new SendEmailMessage(‘new-book’, $book->getTitle())); } }
  • 67.
    • If sameSymfony application is dispatching and consuming messages it works very well • If different Symfony applications are consuming messages, there are issues • If Symfony applications consume some 3-rd party non-Symfony messages, there is huge issue Symfony & Messenger
  • 68.
    { "body":"{"key":"TESTING","variables":{"UserName":"some test username", "Greetings":"Some test greeting 4faf188d396e99e023a3f77c04a9c06d","Email": "antonio@locastic.com"},"to":"antonio@locastic.com"}", "properties":[ ], "headers":{ "type":"AppMessageCommunicationMessage", "X-Message-Stamp-SymfonyComponentMessengerStamp BusNameStamp":"[{"busName":"command_bus"}]", "X-Message-Stamp-SymfonyComponentMessengerStamp SentStamp":"[{"senderClass":"EnqueueMessengerAdapterQueueInteropTransport", "senderAlias":"communication_sync_amqp"}]", "Content-Type":"application/json" } }
  • 69.
    { "body":"{"key":"TESTING","variables":{"UserName":"some test username", "Greetings":"Some test greeting 4faf188d396e99e023a3f77c04a9c06d","Email": "antonio@locastic.com"},"to":"antonio@locastic.com"}", "properties":[ ], "headers":{ "type":"AppMessageCommunicationMessage", "X-Message-Stamp-SymfonyComponentMessengerStamp BusNameStamp":"[{"busName":"command_bus"}]", "X-Message-Stamp-SymfonyComponentMessengerStamp SentStamp":"[{"senderClass":"EnqueueMessengerAdapterQueueInteropTransport", "senderAlias":"communication_sync_amqp"}]", "Content-Type":"application/json" } }
  • 70.
    { "body":"{"key":"TESTING","variables":{"UserName":"some test username", "Greetings":"Some test greeting 4faf188d396e99e023a3f77c04a9c06d","Email": "antonio@locastic.com"},"to":"antonio@locastic.com"}", "properties":[ ], "headers":{ "type":"AppMessageCommunicationMessage", "X-Message-Stamp-SymfonyComponentMessengerStamp BusNameStamp":"[{"busName":"command_bus"}]", "X-Message-Stamp-SymfonyComponentMessengerStamp SentStamp":"[{"senderClass":"EnqueueMessengerAdapterQueueInteropTransport", "senderAlias":"communication_sync_amqp"}]", "Content-Type":"application/json" } } String contains escaped JSON
  • 71.
    { "key": "TESTING", "variables": { "username":"some test userName", "Grettings": "Some test greeting 4faf188d396e99e023a3f77c04a9c06d", "Email:" "antonio@locastic.com" } }
  • 72.
    namespace AppMessenger; … class ExternalJsonMessageSerializerimplements SerializerInterface { public function decode(array $encodedEnvelope): Envelope { $body = $encodedEnvelope['variables']; $headers = $encodedEnvelope['key']; $data = json_decode($body, true); if (null === $data) { throw new MessageDecodingFailedException('Invalid JSON'); } if (!isset($headers['type'])) { throw new MessageDecodingFailedException('Missing "type" header'); } if ($headers !== 'TESTING') { throw new MessageDecodingFailedException(sprintf('Invalid type "%s"', $headers)); } // in case of redelivery, unserialize any stamps $stamps = []; if (isset($headers['stamps'])) { $stamps = unserialize($headers['stamps']); } $envelope = $this->createMyExternalMessage($data); // in case of redelivery, unserialize any stamps $stamps = []; if (isset($headers['stamps'])) { $stamps = unserialize($headers['stamps']); } $envelope = $envelope->with(... $stamps); return $envelope; } … }
  • 73.
    namespace AppMessenger; … class ExternalJsonMessageSerializerimplements SerializerInterface { public function decode(array $encodedEnvelope): Envelope { $body = $encodedEnvelope['variables']; $headers = $encodedEnvelope['key']; $data = json_decode($body, true); if (null === $data) { throw new MessageDecodingFailedException('Invalid JSON'); } if (!isset($headers['type'])) { throw new MessageDecodingFailedException('Missing "type" header'); } if ($headers !== 'TESTING') { throw new MessageDecodingFailedException(sprintf('Invalid type "%s"', $headers)); } // in case of redelivery, unserialize any stamps $stamps = []; if (isset($headers['stamps'])) { $stamps = unserialize($headers['stamps']); } $envelope = $this->createMyExternalMessage($data); // in case of redelivery, unserialize any stamps $stamps = []; if (isset($headers['stamps'])) { $stamps = unserialize($headers['stamps']); } $envelope = $envelope->with(... $stamps); return $envelope; } … } private function createMyExternalMessage($data): Envelope { $message = new MyExternalMessage($data); $envelope = new Envelope($message); // needed only if you need this to be sent through the non-default bus $envelope = $envelope->with(new BusNameStamp('command.bus')); return $envelope; }
  • 74.
    namespace AppMessenger; … class ExternalJsonMessageSerializerimplements SerializerInterface { public function decode(array $encodedEnvelope): Envelope { $body = $encodedEnvelope['variables']; $headers = $encodedEnvelope['key']; $data = json_decode($body, true); if (null === $data) { throw new MessageDecodingFailedException('Invalid JSON'); } if (!isset($headers['type'])) { throw new MessageDecodingFailedException('Missing "type" header'); } if ($headers !== 'TESTING') { throw new MessageDecodingFailedException(sprintf('Invalid type "%s"', $headers)); } // in case of redelivery, unserialize any stamps $stamps = []; if (isset($headers['stamps'])) { $stamps = unserialize($headers['stamps']); } $envelope = $this->createMyExternalMessage($data); // in case of redelivery, unserialize any stamps $stamps = []; if (isset($headers['stamps'])) { $stamps = unserialize($headers['stamps']); } $envelope = $envelope->with(... $stamps); return $envelope; } … } private function createMyExternalMessage($data): Envelope { $message = new MyExternalMessage($data); $envelope = new Envelope($message); // needed only if you need this to be sent through the non-default bus $envelope = $envelope->with(new BusNameStamp('command.bus')); return $envelope; } framework: messenger: ... transports: ... # a transport used consuming messages from an external system # messages are not meant to be *sent* to this transport external_messages: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' serializer: AppMessengerExternalJsonMessageSerializer options: # assume some other system will create this auto_setup: false # exchange config not needed because that's only # for *sending* messages queues: messages_from_external: ~ ...
  • 75.
    • https://github.com/Happyr/message-serializer (customserializer) • http://developer.happyr.com/symfony-messenger-on-aws-lambda • have in mind that Messenger component is similar to ORM • will work in most of cases but in same situation you will need to do custom/your own solution More help:
  • 76.
  • 77.
    • Symfony Mailercomponent • load balancer • /w messenger component can do async in easy way • high availability / failover • Amazon SES, Gmail, Mailchim Mandril, Mailgun, Postmark, Sendgrid Handling emails
  • 78.
  • 79.
    • problem: • differentobjects from source and in our database • multiple sources of data (3rd party) • DataTransformer transforms from source object to our object (DTO) • exporting to CSV files Using DTOs with import and export
  • 80.
    resources: AppEntityOrder: collectionOperations: get: ~ exports: method: POST path:'/orders/export' formats: csv: ['text/csv'] pagination_enabled: false output: "OrderExport::class" normalization_context: groups: ['order-export']
  • 81.
    <?php namespace AppDTO; use SymfonyComponentSerializerAnnotationGroups; finalclass OrderExport { /** * @var string * @Groups({"order-export"}) */ private $orderStatus; /** * @var string * @Groups({"order-export"}) */ private $orderNumber; /** * @var DateTime * @Groups({"order-export"}) */ private $orderDate; /** * @var string * @Groups({"order-export"}) */ private $customerFullName; /** * @var string * @Groups({"order-export"}) */ private $customerEmail; /** * @var string * @Groups({"order-export"})
  • 82.
    <?php namespace AppDTO; use SymfonyComponentSerializerAnnotationGroups; finalclass OrderExport { /** * @var string * @Groups({"order-export"}) */ private $orderStatus; /** * @var string * @Groups({"order-export"}) */ private $orderNumber; /** * @var DateTime * @Groups({"order-export"}) */ private $orderDate; /** * @var string * @Groups({"order-export"}) */ private $customerFullName; /** * @var string * @Groups({"order-export"}) */ private $customerEmail; /** * @var string * @Groups({"order-export"}) namespace AppDataTransformer; use ApiPlatformCoreDataTransformerDataTransformerInterface; … class OrderExportDataTransformer implements DataTransformerInterface { /** * @param Order $data * @param string $to * @param array $context * * @return object|void */ public function transform($data, string $to, array $context = []) { $export = new OrderExport(); $export->setOrderNumber($data->getOrderNumber()); if ($data->getStatus() instanceof Status) { $export->setOrderStatus($data->getStatus()->getStatus()); } $export->setOrderDate($data->getOrderDate()); … … $export->setTotalNumberOfTicketsInOrder($data->getTickets()->count()); $export->setTicketRevenue($data->getTicketsRevenue()->getFormatted()); $export->setDeliveryStatus($data->getDeliveryStatus()->getStatus()); $export->setDeliveryMethodRevenue($data->getDeliveryCost()->getFormatted()); $export->setFeeRevenue($data->getTotalFees()->getFormatted()); $export->setTicketTypes($data->getTicketTypesFormatted()); return $export; } /** * {@inheritdoc} */ public function supportsTransformation($data, string $to, array $context = []): bool { return OrderExport::class === $to && $data instanceof Order; } }
  • 83.
  • 85.
    • Redis +NodeJS + socket.io • Pusher • ReactPHP • … • but to be honest PHP is not build for realtime :) Real-time applications with API platform
  • 87.
    • Fast, writtenin Go • native browser support, no lib nor SDK required (built on top of HTTP and server-sent events) • compatible with all existing servers, even those who don't support persistent connections (serverless architecture, PHP, FastCGI…) • Automatic HTTP/2 and HTTPS (using Let's Encrypt) support • CORS support, CSRF protection mechanism • Cloud Native, follows the Twelve-Factor App methodology • Open source (AGPL) • … Mercure.rocks
  • 88.
    resources: AppEntityGreeting: attributes: mercure: true const eventSource= new EventSource('http://localhost:3000/hub?topic=' + encodeURIComponent('http://example.com/greeting/1')); eventSource.onmessage = event => { // Will be called every time an update is published by the server console.log(JSON.parse(event.data)); }
  • 89.
  • 90.
    • Ask yourself:“Am I sure the code I tested works as it should?” • 100% coverage doesn’t guarantee your code is fully tested and working • Write test first is just one of the approaches • Legacy code: • Start replicating bugs with tests before fixing them • Test at least most important and critical parts Testing tips and tricks
  • 91.
  • 92.
    • Created forPHPUnit • Manipulates the Symfony HttpKernel directly to simulate HTTP requests and responses • Huge performance boost compared to triggering real network requests • Also good to consider Sylius/ApiTestCase The test Http Client in API Platform
  • 94.
    PHP Matcher Library thatenables you to check your response against patterns.
  • 95.
    PHP Matcher Library thatenables you to check your response against patterns.
  • 96.
    Faker (/w Alice) Libraryfor generating random data
  • 97.
  • 98.
    Postman tests +Newman Newman + Postman
  • 99.
    • Infection -tool for mutation testing • PHPStan - focuses on finding errors in your code without actually running it • Continuous integration (CI) -  enables you to run your tests on git on each commit Tools for checking test quality
  • 101.
    Api Platform (Symfony)is awesome! Conclusion
  • 102.
  • 103.