Workshop @ Symfony Con 2016
Hamza Amrouche
@cDaed
API Platform
maintainer
(at least)
Play well with microservices too.
Or any other client (mobile app...)
example.com, api.example.com
Not mandatory for small apps.
SEO and SMO... but solutions exist!
Hypermedia as the Engine of Application State
JSON for Linked Data
{
"@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"
}
]
}
$ 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
# Start Docker
$ docker-compose up -d
# Create MySQL tables
$ docker-compose run web bin/console doctrine:schema:create
http://localhost/app_dev.php
<?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();
}
}
<?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!{
"@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 /booksAn autodiscoverable Linked Data API for free!
Swagger UI automatically detects and documents exposed resources.
Symfony Validator<?php
use Symfony\Component\Validator\Constraints as Assert;
class Book
{
/**
* @Assert\NotBlank
*
* @var string
*/
private $name;
}
<?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;
}
<?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;
}
# 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' } ]
<?php
/**
* @ApiResource(attributes={
* "filters"={"review.is_published", "review.search"}
* })
*/
class Review
{
}
/**
* @ApiResource(attributes={
* "filters"={"book.search"}
* })
*/
class Book
{
}
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);
}
}<?php
/**
* @ApiResource(itemOperations={
* "get"={"method"="GET"},
* "publish-reviews"={"route_name"="book_publish_reviews"},
* })
*/
class Book
{
}
<?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;
}
}<?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;
}
}
# 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:
'^/': ~
# 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 }
# app/config/config.yml
fos_http_cache:
cache_control:
rules:
-
match:
path: ^/content$
headers:
cache_control:
public: true
max_age: 64000
etag: true
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
}
"""
Edit the code and add your custom classes, properties, validation constraints, indexes, IRIs...
ResolveTargetEntity Doctrine mappingsPick types and properties you need from Schema.org:
# my-config-file.yml
types:
Person:
parent: false
properties:
name: ~
birthDate: ~
gender: ~
docker-compose exec web vendor/bin/schema generate-types src/ my-config-file.yml
https://github.com/api-platform/api-platform/blob/v2.0.0-beta.2/app/config/schema.yml
'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'];
});
};
});
Planned features for 2.1 and 2.2: