PHP : Soyez sympa, autodocumentez vos webservices !

le 30/10/2013 par Gabriel Guillon
Tags: Software Engineering

Il est assez crispant de faire appel à un webservice et de se voir rétorquer '40x' ou '50x' ... sans plus d'explications. Bien sûr la doc sur le wiki ou sur le site n'est pas à jour et ne vous éclaire en rien sur ce code d'erreur, ou ne vous indique pas la faute (de frappe) que vous avez faite ou les paramètres manquant.

Je vous propose dans cet article d'être sympa avec les utilisateurs de votre webservice tout en étant vous-même plutôt fainéant : auto(ou presque)documentez vos webservices.

La méthode que je vous propose est celle ne nécessitant que très peu de code supplémentaire, c'est à dire que je ne vais pas utiliser de framework additionnel tel que Swagger. Appelez ça la méthode artisanale, v0.1, ou l'art hache.

J'ai implémenté ça sur des webservices développés sur un de mes serveurs, pour mes besoins personnels, à la mode MVC et REST mais sans framework. Ce petit projet fait 1,2kloc, TU inclus, et est destiné à tourner sur un Raspberry Pi.

L'implémentation devrait être facile à répliquer, et je suis en train de le faire chez mon client (qui a beaucoup de webservices, dont la doc obsolète est dans un wiki *ahum*)

Deux approches :

La première pourrait être que vos webservices répondent à ceci :

[seti@home]~$ curl http://ws/plats/infos

L'approche se défend : mentalement, si un webservice (ici plats) vous parle mal il semble logique de le rappeler avec la même méthode en lui demandant des infos.

Toutefois, cela fait une nouvelle ressource GET, ce qui n'est pas le plus élégant et le plus RESTful. Et en vérité elle occasionne un peu plus de code que la seconde approche : utiliser la méthode http OPTIONS, qui semble presque faite pour ça.

[seti@home]~$ curl -X OPTIONS http://ws/plats

Moins classe et plus long à taper, mais plus respectueux des standards. La réponse serait quelque chose comme ça :

* get :
Recherche un plat

@params classe (dessert, ...)
Eventuellement
@params critère (passé en ilike %critère%)

Exemple : curl http://ws/plats/Dessert/phro

* post :
Ajoute un plat

@params classe (Dessert, ...)
@params nom

Exemple : curl http://ws/plats -d name="Ile flottant" -d classe="Dessert"

L'endroit le plus adéquat pour mettre la doc des webservices est à mon sens dans le code, au plus proche de l'endroit où ils sont implémentés : en tant que commentaire des classes ou méthodes qui les implémente. Chez moi, ces webservices sont portés par une classe modèle. Dans cet exemple : models/Plats.php, dans laquelle chaque méthode implémente le webservice à rendre pour une méthode http donnée ('get', 'post', 'put', 'trace', ...)

Dans models/Plats.php j'ai donc ceci :

class Plats extends Ws {

...

/**
* Recherche un plat
*
* @params classe (dessert, ...)
* Eventuellement
* @params critère (passé en ilike %critère%)
*
* Exemple : curl http://ws/plats/Dessert/phro
*
*/
public function get($class,$name=null)
{
...

/**
* Ajoute un plat
*
* @params classe (Dessert, ...)
* @params nom
*
* Exemple : curl http://ws/plats -d name="Ile flottant" -d classe="Dessert"
*/
public function post($class,$name)
{...

Réfléchissons

Le secret est d'utiliser la réflexion, disponible nativement dans PHP 5. La réflexion permet au code d'en savoir plus sur lui-même. Celle de PHP a en plus la possibilité d'afficher de la documentation si celle-ci est entre '/** */'

Je souhaite implémenter la réflexion de la manière la plus simple et moins intrusive possible, c'est à dire en touchant au moins possible de fichiers (à part pour mettre de la documentation, bien sûr).

L'API s'utilise ainsi :

$refClass=new ReflectionClass(MyClass);

Notez que la classe à réfléchir peut être passée en tant qu'objet ... ou en tant que chaîne de caractère

Pour une méthode :

$refMethod=new ReflectionMethod(MyClass,method);

De la même manière, la méthode peut être passée en tant qu'objet ou chaîne de caractère.

Et pour afficher les commentaires de la méthode :

$refMethod->getDocComment();

Ajoutez à ceci que ReflectionClass a une méthode permettant de lister toutes les méthodes de la classe :

$refClass->getMethods();

J'ai maintenant toutes les billes pour afficher les commentaires des méthodes de toutes mes classes, donc de mes webservices.

Contrôlons

Le premier endroit où mon code sait quel webservice est appelé avec quelle méthode, à part dans index.php, est dans le contrôleur (méthode get ou getAction si vous utilisez Zend, mais pour mon si petit projet je fais du routage à la main, dans index.php)

Mon exemple décrète que curl -X OPTIONS http://ws/plats doit retourner les infos du webservice plats. Il suffit donc d'implémenter une réponse à cette méthode dans chaque contrôleur, le dispatcheur se chargera de l'appeler.

Comme tous mes contrôleurs héritent de la classe WsController, il est possible d'un coup d'un seul d'implémenter l'autodoc pour tous les webservices !

Dans controllers/WsController.php

class WsController
{
    public function options($class=array())
    {
        $infos=array();
        // Le dispatch m'envoie un tableau ; j'en veux pas.
        // Si rien ne m'est passé, j'ai un tableau ; j'en veux pas
        if(!is_string($class))
            $class=get_class($this);
        // Transformation de 'PlatsController' en 'Plats'
        $classes=new ReflectionClass(preg_replace('/Controller/','',$class));
        foreach($classes->getMethods() as $id => $method) {
            // La doc de toutes les classes non finales, et pas pour __construct (des fois qu'elle soit surchargée)
            if ($method->getName() != '__construct' and !$method->isFinal())
            {
                $infos[$method->getName()]=$method->getDocComment();
            }
        }
        return array('infos'=>$infos);
    }

La méthode prend un tableau en paramètre afin de pouvoir la tester en test unitaires (et demander la doc de n'importe quelle classe). Si rien ne lui est passé, elle affichera la doc des méthodes de la classe modèle correspondant à la classe contrôleur qui hérite d'elle : si PlatsController hérite d'elle, options affichera la doc de la classe Plats.

La méthode options n'est pas finale, ce qui veut dire que le contrôleur peut la modifier s'il le souhaite.

Voyons

De la même manière, la vue a une classe par méthode http, appelée par le dispatcheur, et toutes les vues héritent de la même classe mère WsView

class WsView
{
    public function options($data)
    {
        $data=$data['infos'];
        $s='';
        $allow=array();
        foreach($data as $method => $infos)
        {
            // Fait sauter les /** * */
            $infos=trim(preg_replace('#\s*/?\*+/?\s*#',"\n",$infos));
            $s.="\n* $method : \n$infos\n";
            $allow[]=$method;
        }
        // Pour la RFC (et avoir en un coup d'oeil les méthodes dispo)
        $header='Allow: '.strtoupper(join(', ',array_values($allow)));
        return $this->_echo($s,$header);
    }
...

La méthode _echo affiche différemment si le code est lancé sous TU ou si la sortie est destinée à un "navigateur" (cURL, Firefox, ...)

Finalement, voici un schéma simplifié de ma petite application :

Mon appli Ws

Concluons

Le code 'travailleur' n'est logé qu'en deux endroits :

  • La classe mère de tous les contrôleurs.
  • La classe mère de toutes les vues.

Mes classes modèles ne sont pas impactées, et en deux petites fonctions j'ai rendu mes webservices (plus ou moins)autodocumentés !

Toutefois, si vous regardez la RFC, vous verrez qu'il manque quelques étapes afin de rendre mon code conforme :

  • Gestion de Content-Length et Transfer-Encoding afin de répondre avec un Content-Type approprié.
  • Gestion de '*' en URI.

Que pensez-vous de mon approche ?

PS : le code de cet exemple est téléchargeable ici