Mettre en place une API REST 3ème partie

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

get-category-xml

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>.

get-categories-xml

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:

get-categories-list-xml

get-categories-list-json

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();
    }

get-categories-list-search-xml

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:

get-categories-list-search-meta-xml

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())
        ;
    }

 

get-categories-list-search-meta-paginated-xml

Comme d’habitude, le code se trouve sur github: https://github.com/jpsymfony/REST-BEHAT

Rédigé par

2 comments

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.