Workshop @ Symfony Con 2016

Samuel Roze

@samuelroze

 

API Platform
maintainer

Kevin Dunglas
@dunglas

 

API Platform
maintainer

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!

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

Made with Slides.com