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 /books
An 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: