Workshop @ Symfony Con 2016

Hamza Amrouche
@cDaed
API Platform
maintainer
Program
- Why API Platform?
- What is API Platform?
- Create our basic API
- Validation
- Serialization groups & Embeddeds
- Filters
- Events
- Custom controller/action
- Custom data provider
- Authentication
The web has changed!
- More and more Javascript webapps - and Google crawls them
- Users spend more time on mobile than on desktops: responsive websites and mobile apps required
- JSON-LD, Schema.org, Hydra: Linked Data and the semantic web start to be massively used
APIs: the hearth of the new web
- Central point to access data
- Encapsulate business logic
- Same data and same features for desktops, mobiles, customers, providers and partners

API Platform
A framework for the new API-first web
Two separate components
(at least)
- A static HTML5 webapp (SPA)
- A web API
Play well with microservices too.
The web API
- Centralizes R/W access to data
- Holds all the business logic
- Is built with PHP (and API Platform)
- Is stateless (PHP sessions make horizontal scalability harder)
The HTML5 webapp
Or any other client (mobile app...)
- Holds all the presentation logic
- Is downloaded first (Single Page Application)
- Queries the API to retrieve and modify data using AJAX
- Is 100% composed of HTML, JavaScript and CSS assets
- Can be hosted on a CDN
The API and the web app are standalone
- 2 Git repositories and 2 CI
- different servers
- 2 domain names:
example.com
,api.example.com
- The routing is done client-side using HTML5 push state
Not mandatory for small apps.

Immediate benefits
Speed (even on mobile)
- Assets including index.html are downloaded from a CDN
- After the first page load: no more download/parse/render bunch of HTML required at each request
- Only small chunks of raw data transit on the network
- API responses can be cached by the proxy
Scalability and robustness
- The front app is just a set of static files: can be hosted in a CDN
- Stateless API: push and pop servers/containers on demand
Development comfort
- A wizard, an autocomplete box? Do it in JS, no more Symfony forms! (API data validation required)
- Reuse the API in other contexts: native mobile, game stations, TV apps, connected devices or heavy desktop clients
- Give access to your customers and partners to access to raw data through the API
Long term benefits
- Better structure: thanks to the central API, less business logic duplication when the app grows
- Easier refactoring: Touching a component has no impact on the other (spec and test the API format)
- Simpler project management: Separate teams can work on each app
Drawbacks
SEO and SMO... but solutions exist!
Formats, (open) standards, patterns
HTTP + REST + JSON
- Work everywhere
- Lightweight
- Stateless
- HTTP has a powerful caching model
- Extensible (JSON-LD, Hydra, Swagger, HAL...)
- High quality tooling
JSON Web Token (JWT)
- 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
HATEOAS / Linked Data
Hypermedia as the Engine of Application State
- Hypermedia: IRI as identifier
- Ability to reference external data (like hypertext links)
- Generic clients
JSON-LD
JSON for Linked Data
- Standard: W3C recommandation (since 2014)
- Easy to use: looks like a typical JSON document
- Already used by Gmail, GitHub, BBC, Microsoft, US gov...
- Compliant with technologies of the semantic web: RDF, SPARQL, triple store...
{
"@context": "/contexts/Person",
"@id": "/people",
"@type": "hydra:PagedCollection",
"hydra:totalItems": 1,
"hydra:itemsPerPage": 30,
"hydra:firstPage": "/people",
"hydra:lastPage": "/people",
"hydra:member": [
{
"@id": "/people/1",
"@type": "http://schema.org/Person",
"gender": "male",
"name": "Dunglas",
"url": "https://dunglas.fr"
}
]
}
Schema.org
- Define a large set of elements: people, creative work, events, products, chemicals...
- Created and understood by Google, Bing, Yahoo! et Yandex
- Massively used, and run by the W3C (Web schemas group)
- Can be used in HTML (microdata), RDF (RDFa) and JSON-LD
- Can be extended (custom vocabularies)
Hydra
- Describe REST APIs in JSON-LD
- = write support
- = auto-discoverable APIs
- = standard for collections, paginations, errors, filters
- Draft W3C (Work In Progress)
- Swagger
- HAL
- API Problem
- raw JSON
- CSV
- YAML
- XML
Other supported formats
API Platform: the promise
- Fully featured API supporting Swagger + JSON-LD + Hydra + HAL in minutes
- An auto generated doc
- Convenient API spec and test tools using Behat
- Easy authentication management with JWT or OAuth
- CORS and HTTP cache
- All the tools you love: Doctrine ORM, Monolog, Swiftmailer...
- Data model generator using Schema.org
API Platform <3 Symfony
- Built on top of Symfony full-stack
- Install any existing SF bundles
- Follow SF Best Practices
- Can be used in your existing SF app
- (Optional) tightly integrated with Doctrine
Download the skeleton
$ wget https://github.com/api-platform/api-platform/archive/v2.0.0.tar.gz
$ tar xzvf v2.0.0.tar.gz
$ cd api-platform-2.0.0
$ composer create-project api-platform/api-platform book-shop
Alternatively, use Composer
Getting started
# Start Docker
$ docker-compose up -d
# Create MySQL tables
$ docker-compose run web bin/console doctrine:schema:create
http://localhost/app_dev.php
In your preferred browser
Expose your data model through the API
Create your first entities
<?php
use Doctrine\ORM\Mapping as ORM;
use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\Common\Collections\Collection;
namespace AppBundle\Entity;
/**
* @ApiResource
*
* @ORM\Entity
*/
class Book
{
/**
* @ORM\Id
* @ORM\Column(type="string")
* @ORM\GeneratedValue(strategy="UUID")
*
* @var string
*/
private $uuid;
/**
* @ORM\Column(type="string")
*
* @var string
*/
private $name;
/**
* @ORM\OneToMany(targetEntity="Review", mappedBy="book", cascade={"persist"})
*
* @var Collection
*/
private $reviews;
public function __construct()
{
$this->reviews = new ArrayCollection();
}
}
Create your first entities
<?php
use Doctrine\ORM\Mapping as ORM;
use ApiPlatform\Core\Annotation\ApiResource;
namespace AppBundle\Entity;
/**
* @ORM\Entity
*
* @ApiResource
*/
class Review
{
/**
* @ORM\Id
* @ORM\Column(type="string")
* @ORM\GeneratedValue(strategy="UUID")
*
* @var string
*/
private $uuid;
/**
* @ORM\Column(type="text")
*
* @var string
*/
private $contents;
/**
* @ORM\Column(type="boolean")
*
* @var boolean
*/
private $published;
/**
* @ORM\ManyToOne(targetEntity="Book", inversedBy="reviews")
* @ORM\JoinColumn(name="book_uuid", referencedColumnName="uuid")
*
* @var Book
*/
private $book;
}
Update your database
$ docker-compose run web bin/console doctrine:schema:update --force
Browse the documentation!

And you got:
- Full CRUD with support for relations and dates
- Data validation
- Collections pagination
- Error serialization
- Automatic routes registration
- Filters on exposed properties
- Sorting
- Hypermedia entrypoint
{
"@context": "/app_dev.php/contexts/Book",
"@id": "/app_dev.php/books",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "/app_dev.php/books/09d6fc64-7a90-11e6-8d55-0242ac110002",
"@type": "Book",
"name": "Something else",
"reviews": [
"/app_dev.php/reviews/00395c35-7a9f-11e6-8d55-0242ac110002",
"/app_dev.php/reviews/00a1f95f-7a9f-11e6-8d55-0242ac110002",
"/app_dev.php/reviews/00f10e11-7a9f-11e6-8d55-0242ac110002",
"/app_dev.php/reviews/012ad164-7a9f-11e6-8d55-0242ac110002",
"/app_dev.php/reviews/015d1033-7a9f-11e6-8d55-0242ac110002",
"/app_dev.php/reviews/df3287da-7a9e-11e6-8d55-0242ac110002",
"/app_dev.php/reviews/fc375388-7a9e-11e6-8d55-0242ac110002"
]
},
{
"@id": "/app_dev.php/books/1b31199b-7a90-11e6-8d55-0242ac110002",
"@type": "Book",
"name": "Something",
"reviews": []
},
{
"@id": "/app_dev.php/books/1f61e996-7a90-11e6-8d55-0242ac110002",
"@type": "Book",
"name": "Something",
"reviews": []
},
{
"@id": "/app_dev.php/books/2cb3582a-7a90-11e6-8d55-0242ac110002",
"@type": "Book",
"name": "Something",
"reviews": []
},
{
"@id": "/app_dev.php/books/458048a6-7a90-11e6-8d55-0242ac110002",
"@type": "Book",
"name": "Something",
"reviews": []
}
],
"hydra:totalItems": 5
}
GET /books
How it works?
The API bundle reuse the following metadata to expose and document the JSON-LD/Hydra API:
- Doctrine ORM
- Validator constraints
- Serializer groups
- PHPDoc
- Your own (by registering custom metadata loaders)
Full support of JSON-LD, Hydra and Schema.org
An autodiscoverable Linked Data API for free!
User documentation and sandbox
Swagger UI automatically detects and documents exposed resources.
Validation
Symfony Validator
<?php
use Symfony\Component\Validator\Constraints as Assert;
class Book
{
/**
* @Assert\NotBlank
*
* @var string
*/
private $name;
}
Serialization groups
Serializer

Add groups on fields
<?php
/**
* @ApiResource(attributes={
* "normalization_context"={"groups"={"book_read"}},
* "denormalization_context"={"groups"={"book_write"}}
* })
*/
class Book
{
/**
* @Groups({"book_read"})
*/
private $uuid;
/**
* @Groups({"book_write"})
*/
private $name;
/**
* @Groups({"book_read"})
*/
private $reviews;
}
Add groups on fields
<?php
/**
* @ApiResource(attributes={
* "normalization_context"={"groups"={"review_read"}},
* "denormalization_context"={"groups"={"review_write"}}
* })
*/
class Review
{
/**
* @Groups({"review_read", "book_read"})
*/
private $uuid;
/**
* @Groups({"review_read", "review_write", "book_read"})
*/
private $contents;
/**
* @Groups({"review_read"})
*/
private $published;
/**
* @Groups({"review_read"})
*/
private $book;
}
Filters
Built-in filters
- Search (text)
- Date
- Boolean
- Numeric
- Range
- Order (not really a filter)
Create a filter
# app/config/services.yml
services:
book.filters.search:
parent: 'api_platform.doctrine.orm.search_filter'
arguments: [ { uuid: 'exact', name: 'partial' } ]
tags: [ { name: 'api_platform.filter', id: 'book.search' } ]
review.filters.search:
parent: 'api_platform.doctrine.orm.search_filter'
arguments: [ { uuid: 'exact', contents: 'partial' } ]
tags: [ { name: 'api_platform.filter', id: 'review.search' } ]
review.filters.is_published:
parent: 'api_platform.doctrine.orm.boolean_filter'
arguments: [ { published: ~ } ]
tags: [ { name: 'api_platform.filter', id: 'review.is_published' } ]
Activate some filters
<?php
/**
* @ApiResource(attributes={
* "filters"={"review.is_published", "review.search"}
* })
*/
class Review
{
}
/**
* @ApiResource(attributes={
* "filters"={"book.search"}
* })
*/
class Book
{
}
Events

Symfony Events
ApiPlatform Events

Your own listener
namespace AppBundle\EventSubscriber;
use AppBundle\Entity\Book;
use ApiPlatform\Core\EventListener\EventPriorities;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class BookMailSubscriber implements EventSubscriberInterface
{
private $mailer;
public function __construct(\Swift_Mailer $mailer)
{
$this->mailer = $mailer;
}
public static function getSubscribedEvents()
{
return [
KernelEvents::VIEW => [['sendMail', EventPriorities::POST_WRITE]],
];
}
public function sendMail(GetResponseForControllerResultEvent $event)
{
$book = $event->getControllerResult();
$method = $event->getRequest()->getMethod();
if (!$book instanceof Book || Request::METHOD_POST !== $method) {
return;
}
$message = \Swift_Message::newInstance()
->setSubject('A new book has been added')
->setFrom('system@example.com')
->setTo('somebody@example.com')
->setBody(sprintf('The book #%s has been added.', $book->getId()));
$this->mailer->send($message);
}
}
Custom actions
Register the operation
<?php
/**
* @ApiResource(itemOperations={
* "get"={"method"="GET"},
* "publish-reviews"={"route_name"="book_publish_reviews"},
* })
*/
class Book
{
}
It can be an action
<?php
namespace AppBundle\Action;
use AppBundle\Entity\Book;
use AppBundle\Entity\Review;
use Psr\Log\LoggerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\Request;
class PublishReviews
{
private $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
/**
* @Route(
* "/books/{id}/publish-reviews",
* name="book_publish_reviews",
* methods={"POST"},
* defaults={"_api_resource_class"=Book::class, "_api_item_operation_name"="publish-reviews"}
* )
*/
public function __invoke(Book $data, Request $request)
{
$toPublish = $data->getReviews()->filter(function(Review $review) {
return !$review->isPublished();
});
$this->logger->info('Publishing book reviews', [
'book' => $data->getUuid(),
'reviews' => join(',', $toPublish->map(function(Review $review) {
return $review->getUuid();
})->toArray()),
'3rdParty' => $request->query->get('3rd-party', 'all'),
]);
$toPublish->map(function(Review $review) {
$review->setPublished(true);
});
return $data;
}
}
It can be a controller
<?php
namespace AppBundle\Controller;
use AppBundle\Entity\Book;
use AppBundle\Entity\Review;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\Request;
class BookController extends Controller
{
/**
* @Route(
* "/books/{id}/publish-reviews",
* name="book_publish_reviews",
* methods={"POST"},
* defaults={"_api_resource_class"=Book::class, "_api_item_operation_name"="publish-reviews"}
* )
*/
public function publishReviewsAction(Book $data, Request $request)
{
$toPublish = $data->getReviews()->filter(function(Review $review) {
return !$review->isPublished();
});
$this->getContainer()->get('logger')->info('Publishing book reviews', [
'book' => $data->getUuid(),
'reviews' => join(',', $toPublish->map(function(Review $review) {
return $review->getUuid();
})->toArray()),
'3rdParty' => $request->query->get('3rd-party', 'all'),
]);
$toPublish->map(function(Review $review) {
$review->setPublished(true);
});
return $data;
}
}
CORS

# src/app/config.yml
nelmio_cors:
defaults:
allow_origin: ["https://example.com"]
allow_methods: ["POST", "PUT", "GET", "DELETE", "OPTIONS"]
allow_headers: ["content-type", "authorization"]
max_age: 3600
paths:
'^/': ~
NelmioCorsBundle
Security
FOSUserBundle
- Basic user management
- Api Platform Bridge
- User operations
LexikJwtAuth[...]Bundle
- Makes the Symfony form login returning a JWT token instead of setting a cookie (stateless)
- Allows to use Symfony firewall rules to secure API endpoints
# app/config/security.yml
security:
# ...
firewalls:
login:
pattern: ^/login$
stateless: true
anonymous: true
form_login:
check_path: /login
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
require_previous_session: false
api:
pattern: ^/
stateless: true
lexik_jwt: ~
access_control:
- { path: ^/, roles: ROLE_ANONYMOUS, methods: [GET] }
- { path: ^/special, roles: ROLE_USER }
- { path: ^/, roles: ROLE_ADMIN }
Cache with FOSHttpCache
# app/config/config.yml
fos_http_cache:
cache_control:
rules:
-
match:
path: ^/content$
headers:
cache_control:
public: true
max_age: 64000
etag: true
Specs and tests with Behat
Behat and its Behatch extension make testing and API easy.
# features/put.feature
Scenario: Update a resource
When I send a "PUT" request to "/people/1" with body:
"""
{
"name": "Kevin"
}
"""
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json"
And the JSON should be equal to:
"""
{
"@context": "/contexts/Person",
"@id": "/people/1",
"@type": "Person",
"name": "Kevin",
"address": null
}
"""
PHP Schema is a scaffolding tool
Edit the code and add your custom classes, properties, validation constraints, indexes, IRIs...
Thanks to Schema.org and API Platform you get:
- PHP classes, properties, getters and setters (PSR compliant)
- Doctrine ORM mapping (including relations and mapped superclasses)
- Validation constraints from the Validator component
- Full PHPDoc extracted from schema human-readable descriptions
- (optional) PHP interfaces
- (optional)
ResolveTargetEntity
Doctrine mappings - (optional) JSON-LD IRI annotations (useful for the API bundle)
Create a config file
Pick types and properties you need from Schema.org:
# my-config-file.yml
types:
Person:
parent: false
properties:
name: ~
birthDate: ~
gender: ~
Generate PHP classe(s)
docker-compose exec web vendor/bin/schema generate-types src/ my-config-file.yml
Take a look at
src/AppBundle/Entity/
More advanced features
- Relations
- Type overriding
- Custom properties
- Custom types
- Custom annotation generators
https://github.com/api-platform/api-platform/blob/v2.0.0-beta.2/app/config/schema.yml
Tools for the static webapp
- Angular, React, Ember, Backbone or anything else
- Restangular
- Auth0 JWT libraries
- Webpack, Yeoman, Gulp or Grunt, Bower,
Jasmine, Karma...
'use strict';
angular.module('myApp')
.controller('MainCtrl', function ($scope, Restangular) {
var booksApi = Restangular.all('books');
function loadBooks() {
booksApi.getList().then(function (books) {
$scope.books = books;
});
}
loadBooks();
$scope.newBook = {};
$scope.success = false;
$scope.errorTitle = false;
$scope.errorDescription = false;
$scope.createBook = function (form) {
booksApi.post($scope.newBook).then(function () {
loadBooks();
$scope.success = true;
$scope.errorTitle = false;
$scope.errorDescription = false;
$scope.newBook = {};
form.$setPristine();
}, function (response) {
$scope.success = false;
$scope.errorTitle = response.data['hydra:title'];
$scope.errorDescription = response.data['hydra:description'];
});
};
});
An Angular example
API Platform 2 is out
Planned features for 2.1 and 2.2:
- JSONAPI support
- MongoDB native support
- Autogenerated admin
- GraphQL support
Thank you
api-platform-workshop
By Samuel ROZE
api-platform-workshop
- 1,177