A présent que nous avons une API REST basique, nous allons nous attaquer à la vue xml afin d’exposer ou non des champs.
JMSSerializeBundle
Ce bundle que nous avons déjà installé va nous permettre d’exposer les données en tant qu’xml
L’entité Catégorie
<?php namespace AppCoreBundleEntity; use DoctrineORMMapping as ORM; use GedmoMappingAnnotation as Gedmo; use JMSSerializerAnnotation as Serializer; use SymfonyComponentValidatorConstraints as Assert; /** * Category * * @ORMTable(name="category") * @ORMEntity(repositoryClass="AppCoreBundleRepositoryCategoryRepository") * @SerializerExclusionPolicy("ALL") * @SerializerXmlRoot("category") */ class Category { /** * @var integer * * @ORMColumn(name="id", type="integer") * @ORMId * @ORMGeneratedValue(strategy="AUTO") * @SerializerXmlAttribute * @SerializerExpose */ private $id; /** * @var string * * @ORMColumn(name="title", type="string", length=255) * @SerializerExpose * @AssertNotBlank * @AssertLength(min=2, max=100) * @AssertRegex("/^[a-zA-ZáàâäãåçéèêëíìîïñóòôöõúùûüýÿæœÁÀÂÄÃÅÇÉÈÊËÍÌÎÏÑÓÒÔÖÕÚÙÛÜÝŸÆŒ._s-]+$/") */ private $title; /** * @GedmoSlug(fields={"title"}) * @ORMColumn(length=255, unique=true) */ private $slug;
Ici, je dis que, par défaut, je n’expose rien avec @SerializerExclusionPolicy(« ALL »)
Je définis aussi la clef du premier noeud d’une catégorie comme étant le terme « category »
@SerializerXmlRoot("category")
J’expose l’id et le titre, mais pas le slug: @SerializerExpose
Je définis l’id comme attribut de la clef avec @SerializerXmlAttribute
Le rendu xml de toutes les catégories
Quand j’appelle toutes les catégories, je suis ennuyé car j’ai les tags <result> et <entry>.
Pour changer cela, nous allons créer une classe appelée Categories.php, dans laquelle nous allons formater la vue.
Cette classe étendra une interface qui aura les méthodes getData.
<?php namespace App\ApiBundle\Representation; use JMS\Serializer\Annotation as Serializer; use Pagerfanta\Pagerfanta; /** * @Serializer\ExclusionPolicy("ALL") * @Serializer\XmlRoot("categories") */ class Categories implements RepresentationInterface { /** * @Serializer\Expose * @Serializer\Type("array<App\CoreBundle\Entity\Category>") * @Serializer\XmlList(inline=true, entry="category") * @Serializer\SerializedName("categories") */ private $data; public function __construct($data) { $this->data = $data; } public function getData() { return $this->data; } }
- J’indique que le tag root sera categories : @SerializerXmlRoot(« categories »)
- J’expose les data : @SerializerExpose
- J’indique de quel type seront les data : @SerializerType(« array<AppCoreBundleEntityCategory> »)
- J’indique que la clef de chaque catégorie est category : @SerializerXmlList(inline=true, entry= »category »)
- J’indique que la clef de la liste des catégories, en json, est categories : @SerializerSerializedName(« categories »)
Le categoryController
public function getCategoriesAction() { $categories = $this->get('app_core.repository.category')->getCategories(); return new Categories($categories); }
Notre méthode getCategoriesAction renvoie à présent new Categories. Nous allons donc avoir comme résultat cet objet qui aura comme attribut data, qui contiendra toutes les catégories:
Les paramFetchers
Il est tout à fait possible d’utiliser les paramFetchers pour exécuter des requêtes sur notre API. Ce sont des paramètres que l’on peut utiliser dans l’url, ou envoyer sous forme de filtres dans l’interface générée par nelmio.
Modification de notre GetCategoriesAction
/** * @Rest\Get("/", name="app_api_categories") * @Rest\QueryParam( * name="keyword", * requirements="[a-zA-Z0-9]+", * nullable=true, * description="The keyword to search for." * ) * @Rest\QueryParam( * name="order", * requirements="asc|desc", * default="asc", * description="Sort order (asc or desc)." * ) * @Doc\ApiDoc( * section="Categories", * resource=true, * description="Get the list of all categories.", * statusCodes={ * 200="Returned when successful", * } * ) */ public function getCategoriesAction(ParamFetcherInterface $paramFetcher) { $categories = $this->get('app_core.repository.category')->search( $paramFetcher->get('keyword'), $paramFetcher->get('order') ); return new Categories($categories); }
Il faut bien sûr créer la méthode search dans le repository:
/** * @param string $term * @param string $order * * @return \Traversable */ public function search($term, $order = 'asc') { $qb = $this ->createQueryBuilder('c') ->select('c') ->orderBy('c.title', $order) ; if ($term) { $qb ->where('c.title LIKE ?1') ->setParameter(1, '%' . $term . '%') ; } return $qb->getQuery()->execute(); }
La pagination de l’API
A présent, tout est pratiquement en place pour paginer l’API. Il faut pour cela rajouter une nouvelle couche pour encapsuler les résultats fournis par la classe Categories.php, et qui fera appel à pagerFanta, et le tour sera joué.
L’abstractRepository, étendu par tous les repositories
<?php namespace App\CoreBundle\Repository; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\QueryBuilder; use Pagerfanta\Adapter\DoctrineORMAdapter; use Pagerfanta\Pagerfanta; abstract class AbstractRepository extends EntityRepository { protected function paginate(QueryBuilder $qb, $limit = 20, $offset = 0) { $limit = (int) $limit; if (0 === $limit) { throw new \LogicException('$limit must be greater than 0.'); } $pager = new Pagerfanta(new DoctrineORMAdapter($qb)); $pager->setMaxPerPage((int) $limit); $pager->setCurrentPage(ceil(($offset + 1) / $limit)); return $pager; } }
Evolution de la fonction search du repository
/** * @param string $term * @param string $order * * @return \Traversable */ public function search($term, $order = 'asc', $limit = 20, $offset = 0) { $qb = $this ->createQueryBuilder('c') ->select('c') ->orderBy('c.title', $order) ; if ($term) { $qb ->where('c.title LIKE ?1') ->setParameter(1, '%' . $term . '%') ; } return $this->paginate($qb, $limit, $offset); }
A présent, nous retournons un pager, et non plus les résultats, et nous prenons en compte la limit et l’offset.
Evolution de la classe Categories
A présent, notre classe doit rajouter des métas: limit, current_items, total_items et offset, et prendre en argument non pas un tableau de catégories, mais une instance de pagerFanta:
<?php namespace App\ApiBundle\Representation; use JMS\Serializer\Annotation as Serializer; use Pagerfanta\Pagerfanta; /** * @Serializer\ExclusionPolicy("ALL") * @Serializer\XmlRoot("categories") */ class Categories implements RepresentationInterface { /** * @Serializer\Expose * @Serializer\XmlKeyValuePairs */ private $meta; /** * @Serializer\Expose * @Serializer\Type("array<App\CoreBundle\Entity\Category>") * @Serializer\XmlList(inline=true, entry="category") * @Serializer\SerializedName("categories") */ private $data; public function __construct(PagerFanta $data) { $this->data = $data; $this->addMeta('limit', $data->getMaxPerPage()); $this->addMeta('current_items', count($data->getCurrentPageResults())); $this->addMeta('total_items', $data->getNbResults()); $this->addMeta('offset', $data->getCurrentPageOffsetStart()); } public function addMeta($key, $value) { $this->meta[$key] = $value; } public function getData() { return $this->data; } public function getMeta($key) { return $this->meta[$key]; } }
Evolution du contrôleur
Nous devons à présent rajouter deux paramFetcher, limit et offset:
/** * @Rest\Get("/", name="app_api_categories") * @Rest\QueryParam( * name="keyword", * requirements="[a-zA-Z0-9]+", * nullable=true, * description="The keyword to search for." * ) * @Rest\QueryParam( * name="order", * requirements="asc|desc", * default="asc", * description="Sort order (asc or desc)." * ) * @Rest\QueryParam( * name="limit", * requirements="\d+", * default="20", * description="Max number of categories per page." * ) * @Rest\QueryParam( * name="offset", * requirements="\d+", * default="0", * description="The pagination offset." * ) * @Doc\ApiDoc( * section="Categories", * resource=true, * description="Get the list of all categories.", * statusCodes={ * 200="Returned when successful", * } * ) */ public function getCategoriesAction(ParamFetcherInterface $paramFetcher) { $repository = $this->get('app_core.repository.category'); $categories = $repository->search( $paramFetcher->get('keyword'), $paramFetcher->get('order'), $paramFetcher->get('limit'), $paramFetcher->get('offset') ); return new Categories($categories); }
A ce stade, nous n’avons pas encore la pagination, mais nous obtenons ceci en appelant notre API:
La vue paginée
Il est temps de créer la classe qui va mettre en place les métas de pagination en permettant de « naviguer » dans l’API:
<?php namespace App\ApiBundle\Representation; use FOS\RestBundle\View\View; use FOS\RestBundle\View\ViewHandlerInterface; use Pagerfanta\Pagerfanta; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class CategoriesViewHandler { private $viewHandler; private $urlGenerator; public function __construct(UrlGeneratorInterface $urlGenerator, ViewHandlerInterface $viewHandler) { $this->urlGenerator = $urlGenerator; $this->viewHandler = $viewHandler; } public function handleRepresentation(Categories $representation, array $params = array()) { $links = $this->getNavigationLinks($representation->getData(), $params); foreach ($links as $type => $url) { $representation->addMeta($type.'_page_link', $url); } $response = $this->viewHandler->handle(View::create($representation)); if ($total = $representation->getMeta('total_items')) { $response->headers->set('X-Total-Count', $total); $response->headers->set('Link', $this->getNavigationLinkHeader($links)); } return $response; } private function getNavigationLinks(Pagerfanta $pager, array $params = array()) { $page = $pager->getCurrentPage(); $limit = $pager->getMaxPerPage(); $links = []; if ($pager->getCurrentPage() > 1) { $links['first'] = $this->generateUrl('app_api_categories', array_merge($params, [ 'offset' => $this->getOffset(1, $limit), ])); } if ($pager->hasPreviousPage()) { $links['previous'] = $this->generateUrl('app_api_categories', array_merge($params, [ 'offset' => $this->getOffset($pager->getPreviousPage(), $limit), ])); } if ($pager->hasNextPage()) { $links['next'] = $this->generateUrl('app_api_categories', array_merge($params, [ 'offset' => $this->getOffset($pager->getNextPage(), $limit), ])); } if ($pager->getNbPages() != $page) { $links['last'] = $this->generateUrl('app_api_categories', array_merge($params, [ 'offset' => $this->getOffset($pager->getNbPages(), $limit), ])); } return $links; } private function getNavigationLinkHeader(array $links) { $items = []; foreach ($links as $type => $url) { $items[] = sprintf('<%s>; rel="%s"', $url, $type); } return implode(', ', $items); } private function getOffset($page, $limit) { return ($page - 1) * $limit; } private function generateUrl($route, array $params = array()) { return $this->urlGenerator->generate($route, $params, true); } }
N’oublions pas de déclarer le service correspondant:
<services> <service id="app_api.categories_view_handler" class="App\ApiBundle\Representation\CategoriesViewHandler"> <argument type="service" id="router"/> <argument type="service" id="fos_rest.view_handler"/> </service> </services>
Que fait ce service ?
La fonction handleRepresentation prend en compte des catégories (qui renvoie un objet Categories comportant les metas limit, current_items, total_items et offset, filtrées avec ces paramFetcher), ainsi que les paramFetchers passés en paramètre.
En fonction des résultats, la fonction getNavigationLinks renvoie les liens first, next, previous, last en modifiant l’offset. Les metas sont alors rajoutées à celles retournées par Categories. Et de nouvelles metas, ajoutées au header, sont injectées: X-total-Count et Link (qui est la concaténation des quatre liens first, next, previous et last).
Mise à jour de la fonction getCategoriesAction du contrôleur des catégories
public function getCategoriesAction(ParamFetcherInterface $paramFetcher) { $repository = $this->get('app_core.repository.category'); $categories = $repository->search( $paramFetcher->get('keyword'), $paramFetcher->get('order'), $paramFetcher->get('limit'), $paramFetcher->get('offset') ); return $this->get('app_api.categories_view_handler') ->handleRepresentation(new Categories($categories), $paramFetcher->all()) ; }
Comme d’habitude, le code se trouve sur github: https://github.com/jpsymfony/REST-BEHAT
2 comments
Vraiment merci pour tout ce boulot.
It’s great !
thanks!