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.
3. Locastic
Helping clients create web
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
7. –Fabien Potencier (creator of Symfony), SymfonyCon 2017
“API Platform is the most advanced API platform,
in any framework or language.”
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
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 HTTP caching
• and basically everything needed to build modern APIs.
API Platform built-in
features:
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
27. // 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 …
}
30. 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();
}
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)
32.
33.
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
35.
36. 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('...');
}
}
}
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 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
41. JWT tip
A database-less user
provider
# config/packages/security.yaml
security:
providers:
jwt:
lexik_jwt: ~
security:
firewalls:
api:
provider: jwt
guard:
# ...
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
45. <?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;
}
46. <?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();
}
}
47. <?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;
}
}
48. <?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']
54. 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;
// ...
}
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
59.
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
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;
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()));
}
}
64. 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()));
}
}
65.
66.
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
72. 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;
}
…
}
73. 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;
}
74. 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: ~
...
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:
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
85. • Redis + NodeJS + socket.io
• Pusher
• ReactPHP
• …
• but to be honest PHP is not build for realtime :)
Real-time applications
with API platform
86.
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
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));
}
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
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
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