Création de l'API REST pour les projets PHP hérités
Publié: 2022-03-11Construire ou concevoir une API REST n'est pas une tâche facile, en particulier lorsque vous devez le faire pour des projets PHP hérités. Il existe aujourd'hui de nombreuses bibliothèques tierces qui facilitent la mise en œuvre d'une API REST, mais leur intégration dans les bases de code héritées existantes peut être plutôt décourageante. Et, vous n'avez pas toujours le luxe de travailler avec des frameworks modernes, tels que Laravel et Symfony. Avec les projets PHP hérités, vous pouvez souvent vous retrouver quelque part au milieu de frameworks internes obsolètes, exécutés sur d'anciennes versions de PHP.
Dans cet article, nous examinerons certains défis courants liés à la mise en œuvre d'API REST à partir de zéro, quelques façons de contourner ces problèmes et une stratégie globale pour créer des serveurs d'API basés sur PHP personnalisés pour les projets hérités.
Bien que l'article soit basé sur PHP 5.3 et supérieur, les concepts de base sont valables pour toutes les versions de PHP au-delà de la version 5.0, et peuvent même être appliqués à des projets non-PHP. Ici, nous ne couvrirons pas ce qu'est une API REST en général, donc si vous ne la connaissez pas, assurez-vous de la lire d'abord.
Pour vous faciliter la tâche, voici une liste de certains termes utilisés tout au long de cet article et leurs significations :
- Serveur API : application REST principale au service de l'API, ici écrite en PHP.
- Point de terminaison API : une « méthode » principale avec laquelle le client communique pour effectuer une action et produire des résultats.
- URL du point de terminaison de l'API : URL par laquelle le système backend est accessible au monde entier.
- Jeton API : identifiant unique transmis via des en-têtes HTTP ou des cookies à partir desquels l'utilisateur peut être identifié.
- App : application cliente qui va communiquer avec l'application REST via des endpoints API. Dans cet article, nous supposerons qu'il est basé sur le Web (bureau ou mobile), et qu'il est donc écrit en JavaScript.
Étapes initiales
Modèles de chemin
L'une des toutes premières choses que nous devons décider est à quel chemin d'URL les points de terminaison de l'API seront disponibles. Il existe 2 façons populaires :
- Créez un nouveau sous-domaine, tel que api.example.com.
- Créez un chemin, tel que example.com/api.
En un coup d'œil, il peut sembler que la première variante est plus populaire et attrayante. En réalité, cependant, si vous construisez une API spécifique à un projet, il pourrait être plus approprié de choisir la deuxième variante.
L'une des raisons les plus importantes derrière l'adoption de la deuxième approche est que cela permet aux cookies d'être utilisés comme moyen de transfert d'informations d'identification. Les clients basés sur un navigateur enverront automatiquement les cookies appropriés dans les requêtes XHR, éliminant ainsi le besoin d'un en-tête d'autorisation supplémentaire.
Une autre raison importante est que vous n'avez rien à faire concernant les problèmes de configuration ou de gestion des sous-domaines où les en-têtes personnalisés peuvent être supprimés par certains serveurs proxy. Cela peut être une épreuve fastidieuse dans les projets hérités.
L'utilisation de cookies peut être considérée comme une pratique "non RESTful", car les requêtes REST doivent être sans état. Dans ce cas, nous pouvons faire un compromis et transmettre la valeur du jeton dans un cookie au lieu de la transmettre via un en-tête personnalisé. En fait, nous utilisons les cookies comme un simple moyen de transmettre directement la valeur du jeton au lieu du session_id. Cette approche pourrait être considérée comme sans état, mais nous pouvons la laisser à vos préférences.
Les URL de point de terminaison d'API peuvent également être versionnées. De plus, ils peuvent inclure le format de réponse attendu en tant qu'extension dans le nom du chemin. Bien que ceux-ci ne soient pas critiques, en particulier pendant les premières étapes du développement de l'API, à long terme, ces détails peuvent certainement être payants. Surtout lorsque vous devez implémenter de nouvelles fonctionnalités. En vérifiant quelle version le client attend et en fournissant le format nécessaire pour la rétrocompatibilité peut être la meilleure solution.
La structure de l'URL du point de terminaison de l'API peut se présenter comme suit :
example.com/api/${version_code}/${actual_request_path}.${format}
Et, un vrai exemple :
example.com/api/v1.0/records.json
Routage
Après avoir choisi une URL de base pour les points de terminaison de l'API, la prochaine chose que nous devons faire est de penser à notre système de routage. Il pourrait être intégré dans un framework existant, mais si cela est trop lourd, une solution de contournement potentielle consiste à créer un dossier nommé "api" à la racine du document. De cette façon, l'API peut avoir une logique complètement séparée. Vous pouvez étendre cette approche en plaçant la logique de l'API dans ses propres fichiers, comme ceci :
Vous pouvez considérer "www/api/Apis/Users.php" comme un "contrôleur" distinct pour un point de terminaison d'API particulier. Ce serait formidable de réutiliser les implémentations de la base de code existante, par exemple de réutiliser des modèles déjà implémentés dans le projet pour communiquer avec la base de données.
Enfin, assurez-vous de faire pointer toutes les requêtes entrantes de "/api/*" vers "/api/index.php". Cela peut être fait en modifiant la configuration de votre serveur Web.
Classe d'API
Version et format
Vous devez toujours définir clairement les versions et les formats que vos points de terminaison d'API acceptent et quels sont ceux par défaut. Cela vous permettra de créer de nouvelles fonctionnalités à l'avenir tout en conservant les anciennes fonctionnalités. La version de l'API peut essentiellement être une chaîne, mais vous pouvez utiliser des valeurs numériques pour une meilleure compréhension et une meilleure comparabilité. Il est bon d'avoir des chiffres de rechange pour les versions mineures car cela indiquerait clairement que seules quelques choses sont différentes :
- v1.0 signifierait la première version.
- v1.1 première version avec quelques modifications mineures.
- v2.0 serait une toute nouvelle version.
Le format peut être tout ce dont votre client a besoin, y compris, mais sans s'y limiter, JSON, XML et même CSV. En la fournissant via une URL en tant qu'extension de fichier, l'URL du point de terminaison de l'API garantit la lisibilité et il devient facile pour le consommateur d'API de savoir à quel format il peut s'attendre :
- "/api/v1.0/records.json" renverrait un tableau JSON d'enregistrements
- "/api/v1.0/records.xml" renverrait le fichier XML des enregistrements
Il convient de souligner que vous devrez également envoyer un en-tête Content-Type approprié dans la réponse pour chacun de ces formats.
Lors de la réception d'une demande entrante, l'une des premières choses à faire est de vérifier si le serveur d'API prend en charge la version et le format demandés. Dans votre méthode principale, qui gère la requête entrante, analysez $_SERVER['PATH_INFO'] ou $_SERVER['REQUEST_URI'] pour déterminer si le format et la version demandés sont pris en charge. Ensuite, continuez ou renvoyez une réponse 4xx (par exemple 406 "Non acceptable"). La partie la plus critique ici est de toujours retourner quelque chose que le client attend. Une alternative à cela serait de vérifier l'en-tête de la demande "Accepter" au lieu de l'extension du chemin de l'URL.
Itinéraires autorisés
Vous pouvez tout transférer de manière transparente à vos contrôleurs d'API, mais il peut être préférable d'utiliser un ensemble de routes autorisées sur liste blanche. Cela réduirait un peu la flexibilité, mais fournira un aperçu très clair de ce à quoi ressembleront les URL des points de terminaison de l'API la prochaine fois que vous reviendrez au code.
private $public_routes = array( 'system' => array( 'regex' => 'system', ), 'records' => array( 'regex' => 'records(?:/?([0-9]+)?)', ), );
Vous pouvez également les déplacer vers des fichiers séparés pour rendre les choses plus propres. La configuration ci-dessus sera utilisée pour activer les requêtes vers ces URL :
/api/v1.0/system.json /api/v1.0/records.json /api/v1.0/records/7.json
Gestion des données PUT
PHP gère automatiquement les données POST entrantes et les place sous $_POST superglobal. Cependant, ce n'est pas le cas avec les requêtes PUT. Toutes les données sont "enterrées" dans php://input . N'oubliez pas de l'analyser dans une structure ou un tableau séparé avant d'invoquer la méthode API réelle. Un simple parse_str pourrait suffire, mais si le client envoie une requête en plusieurs parties, une analyse supplémentaire peut être nécessaire pour gérer les limites du formulaire. Le cas d'utilisation typique des requêtes en plusieurs parties inclut les téléchargements de fichiers. La détection et la gestion des requêtes en plusieurs parties peuvent être effectuées comme suit :
self::$input = file_get_contents('php://input'); // For PUT/DELETE there is input data instead of request variables if (!empty(self::$input)) { preg_match('/boundary=(.*)$/', $_SERVER['CONTENT_TYPE'], $matches); if (isset($matches[1]) && strpos(self::$input, $matches[1]) !== false) { $this->parse_raw_request(self::$input, self::$input_data); } else { parse_str(self::$input, self::$input_data); } }
Ici, parse_raw_request pourrait être implémenté comme :
/** * Helper method to parse raw requests */ private function parse_raw_request($input, &$a_data) { // grab multipart boundary from content type header preg_match('/boundary=(.*)$/', $_SERVER['CONTENT_TYPE'], $matches); $boundary = $matches[1]; // split content by boundary and get rid of last -- element $a_blocks = preg_split("/-+$boundary/", $input); array_pop($a_blocks); // loop data blocks foreach ($a_blocks as $id => $block) { if (empty($block)) { continue; } // parse uploaded files if (strpos($block, 'application/octet-stream') !== false) { // match "name", then everything after "stream" (optional) except for prepending newlines preg_match("/name=\"([^\"]*)\".*stream[\n|\r]+([^\n\r].*)?$/s", $block, $matches); // parse all other fields } else { // match "name" and optional value in between newline sequences preg_match('/name=\"([^\"]*)\"[\n|\r]+([^\n\r].*)?\r$/s', $block, $matches); } $a_data[$matches[1]] = $matches[2]; } }
Avec cela, nous pouvons avoir la charge utile de requête nécessaire à Api :: $ input en tant qu'entrée brute et Api :: $ input_data en tant que tableau associatif.

Faire semblant de mettre/supprimer
Parfois, vous pouvez vous retrouver dans une situation où le serveur ne prend en charge rien d'autre que les méthodes HTTP GET/POST standard. Une solution courante à ce problème consiste à « simuler » PUT/DELETE ou toute autre méthode de requête personnalisée. Pour cela, vous pouvez utiliser un paramètre "magique", tel que "_method". Si vous le voyez dans votre tableau $_REQUEST , supposez simplement que la demande est du type spécifié. Les frameworks modernes comme Laravel intègrent de telles fonctionnalités. Il offre une grande compatibilité dans le cas où votre serveur ou client a des limitations (par exemple, une personne utilise le réseau Wi-Fi de son travail derrière un proxy d'entreprise qui n'autorise pas les requêtes PUT.)
Transfert vers une API spécifique
Si vous n'avez pas le luxe de réutiliser les chargeurs automatiques de projet existants, vous pouvez créer le vôtre à l'aide de la fonction spl_autoload_register . Définissez-le dans votre page « api/index.php » et appelez votre classe API située dans « api/Api.php ». La classe API agit comme un middleware et appelle la méthode réelle. Par exemple, une requête à "/api/v1.0/records/7.json" devrait finir par invoquer la méthode GET "Apis/Records.php" avec le paramètre 7. Cela garantirait la séparation des préoccupations et fournirait un moyen de conserver le nettoyeur logique. Bien sûr, s'il est possible d'intégrer cela plus profondément dans le framework que vous utilisez et de réutiliser ses contrôleurs ou itinéraires spécifiques, vous devriez également envisager cette possibilité.
Exemple "api/index.php" avec autoloader primitif :
<?php // Let's define very primitive autoloader spl_autoload_register(function($classname){ $classname = str_replace('Api_', 'Apis/', $classname); if (file_exists(__DIR__.'/'.$classname.'.php')) { require __DIR__.'/'.$classname.'.php'; } }); // Our main method to handle request Api::serve();
Cela chargera notre classe Api et commencera à la servir indépendamment du projet principal.
OPTIONS Demandes
Lorsqu'un client utilise un en-tête personnalisé pour transférer son jeton unique, le navigateur doit d'abord vérifier si le serveur prend en charge cet en-tête. C'est là que la requête OPTIONS entre en jeu. Son but est de s'assurer que tout va bien et en toute sécurité pour le client et le serveur API. La requête OPTIONS peut donc se déclencher à chaque fois qu'un client essaie de faire quoi que ce soit. Cependant, lorsqu'un client utilise des cookies pour les informations d'identification, cela évite au navigateur d'avoir à envoyer cette demande OPTIONS supplémentaire.
Si un client demande POST /users/8.json avec des cookies, sa demande sera assez standard :
- L'application effectue une requête POST à /users/8.json.
- Le navigateur exécute la requête et reçoit une réponse.
Mais avec une autorisation personnalisée ou un en-tête de jeton :
- L'application effectue une requête POST à /users/8.json.
- Le navigateur arrête de traiter la requête et lance une requête OPTIONS à la place.
- La requête OPTIONS est envoyée à /users/8.json.
- Le navigateur reçoit une réponse avec une liste de toutes les méthodes et en-têtes disponibles, tels que définis par l'API.
- Le navigateur continue avec la demande POST d'origine uniquement si l'en-tête personnalisé est présent dans la liste des en-têtes disponibles.
Cependant, gardez à l'esprit que même lorsque vous utilisez des cookies, avec PUT/DELETE, vous pouvez toujours recevoir cette demande OPTIONS supplémentaire. Soyez donc prêt à y répondre.
API des enregistrements
Structure basique
Notre exemple d'API Records est assez simple. Il contiendra toutes les méthodes de requête et renverra la sortie à la même classe d'API principale. Par exemple:
<?php class Api_Records { public function __construct() { // In here you could initialize some shared logic between this API and rest of the project } /** * Get individual record or records list */ public function get($id = null) { if ($id) { return $this->getRecord(intval($id)); } else { return $this->getRecords(); } } /** * Update record */ public function put($record_id = null) { // In real world there would be call to model with validation and probably token checking // Use Api::$input_data to update return Api::responseOk('OK', array()); } // ...
Ainsi, définir chaque méthode HTTP nous permettra de créer plus facilement une API de style REST.
Formatage de la sortie
Répondre naïvement avec tout ce qui est reçu de la base de données au client peut avoir des conséquences catastrophiques. Afin d'éviter toute exposition accidentelle de données, créez une méthode de format spécifique qui renverrait uniquement les clés de la liste blanche.
Un autre avantage des clés sur liste blanche est que vous pouvez écrire une documentation basée sur celles-ci et effectuer toutes les vérifications de type en vous assurant, par exemple, que user_id sera toujours un entier, le drapeau is_banned sera toujours booléen vrai ou faux, et les dates et heures auront une norme forme de réponse.
Sortie des résultats
En-têtes
Des méthodes distinctes pour la sortie des en-têtes garantiront que tout ce qui est envoyé au navigateur est correct. Cette méthode peut utiliser les avantages de rendre l'API accessible via le même domaine tout en conservant la possibilité de recevoir un en-tête d'autorisation personnalisé. Le choix entre le même domaine ou un domaine tiers peut se faire à l'aide des en-têtes de serveur HTTP_ORIGIN et HTTP_REFERER. Si l'application détecte que le client utilise l'autorisation x (ou tout autre en-tête personnalisé), elle doit autoriser l'accès depuis toutes les origines, autoriser l'en-tête personnalisé. Donc ça pourrait ressembler à ça :
header('Access-Control-Allow-Origin: *'); header('Access-Control-Expose-Headers: x-authorization'); header('Access-Control-Allow-Headers: origin, content-type, accept, x-authorization'); header('X-Authorization: '.YOUR_TOKEN_HERE);
Cependant, si le client utilise des informations d'identification basées sur des cookies, les en-têtes peuvent être légèrement différents, n'autorisant que les en-têtes liés à l'hôte et aux cookies demandés pour les informations d'identification :
header('Access-Control-Allow-Origin: '.$origin); header('Access-Control-Expose-Headers: set-cookie, cookie'); header('Access-Control-Allow-Headers: origin, content-type, accept, set-cookie, cookie'); // Allow cookie credentials because we're on the same domain header('Access-Control-Allow-Credentials: true'); if (strtolower($_SERVER['REQUEST_METHOD']) != 'options') { setcookie(TOKEN_COOKIE_NAME, YOUR_TOKEN_HERE, time()+86400*30, '/', '.'.$_SERVER['HTTP_HOST']); }
Gardez à l'esprit que la demande OPTIONS ne prend pas en charge les cookies, donc l'application ne les enverra pas avec elle. Et, enfin, cela permet à toutes nos méthodes HTTP souhaitées d'avoir une expiration du contrôle d'accès :
header('Access-Control-Allow-Methods: POST, GET, OPTIONS, PUT, DELETE'); header('Access-Control-Max-Age: 86400');
Corps
Le corps lui-même doit contenir la réponse dans un format demandé par votre client avec un statut HTTP 2xx en cas de succès, un statut 4xx en cas d'échec dû au client et un statut 5xx en cas d'échec dû au serveur. La structure de la réponse peut varier, bien que la spécification des champs « statut » et « réponse » puisse également être bénéfique. Par exemple, si le client essaie d'enregistrer un nouvel utilisateur et que le nom d'utilisateur est déjà pris, vous pouvez envoyer une réponse avec le statut HTTP 200 mais un JSON dans le corps qui ressemble à :
{“status”: “ERROR”, “response”: ”username already taken”}
… au lieu de l'erreur HTTP 4xx directement.
Conclusion
Il n'y a pas deux projets identiques. La stratégie décrite dans cet article peut ou non convenir à votre cas, mais les concepts de base doivent néanmoins être similaires. Il convient de noter que toutes les pages ne peuvent pas avoir la dernière tendance ou le framework à jour, et parfois la colère concernant "pourquoi mon bundle REST Symfony ne fonctionne pas ici" peut être transformée en une motivation pour construire quelque chose d'utile, quelque chose qui fonctionne. Le résultat final peut ne pas être aussi brillant, car il s'agira toujours d'une implémentation personnalisée et spécifique au projet, mais en fin de compte, la solution sera quelque chose qui fonctionne vraiment ; et dans un scénario comme celui-ci, cela devrait être l'objectif de chaque développeur d'API.
Des exemples d'implémentations des concepts abordés ici ont été téléchargés dans un référentiel GitHub pour plus de commodité. Vous ne voudrez peut-être pas utiliser ces exemples de codes directement en production tels quels, mais cela pourrait facilement servir de point de départ pour votre prochain projet d'intégration d'API PHP hérité.
Vous avez dû implémenter un serveur d'API REST pour un projet hérité récemment ? Partagez votre expérience avec nous dans la section des commentaires ci-dessous.