Mettre en place une API avec Zend_Json_Server

Vous avez une jolie application Web et vous souhaitez fournir une API ? Rien de plus simple avec Zend_Json_Server. Ce composant du Zend Framework permet de mettre en place une API utilisant le protocole JSON-RPC.

Créer une interface

1ère étape, on va créer une ou plusieurs classes qui vont servir d'interface. Pour ce tutoriel, on va reprendre le fameux exemple de la calculatrice :

/library/MyApp/Api/Calculator.php

<?php
class MyApp_Api_Calculator
{
    
/**
     * Retourne la division de deux variables
     *
     * @param  int $x
     * @param  int $y
     * @return float
     */
    
public function divide($x$y)
    {
        if (
$y == 0) {
            throw new 
Exception('Divide by zero', -32100);
        }
        return 
$x $y;
    }
}
?>

Pour simplifier, je n'ai inclut qu'une seule méthode, la division qui offre l'avantage qu'on peut facilement lui faire générer une exception en lui passant zéro dans le deuxième argument. Pour le code associé à l'exception, il faut choisir un nombre compris entre -32768 et -32100. Cette plage de valeur est réservée pour les erreurs survenues au niveau de l'application. Ce qui est important, c'est que les arguments et la valeur de retour soient décrits dans les commentaires, car Zend_Json_Server va utiliser ces informations pour produire la carte de description des services (Service Mapping Description ou SMD, voir plus loin dans cet article).

Mise en place du serveur

Pour un serveur RPC, on n'a pas besoin du système MVC du Zend Framework. En effet, Zend_Json_Server va faire office de routeur et les classes d'interfaces vont servir de contrôleurs. On va donc placer notre fichier api.php directement dans le répertoire public de notre application. Si les règles de réécriture de votre serveur Web sont correctes, ce fichier sera alors exécuté sans passer par index.php.

/public/api.php

<?php
// Chemin du répertoire application
define('APPLICATION_PATH'realpath(dirname(__FILE__) . '/../application'));

// Environnement
define('APPLICATION_ENV', (getenv('APPLICATION_ENV') ? getenv('APPLICATION_ENV') : 'production'));

// On s'assure que la bibliothèque est bien dans le chemin d'inclusion
set_include_path(realpath(APPLICATION_PATH '/../library'));

// Initialisation des ressources
require_once 'Zend/Application.php';
$application = new Zend_Application(
    
APPLICATION_ENV,
    
APPLICATION_PATH '/configs/application.ini'
);

$application->getBootstrap()->bootstrap('Db');

// Désactive l'affichage des messages d'erreur dans tous les environnements
// car cela entrainerait le renvoi de données invalides
ini_set('display_errors''off');

$server = new Zend_Json_Server();
$server->setClass('MyApp_Api_Calculator');
$server->handle();
?>

On a donc une séquence de bootstrap classique où on n'initialise que les ressources nécessaires. Ici, j'ai chargé uniquement la base de données. Ensuite, on crée un serveur Zend_Json_Server, on lui passe la ou les classes qui composent nos services et on lui demande de traiter la requête avec l'appel de la méthode handle.

Un client pour notre API

Bizarrement, le Zend Framework ne dispose pas encore d'un composant Zend_Json_Client susceptible de se connecter à notre serveur. J'ai donc fait une petite recherche avec mon ami Google et j'ai trouvé une ébauche de composant proposé par Kevin Nuut. Je l'ai modifié pour lui ajouter le support de l'identification HTTP et du SMD. J'ai également amélioré un peu la gestion des erreurs.


<?php
/**
 * Client JSON-RPC 2.0
 *
 * @author Kevin Nuut
 * @link http://framework.zend.com/issues/browse/ZF-7044
 *
 * @author Maxence Delannoy
 * @link http://wiip.fr
 */
class Wiip_Json_Client
{
    protected 
$_client;
    protected 
$_id;
    protected 
$_smd;

    
/**
     * Constructor
     *
     * @param string $uri
     * @param int $id
     */
    
public function __construct($uri$id 1)
    {
        if (empty(
$id) or !(is_int($id))) {
            throw new 
Exception('The request identifier must be an integer not null');
        }
        
$this->_client = new Zend_Http_Client($uri);
        
$this->_id     = (int) $id;
    }

    
/**
     *
     * @param string $name
     * @param mixed $arguments
     * @return mixed
     */
    
public function __call($name$arguments)
    {        
        if (isset(
$this->_smd)) {
            
// Check if method name is in SMD
            
if (!isset($this->_smd['services'][$name])) {
                throw new 
Exception(
                    
'Method ' $name ' not found',
                    -
32601
                
);
            }
            
            
// Check parameters
            
foreach($arguments as $pos => $arg) {
                
$paramDef $this->_smd['services'][$name]['parameters'][$pos];
                if (
gettype($arg) != $paramDef['type']) {
                    throw new 
Exception(
                        
'Invalid param (type of ' $paramDef['name']
                        . 
' should be ' $paramDef['type'] . ')',
                        -
32602
                    
);
                }
            }
        }

        
$setup = array(
            
'method' => $name,
            
'params' => $arguments,
            
'id'     => ++$this->_id
        
);

        
$json Zend_Json::encode($setup);
        
$this->_client->setRawData($json);
        
$response $this->_client->request('POST');

        if (
$response->isError()) {
            throw new 
Exception(
                
$response->getStatus() . ' ' $response->getBody()
            );
        }

        
$responseBody $response->getBody();

        
$jsonRet  Zend_Json::decode($responseBody);
        if (
$jsonRet == null) {
            throw new 
Exception('Response parse error: ' $responseBody);
        }

        if (isset(
$jsonRet['id'])) {
            if (
$jsonRet['id'] == $this->_id) {
                if (isset(
$jsonRet['result'])) {
                    return 
$jsonRet['result'];
                } elseif (isset(
$jsonRet['error'])) {
                    throw new 
Exception(
                        
$jsonRet['error']['message'],
                        
$jsonRet['error']['code']
                    );
                } else {
                    throw new 
Exception('Invalid response: ' $responseBody);
                }
            } else {
                throw new 
Exception(
                    
'Invalid response ID #'
                    
$this->_id ' != #'
                    
$jsonRet['id']
                );
            }
        } else {
            throw new 
Exception('No response ID in response');
        }
    }

    
/**
     * Fetch the Service Mapping Description (SMD) from the server
     *
     * @param string $smdUri
     * @return array
     */
    
public function getSmd($smdUri null)
    {
        if (isset(
$smdUri)) {
            
// Replace URI for this request
            
$rpcUri $this->_client->getUri();
            
$this->_client->setUri($smdUri);
        }

        
$response $this->_client->request('GET');
        
$this->_smd Zend_Json::decode($response->getBody());
        
        
// Restore URI
        
if (isset($smdUri)) {
            
$this->_client->setUri($rpcUri);
        }

        return 
$this->_smd;
    }

    
/**
     * Set the username and the password for authentification
     *
     * @param string $user
     * @param string $password
     */
    
public function setAuth($user$password)
    {
        
$this->_client->setAuth($user$password);
    }
}
?>

Pour utiliser ce composant, vous pouvez l'enregistrer dans /library/Wiip/Json/Client.php. N'hésitez pas à changer le préfixe si vous voulez l'intégrer dans votre propre bibliothèque. Attention, il n'a pas été testé avec la version 1.0 de JSON-RPC.

Petit test

Et voici un petit script CLI qui appelle notre méthode divide et affiche son résultat :

/cli/test-api.php

<?php
#!/usr/bin/php
<?php
// Chemin du répertoire application
define('APPLICATION_PATH'realpath(dirname(__FILE__) . '/../application'));

// On s'assure que la bibliothèque est bien dans le chemin d'inclusion
set_include_path(realpath(APPLICATION_PATH '/../library'));

require 
'Zend/Loader/Autoloader.php';
$autoloader Zend_Loader_Autoloader::getInstance();
// Charge l'espace de nom Wiip pour Wiip_Json_Client
$autoloader->registerNamespace('Wiip_');

$myApp = new Wiip_Json_Client('http://mondomaine.tld/api.php');

echo 
"\n-- Division --\n\n";
echo 
$myApp->divide(15); // Affiche float(0.2)
echo "\n";

echo 
"\n-- Division par zero --\n\n";
echo 
$myApp->divide(10); // Affiche Exception: Divide by zero (code -32100)
echo "\n";
?>

Un peu d'info sur le protocole

Pour appeler la fonction distante, notre client génère une requête POST contenant un objet JSON ressemblant à ceci :

{"jsonrpc": "2.0", "method": "divide", "params": [1, 5], "id": 1}

On trouve la version du protocole utilisé (ici la version 2.0), le nom de la méthode et éventuellement des arguments sous la forme d'un tableau, et un ID qui est incrémenté à chaque requête.

Le serveur répond alors avec les données suivantes :

{"jsonrpc": "2.0", "result": 0.2, "id": 1}

Le serveur renvoie la version du protocole ainsi que l'ID de la requête. Le résultat de la fonction se trouve dans le membre result.

Si l'appel de la fonction entraine une erreur comme dans le cas de notre division par zéro, le serveur renvoie ce genre de données :

{"error":{"code":-32100,"message":"Divide by zero","data":null},"id":"5"}

Pour plus d'information sur JSON-RPC, vous pouvez consulter ce document.

Retrouver la carte des services

Le serveur JSON-RPC peut fournir une carte des services qui décrit les différentes fonctions disponibles. C'est en fait un objet JSON qui liste toutes les méthodes avec leurs paramètres et leur valeur de retour. On y trouve aussi la version du protocole utilisé, le type de transport... etc. Zend_Server_Json est capable de générer automatiquement cette carte en utilisant l'API Reflection de PHP. Pour ajouter cette fonctionnalité à notre serveur, il suffit d'ajouter le code suivant juste avant l'appel à la méthode handle de notre serveur :

<?php
[..]
if (
$_SERVER['REQUEST_METHOD'] == 'GET') {
    
// Indicate the URL endpoint and the JSON-RPC version used
    
$server->setTarget($_SERVER['PHP_SELF'])
           ->
setEnvelope(Zend_Json_Server_Smd::ENV_JSONRPC_2);

    
// Return the SMD to the client
    
header('Content-Type: application/json');
    echo 
$server->getServiceMap();
    return;
}
?>

Pour obtenir le SMD, il suffit donc d'effectuer une requête GET sur l'adresse /api.php

Dans mon composant Wiip_Json_Client, j'ai une méthode getSmd qui permet de récupérer le SMD. Si vous l'appelez avant d'exécuter vos appels distants, ces derniers seront contrôlés avant que la requête ne soit effectivement envoyée au serveur.

Identification

Le plus souvent, votre API demandera à ce que l'utilisateur s'identifie pour protéger l'accès à certaines ressources ou pour éventuellement fixer des limites à son utilisation. On peut utiliser l'identification HTTP Basic et Zend_Auth pour cela :

<?php
[..]

// On initialise la ressource Db pour pouvoir accéder à la base de données
$application->bootstrap(
    array(
        
'Autoloader',
        
'Db'
    
)
);

// On vérifie que l'auteur est bien identifié. Si ce n'est pas le cas, on renvoie
// un code 401
if (empty($_SERVER['PHP_AUTH_USER'])) {
    
// Pas d'information d'identification, on interdit l'accès
    
header('HTTP/1.1 401 Unauthorized');
    die(
'Unauthorized');
} else {
    
// On vérifie les identifiants
    
$users = new Wiip_Model_Users();
    
$authAdapter = new Zend_Auth_Adapter_DbTable(
         
$users->getAdapter(),
         
$users->info('name'),
         
'user',
         
'password',
         
'MD5(?)'
    
);
    
$authAdapter->setIdentity($_SERVER['PHP_AUTH_USER']);
    
$authAdapter->setCredential($_SERVER['PHP_AUTH_PW']);
    
$result $auth->authenticate($authAdapter);
    if (!
$result->isValid()) {
        
header('HTTP/1.1 401 Unauthorized');
        die(
'Unauthorized');
    }
}

// On peut continuer et créer l'objet Zend_Json_Server
[..]
?>

Ici j'utilise l'adaptateur Zend_Auth_Adapter_DbTable pour vérifier les informations de connexion par rapport à une table de base de données.

Pour accéder à présent à l'API, il faut définir un nom d'utilisateur et un mot de passe au niveau du client avec la méthode setAuth :

<?php
$myApp
->setAuth('NomUtilisateur''MotDePasse');
?>

Commentaires

Bonjour,

Excellent choix que json-rpc, même par rapport à xml-rpc.

A noter que jusqu'à trés récemment le serveur json-rpc de ZF était buggué, cf http://framework.zend.com/issues/browse/ZF-5916
C'est heureusement corrigé, au moins sur le svn (7 mois d'attente quand même...).

Pour ceux qui auraient besoin d'un client en python: http://www.desfrenes.com/blog/post/python-web2py-et-services-en-json-rpc

et pour ceux qui voudraient faire du json-rpc de façon beaucoup (mais alors beaucoup) plus simple qu'avec ZF: http://www.desfrenes.com/blog/post/python-web2py-et-services-en-json-rpc

;-)

Personnellement je trouve que ça fait beaucoup de code à écrire pour faire une API même simple... Je préfère de loin utiliser toute l'API Restful de Symfony pour faire ce genre de choses en trois coups de cuiller à pot.

Je ne vois pas ce que vous trouvez de compliqué là dedans. Si j'enlève le code de bootstrap, code nécessaire pour n'importe quel framework/langage et le code pour obtenir le SMD, on peut exposer une classe avec simplement les lignes de code suivantes :


$server = new Zend_Json_Server();
$server->setClass('MaClasse');
$server->handle();

Pour une api REST avec le ZF > 1.9, vous pouvez consulter l'article suivant : http://techchorus.net/create-restful-applications-using-zend-framework

Argh, je me démène avec la release 2 d'ovh, je ne peux rien installer dessus... Moralité je prendrai une debian la prochaine fois, bien fait pour moi !
pompe a chaleur

Je vois pas trop le rapport avec le post, mais une release 2 on peut la déverrouiller. Il faut bidouiller dans la configuration de Gentoo (je ne me souvient plus exactement de la manip., mais c'est possible).

Ajouter un commentaire