Une API rapide et sans FOSRest

Avec une utilisation de plus en plus intensive des technologies frontend, les API sont les pierres angulaires du développement web moderne. Quels que soient les langages utilisés, de nombreuses solutions existent avec pour chacune un niveau de complexité et de fonctionnalités différentes. Leurs succès sont indéniables et dessinent l'avenir logique de beaucoup de frameworks backend. Dans l'écosystème Symfony, le bundle FosRest se taille la part du Lion des implémentations API. Mais est-il possible de faire sans ?

Le développement d'une API est stratégique à plusieurs égards. Il est d'abord la clé de voûte de votre projet en réalisant la transmission des données à d'autres services internes et externes.

Il permettra aussi de contrôler l'utilisation des informations et les droits d'accès. Enfin, il décloisonne la couche d'affichage de la couche des données pour offrir un plus grand confort de développement et d'évolutivité.

Surement plus qu'à l'habitude, une attention particulière doit être portée à la sécurité, la scalabilité et la documentation de votre code.

Même si une majorité des API fonctionnent en JSON, dans cet article nous présentons une API x-form. Il est très facile de transposer ce code pour qu'il fonctionne directement avec du JSON.

API et FosRestBundle ?

Nous allons vous présenter dans cet article une voie différente de FosRestBundle pour le développement de votre API. Loin de penser que ce bundle est inutile ou mal adapté, ils nous a semblé intéressant de mettre de côté certaines abstractions pour aller à l'essentiel.

Ne nous trompons pas, FosRestBundle est aujourd'hui la solution dans Symfony et les ressources pour apprendre sont multiples et simples. Bien sûr, d'autres solutions intéressantes existent comme API platform.

Toutes ces solutions vous proposent par défaut de mapper vos entités et d'automatiser la génération d'un "CRUD API". Mais pour un petit projet qui présente des cas particuliers, est-il possible de développer plus efficacement et rapidement from scratch ?

Symfony propose nativement d'excellentes solutions pour réaliser sans trop de difficulté un projet API. C'est partit !

Le point de départ d'une API : la documentation

Mais pourquoi commencer par ça ? Ne faut-il pas faire la doc en fin de développement ?

Dans notre cas, non, car la documentation sera votre guide pour constamment vérifier le fonctionnement mais aussi la logique de votre implémentation.

Il faut se souvenir que si vous serez le premier utilisateur de votre interface, une API doit de par sa nature être facile d'utilisation par d'autres développeurs. Ainsi, l'utilisation d'une sandbox à l’intérieur même de la documentation vous permettra d'être plus efficace et sera un allié précieux en debug.

Ici, nous utiliserons NelmioApiDocBundle qui fonctionne avec ApiDoc. Attention, la version 3 (master) intègre maintenant swagger.

Après avoir installé le bundle et paramétré la configuration, vous allez pouvoir commencer à rajouter les annotations spécifiques sur chacun de vos controllers API.

 /**
     * @ApiDoc(
     *  section="Project",
     *  authentication=true,
     *  headers={
     *      {
     *          "name"="X-Auth-Token",
     *          "description"="Authorization key"
     *      }
     *  },
     *  statusCodes={
     *         200="Returned when successful",
     *         401="Returned when the user is not authorized",
     *  },
     *  description="Return a collection of projects"
     * )
     * @Route("/", name="app_api_projects", defaults={"_format": "json"})
     * @Method("GET")
     */
  • section permet de regrouper des endpoints d'une même catégorie.
  • authentification qui indique si cette route est publique ou non
  • headers va définir les paramètres des entêtes (typiquement le token d'authentification)
  • statusCodes décrit les codes erreurs attendus

Il est aussi nécessaire de préciser la/les méthodes du endpoint. Autre annotation essentielle : input pour, par exemple, utiliser un formulaire comme référence des arguments du endpoint.

Pour le reste, je vous laisse découvrir la doc.

API et gestion des accès

À cette étape, vous devez déterminer si votre API sera utilisée par appel serveur (ce qui vous permettra de me protéger par whitelist ip) ou directement depuis​ un frontend connecté (avec potentiellement un token).

Nous allons dans un premier temps créer un provider spécifique qui va gérer le token de l'utilisateur qui sera nécessaire pour accéder aux endpoints.

Dans notre cas, nous souhaitons que notre API soit hybride avec des parties publiques et d'autres accessibles uniquement aux utilisateurs connectés.

Un token sera donc attribué à l'utilisateur lors de la connexion et devra être passé dans le header de chaque requête par l'attribut 'X-Auth-Token'. Une​ gestion de l'expiration de ce token sera mis en place.

Si le token passé est correct, l'utilisateur sera connecté au backend, en revanche, si il est invalide, un code 401 sera renvoyé.

Vous devez d'abord créer un "authentificator" qui va réaliser la vérification du token et appeler le provider pour récupérer l'utilisateur.

AuthTokenAuthenticator.php

namespace AppBundle\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;

use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\HttpUtils;

class AuthTokenAuthenticator implements , AuthenticationFailureHandlerInterface
{
    const TOKEN_VALIDITY_DURATION = 12 * 3600;

    protected $httpUtils;

    public function __construct(HttpUtils $httpUtils)
    {
        $this->httpUtils = $httpUtils;
    }

    public function createToken(Request $request, $providerKey)
    {
        $authTokenHeader = $request->headers->get('X-Auth-Token');

        if (!$authTokenHeader) {
            throw new UnauthorizedHttpException('X-Auth-Token header is required');
        }

        return new PreAuthenticatedToken(
            'anon.',
            $authTokenHeader,
            $providerKey
        );
    }

 public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
    {
        if (!$userProvider instanceof AuthTokenUserProvider) {
            throw new \InvalidArgumentException(
                sprintf(
                    'The user provider must be an instance of AuthTokenUserProvider (%s was given).',
                    get_class($userProvider)
                )
            );
        }

        $authTokenHeader = $token->getCredentials();
        $authToken = $userProvider->getAuthToken($authTokenHeader);

        if (!$authToken || !$this->isTokenValid($authToken)) {
            throw new BadCredentialsException('Invalid authentication token');
        }

        $user = $authToken->getUser();
        $pre = new PreAuthenticatedToken(
            $user,
            $authTokenHeader,
            $providerKey,
            $user->getRoles()
        );

        $pre->setAuthenticated(true);

        return $pre;
    }

    public function supportsToken(TokenInterface $token, $providerKey)
    {
        return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
    }

    private function isTokenValid($authToken)
    {
        return (time() - $authToken->getCreatedAt()->getTimestamp()) < self::TOKEN_VALIDITY_DURATION;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {

        throw $exception;
    }
}

AuthTokenUserProvider.php

namespace AppBundle\Security;

use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Doctrine\ORM\EntityRepository;

class AuthTokenUserProvider implements UserProviderInterface
{
    protected $authTokenRepository;
    protected $userRepository;

    public function __construct(EntityRepository $authTokenRepository, EntityRepository $userRepository)
    {
        $this->authTokenRepository = $authTokenRepository;
        $this->userRepository = $userRepository;
    }

    public function getAuthToken($authTokenHeader)
    {
        return $this->authTokenRepository->findOneByValue($authTokenHeader);
    }

    public function loadUserByUsername($email)
    {
        return $this->userRepository->findByEmail($email);
    }

    public function refreshUser(UserInterface $user)
    {
        // Le systéme d'authentification est stateless, on ne doit donc jamais appeler la méthode refreshUser
        throw new UnsupportedUserException();
    }

    public function supportsClass($class)
    {
        return 'AppBundle\Entity\User' === $class;
    }
}

déclarer les services (sans l'autowiring):

auth_token_user_provider:
        class: AppBundle\Security\AuthTokenUserProvider
        arguments: ["@auth_token_repository", "@user_repository"]
        public:    false

auth_token_authenticator:
        class:     AppBundle\Security\AuthTokenAuthenticator
        arguments: ["@security.http_utils"]
        public:    false

et ensuite déclarer le provider et l'associer dans un firewall du security.yml :

providers:
        auth_token_user_provider:
            id: auth_token_user_provider

firewalls:
       public:
            pattern: ^(/api/users/token|/api/users/retrieve_password)
            security: false 

        api:
            pattern: ^/api/
            stateless: true
            simple_preauth:
                authenticator: auth_token_authenticator
            provider: auth_token_user_provider
            anonymous: ~

Dans ce cas nous décidons de protéger les urls avec le préfixe /api/ par le provider auth_token_user_provider Nous avons aussi rajouter une zone publique pour permettre à certains endpoints de fonctionner sans token.

Pour attribuer un token aux utilisateurs, il est maintenant nécessaire de créer un endpoint spécifique qui va vérifier l'identifiant et le mot de passe pour ensuite attribuer le token.

 /**
     * @ApiDoc(
     *  section="Token",
     *  description="Return user token",
     *  parameters={
     *      {"name"="email", "dataType"="string", "required"=true},
            {"name"="password", "dataType"="password", "required"=true}
     *  }
     * )
     * @Route("/token", name="app_api_users_token")
     * @Method("POST")
     */
    public function postAuthTokensAction(Request $request)
    {
        $em = $this->get('doctrine.orm.entity_manager');
        $serializer = $this->get("serializer");

        $user = $em->getRepository('AppBundle:User')->findOneByEmail($request->get('email'));

        if (!$user) { 
            throw $this->createAccessDeniedException('Invalid user');
        }

        $encoder = $this->get("security.encoder_factory")->getEncoder($user);

        $isPasswordValid = $encoder->isPasswordValid($user->getPassword(), $request->get('password'), $user->getSalt());

        if (!$isPasswordValid) {
            throw $this->createAccessDeniedException('Invalid credentials');
        }

        $authToken = new AuthToken();
        $authToken->setValue(base64_encode(random_bytes(50)));
        $authToken->setCreatedAt(new \DateTime('now'));
        $authToken->setUser($user);

        $em->persist($authToken);
        $em->flush();

        return new Response($serializer->serialize($authToken, "json"));
    }

Dans cet exemple, nous utilisons le service de sérialisation de Symfony sans passer par JMSSerializer. Nous verrons l'implémentation de cette partie dans la suite de cet article.

La sérialisation

Le principe de la sérialisation est simple à comprendre. Dans Symfony, nous avons l'habitude de manipuler des entités ou des collections. La première étape est de normaliser les données pour aboutir à un Array puis de le sérialiser dans le format souhaité (JSON, XML, CSV....)

Nous allons donc créer un Normalizer qui va définir pour un type d'entité comment sera peuplé le tableau PHP :

namespace AppBundle\Serializer;

use AppBundle\Entity\AuthToken;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

class AuthTokenNormalizer implements NormalizerInterface
{
    /**
     * {@inheritdoc}
     */
    public function normalize($object, $format = null, array $context = array())
    {

        return [
            'user_id'   => $object->getUser()->getId(),
            'login' => $object->getUser()->getEmail(),
            'token' => $object->getValue(),
            'created_at' => $object->getCreatedAt()
        ];
    }

    /**
     * {@inheritdoc}
     */
    public function supportsNormalization($data, $format = null)
    {
        return $data instanceof AuthToken;
    }
}

On déclare le normalizer comme un service :

app.serializer.auth_token:
        class: AppBundle\Serializer\AuthTokenNormalizer
        public: false
        tags:
            - { name: serializer.normalizer }

Maintenant quand vous utiliserez le serializer, celui-ci utilisera le normalizer disponible pour l'entité qu'il doit traiter puis générera le bon flux en fonction du format demandé (dans ce cas du JSON).

$this->get("serializer")->serialize($authToken, "json");

À noter : vous pouvez passer dans le serializer un Array Collection ou une entité.

API et formulaires

Dans le cas d'insertion ou de mise à jour, il est essentiel de contrôler vos données entrantes. Symfony propose la notion d'asserts depuis les forms. Nous partons donc de cette base pour l'adapter au traitement API.

Le formulaire va être utilisé pour recevoir les données. En entrée, vous allez recevoir un flux JSON qu'il faudra juste passer dans votre formulaire pour réaliser le bind et vous permettre de tester les entrées et valider le traitement. Un simple decode du contenu de la request et le tour est joué.

 $form = $this->createForm(UserType::class);
        $serializer = $this->get("serializer");

        $form->submit($request->request->all(), false);

        if ($form->isSubmitted() && $form->isValid()) {
            $em = $this->container->get("doctrine.orm.default_entity_manager");
            $encoder = $this->container->get("security.encoder_factory")->getEncoder(new User());

            $user = $form->getData();

            $user->setRoles(array('ROLE_USER'));
            $user->setPassword(
                $encoder->encodePassword(
                    $user->getPlainPassword(),
                    $user->getSalt()
                    )
                );
            $em->persist($user);
            $em->flush();

            $authToken = $this->get('app.manager.user')->createToken($user);

            $this->container->get("app.manager.mail")->send($user, "user.new", []);

            return new Response($serializer->serialize($authToken, "json"));
        }
        else {
            $data['error'] = (string)$form->getErrors(true,false); 
        }

Attention toutefois, vous devrez supprimer la gestion du csrf dans les forms :

  * {@inheritdoc}
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle\Entity\User',
            'csrf_protection'   => false
        ));
        return;
    }

API from Scratch , facile ?

Bon.. cet article peut paraître long mais finalement vous aurez peu de choses à mettre en place pour que tout marche !

Et comme le dirait un célèbre youtubeur, à 100 poces bleus je vous parlerai de swagger.

Commentaires

Il n'y a actuellement aucun commentaire. Soyez le premier !

Articles liés