Disponible pour vos projets! Contactez-moi

Tables de données avec Symfony, Hateoas et AngularJS

Tables de données avec Symfony, Hateoas et AngularJS Image

Récemment, j'ai dû créer des tables pour présenter les données d'une API REST Symfony2, j'ai donc décidé d'écrire cet article pour détailler le processus que j'ai utilisé.

Je vais créer un endpoint REST pour récupérer une liste de produits et un tableau simple avec tri et pagination pour présenter les données à l'aide d'AngularJS.

Backend

J'ai créé un nouveau projet Symfony et installé FOSRestBundle, puis activé le view listener:

fos_rest:
    view:
        view_response_listener: 'force'

J'ai installé BazingaHateoasBundle avec la configuration par défaut et créé une entité simpleProduct:

<?php

namespace Acme\DemoBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity()
 */
class Product
{
    /**
     * @ORM\Id()
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue()
     */
    private $id;

    /**
     * @ORM\Column(type="string")
     */
    private $title;

    /**
     * @ORM\Column(type="decimal")
     */
    private $price;

    /**
     * @ORM\Column(type="datetime")
     */
    private $createdDate;

    // Les Setters and getters ici
}

Voici l'endpoint REST pour récupérer une liste de produits:

<?php

namespace Acme\DemoBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use FOS\RestBundle\Controller\Annotations as Rest;

class ProductController extends Controller
{
    /**
     * @Rest\Get(path="/api/products")
     * @Rest\View()
     */
    public function getAllAction()
    {
        $repository = $this->getDoctrine()->getManager()
            ->getRepository('AcmeDemoBundle:Product');

        return $repository->findAll();
    }
}

Si j'envoie une requête JSON GET à /api/products, j'obtiendrai quelque chose comme:

[
   {
      "id":1,
      "title":"Laptop",
      "price":500,
      "createdDate":"2014-11-05T08:17:15+0100"
   }
]

J'utilise le IdenticalPropertyNamingStrategy deJMSSerializer pour simplifier les choses

Ajout de la pagination

D'abord, je dois installer la bibliothèque Pagerfanta:

$ composer require pagerfanta/pagerfanta

Je m'appuie sur la bibliothèque Hateoas pour ajouter les informations de pagination à la ressource (qui proviendront directement de l'instance du pager).

L'action du contrôleur doit être modifiée pour accepter deux paramètres de requête: page et limite.

<?php

namespace Acme\DemoBundle\Controller;

use Hateoas\Configuration\Route;
use Hateoas\Representation\Factory\PagerfantaFactory;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use FOS\RestBundle\Controller\Annotations as Rest;
use Symfony\Component\HttpFoundation\Request;

class ProductController extends Controller
{
    /**
     * @Rest\Get(name="product_list", path="/api/products", defaults={"_format" = "json"})
     * @Rest\View()
     */
    public function getAllAction(Request $request)
    {
        $limit = $request->query->getInt('limit', 10);
        $page = $request->query->getInt('page', 1);

        $queryBuilder = $this->getDoctrine()->getManager()->createQueryBuilder()
            ->select('p')
            ->from('AcmeDemoBundle:Product', 'p');

        $pagerAdapter = new DoctrineORMAdapter($queryBuilder);
        $pager = new Pagerfanta($pagerAdapter);
        $pager->setCurrentPage($page);
        $pager->setMaxPerPage($limit);

        $pagerFactory = new PagerfantaFactory();

        return $pagerFactory->createRepresentation(
            $pager,
            new Route('product_list', array('limit' => $limit, 'page' => $page))
        );
    }
}

Ajout du tri

Pour gérer le tri, un nouveau paramètre de requête sous la forme sorting[column]=direction doit être accepté, permettant de prendre en charge le tri selon plusieurs colonnes.

J'ai également refait la logique de création du pageur et l'ai mise dans le ProductRepository.

<?php

namespace Acme\DemoBundle\Controller;

use Hateoas\Configuration\Route;
use Hateoas\Representation\Factory\PagerfantaFactory;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use FOS\RestBundle\Controller\Annotations as Rest;
use Symfony\Component\HttpFoundation\Request;

class ProductController extends Controller
{
    /**
     * @Rest\Get(name="product_list", path="/api/products")
     * @Rest\View()
     */
    public function getAllAction(Request $request)
    {
        $limit = $request->query->getInt('limit', 10);
        $page = $request->query->getInt('page', 1);
        $sorting = $request->query->get('sorting', array());

        $productsPager = $this->getDoctrine()->getManager()
            ->getRepository('AcmeDemoBundle:Product')
            ->findAllPaginated($limit, $page, $sorting);

        $pagerFactory = new PagerfantaFactory();

        return $pagerFactory->createRepresentation(
            $productsPager,
            new Route('product_list', array(
                'limit' => $limit,
                'page' => $page,
                'sorting' => $sorting
            ))
        );
    }
}

Le repository product:

<?php

namespace Acme\DemoBundle\Repository;

use Doctrine\ORM\EntityRepository;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;

class ProductRepository extends EntityRepository
{
    public function findAllPaginated($limit, $page, array $sorting = array())
    {
        $fields = array_keys($this->getClassMetadata()->fieldMappings);
        $queryBuilder = $this->createQueryBuilder('p');

        foreach ($fields as $field) {
            if (isset($sorting[$field])) {
                $direction = ($sorting[$field] === 'asc') ? 'asc' : 'desc';
                $queryBuilder->addOrderBy('p.'.$field, $direction);
            }
        }

        $pagerAdapter = new DoctrineORMAdapter($queryBuilder);
        $pager = new Pagerfanta($pagerAdapter);
        $pager->setCurrentPage($page);
        $pager->setMaxPerPage($limit);

        return $pager;
    }
}

Si j'envoie une requête JSON GET à /api/products?sorting[price]=asc&sorting[name]=asc, les produits sont triés par prix et par titre selon l'ordre croissant.

{
   "page":1,
   "limit":10,
   "pages":3,
   "total":23,
   "_links":{
      "self":{
         "href":"\/article1\/web\/app_dev.php\/api\/products?limit=10&page=1&sorting%5Bprice%5D=asc&sorting%5Bname%5D=asc"
      },
      "first":{
         "href":"\/article1\/web\/app_dev.php\/api\/products?limit=10&page=1&sorting%5Bprice%5D=asc&sorting%5Bname%5D=asc"
      },
      "last":{
         "href":"\/article1\/web\/app_dev.php\/api\/products?limit=10&page=3&sorting%5Bprice%5D=asc&sorting%5Bname%5D=asc"
      },
      "next":{
         "href":"\/article1\/web\/app_dev.php\/api\/products?limit=10&page=2&sorting%5Bprice%5D=asc&sorting%5Bname%5D=asc"
      }
   },
   "_embedded":{
      "items":[
         {
            "id":31,
            "title":"Phone",
            "price":200,
            "createdDate":"2012-11-05T08:17:15+0100"
         },

         // plus d'entrées ici
      ]
   }
}

Je n'ai pas ajouté de lien vers le produit car nous affichons simplement une liste de produits dans cet exemple.

La propriété _links de la représentation elle-même ne sera pas utilisée par l'application AngularJS mais sera utile pour d'autres clients HTTP utilisant l'API.

Frontend

Pour le point d'entrée du frontend, j'ai créé un nouveau contrôleur et un modèle contenant la logique de bootstrap de l'application javascript:

<?php

namespace Acme\DemoBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class AppController extends Controller
{
    /**
     * @Route(name="index", path="/")
     * @Template()
     */
    public function indexAction()
    {
        return array();
    }
}

Pour plus de simplicité, le template n'est qu'un simple template HTML5 avec les dépendances directement extraites des CDN. Comme l'application n'a qu'une vue, j'affiche directement la table.

Pour la gestion des tables, j'utilise ngTable qui est celui que je préfère car il est facile à personnaliser. J'ai également inclus Underscore.js qui a des fonctions utiles.

<!doctype html>
<html class="no-js" lang="" ng-app="app">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>Products</title>
    <meta name="description" content="">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap.min.css">
</head>
<body>

<h1 class="text-center">Products</h1>

<div class="col-lg-8 col-lg-offset-2">

    {% verbatim %}
    <table ng-controller="ProductController" ng-table="tableParams" class="table">
        <tr ng-repeat="product in $data">
            <td data-title="'Title'" sortable="'title'">
                {{product.title}}
            </td>
            <td data-title="'Price'" sortable="'price'">
                {{product.price | currency}}
            </td>
            <td data-title="'Created Date'" sortable="'createdDate'">
                {{product.createdDate | date}}
            </td>
        </tr>
    </table>
    {% endverbatim %}

</div>

<script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.3.0/angular.min.js"></script>
<script src="//cdn.rawgit.com/esvit/ng-table/master/ng-table.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js"></script>

<script>
     // Définit l'URL de base de l'API pour y accéder dans l'application
    angular.module('app', ['ngTable'])
           .constant('config', {
                baseUrl: '{{ app.request.getBaseURL() }}/api'
            });
</script>

<script src="{{ asset('bundles/acmedemo/js/product.js') }}"></script>
</body>
</html>

L'attribut sortable sur les éléments td correspond au nom des colonnes dans les paramètres de la table orderBy

Je peux maintenant créer le ProductController pour configurer la table qui récupère les données de l’API backend:

(function () {

    angular
        .module('app')
        .controller('ProductController', ProductController);

    function ProductController($scope, $location, $http, config, ngTableParams) {
        this.$http = $http;
        this.config = config;

        // Valeurs par défaut, généralement extraites de l'url
        var sorting = {title: 'asc'};
        var page = 1;
        var count = 10;

        // Configure et publie la table sur le scope
        $scope.tableParams = new ngTableParams({page: page, count: count, sorting: sorting},
           {
                total: 0,
                getData: function ($defer, tableParams) {
                    this.fetchProducts(this.createQuery(tableParams), tableParams, $defer);
                }.bind(this)
            }
        );
    }

    /**
    * Crée l'objet de requête que nous devons envoyer à notre API à partir des paramètres de la table.
     */
    ProductController.prototype.createQuery = function (tableParams) {
        var query = {
            page: tableParams.page(),
            limit: tableParams.count()
        };

        // Le paramètre orderBy est de la forme ["+state", "-title"]
        // où '+' represente ascendant and '-' descendant
        // Nous devons le convertir au format accepté par notre API
        _.each(tableParams.orderBy(), function (dirColumn) {
            var key = 'sorting[' + dirColumn.slice(1) + ']';
            query[key] = (dirColumn[0] === '+') ? 'asc' : 'desc';
        });

        return query;
    };

    /**
     * Récupére la liste de produits en envoyant une requête HTTP à l'endpoint de liste de produits.
     */
    ProductController.prototype.fetchProducts = function (query, tableParams, $defer) {
        this.$http({
            url: this.config.baseUrl + '/products',
            method: 'GET',
            params: query
        }).then(
            // Callback de succès
            function (response) {
                var data = response.data;
                var products = data._embedded.items;

                // Définit le nombre total de produits
                tableParams.total(data.total);

                // Résoud le différé avec le tableau de produits
                $defer.resolve(products);
            }
        );
    }

})();

Pour améliorer le code, je pourrais déplacer l'interaction HTTP dans un service distinct, par exemple ProductApi et l'injecter dans leProductController.

Ensuite, le ProductController peut être facilement extrait dans unTableController utilisé comme base pour tous les contrôleurs de table.

image