Tables de données avec Symfony, Hateoas et AngularJS
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émentstd
correspond au nom des colonnes dans les paramètres de la tableorderBy
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.