Te encomendaron hacer una nueva API REST para tu aplicación PHP.
Podrías hacer en PHP puro, claro que sí pero tendrías que ponerte a tratar con una cantidad de cosas bastante molestas, entre ellas:
- El ruteo
- Los diferentes métodos HTTP
- La autenticación/autorización
- El caché
- La documentación
Qué pereza, ¿no? Al fin de cuentas… una API es una API… tiene que haber alguna herramienta que simplifique esto.
Claro que la hay.
Hace unos años te habría recomendado sin pensarlo dos veces usar Tonic (De hecho, yo mismo lo usé para armar un proyecto bastante lindo de procesamiento de imágenes).
Pero… como todo en esta vida, la tecnología evoluciona y, para qué usar una herramienta menos potente cuando podríamos usar una más moderna, ¿no?.
Salvo que tengas muchas ganas de aprender algunas cosas y prefieras hacerlo todo a pulmón como hice yo en este repositorio (Bueno, en rigor de verdad no empecé de 0, usé unas cuantas librerías de Symfony y alguna otra cosita como Swagger-PHP para generar la documentación de OpenAPI pero aún así… le puse algunas horas de coding).
Ahora bien, si lo tuyo es ir directo a lo rápido y seguro no hay muchas dudas: API-Platform es lo que buscás.
Se trata de un framework basado en lo mejorcito que hay ahí afuera, se trata de definir tus modelos y… no mucho más.
Una API crocante en 10′.
Vamos por pasos:
Instalar el framework
Bueno… un pequeño pasito antes es instalar Docker y docker-compose, pero seguramente ya lo hiciste.
Vamos al framework
gh repo create --clone --template api-platform/api-platform my-api --public
cd my-api
docker compose up -d --wait
open https://localhost
No te preocupes si ve un aviso algo tenebroso como este:
Nadie te está por robar todo el dinero de tu cuenta, simplemente se trata de que estás usando https para entrar a tu propio localhost y el browser no acepta el certificado autofirmado.
Si querés seguir podés hacerlo con tranquilidad. Si no, podés buscar ayuda para configurar certificados auto-firmados.
En cualquier caso, deberías terminar viendo algo como:
No está tan mal para haber requerido 4 comandos, ¿no?
Definir tus recursos
Para empezar con algo simple, definí tus entidades dentro del directorio api/src/Entity
. Por ejemplo, esta es una de las que yo usé en mi proyecto:
<?php namespace App\Entity; use App\Repository\FactionRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: FactionRepository::class)] #[ORM\Table(name: '`factions`')] class Faction { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 128)] private ?string $faction_name = null; #[ORM\Column(type: Types::TEXT)] private ?string $description = null; #[ORM\OneToMany(targetEntity: Character::class, mappedBy: 'faction')] private Collection $characters; public function __construct(string $faction_name, string $description, ?int $id = null) { $this->id = $id; $this->faction_name = $faction_name; $this->description = $description; $this->characters = new ArrayCollection(); } public function getId(): ?int { return $this->id; } public function getFactionName(): ?string { return $this->faction_name; } public function setFactionName(string $faction_name): static { $this->faction_name = $faction_name; return $this; } public function getDescription(): ?string { return $this->description; } public function setDescription(string $description): static { $this->description = $description; return $this; } /** * @return Collection<int, Character> */ public function getCharacters(): Collection { return $this->characters; } public function addCharacter(Character $character): static { if (!$this->characters->contains($character)) { $this->characters->add($character); $character->setFaction($this); } return $this; } public function removeCharacter(Character $character): static { if ($this->characters->removeElement($character)) { // set the owning side to null (unless already changed) if ($character->getFaction() === $this) { $character->setFaction(null); } } return $this; } public function __toString(): string { return $this->faction_name; } }
En este caso estoy usando Doctrine pero existen muchas otras opciones.
Hasta acá el modelo.
Si vas a https://localhost/docs
vas a ver algo como:
Que tampoco está tan mal, ¿no? Una interface Swagger interactiva que describe todas las operaciones disponibles en tu API.
¿Que por qué dice «greetings»? Ah, sí… detallín. Greetings es el modelo dummy que viene con el ejemplo.
Probablemente lo que esperabas era algo más del estilo de:
Lograrlo es bastante fácil, se trata de agregar esta etiqueta al comienzo de la definición de la clase Faction
:
#[ApiResource]
Luego re-cargar y… voilà. La API está lista… o casi.
Actualizar la base de datos
Para poder ingresar/leer datos no sólo se necesita el código PHP, la base de datos también debe estar actualizada.
Usando estos comandos:
docker compose exec php php bin/console make:migration
Y
docker compose exec php php bin/console doctrine:migrations:migrate -q
Estará todo listo para probar la API.
Con el comando:
curl -X 'POST' \ 'https://localhost/factions' \ -H 'accept: application/ld+json' \ -H 'Content-Type: application/ld+json' \ -d '{ "faction_name": "A Faction", "description": "A description" }'
Obtendrás esto como respuesta:
{ "@context": "/contexts/Faction", "@id": "/factions/1", "@type": "Faction", "id": 1, "faction_name": "A Faction", "description": "A description", "characters": [], "factionName": "A Faction" }
Y también unos cuantos encabezados interesantes:
accept-patch: application/merge-patch+json alt-svc: h3=":443"; ma=2592000 cache-control: no-cache,private content-location: /factions/1 content-type: application/ld+json; charset=utf-8 date: Thu,17 Oct 2024 18:01:30 GMT link: <https://localhost/docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation" location: /factions/1 permissions-policy: browsing-topics=() server: Caddy vary: Accept x-content-type-options: nosniff x-debug-token: 0d76d9 x-debug-token-link: https://localhost/_profiler/0d76d9 x-frame-options: deny x-robots-tag: noindex
Todo esto también lo podés ver más cómodamente desde acá:
Y por supuesto, podés probar los otros métodos HTTP.
Agregar lógica de negocio
¿Validaciones dijiste? Ningún problema, hagamos algunas.
¿Qué tal una que no permita dejar vacío el nombre ni la descripción de la Faction?
Symfony al rescate: usá el atributo Symfony\Component\Validator\Constraints\NotBlank
y a otra cosa:
#[ORM\Entity(repositoryClass: FactionRepository::class)] #[ORM\Table(name: '`factions`')] #[ApiResource] class Faction { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 128)] #[NotBlank] private ?string $faction_name = null; #[ORM\Column(type: Types::TEXT)] #[NotBlank] private ?string $description = null;
Si probás ejecutar:
curl -X 'POST' \ 'https://localhost/factions' \ -H 'accept: application/ld+json' \ -H 'Content-Type: application/ld+json' \ -d '{ "faction_name": "", "description": "Not empty" }'
Vas a obtener:
{ "@id": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3", "@type": "ConstraintViolationList", "status": 422, "violations": [ { "propertyPath": "faction_name", "message": "This value should not be blank.", "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3" } ], "detail": "faction_name: This value should not be blank.", "hydra:title": "An error occurred", "hydra:description": "faction_name: This value should not be blank.", "type": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3", "title": "An error occurred" }
Control de acceso
El control de acceso se puede implementar de forma muy sencilla también: a través de la propiedad security
del atributo #[ApiResource]
.
Basta con cambiar el encabezado por:
#[ApiResource(security: "is_granted('ROLE_USER')")] class Faction
Para que, al hacer esta petición:
curl -X 'GET' \ 'https://localhost/factions?page=1' \ -H 'accept: application/ld+json'
La respuesta sea:
{ "@id": "/errors/401", "@type": "hydra:Error", "title": "An error occurred", "detail": "Full authentication is required to access this resource.", "status": 401, "type": "/errors/401", "trace": [ { "file": "/app/vendor/symfony/security-http/Firewall/ExceptionListener.php", "line": 189, "function": "throwUnauthorizedException", "class": "Symfony\\Component\\Security\\Http\\Firewall\\ExceptionListener", "type": "->" }, { "file": "/app/vendor/symfony/security-http/Firewall/ExceptionListener.php", "line": 148, "function": "startAuthentication", "class": "Symfony\\Component\\Security\\Http\\Firewall\\ExceptionListener", "type": "->" }, { "file": "/app/vendor/symfony/security-http/Firewall/ExceptionListener.php", "line": 103, "function": "handleAccessDeniedException", "class": "Symfony\\Component\\Security\\Http\\Firewall\\ExceptionListener", "type": "->" }, { "file": "/app/vendor/symfony/event-dispatcher/Debug/WrappedListener.php", "line": 116, "function": "onKernelException", "class": "Symfony\\Component\\Security\\Http\\Firewall\\ExceptionListener", "type": "->" }, { "file": "/app/vendor/symfony/event-dispatcher/EventDispatcher.php", "line": 220, "function": "__invoke", "class": "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener", "type": "->" }, { "file": "/app/vendor/symfony/event-dispatcher/EventDispatcher.php", "line": 56, "function": "callListeners", "class": "Symfony\\Component\\EventDispatcher\\EventDispatcher", "type": "->" }, { "file": "/app/vendor/symfony/event-dispatcher/Debug/TraceableEventDispatcher.php", "line": 139, "function": "dispatch", "class": "Symfony\\Component\\EventDispatcher\\EventDispatcher", "type": "->" }, { "file": "/app/vendor/symfony/http-kernel/HttpKernel.php", "line": 239, "function": "dispatch", "class": "Symfony\\Component\\EventDispatcher\\Debug\\TraceableEventDispatcher", "type": "->" }, { "file": "/app/vendor/symfony/http-kernel/HttpKernel.php", "line": 91, "function": "handleThrowable", "class": "Symfony\\Component\\HttpKernel\\HttpKernel", "type": "->" }, { "file": "/app/vendor/symfony/http-kernel/Kernel.php", "line": 197, "function": "handle", "class": "Symfony\\Component\\HttpKernel\\HttpKernel", "type": "->" }, { "file": "/app/vendor/symfony/runtime/Runner/Symfony/HttpKernelRunner.php", "line": 35, "function": "handle", "class": "Symfony\\Component\\HttpKernel\\Kernel", "type": "->" }, { "file": "/app/vendor/autoload_runtime.php", "line": 29, "function": "run", "class": "Symfony\\Component\\Runtime\\Runner\\Symfony\\HttpKernelRunner", "type": "->" }, { "file": "/app/public/index.php", "line": 5, "function": "require_once" } ], "hydra:title": "An error occurred", "hydra:description": "Full authentication is required to access this resource." }
Desplegar en Producción
A la hora de desplegar la recomendación es usar el mismo docker compose que trae el framework. ¿Tenés dudas sobre este punto? Acá tenés un recurso que puede ser interesante.
Pues aquí bastará con tener algún servidor donde desplegar (Mi recomendación aquí es usar un sencillo Droplet de DigitalOcean), instalarle docker y crear un contexto especial, de modo de terminar lanzando un comando tipo:
docker compose -c prod docker-compose.yml up -d --wait
Ah! Casi olvido un detalle importante: modificar el archivo .env
para que diga APP_ENV=prod
en lugar de APP_ENV=dev
.
Y listo. Bueno, luego vendrá, si corresponde, mover el DNS y demás pero eso será igual se trate o no de una API.
Qué más se puede hacer con API-Platform
La verdad es que mucho, pero no puedo meter todo en un único post.
Dejo algunas cosas que me parecen sumamente interesantes para que las investigues:
- Scaffolding de clientes
- Comunicación en tiempo real a través de Mercure
- Generación de los modelos a partir de especificaciones OpenApi
¿Te convencí? Dejame tus preguntas en los comentarios.
- Cómo enviarencabezados SOAP desde PHP - 09/12/2024
- Por qué PHP 8 no satisface el requisito ^7.3 de composer - 09/12/2024
- Cómo usar PHPUnit - 03/12/2024