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

Using API Platform to build ticketing system #symfonycon

Using API Platform to build ticketing system #symfonycon

Why is API platform a way to go and the new standard in developing apps? In this talk, I want to show you some real examples that we built using API platform including a ticketing system for the world’s biggest bicycle marathon and a social network that is a mixture of both Tinder and Facebook Messenger.

We had to tackle problems regarding the implementation of tax laws in 18 different countries, dozens of translations (including Arabic), multiple role systems, different timezones, overall struggle with a complicated logic with an infinite number of branches, and more. Are you interested? Sign up for the talk.

Antonio Peric-Mazar

November 22, 2019
Tweet

More Decks by Antonio Peric-Mazar

Other Decks in Programming

Transcript

  1. Using API platform to build ticketing
    system
    Antonio Perić-Mažar, Locastic
    22.11.2019. - #SymfonyCon, Amsterdam

    View Slide

  2. Antonio
    Perić-Mažar
    CEO @ Locastic
    Co-founder @ Litto
    Co-founder @ Tinel Meetup
    t: @antonioperic
    m: [email protected]

    View Slide

  3. Locastic
    Helping clients create web
    and mobile apps since 2011
    • UX/UI
    • Mobile apps
    • Web apps
    • Training & Consulting
    www.locastic.com
    @locastic

    View Slide

  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

    View Slide

  5. • Social network
    • chat based
    • matching similar to Tinder :)
    • few CRM/ERP applications
    Context & API Platform
    Experience

    View Slide

  6. What is API platform ?

    View Slide

  7. –Fabien Potencier (creator of Symfony), SymfonyCon 2017
    “API Platform is the most advanced API platform,
    in any framework or language.”

    View Slide

  8. • 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

    View Slide

  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:

    View Slide

  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:

    View Slide

  11. • invalidation-based HTTP caching
    • and basically everything needed to build modern APIs.
    API Platform built-in
    features:

    View Slide

  12. Creating Simple CRUD
    in a minute

    View Slide

  13. Create
    Entity
    Step One
    // src/Entity/Greeting.php
    namespace App\Entity;
    class Greeting
    {
    private $id;
    public $name = '';
    public function getId(): int
    {
    return $this->id;
    }
    }

    View Slide

  14. Create
    Mapping
    Step Two
    # config/doctrine/Greeting.orm.yml
    App\Entity\Greeting:
    type: entity
    table: greeting
    id:
    id:
    type: integer
    generator: { strategy: AUTO }
    fields:
    name:
    type: string
    length: 100

    View Slide

  15. Add
    Validation
    Step Three
    # config/validator/greeting.yaml
    App\Entity\Greeting:
    properties:
    name:
    - NotBlank: ~

    View Slide

  16. Expose
    Resource
    Step Four # config/api_platform/resources.yaml
    resources:
    App\Entity\Greeting: ~

    View Slide

  17. View Slide

  18. View Slide

  19. View Slide

  20. View Slide

  21. View Slide

  22. Serialization Groups
    Read
    Write

    View Slide

  23. View Slide

  24. Use
    YAML
    Configuration advice

    View Slide

  25. User Management &
    Security

    View Slide

  26. • 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

    View Slide

  27. // src/Entity/User.php
    namespace App\Entity;
    use Symfony\Component\Security\Core\User\UserInterface;
    use Symfony\Component\Serializer\Annotation\Groups;
    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 …
    }

    View Slide

  28. // src/Entity/User.php
    namespace App\Entity;
    use Symfony\Component\Security\Core\User\UserInterface;
    use Symfony\Component\Serializer\Annotation\Groups;
    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
    App\Entity\User:
    type: entity
    table: users
    repositoryClass: App\Repository\UserRepository
    id:
    id:
    type: integer
    generator: { strategy: AUTO }
    fields:
    email:
    type: string
    length: 255
    password:
    type: string
    length: 255
    roles:
    type: array

    View Slide

  29. // src/Entity/User.php
    namespace App\Entity;
    use Symfony\Component\Security\Core\User\UserInterface;
    use Symfony\Component\Serializer\Annotation\Groups;
    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
    App\Entity\User:
    type: entity
    table: users
    repositoryClass: App\Repository\UserRepository
    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:
    App\Entity\User:
    attributes:
    normalization_context:
    groups: ['user-read']
    denormalization_context:
    groups: ['user-write']

    View Slide

  30. use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
    use App\Entity\User;
    use Doctrine\ORM\EntityManagerInterface;
    use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
    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();
    }

    View Slide

  31. • 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)

    View Slide

  32. View Slide

  33. View Slide

  34. • 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

    View Slide

  35. View Slide

  36. User security
    checker
    Security
    namespace App\Security;
    use App\Exception\AccountDeletedException;
    use App\Security\User as AppUser;
    use Symfony\Component\Security\Core\Exception\AccountExpiredException;
    use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticat
    use Symfony\Component\Security\Core\User\UserCheckerInterface;
    use Symfony\Component\Security\Core\User\UserInterface;
    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('...');
    }
    }
    }

    View Slide

  37. User security
    checker
    Security
    # config/packages/security.yaml
    # ...
    security:
    firewalls:
    main:
    pattern: ^/
    user_checker: App\Security\UserChecker
    # ...

    View Slide

  38. Resource and
    operation
    level
    Security
    # api/config/api_platform/resources.yaml
    App\Entity\Book:
    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'

    View Slide

  39. Resource and
    operation
    level using
    Voters
    Security
    # api/config/api_platform/resources.yaml
    App\Entity\Book:
    itemOperations:
    get:
    security_: 'is_granted('READ', object)'
    put:
    security_: 'is_granted('UPDATE', object)'

    View Slide

  40. • 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

    View Slide

  41. JWT tip
    A database-less user
    provider
    # config/packages/security.yaml
    security:
    providers:
    jwt:
    lexik_jwt: ~
    security:
    firewalls:
    api:
    provider: jwt
    guard:
    # ...

    View Slide

  42. Creating
    multi-language APIs

    View Slide

  43. • 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

    View Slide

  44. use Locastic\ApiPlatformTranslationBundle\Model\AbstractTranslatable;
    class Post extends AbstractTranslatable
    {
    // ...
    protected function createTranslation()
    {
    return new PostTranslation();
    }
    }

    View Slide

  45. use Locastic\ApiPlatformTranslationBundle\Model\AbstractTranslatable;
    class Post extends AbstractTranslatable
    {
    // ...
    protected function createTranslation()
    {
    return new PostTranslation();
    }
    }
    use Locastic\ApiPlatformTranslationBundle\Model\AbstractTranslatable;
    class Post extends AbstractTranslatable
    {
    // ...
    /**
    * @Groups({"post_write", "translations"})
    */
    protected $translations;
    }

    View Slide

  46. use Locastic\ApiPlatformTranslationBundle\Model\AbstractTranslatable;
    class Post extends AbstractTranslatable
    {
    // ...
    protected function createTranslation()
    {
    return new PostTranslation();
    }
    }
    use Locastic\ApiPlatformTranslationBundle\Model\AbstractTranslatable;
    class Post extends AbstractTranslatable
    {
    // ...
    /**
    * @Groups({"post_write", "translations"})
    */
    protected $translations;
    }
    use Locastic\ApiPlatformTranslationBundle\Model\AbstractTranslatable;
    use Symfony\Component\Serializer\Annotation\Groups;
    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();
    }
    }

    View Slide

  47. use Symfony\Component\Serializer\Annotation\Groups;
    use Locastic\ApiTranslationBundle\Model\AbstractTranslation;
    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;
    }
    }

    View Slide

  48. use Symfony\Component\Serializer\Annotation\Groups;
    use Locastic\ApiTranslationBundle\Model\AbstractTranslation;
    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;
    }
    }
    AppBundle\Entity\Post:
    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']

    View Slide

  49. 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"
    }
    }
    }

    View Slide

  50. 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!"
    }

    View Slide

  51. 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"
    }
    }
    }

    View Slide

  52. • https://github.com/lexik/LexikTranslationBundle
    • or you can write your own:
    • https://locastic.com/blog/symfony-translation-process-
    automation/
    Static translation

    View Slide

  53. Manipulating context
    and avoding to have /
    api/admin

    View Slide

  54. Manipulating
    the Context
    Context
    namespace App\Entity;
    use ApiPlatform\Core\Annotation\ApiResource;
    use Symfony\Component\Serializer\Annotation\Groups;
    /**
    * @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;
    // ...
    }

    View Slide

  55. Manipulating
    the Context
    Context
    # api/config/services.yaml
    services:
    # ...
    'App\Serializer\BookContextBuilder':
    decorates: 'api_platform.serializer.context_builder'
    arguments: [ '@App\Serializer\BookContextBuilder.inner' ]
    autoconfigure: false

    View Slide

  56. // api/src/Serializer/BookContextBuilder.php
    namespace App\Serializer;
    use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
    use App\Entity\Book;
    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;
    }
    }

    View Slide

  57. Symfony Messenger
    Component

    View Slide

  58. • 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

    View Slide

  59. View Slide

  60. • 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

    View Slide

  61. CQRS
    Symfony Messenger & API Platform
    App\Entity\PasswordResetRequest:
    collectionOperations:
    post:
    status: 202
    itemOperations: []
    attributes:
    messenger: true
    output: false

    View Slide

  62. CQRS
    Symfony Messenger & API Platform
    namespace App\Handler;
    use App\Entity\PasswordResetRequest;
    use Symfony\Component\Messenger\Handler\MessageHandlerInterfac
    final class PasswordResetRequestHandler implements MessageHand
    {
    public function __invoke(PasswordResetRequest $forgotPassw
    {
    // do some heavy things
    }
    }
    namespace App\Entity;
    final class PasswordResetRequest
    {
    public $email;
    }

    View Slide

  63. namespace App\DataPersister;
    use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
    use App\Entity\ImageMedia;
    use Doctrine\ORM\EntityManagerInterface;
    use Symfony\Component\Messenger\MessageBusInterface;
    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()));
    }
    }

    View Slide

  64. namespace App\EventSubscriber;
    use ApiPlatform\Core\EventListener\EventPriorities;
    use App\Entity\Book;
    use Symfony\Component\EventDispatcher\EventSubscriberInterface;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpKernel\Event\ViewEvent;
    use Symfony\Component\HttpKernel\KernelEvents;
    use Symfony\Component\Messenger\MessageBusInterface;
    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()));
    }
    }

    View Slide

  65. View Slide

  66. View Slide

  67. • 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

    View Slide

  68. {
    "body":"{\"key\":\"TESTING\",\"variables\":{\"UserName\":\"some test username \",
    \"Greetings\":\"Some test greeting 4faf188d396e99e023a3f77c04a9c06d\",\"Email\":
    \"[email protected]\"},\"to\":\"[email protected]\"}",
    "properties":[
    ],
    "headers":{
    "type":"App\\Message\\CommunicationMessage",
    "X-Message-Stamp-Symfony\\Component\\Messenger\\Stamp\
    \BusNameStamp":"[{\"busName\":\"command_bus\"}]",
    "X-Message-Stamp-Symfony\\Component\\Messenger\\Stamp\
    \SentStamp":"[{\"senderClass\":\"Enqueue\\\\MessengerAdapter\\\\QueueInteropTransport\",
    \"senderAlias\":\"communication_sync_amqp\"}]",
    "Content-Type":"application\/json"
    }
    }

    View Slide

  69. {
    "body":"{\"key\":\"TESTING\",\"variables\":{\"UserName\":\"some test username \",
    \"Greetings\":\"Some test greeting 4faf188d396e99e023a3f77c04a9c06d\",\"Email\":
    \"[email protected]\"},\"to\":\"[email protected]\"}",
    "properties":[
    ],
    "headers":{
    "type":"App\\Message\\CommunicationMessage",
    "X-Message-Stamp-Symfony\\Component\\Messenger\\Stamp\
    \BusNameStamp":"[{\"busName\":\"command_bus\"}]",
    "X-Message-Stamp-Symfony\\Component\\Messenger\\Stamp\
    \SentStamp":"[{\"senderClass\":\"Enqueue\\\\MessengerAdapter\\\\QueueInteropTransport\",
    \"senderAlias\":\"communication_sync_amqp\"}]",
    "Content-Type":"application\/json"
    }
    }

    View Slide

  70. {
    "body":"{\"key\":\"TESTING\",\"variables\":{\"UserName\":\"some test username \",
    \"Greetings\":\"Some test greeting 4faf188d396e99e023a3f77c04a9c06d\",\"Email\":
    \"[email protected]\"},\"to\":\"[email protected]\"}",
    "properties":[
    ],
    "headers":{
    "type":"App\\Message\\CommunicationMessage",
    "X-Message-Stamp-Symfony\\Component\\Messenger\\Stamp\
    \BusNameStamp":"[{\"busName\":\"command_bus\"}]",
    "X-Message-Stamp-Symfony\\Component\\Messenger\\Stamp\
    \SentStamp":"[{\"senderClass\":\"Enqueue\\\\MessengerAdapter\\\\QueueInteropTransport\",
    \"senderAlias\":\"communication_sync_amqp\"}]",
    "Content-Type":"application\/json"
    }
    }
    String contains escaped JSON

    View Slide

  71. {
    "key": "TESTING",
    "variables": {
    "username": "some test userName",
    "Grettings": "Some test greeting 4faf188d396e99e023a3f77c04a9c06d",
    "Email:" "[email protected]"
    }
    }

    View Slide

  72. namespace App\Messenger;

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

    }

    View Slide

  73. namespace App\Messenger;

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

    View Slide

  74. namespace App\Messenger;

    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: App\Messenger\ExternalJsonMessageSerializer
    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: ~
    ...

    View Slide

  75. • 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:

    View Slide

  76. Handling emails

    View Slide

  77. • 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

    View Slide

  78. Creating reports
    (exports)

    View Slide

  79. • 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

    View Slide

  80. resources:
    App\Entity\Order:
    collectionOperations:
    get: ~
    exports:
    method: POST
    path: '/orders/export'
    formats:
    csv: ['text/csv']
    pagination_enabled: false
    output: "OrderExport::class"
    normalization_context:
    groups: ['order-export']

    View Slide

  81. namespace App\DTO;
    use Symfony\Component\Serializer\Annotation\Groups;
    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"})

    View Slide

  82. namespace App\DTO;
    use Symfony\Component\Serializer\Annotation\Groups;
    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 App\DataTransformer;
    use ApiPlatform\Core\DataTransformer\DataTransformerInterface;

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

    View Slide

  83. Real-time applications
    with API platform

    View Slide

  84. View Slide

  85. • Redis + NodeJS + socket.io
    • Pusher
    • ReactPHP
    • …
    • but to be honest PHP is not build for realtime :)
    Real-time applications
    with API platform

    View Slide

  86. View Slide

  87. • 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

    View Slide

  88. resources:
    App\Entity\Greeting:
    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));
    }

    View Slide

  89. Testing

    View Slide

  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

    View Slide

  91. Handy testing tools

    View Slide

  92. • 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

    View Slide

  93. View Slide

  94. PHP Matcher
    Library that enables you to check your response against patterns.

    View Slide

  95. PHP Matcher
    Library that enables you to check your response against patterns.

    View Slide

  96. Faker (/w Alice)
    Library for generating random data

    View Slide

  97. Postman tests
    Newman + Postman

    View Slide

  98. Postman tests + Newman
    Newman + Postman

    View Slide

  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

    View Slide

  100. View Slide

  101. Api Platform (Symfony) is awesome!
    Conclusion

    View Slide

  102. Thank you!

    View Slide

  103. Questions?
    Antonio Perić-Mažar
    t: @antonioperic
    m: [email protected]

    View Slide