Creazione di API REST per progetti PHP legacy
Pubblicato: 2022-03-11Costruire o progettare un'API REST non è un compito facile, soprattutto quando devi farlo per progetti PHP legacy. Al giorno d'oggi ci sono molte librerie di terze parti che semplificano l'implementazione di un'API REST, ma integrarle nelle basi di codice legacy esistenti può essere piuttosto scoraggiante. E non hai sempre il lusso di lavorare con framework moderni, come Laravel e Symfony. Con i progetti PHP legacy, puoi spesso trovarti da qualche parte nel mezzo di framework interni deprecati, in esecuzione su vecchie versioni di PHP.
In questo articolo, daremo un'occhiata ad alcune sfide comuni nel tentativo di implementare le API REST da zero, alcuni modi per aggirare questi problemi e una strategia generale per la creazione di server API personalizzati basati su PHP per progetti legacy.
Sebbene l'articolo sia basato su PHP 5.3 e versioni successive, i concetti di base sono validi per tutte le versioni di PHP oltre la versione 5.0 e possono essere applicati anche a progetti non PHP. Qui, non tratteremo cos'è un'API REST in generale, quindi se non hai familiarità con essa assicurati di leggerla prima.
Per semplificarti il seguito, ecco un elenco di alcuni termini utilizzati in questo articolo e dei loro significati:
- Server API: applicazione REST principale a servizio dell'API, in questo caso scritta in PHP.
- Endpoint API: un "metodo" di back-end con cui il client comunica per eseguire un'azione e produrre risultati.
- URL dell'endpoint API: URL attraverso il quale il sistema di back-end è accessibile al mondo.
- Token API: un identificatore univoco passato tramite intestazioni HTTP o cookie da cui è possibile identificare l'utente.
- App: applicazione client che comunicherà con l'applicazione REST tramite endpoint API. In questo articolo assumeremo che sia basato sul Web (desktop o mobile) e quindi sia scritto in JavaScript.
Passi iniziali
Modelli di percorso
Una delle prime cose che dobbiamo decidere è in quale percorso URL saranno disponibili gli endpoint API. Ci sono 2 modi popolari:
- Crea un nuovo sottodominio, ad esempio api.example.com.
- Crea un percorso, ad esempio example.com/api.
A prima vista, può sembrare che la prima variante sia più popolare e attraente. In realtà, tuttavia, se stai creando un'API specifica per un progetto, potrebbe essere più appropriato scegliere la seconda variante.
Uno dei motivi più importanti alla base dell'adozione del secondo approccio è che ciò consente di utilizzare i cookie come mezzo per trasferire le credenziali. I client basati su browser invieranno automaticamente i cookie appropriati all'interno delle richieste XHR, eliminando la necessità di un'intestazione di autorizzazione aggiuntiva.
Un altro motivo importante è che non è necessario fare nulla per quanto riguarda la configurazione del sottodominio o problemi di gestione in cui le intestazioni personalizzate potrebbero essere rimosse da alcuni server proxy. Questo può essere un noioso calvario nei progetti legacy.
L'uso dei cookie può essere considerato una pratica "non RESTful" poiché le richieste REST dovrebbero essere stateless. In questo caso possiamo fare un compromesso e passare il valore del token in un cookie invece di passarlo tramite un'intestazione personalizzata. In effetti, stiamo usando i cookie solo come un modo per passare direttamente il valore del token anziché il session_id. Questo approccio potrebbe essere considerato apolide, ma possiamo lasciarlo alle tue preferenze.
Gli URL degli endpoint API possono anche essere sottoposti a versionamento. Inoltre, possono includere il formato di risposta previsto come estensione nel nome del percorso. Sebbene questi non siano critici, specialmente durante le prime fasi di sviluppo dell'API, a lungo termine questi dettagli possono sicuramente dare i loro frutti. Soprattutto quando è necessario implementare nuove funzionalità. Controllare quale versione si aspetta il client e fornire il formato necessario per la compatibilità con le versioni precedenti può essere la soluzione migliore.
La struttura dell'URL dell'endpoint API potrebbe essere la seguente:
example.com/api/${version_code}/${actual_request_path}.${format}
E, un vero esempio:
example.com/api/v1.0/records.json
Instradamento
Dopo aver scelto un URL di base per gli endpoint API, la prossima cosa che dobbiamo fare è pensare al nostro sistema di routing. Potrebbe essere integrato in un framework esistente, ma se è troppo ingombrante, una potenziale soluzione alternativa è creare una cartella denominata "api" nella radice del documento. In questo modo l'API può avere una logica completamente separata. Puoi estendere questo approccio inserendo la logica dell'API nei propri file, come questo:
Puoi pensare a "www/api/Apis/Users.php" come a un "controller" separato per un particolare endpoint API. Sarebbe fantastico riutilizzare le implementazioni dalla codebase esistente, ad esempio riutilizzare i modelli già implementati nel progetto per comunicare con il database.
Infine, assicurati di indirizzare tutte le richieste in arrivo da "/api/*" a "/api/index.php". Questo può essere fatto modificando la configurazione del server web.
Classe API
Versione e formato
Dovresti sempre definire chiaramente quali versioni e formati accettano i tuoi endpoint API e quali sono quelli predefiniti. Ciò ti consentirà di creare nuove funzionalità in futuro mantenendo le vecchie funzionalità. La versione dell'API può essere fondamentalmente una stringa, ma puoi utilizzare valori numerici per una migliore comprensione e comparabilità. È bene avere cifre di riserva per le versioni secondarie perché indicherebbe chiaramente che solo poche cose sono diverse:
- v1.0 significherebbe prima versione.
- v1.1 prima versione con alcune modifiche minori.
- v2.0 sarebbe una versione completamente nuova.
Il formato può essere qualsiasi cosa di cui il tuo cliente ha bisogno, inclusi ma non limitati a JSON, XML e persino CSV. Fornendolo tramite URL come estensione di file, l'URL dell'endpoint API garantisce la leggibilità e diventa un gioco da ragazzi per il consumatore API sapere quale formato può aspettarsi:
- "/api/v1.0/records.json" restituirebbe una matrice di record JSON
- "/api/v1.0/records.xml" restituirebbe il file XML dei record
Vale la pena sottolineare che dovrai anche inviare un'intestazione Content-Type nella risposta per ciascuno di questi formati.
Dopo aver ricevuto una richiesta in arrivo, una delle prime cose da fare è verificare se il server API supporta la versione e il formato richiesti. Nel tuo metodo principale, che gestisce la richiesta in arrivo, analizza $_SERVER['PATH_INFO'] o $_SERVER['REQUEST_URI'] per determinare se il formato e la versione richiesti sono supportati. Quindi, continua o restituisci una risposta 4xx (ad es. 406 "Non accettabile"). La parte più critica qui è restituire sempre qualcosa che il cliente si aspetta. Un'alternativa a questo sarebbe controllare l'intestazione della richiesta "Accetta" invece dell'estensione del percorso URL.
Percorsi consentiti
Puoi inoltrare tutto in modo trasparente ai tuoi controller API, ma potrebbe essere meglio utilizzare un insieme di percorsi consentiti nella whitelist. Ciò ridurrebbe leggermente la flessibilità, ma fornirà informazioni molto chiare sull'aspetto degli URL degli endpoint API la prossima volta che si torna al codice.
private $public_routes = array( 'system' => array( 'regex' => 'system', ), 'records' => array( 'regex' => 'records(?:/?([0-9]+)?)', ), );
Puoi anche spostarli in file separati per rendere le cose più pulite. La configurazione precedente verrà utilizzata per abilitare le richieste a questi URL:
/api/v1.0/system.json /api/v1.0/records.json /api/v1.0/records/7.json
Gestione dei dati PUT
PHP gestisce automaticamente i dati POST in entrata e li inserisce in $ _POST superglobal . Tuttavia, questo non è il caso delle richieste PUT. Tutti i dati vengono "sepolti" in php://input . Non dimenticare di analizzarlo in una struttura o matrice separata prima di richiamare il metodo API effettivo. Un semplice parse_str potrebbe essere sufficiente, ma se il client invia una richiesta multipart potrebbe essere necessaria un'analisi aggiuntiva per gestire i limiti del modulo. Il tipico caso d'uso delle richieste in più parti include i caricamenti di file. Il rilevamento e la gestione delle richieste multipart possono essere eseguiti come segue:
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); } }
Qui, parse_raw_request potrebbe essere implementato come:
/** * 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]; } }
Con questo, possiamo avere il carico utile della richiesta necessario su Api::$input come input grezzo e Api::$input_data come array associativo.

Falso PUT/DELETE
A volte puoi vederti in una situazione in cui il server non supporta nient'altro che i metodi HTTP GET/POST standard. Una soluzione comune a questo problema è "falsificare" PUT/DELETE o qualsiasi altro metodo di richiesta personalizzato. Per questo puoi usare un parametro "magico", come "_method". Se lo vedi nell'array $_REQUEST , supponi semplicemente che la richiesta sia del tipo specificato. I framework moderni come Laravel hanno tali funzionalità integrate. Fornisce una grande compatibilità nel caso in cui il tuo server o client abbia limitazioni (ad esempio una persona sta utilizzando la rete Wi-Fi del suo lavoro dietro un proxy aziendale che non consente richieste PUT).
Inoltro a API specifica
Se non hai il lusso di riutilizzare i caricatori automatici di progetti esistenti, puoi crearne di tuoi con l'aiuto della funzione spl_autoload_register . Definiscilo nella tua pagina "api/index.php" e chiama la tua classe API situata in "api/Api.php". La classe API funge da middleware e chiama il metodo effettivo. Ad esempio, una richiesta a "/api/v1.0/records/7.json" dovrebbe finire per invocare il metodo GET "Apis/Records.php" con il parametro 7. Ciò garantirebbe la separazione delle preoccupazioni e fornirebbe un modo per mantenere il pulitore logico. Ovviamente, se è possibile integrarlo più in profondità nel framework che stai utilizzando e riutilizzare i suoi controller o percorsi specifici, dovresti considerare anche questa possibilità.
Esempio "api/index.php" con caricatore automatico primitivo:
<?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();
Questo caricherà la nostra classe Api e inizierà a servirla indipendentemente dal progetto principale.
OPZIONI Richieste
Quando un client utilizza un'intestazione personalizzata per inoltrare il proprio token univoco, il browser deve prima verificare ogni volta che il server supporta tale intestazione. È qui che entra in gioco la richiesta OPTIONS. Il suo scopo è garantire che tutto sia a posto e sicuro sia per il client che per il server API. Quindi la richiesta OPTIONS potrebbe essere attivata ogni volta che un client tenta di fare qualcosa. Tuttavia, quando un client utilizza i cookie per le credenziali, evita al browser di dover inviare questa richiesta OPTIONS aggiuntiva.
Se un client richiede POST /users/8.json con i cookie, la sua richiesta sarà piuttosto standard:
- L'app esegue una richiesta POST a /users/8.json.
- Il browser esegue la richiesta e riceve una risposta.
Ma con l'autorizzazione personalizzata o l'intestazione del token:
- L'app esegue una richiesta POST a /users/8.json.
- Il browser interrompe l'elaborazione della richiesta e avvia invece una richiesta OPTIONS.
- La richiesta OPTIONS viene inviata a /users/8.json.
- Il browser riceve una risposta con un elenco di tutti i metodi e le intestazioni disponibili, come definito dall'API.
- Il browser continua con la richiesta POST originale solo se l'intestazione personalizzata è presente nell'elenco delle intestazioni disponibili.
Tuttavia, tieni presente che anche quando utilizzi i cookie, con PUT/DELETE potresti comunque ricevere quella richiesta di OPZIONI aggiuntive. Quindi preparati a rispondere.
API dei record
Struttura basilare
Il nostro esempio di API Records è piuttosto semplice. Conterrà tutti i metodi di richiesta e restituirà l'output alla stessa classe API principale. Per esempio:
<?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()); } // ...
Quindi definire ogni metodo HTTP ci consentirà di creare API in stile REST più facilmente.
Formattazione dell'output
Rispondere ingenuamente con tutto ciò che viene ricevuto dal database al client può avere conseguenze catastrofiche. Per evitare l'esposizione accidentale dei dati, creare un metodo di formattazione specifico che restituisca solo chiavi autorizzate.
Un altro vantaggio delle chiavi inserite nella whitelist è che puoi scrivere documentazione basata su queste ed eseguire tutti i controlli di tipo assicurandoti, ad esempio, che user_id sia sempre un numero intero, flag is_banned sarà sempre booleano true o false e date times avranno uno standard formato di risposta.
Risultati di output
Intestazioni
Metodi separati per l'output delle intestazioni assicureranno che tutto ciò che viene inviato al browser sia corretto. Questo metodo può sfruttare i vantaggi di rendere l'API accessibile tramite lo stesso dominio pur mantenendo la possibilità di ricevere un'intestazione di autorizzazione personalizzata. La scelta tra il dominio stesso o di terze parti può avvenire con l'aiuto delle intestazioni del server HTTP_ORIGIN e HTTP_REFERER. Se l'app rileva che il client sta utilizzando l'autorizzazione x (o qualsiasi altra intestazione personalizzata), dovrebbe consentire l'accesso da tutte le origini, consentire l'intestazione personalizzata. Quindi potrebbe assomigliare a questo:
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);
Tuttavia, se il client utilizza credenziali basate su cookie, le intestazioni potrebbero essere leggermente diverse, consentendo solo le intestazioni relative all'host e ai cookie richieste per le credenziali:
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']); }
Tieni presente che la richiesta OPTIONS non supporta i cookie, quindi l'app non li invierà con essa. E, infine, questo consente a tutti i nostri metodi HTTP desiderati di avere la scadenza del controllo di accesso:
header('Access-Control-Allow-Methods: POST, GET, OPTIONS, PUT, DELETE'); header('Access-Control-Max-Age: 86400');
Corpo
Il corpo stesso dovrebbe contenere la risposta in un formato richiesto dal tuo client con uno stato HTTP 2xx in caso di esito positivo, stato 4xx in caso di errore dovuto al client e stato 5xx in caso di errore dovuto al server. La struttura della risposta può variare, sebbene anche specificare i campi "stato" e "risposta" potrebbe essere utile. Ad esempio, se il client sta tentando di registrare un nuovo utente e il nome utente è già stato utilizzato, è possibile inviare una risposta con stato HTTP 200 ma un JSON nel corpo che assomiglia a:
{“status”: “ERROR”, “response”: ”username already taken”}
… invece dell'errore HTTP 4xx direttamente.
Conclusione
Non esistono due progetti esattamente uguali. La strategia delineata in questo articolo può o non può essere adatta al tuo caso, ma i concetti fondamentali dovrebbero comunque essere simili. Vale la pena notare che non tutte le pagine possono avere l'ultimo framework di tendenza o aggiornato dietro di sé, e talvolta la rabbia riguardo al "perché il mio bundle REST Symfony non funziona qui" può essere trasformata in una motivazione per costruire qualcosa di utile, qualcosa che funziona. Il risultato finale potrebbe non essere così brillante, poiché sarà sempre un'implementazione personalizzata e specifica del progetto, ma alla fine la soluzione sarà qualcosa che funziona davvero; e in uno scenario come questo quello dovrebbe essere l'obiettivo di ogni sviluppatore di API.
Esempi di implementazioni dei concetti discussi qui sono stati caricati in un repository GitHub per comodità. Potresti non voler utilizzare questi codici di esempio direttamente in produzione così come sono, ma questo potrebbe facilmente funzionare come punto di partenza per il tuo prossimo progetto di integrazione dell'API PHP legacy.
Di recente è stato necessario implementare un server API REST per alcuni progetti legacy? Condividi la tua esperienza con noi nella sezione commenti qui sotto.