logo cosasdedevs
Cómo construir un framework sencillo con PHP para crear APIs

Cómo construir un framework sencillo con PHP para crear APIs



My Profile
Abr 27, 2022

¡Hola 👋! Después de terminar la guía y tomarme una temporada de descanso del blog, retomo el ritmo habitual de tutoriales. Este tutorial puede que no reciba muchas visitas, pero me pareció muy interesante el tema de ver cómo construir nuestro propio framework con PHP para crear APIs, así que me puse manos a la obra y ahora que ya lo tengo listo, te voy a explicar cómo lo he hecho.

¿Qué vamos a poder hacer con este framework? 🤨

Nuestro framework va a tener excepciones customizadas, enrutamiento, middlewares para poder permitir por ejemplo autenticación y una clase de respuesta centralizada para mantener un estándar en nuestras respuestas. También podremos guardar logs de errores para ser revisados posteriormente.

¿Qué necesito saber antes de empezar? 🤔

En este tutorial veremos algunos conceptos avanzados de PHP como el uso de namespaces, composer y POO. Si no tienes claros estos conceptos, te recomiendo que primero les eches un vistazo para poder entender bien este tutorial.

También necesitarás conocimientos sobre el funcionamiento de una API y el protocolo HTTP, si no tienes claros estos conceptos, te dejo mi guía completamente gratuita donde lo explico en profundidad.

Guía para aprender a trabajar con APIs

¿En qué me he basado para crear este tutorial?

He tomado ideas de frameworks como Laravel y Slim. El sistema de helpers y la idea de crear una excepción customizada para respuestas HTTP las cuales verás más adelante como funcionan, lo aprendí en cursos de Platzi.

Crear el archivo composer.json

Lo primero que necesitaremos será crear el archivo composer.json en la raíz de nuestro proyecto y ahí añadiremos el siguiente JSON:

{
  "name": "albertorc87/easyapi",
  "description": "EasyAPI es un micro framework pensado en el desarrollo de APIs con arquitectura REST y que no depende de ninguna librería.",
  "type": "package",
  "license": "MIT",
  "authors": [
    {
      "name": "Alberto",
      "email": "alberto.r.caballero.87@gmail.com",
      "homepage": "https://cosasdedevs.com/"
    }
  ],
  "minimum-stability": "dev",
  "require": {
    "php": "7.4 - 8.1"
  },
  "autoload": {
    "psr-4": {
      "EasyAPI\\": "src/"
    },
    "files": [
      "src/helpers.php"
    ]
  }
}

En él, indicamos el nombre del paquete al que vamos a llamar EasyAPI, una breve descripción, la licencia (MIT) y estabilidad mínima que por el momento es dev. Como versiones de PHP válidas indicamos que funcionará desde la 7.4 hasta la 8.1 (ambas incluidas). Por último configuramos el autoload para usar el namespace EasyAPI en todos los archivos que estén dentro de la carpeta src. También un archivo de helpers donde posteriormente podremos añadir funciones que podrán ser usadas en todo el proyecto.

El siguiente paso es crear el archivo src/helpers.php en la raíz del proyecto. De momento estará vacío, más adelante añadiremos alguna función para posteriormente usarla dentro del proyecto.

Una vez hecho esto, lanzamos el siguiente comando:

composer dump

Este se encargará de crear la carpeta vendor y preparar la configuración para que podamos usar el namespace EasyAPI y las funciones que almacenemos en el archivo helpers.php.

Crear excepciones customizadas

El siguiente paso es crear las excepciones customizadas. ¿Por qué hacemos esto? Porque serán excepciones del propio framework y las lanzaremos nosotros cuando sea necesario. También nos servirán para saber que estas excepciones vienen por un uso erróneo del framework (menos en una exepción que vamos a crear llamada HttpException que tendrá otro uso).

Para ello, dentro de la carpeta src, vamos a añadir una carpeta llamada Exceptions.

Una vez hecho esto, creamos un archivo llamado EasyApiException.php con el siguiente contenido:

<?php

namespace EasyAPI\Exceptions;
use Exception;

class EasyApiException extends Exception
{
    public function __construct($message, $code = 0, Exception $previous = null) {
        parent::__construct($message, $code, $previous);
    }
}

Aquí solamente extendemos de Exception para crear nuestra excepción customizada. Como podéis observar, estamos usando el namespace que configuramos en el composer.json.

La siguiente excepción a crear se llamará RouterException.php y esta la utilizaremos cuando tengamos algún problema en el futuro sistema de enrutamiento de nuestro paquete.

<?php

namespace EasyAPI\Exceptions;
use Exception;

class RouterException extends Exception
{
    public function __construct($message, $code = 0, Exception $previous = null) {
        parent::__construct($message, $code, $previous);
    }
}

Por último, vamos a crear una excepción llamada HttpException.php.

<?php

namespace EasyAPI\Exceptions;
use Exception;

class HttpException extends Exception
{
    public function __construct($message = 'Internal Server Error', $status_http_code = 500, Exception $previous = null) {
        parent::__construct($message, $status_http_code, $previous);
    }
}

Esta excepción es un poco distinta a las anteriores y es en esta excepción podremos enviar un mensaje y código de estado HTTP que después se mostrará al usuario. Por lo tanto, podremos lanzar esta excepción en cualquier parte del código de la API que construyamos posteriormente y el framework se encargará de capturarlo y mostrarlo de forma legible al usuario.

Middlewares

Como comentamos anteriormente, los middlewares nos servirán de paso intermedio entre la petición que realiza el usuario y la respuesta. Al crear una ruta, nosotros tendremos la opción de añadir un middleware a esa ruta y cuando el usuario ejecute una petición a esa ruta, el middleware se ejecutará y efectuará las comprobaciones que necesite.

Ahora vamos a crear la clase base de middleware de la cual deberán extender todos los middlewares que creemos para que funcione correctamente. Para ello vamos a la carpeta src y creamos un archivo que se llamará Middleware.php con el siguiente contenido:

<?php

namespace EasyAPI;
use EasyAPI\Request;

abstract class Middleware
{
    abstract public function handle(Request $request): Request;
}

Como podéis observar, la clase base tiene un método que deberán tener todos los middleware que generemos y que además recibe y responde un objeto Request. Este objeto Request lo vamos a crear a continuación y lo podremos usar para capturar información que recibamos en el middleware.

Para ello, creamos el archivo Request.php dentro de la carpeta src:

<?php

namespace EasyAPI;

class Request
{
    private $data = [];

    public function setData(string $key, $value)
    {
        $this->data[$key] = $value;
    }

    public function getData(string $key)
    {
        return $this->data[$key] ?? null;
    }
}

Como veis, solo guarda un array y tiene dos métodos, uno para guardar la información del array y otro para retornarla.

Enrutamiento

El siguiente paso que vamos a ver es el enrutamiento. Los métodos y variables de esta clase serán estáticos para poder añadir posteriormente las URLs y luego acceder a ellas. Esta clase tendrá dos métodos principales. Uno se encargará de realizar toda la validación y almacenar las rutas y el otro retornará la información de la ruta si todo ha ido bien.

Tendremos otros cinco métodos adicionales que tendrán el mismo nombre que el método HTTP que queramos utilizar y que llamarán al método route enviando el método HTTP por parámetro.

Dicho esto, vamos a la carpeta src y creamos un archivo que se llamará Router.php con el siguiente contenido:

<?php

namespace EasyAPI;

use EasyAPI\Exceptions\RouterException;
use EasyAPI\Exceptions\HttpException;

use EasyAPI\Middleware;
use EasyAPI\Request;

class Router
{
    private static $urls = [];
    private const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];

    private static function route(string $method, string $route, $handler, string $middleware = null): void
    {

        $invalid_type = !is_callable($handler) && !is_string($handler);
        $invalid_format = is_string($handler) && !preg_match('/@/', $handler);
        if($invalid_type || $invalid_format) {
            throw new RouterException('$handler must be a string with class and method separated by at @ or an anonymous function');
        }

        $method = strtoupper($method);

        if(!in_array($method, self::METHODS)) {
            throw new RouterException('Invalid method ' . $method);
        }

        if(!isset(self::$urls[$method])) {
            self::$urls[$method] = [];
        }

        if(isset(self::$urls[$method][$route])) {
            throw new RouterException("Route $route already exists with method $method");
        }

        self::$urls[$method][$route] = [
            'handler' => $handler,
            'middleware' => $middleware
        ];
    }


    public static function getRouteInfo(): array
    {
        $uri = $_SERVER['REQUEST_URI'] ?? '';
        $method = $_SERVER["REQUEST_METHOD"] ?? '';

        // clean uri
        if(preg_match('/(?<uri>.*?)[\?|#]/', $uri, $m)) {
            $uri = $m['uri'];
        }

        if(!isset(self::$urls[$method])) {
            throw new RouterException("There aren't any route with method $method");
        }

        foreach(self::$urls[$method] as $route => &$data) {

            $handler = $data['handler'];
            $middleware = $data['middleware'];
            if(preg_match('/^' . str_replace(['/'], ['\/'], $route) . '$/', $uri, $m)) {
                $path_params = [];

                foreach($m as $key => $val) {
                    if(is_numeric($key)) {
                        continue;
                    }

                    $path_params[$key] = $val;
                }

                $request = new Request();
                if($middleware) {
                    $middleware = new $middleware;
                    if(!($middleware instanceof Middleware)) {
                        throw new RouterException("Invalid middleware, must be extends of EasyAPI\\Middleware");
                    }
                    $request = $middleware->handle($request);
                }
                $path_params['request'] = $request;
                return [
                    'handler' => $handler,
                    'path_params' => $path_params,
                ];
            }
        }

        unset($data);
        throw new HttpException('Not found', 404);
    }

    public static function get(string $route, $handler, string $middleware = null): void
    {
        self::route('GET', $route, $handler, $middleware);
    }

    public static function post(string $route, $handler, string $middleware = null): void
    {
        self::route('POST', $route, $handler, $middleware);
    }

    public static function put(string $route, $handler, string $middleware = null): void
    {
        self::route('PUT', $route, $handler, $middleware);
    }

    public static function patch(string $route, $handler, string $middleware = null): void
    {
        self::route('PATCH', $route, $handler, $middleware);
    }

    public static function delete(string $route, $handler, string $middleware = null): void
    {
        self::route('DELETE', $route, $handler, $middleware);
    }
}

El método route es el que se encarga de guardar la ruta dentro de la variable de clase $urls. Este recibirá los siguientes parámetros:

$method: El método HTTP, puede ser GET, POST, PUT, PATCH o DELETE.

$route: La ruta que queramos crear. Ejemplo: /api/v1/tasks.

$handler: Este podrá ser una función anónima o la ruta de una clase y el método que queramos llamar separados por una arroba (@).

$middleware: Si queremos emplear un middleware, aquí deberemos pasar el nombre de la clase de nuestro middleware.

El método route se encarga de validar esta información y la guarda dentro de la variable $urls. Cuando el usuario realice una petición, nuestro sistema llamará al método getRouteInfo el cual comprueba la URI y método HTTP usado y comprueba que están dentro del array $urls. Para ello recorre este array y además nos permite utilizar expresiones regulares para poder enviar parámetros de ruta en la URL. Si es así, los almacenará y los enviará como parámetros al método o clase anónima que asociemos a esa URL.

También, si tenemos un middleware, validamos que extienda de la clase base Middleware y crea una instancia si es así. Adicionalmente, creamos una instancia de Request para almacenar información adicional.

Este objeto request también lo podrá recibir nuestra función anónima o método para emplear esa información posteriormente.

Si os fijáis, aquí usamos por primera vez la excepción HttpException si no encontramos la ruta. Cuando se lance esta excepción, el usuario recibirá un JSON con el mensaje de error añadido en la excepción y como código de estado HTTP, el indicado en la excepción. En este caso, el 404. 

Respuestas

Ahora vamos a crear nuestra clase de respuestas. Esta recibirá las cabeceras, respuesta, código de estado HTTP y formato en el que queramos dar la respuesta y tendrá un método el cual se encargará de preparar y lanzar esa respuesta. Cuando añadamos una función anónima o clase y método en una ruta, obligaremos al usuario a siempre responder una instancia de esta clase para poder controlar el formato de la respuesta.

Dicho esto, vamos a crear el archivo. Para ello, vamos a la carpeta src y creamos un archivo que se llamará Response.php y el cual contendrá la siguiente información.

<?php

namespace EasyAPI;

use EasyAPI\Exceptions\EasyApiException;

class Response
{

    private $headers = [];
    private $response = '';
    private $status_code = null;

    public function __construct(
        string $type,
        $data = null,
        int $status_code = 200,
        array $headers = []
    )
    {
        $this->status_code = $status_code;

        foreach($headers as $header_name => $header_value) {
            $this->headers[strtolower($header_name)] = $header_value;
        }

        if($status_code < 100 || $status_code > 599) {
            throw new EasyApiException('Invalid status code, must be a number between 100 and 599');
        }

        switch($type) {
            case 'raw':
                $this->raw($data);
                break;
            case 'json':
                $this->json($data);
                break;
            case 'html':
                $this->html($data);
                break;
            default:
                throw new EasyApiException('Invalid Response Type, only valids are raw, json and html');
                break;
        }
    }

    private function raw(string $data): void
    {
        if(empty($this->headers['content-type'])) {
            $this->headers['content-type'] = 'text/plain; charset=utf-8';
        }

        $this->response = $data;
    }

    private function json($data): void
    {
        $this->headers['content-type'] = 'application/json; charset=utf-8';

        if($this->status_code > 399) {
            $response = [
                'status' => 'error',
                'error' => $data
            ];
        }
        else {
            $response = [
                'status' => 'success',
                'data' => $data
            ];
        }

        $this->response = json_encode($response);
    }

    private function html(string $data): void
    {
        $this->headers['content-type'] = 'text/html; charset=utf-8';

        $this->response = $data;
    }

    public function returnData()
    {
        foreach($this->headers as $header_name => $header_value) {
            header("$header_name: $header_value");
        }

        http_response_code($this->status_code);

        echo $this->response;
    }
}

Como podéis observar, el constructor puede recibir cuatro parámetros que explicaré a continuación:

$type: Podrá ser raw, json y html, a continuación explicaremos las diferencias.

$data: La información que queramos retornar al usuario. Puede ser un string o en el caso de usar el tipo JSON un array el cual convertiremos al formato JSON posteriormente. También puede estar vacío.

$status_code: El código de estado HTTP.

$headers: Cabeceras adicionales que podamos necesitar enviar en nuestra respuesta.

En el constructor, validaremos la información y según el tipo enviado, ejecutaremos el método raw, json o html el cual os voy a explicar.

Método RAW

Este método recibe un string con la información a retornar al usuario y si no enviamos una cabecera de tipo content-type, por defecto añadiremos la cabecera para enviar texto plano. Este tipo lo podríamos utilizar, por ejemplo, para retornar ficheros.

Método JSON

Cuando seleccionamos el tipo JSON, por defecto añadimos la cabecera content-type para respuestas de tipo JSON. Este método podrá recibir un string o array y en este caso, seguiremos un estándar para las respuestas en las que retornaremos un status que será success o error según el código de estado HTTP y la información enviada por el usuario se guardará dentro de la clave data o error según si la respuesta es válida o errónea. Por último, convertimos la respuesta en JSON para poder retornar la información al usuario.

Método HTML

El método HTML añadirá la cabecera para retornar HTML y podrá recibir un string con el HTML a retornar al usuario.

Por último, tenemos un método llamado returnData, el cual se encargará de enviar la respuesta al usuario generando las cabeceras, el código de estado HTTP y la respuesta al usuario.

Helpers

Como comentamos anteriormente, gracias a la configuración que añadimos en el composer.json, podemos valernos del archivo src/helpers.php para añadir funciones que podremos usar en todo el proyecto. Esto nos serviría para casos en los que tenemos funciones sueltas que no podrían encajar en ninguna clase en concreto.

Para ver un caso de uso, vamos a crear una función llamada view que se encargará de crear el objeto Response y retornarlo. Tampoco es que nos solucione mucho la vida, pero para ver el caso de uso está bien 😂.

Abrimos el archivo src/helpers.php y añadimos el siguiente código:

<?php

if(!function_exists('view')) {
    function view(
        string $type,
        $data = null,
        int $status_code = 200,
        array $headers = []
    ): EasyAPI\Response
    {
        return new EasyAPI\Response($type, $data, $status_code, $headers);
    }
}

Gracias a esto, desde cualquier parte de nuestro proyecto, podremos llamar a la función view.

Clase principal

Por último, vamos a crear la clase principal. Esta se encargará de validar que la configuración del proyecto sea correcta y tendrá un método llamado send se encargará de dar la respuesta al usuario según al endpoint al que acceda.

Para ello vamos a crear un archivo llamado App.php dentro de la carpeta src y que contendrá el siguiente código:

<?php

namespace EasyAPI;

use EasyAPI\Router;
use EasyAPI\Response;
use EasyAPI\Exceptions\HttpException;
use EasyAPI\Exceptions\EasyApiException;
use Throwable;

class App {

    public function __construct()
    {
        $this->checkEnv();
    }

    private function checkEnv(): void
    {

        if(empty($_ENV['ROOT_PROJECT'])) {
            throw new EasyApiException('You must define ROOT_PROJECT environment variable with root path of the project');
        }

        if(!is_dir($_ENV['ROOT_PROJECT'])) {
            throw new EasyApiException('ROOT_PROJECT environment variable dir not exists');
        }

        $last_char = substr($_ENV['ROOT_PROJECT'], -1);

        if(in_array($last_char, ['/', '\\'])) {
            $_ENV['ROOT_PROJECT'] = substr_replace($_ENV['ROOT_PROJECT'] ,"",-1);
        }

        if(!isset($_ENV['DEBUG_MODE'])) {
            throw new EasyApiException('You must define DEBUG_MODE environment variable env with value "true" or "false"');
        }

        if(is_bool($_ENV['DEBUG_MODE'])) {
            return;
        }

        if(!is_string($_ENV['DEBUG_MODE'])) {
            throw new EasyApiException('Invalid format in DEBUG_MODE environment variable, valid values "true" or "false"');
        }

        $debug_mode = strtolower($_ENV['DEBUG_MODE']);
        if($debug_mode === 'true') {
            $_ENV['DEBUG_MODE'] = true;
        }
        elseif($debug_mode === 'false') {
            $_ENV['DEBUG_MODE'] = false;
        }
        else {
            throw new EasyApiException('Invalid value in DEBUG_MODE environment variable, only valid values "true" or "false"');
        }
    }

    public function send()
    {

        try {
            $route_info = Router::getRouteInfo();

            $path_params = $route_info['path_params'];
            $handler = $route_info['handler'];

            if(is_callable($handler) || !preg_match('/@/', $handler)) {
                $response = call_user_func_array($handler, array_values($path_params));
            }
            else {
                list($class, $method) = explode('@', $handler, 2);
                $response = call_user_func_array([new $class, $method], array_values($path_params));
            }

            if(!($response instanceof Response)) {
                throw new EasyApiException('Invalid response format');
            }

            $response->returnData();
        }
        catch(HttpException $e) {
            $response = new Response('json', $e->getMessage(), $e->getCode());
            $response->returnData();
        }
        catch(Throwable $e) {
            if($_ENV['DEBUG_MODE']) {
                throw $e;
            }
            $this->saveLog($e);
            $response = new Response('json', 'Internal Server Error', 500);
            $response->returnData();
        }
    }

    private function saveLog($e): void
    {
        $log_dir = $_ENV['ROOT_PROJECT'] . '/logs/';

        if(!is_dir($log_dir)) {
            mkdir($log_dir);
        }

        $data = [
            'DATE' => date('Y-m-d H:i:s'),
            'ENDPOINT' => $_SERVER['REQUEST_URI'] ?? '',
            'METHOD' => $_SERVER['REQUEST_METHOD'] ?? '',
            'MESSAGE_ERROR' => $e->getMessage(),
            'TRACE' => $e->getTrace()[0]
        ];

        $log_file = $log_dir . 'logs-' . date('Y-m-d') . '.log';

        file_put_contents($log_file, json_encode($data, JSON_PRETTY_PRINT) . PHP_EOL, FILE_APPEND);
    }
}

Como podéis observar, en el constructor, llamamos a un método llamado checkEnv. Este se encarga de revisar que existen dos variables de entorno que necesitaremos llamadas DEBUG_MODE y ROOT_PROJECT

La primera podrá recibir como valor un booleano. Si es true, las excepciones que no sean de tipo HttpException se mostrarán tal cual a nosotros cuando realicemos una petición. Esto nos vendrá bien para corregir problemas cuando estemos en un entorno local o de desarrollo. Si el valor el false, la respuesta será un JSON con un error genérico y guardaremos un log con el error. Esto lo veremos en profundidad más adelante.

En ROOT_PROJECT, deberemos guardar la ruta absoluta de nuestro proyecto. De momento solo lo utilizaremos para generar una carpeta de logs y ahí guardar los errores.

Una vez creada la instancia de App, debemos llamar al método send el cual se encarga de mostrar una respuesta al usuario.

En este método, primero obtenemos la información de la URI a la que ha accedido el usuario y comprueba si esa ruta tenía asociada una función anónima o una clase y su método. Según el tipo la ejecuta de una forma u otra.

Después validamos que la respuesta sea una instancia de Response y si no es así lanza un error. Si todo ha ido bien, llama al método returnData de nuestro objeto Response para generar la respuesta del usuario.

Si hay una excepción de tipo HttpException, capturamos el mensaje y código de estado y se lo retornamos al usuario.

Por último, si hay una excepción que no tenemos controlada, primero revisamos si DEBUG_MODE es true o false. Si es true, lanzamos la excepción tal cual. Si no, llamamos al método saveLog el cual recibe la excepción. Después comprobamos que existe un directorio para logs en la raíz de nuestro proyecto y si no es así lo creamos. Una vez hecho esto, guardamos la información del error para posteriormente ser revisado y corregido.

Después de guardar el log, mostarmos al usuario un error genérico, ya que si retornamos el error tal cual, podríamos enviar información comprometida de nuestra API al usuario.

Y listo, eso es todo por este tutorial. Si queréis ver el framework en acción, he subido el paquete a packagist y tenéis toda la documentanción en el README para trabajar con el 💪.

Paquete en packagist

Proyecto en GitHub con la versión con la que hemos trabajado

Proyecto en GitHub con la última versión

Espero que este post te ayude y como siempre, te recomiendo seguirme en Twitter para estar al tanto de los nuevo contenido. Ahora también puedes seguirme en Instagram donde estoy subiendo tips, tutoriales en vídeo e información sobre herramientas para developers.

Por último os dejo mi guía para aprender a trabajar con APIs donde explico todo el funcionamiento de una API, el protocolo HTTP y veremos como construir una API con arquitectura REST.

Nos leemos 👋.

148 vistas

🐍 Sígueme en Twitter

Si te gusta el contenido que subo y no quieres perderte nada, sígueme en Twitter y te avisaré cada vez que cree contenido nuevo 💪
Luego ¡Te sigo!

Nos tomamos en serio tu privacidad

Utilizamos cookies propias y de terceros para mejorar la experiencia del usuario a través de su navegación. Si pulsas entendido aceptas su uso. Ver política de cookies.