Cuando trabajabas con máquinas virtuales no había dudas: cuanto más completa sea la máquina mejor.
Sí, instalarla por primera vez era un trabajito. Que definir el hardware, el disco, el sistema operativo, instalarl Apache, MySQL, Git… una mañana se te iba en un abrir y cerrar de ojos. Pero funcionaba.
Luego apareció Vagrant y fue como tocar el cielo con las manos… por un tiempo.
Ahora te estás queriendo pasar a Docker y el saber popular dice que hay que tener muchos contenedores pequeños en lugar de un único monolito que tenga todo lo necesario para correr la app.
¿Por qué esa es la mejor opción?
Dejame que te plantee la pregunta al revés: ¿por qué es una buena idea meter todo en una única máquina virtual?
Tal vez te parezca ridículo pero seguime el juego por un momento.
La respuesta es obvia, ¿no?: ¡porque te quedás sin máquina en un segundo!
Ahora bien, si tuvieras memoria infinita y un procesador que no se agota nunca… ¿no preferirías tener una VM por cada proceso?
Imaginate, un Apache en una VM, el MySQL en otra, tal vez un Redis por allá, una VM para correr los cronjobs…
Tan mal no estaría, ¿verdad?
Bueno, tal vez sería compleja la orquestación de todo eso, pero tampoco sería tan terrible.
De esa forma, cada VM podría tener su propio sistema operativo, sus propias dependencias instaladas y, en caso de que algo falle, sería sólo ese servicio el que se vería afectado.
De pronto no suena tan mal, ¿o sí?
Precisamente esa es la idea de usar Docker (O, más correctamente, contenedores): tener diferentes ambientes de ejecución auto-contenidos.
Pero… ¿no vas a caer en el mismo problema de correr muchas VMs en una misma computadora?
No. Acá es donde la cosa se pone más técnica pero digamos que la diferencia principal entre una VM y un contenedor es que la VM virtualiza hardware mientras que el contenedor sólo virtualiza software.
En la práctica, esto quiere decir que el contenedor es mucho más liviano que una VM.
La contracara es que el contenedor te da algo menos de flexibilidad.
Por ejemplo, es muy fácil tener corriendo una VM con Windows y otra con Linux en un host Mac. Hacer lo mismo con contenedores… no tanto.
Ahora bien, si tus contenedores usan el mismo sistema operativo de base (Por ejemplo son todos Linux), en un mismo host podés albergar significativamente más contenedores que VMs.
La siguiente pregunta es… administrar tantos contenedores ¿no se vuelve complejo?
Y… la respuesta es que un poco sí.
Ahí es donde aparece docker-compose para salvar el día. Tema para otro post.
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.
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.
{
"@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
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.
Estoy tratando de leer un xml el cual contiene namespaces, que pesadilla 🙁.
La verdad es bastante confuso para mi ese tema; he tratado de varias maneras pero hasta ahora sin éxito, por ahí en la web nos indicaron que lo mas conveniente es hacerlo mediante DOM, estuve leyendo la documentación en el sitio oficial de php, pero en serio que no logro ni siquiera ingresar al nodo principal.
Me recomendaron usar la librería SimpleXMLElement, pero es peor, siempre devuelve null.
¿Tu historia se parece a esta? No estás solo.
Personalmente, el formato XML me ha dado más dolores de cabeza de los que me gusta recordar… de hecho, creo que quien lo inventó debía ser un poco sádico.
Pero bueno… lamentablemente, aún hay quienes lo usan así que… será mejor intentar amigarse con él ¿no?
Voy a intentar ilustrar la solución a este problema usando un ejemplo real.
Imaginá que recibiste un texto como este:
<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope
xmlns:s="http://www.w3.org/2003/05/soap-envelope"
xmlns:a="http://www.w3.org/2005/08/addressing"
xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<s:Header>
<a:Action s:mustUnderstand="1">http://wcf.dian.colombia/IWcfDianCustomerServices/GetStatusZipResponse</a:Action>
<o:Security
xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1">
<u:Timestamp u:Id="_0">
<u:Created>2022-07-16T21:22:34.242Z</u:Created>
<u:Expires>2022-07-16T21:27:34.242Z</u:Expires>
</u:Timestamp>
</o:Security>
</s:Header>
<s:Body>
<GetStatusZipResponse
xmlns="http://wcf.dian.colombia">
<GetStatusZipResult
xmlns:b="http://schemas.datacontract.org/2004/07/DianResponse"
xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
<b:DianResponse>
<b:ErrorMessage
xmlns:c="http://schemas.microsoft.com/2003/10/Serialization/Arrays">
<c:string>Regla: FAJ41, Notificación: El contenido de este elemento no corresponde al nombre y código valido.</c:string>
<c:string>Regla: FAJ73, Notificación: Estructura código no valida</c:string>
</b:ErrorMessage>
<b:IsValid>true</b:IsValid>
<b:StatusCode>00</b:StatusCode>
<b:StatusDescription>Procesado Correctamente.</b:StatusDescription>
<b:StatusMessage>La Factura electrónica SETP-99111223, ha sido autorizada.</b:StatusMessage>
<b:XmlBase64Bytes></b:XmlBase64Bytes>
<b:XmlBytes i:nil="true"/>
<b:XmlDocumentKey></b:XmlDocumentKey>
<b:XmlFileName>face_f0900056122003b023380</b:XmlFileName>
</b:DianResponse>
</GetStatusZipResult>
</GetStatusZipResponse>
</s:Body>
</s:Envelope>
Y necesitás obtener la información de las etiquetas:
Este xml no es cualquier xml. Se trata de parte de una respuesta emitida por un webservice SOAP con lo cual, probablemente la mejor opción para resolver este problema sea evitarlo directamente, es decir, apoyarte en la clase SOAPClient.
De hecho, si lo miras con un poco más de detenimiento, se trata de una factura electrónica generada para Colombia. Si tu problema se parece a ese tal vez te convenga revisar este artículo.
Dicho esto, supongamos que, por alguna razón, esa opción no está disponible y no queda otra que interpretar a mano el xml.
Recargá el café y comencemos.
Usando xpath
Una primera aproximación a esto sería usar un código tipo:
<?php
$xml = new SimpleXMLElement($xmlString);
$created = $xml->xpath("//s:Envelope/s:Header/o:Security/u:Timestamp/u:Created");
print_r($created);
Pero al ejecutarlo vas a obtener algo como:
Warning: SimpleXMLElement::xpath(): Undefined namespace prefix in /app/index.php on line 3
¿Qué pasó?
El problema es que xpath no conoce el mapeo de prefijos (s:, o: y u:) a las respectivas definiciones de namespaces y, por lo tanto, no será capaz de interpretar el texto del XML.
Una solución posible para esto es ayudar un poco al pobre xpath.
Y, a partir de ahí, si lo que efectivamente quieres es obtener la fecha puedes valerte de la clase DateTime o, su versión inmutable, DateTimeImmutable:
<?php
$xml = new SimpleXMLElement($xmlString);
$xml->registerXPathNamespace('s', 'http://www.w3.org/2003/05/soap-envelope');
$xml->registerXPathNamespace('o', "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd");
$xml->registerXPathNamespace('u', "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd");
$createdValue = $xml->xpath("//s:Envelope/s:Header/o:Security/u:Timestamp/u:Created")[0][0]->__toString();
try {
$createdDate = new DateTimeImmutable($createdValue);
echo "Created at: ".$createdDate->format("D M d H:i:s O").PHP_EOL;
} catch (DateMalformedStringException $e) {
echo $e->getMessage().PHP_EOL;
}
Usando las propiedades del objeto SimpleXMLElement
En esta versión, en lugar de ir por el xpath lo haremos ingresando directamente a las propiedades del objeto SimpleXMLElement.
Cada uno de los elementos definidos en tags hijos del elemento principal (<s:envelope> en este caso) se convierte en forma automática, en una propiedad de sus elementos hijos.
Warning: Attempt to read property "Security" on null in /app/index.php on line 53
Fatal error: Uncaught Error: Call to a member function children() on null in /app/index.php:54
El problema aquí es que la identificación de esos hijos también depende del correcto mapeo del Namespace a su definición, con lo cual, es necesario indicar este hecho para que la navegación funcione
En conclusión, trabajar con namespaces y XML no es tan complejo como puede parecer. Bueno, tan no complejo como XML permite al menos.
Sólo se trata de tener en cuenta cómo funcionan los namespaces y darle un poquito de guía a la librería SimpleXMLElement para que pueda hacer su magia como siempre.
Las integraciones con las APIs de Twitter y Google no esconden ningún secreto.
Sólo te falta probar el último feature.
Le das a correr y…
Timeout.
Lo qué??
Revisás el código de arriba abajo… ¿Cómo es posible?
Si hace 5 minutos funcionaba de 10…
Levantás la vista y ves como el router te sonríe con sorna:
Como diciéndote: A ver cuándo vas a aprender quién manda acá.
¿Otra vez se cayó Internet?
¿De nuevo vas a tener que llamar al proveedor para que lo reparen de una buena vez?
Y… otra no queda… si hay que testear la API… se necesita conectividad…
¿O no?
Y de pronto… muy bajito en la distancia se escucha la voz del maestro Yoda susurrando «Tests escribir debes».
Exacto. Si querés tener un entorno sólido de desarrollo tenés que aislarte de las dependencias de terceros.
Cómo escribir tests que no dependan de terceros
Las soluciones son múltiples y mucho dependerá del contexto pero todas siguen la misma lógica: en vez de depender de un servicio poco confiable (O al menos, uno que está fuera de tu control), usá un doble del cual tengas control total.
Usar un doble de test a nivel unitario
Empecemos por un ejemplo simple a nivel de test unitario.
Imaginemos que tenés un código que usa una librería como esta y es algo del estilo de:
<?php
declare(strict_types=1);
namespace App;
use Exception;
use GuzzleHttpExceptionGuzzleException;
use NowehTwitterApiClient;
readonly class TwitterMonitor
{
public function __construct(private Client $client)
{
}
/**
* @return array<Mention>
* @throws Exception
* @throws GuzzleException
*/
public function getRecentMentions(string $uid): array
{
$mentions = $this->client
->timeline()
->getRecentMentions($uid)
->performRequest()
;
foreach ($mentions->data as $mention) {
$return[] = new Mention($mention);
}
return $return;
}
}
Está claro que, si $client->performRequest() falla, por ejemplo por falta de conectividad, todo lo que sigue va a fallar.
Algo que podrías hacer en tu test es usar un mock. Sería algo así como:
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use App\Mention;
use App\TwitterMonitor;
use GuzzleHttp\Exception\GuzzleException;
use Noweh\TwitterApi\Timeline;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Noweh\TwitterApi\Client;
class TwitterMonitorShould extends TestCase
{
const string UID = "1346889436626259968";
private static function a2stdClass(array $array): stdClass
{
return json_decode(json_encode($array));
}
/**
* @throws Exception|GuzzleException
*/
#[Test]
public function returnUsersLastMentions(): void
{
$timeline = $this
->getMockBuilder(Timeline::class)
->disableOriginalConstructor()
->getMock();
$timeline
->expects($this->once())
->method('getRecentMentions')
->with(self::UID)
->willReturn($timeline);
$mentionsData = self::a2stdClass([
'data' => [
[
"author_id" => "2244994945",
"created_at" => "Wed Jan 06 18:40:40 +0000 2021",
"id" => "1346889436626259968",
"text" => "Learn how to use the user Tweet timeline and user mention timeline endpoints in the X API v2 to explore Tweet\\u2026 https:\\/\\/t.co\\/56a0vZUx7i",
"username" => "XDevelopers"
]
]
]);
$timeline
->expects($this->once())
->method('performRequest')
->willReturn($mentionsData);
$client = $this
->getMockBuilder(Client::class)
->disableOriginalConstructor()
->getMock();
$client
->expects($this->once())
->method('timeline')
->willReturn($timeline);
$userMonitor = new TwitterMonitor($client);
$this->assertEquals([new Mention($mentionsData->data[0]),], $userMonitor->getRecentMentions(self::UID));
}
}
De esta forma, cuando ejecutes tus tests con phpUnit la llamada a Twitter no se realizará, si no que será el doble quien responda con la respuesta pre-armada.
Usar un servicio mockeado
Esta técnica puede ser algo más compleja de implementar pero probablemente sea mejor si lo que buscás es hacer algún tipo de test de caja negra.
La idea aquí es «engañar» a tu aplicación para que crea que está hablando con el servicio real cuando, en realidad, está interactuando con uno local.
Si no querés montarte tu propio servicio, podés usar una herramienta como mockoon, la idea es que puedas hacerle peticiones como si estuvieras yendo contra el servicio real.
Es importante comprender que lo que podés testear es aquello que controlás, para todo lo demás tenés que asumir que el comportamiento esperado es el real. En otras palabras: dado que los terceros hacen su parte como se espera, tu aplicación debe comportarse como se espera, en caso de que no se cumpla la premisa no estás obligado a dar garantías.
Obviamente, te conviene dar garantías de que tu aplicación nunca va a generar daños pero esa es otra historia.
Veamos cómo sería en este caso.
Ante todo, tenemos que averiguar a qué URL estamos haciendo los requests. Esta parte no es muy compleja: se trata de seguir la cadena de llamadas hacia atrás:
Empezamos por Client::timeline() donde nos encontramos con que la respuesta es un objeto Timeline, el cual será el que eventualmente ejecutará el método performRequest.
Entramos un poco más y nos encontramos con:
if ($this->auth_mode === 0) { // Bearer Token
// Inject the Bearer token header
$client = new Client(['base_uri' => self::API_BASE_URI]);
$headers['Authorization'] = 'Bearer ' . $this->bearer_token;
} elseif ($this->auth_mode === 1) { // OAuth 1.0a User Context
// Insert Oauth1 middleware
$stack = HandlerStack::create();
$middleware = new Oauth1([
'consumer_key' => $this->consumer_key,
'consumer_secret' => $this->consumer_secret,
'token' => $this->access_token,
'token_secret' => $this->access_token_secret,
]);
$stack->push($middleware);
$client = new Client([
'base_uri' => self::API_BASE_URI,
'handler' => $stack,
'auth' => 'oauth'
]);
} else { // OAuth 2.0 Authorization Code Flow
throw new RuntimeException('OAuth 2.0 Authorization Code Flow had not been implemented & also requires user interaction.');
}
Es decir: la pieza clave aquí es la constante AbstractController::API_BASE_URI, cuya definición no es otra que https://api.twitter.com/2/… qué sorpresa, ¿no?
Vamos a usar este dato para configurar nuestro proxy de modo de que la aplicación jamás se entere de que está hablando con un doble y no con el verdadero Twitter.
Con esto estarás listo para tener un servidor mockeado para empezar a jugar
Por ejemplo, podrías cambiar el contenido de la respuesta que generó Mockoon para que retorne:
"data": [
{
"author_id": "22123122345",
"created_at": "Wed Oct 23 18:10:20 +0000 2024",
"id": "2346889456626258962",
"text": "Check out this article by @mchojrin!",
"username": "PHP4Ever"
}
],
Y probar tu aplicación vía CLI o web, depende del caso a ver si está levantando esta respuesta.
Bueno… en realidad todavía vas a tener un pequeño inconveniente en este caso en particular: la librería tiene la URL de Twitter hardcodeada y vos necesitás que Mockoon atrape todos esos requests.
Hay varias soluciones que podés tomar para esto, las nombro solamente para no irme demasiado del tema de este post, en todo caso, escribiré algún otro detallando:
Cambiar el código de la librería para que apunte, en lugar de a twitter.com a localhost:3000 (La mala)
Montar un proxy en tu computadora que redirija el tráfico destinado a twitter.com hacia localhost:3000 (La fea)
Derivar una clase de la que te propone la librería y hacer que la URL base sea un parámetro del constructor (La buena)
Más allá de cuál sea la solución que tomes, con este esquema vas a poder testear End-to-End sin depender de la conectividad.
El lado oscuro de los dobles
Si bien estas técnicas son muy útiles para trabajar tranquilo, dependen de un hecho fundamental: las respuestas hechas a mano deben coincidir (en estructura al menos) con aquellas devueltas por el servicio real.
Si bien esto puede parecer obvio, es importante recordarlo porque muchas veces la propia documentación de la API contra la que estás trabajando está desactualizada y, en ese caso… nada más cierto que la realidad misma.
A que adivino: apareció un bug en producción justo cuando estabas por irte a casa.
Viendo como el plan del fin de semana se aleja en el horizonte pensaste: basta. Es hora de tener tests automatizados.
Ok, tal vez la historia real no sea exactamente esta, pero apuesto a que estuve cerca.
Pues bien, el primer paso obligado es instalar phpUnit.
Esta parte es fácil:
composer require --dev phpunit/phpunit
Ojo con esto, verificá que la versión de phpunit sea compatible con tu versión de php.
Para este post asumiré que estás trabajando sobre php 8.3, con lo cual, la versión de phpunit que te corresponde es la 11. Es decir que el comando correcto sería:
composer require --dev phpunit/phpunit ^11
Antes de continuar, verificá que está todo en su lugar con:
./vendor/bin/phpunit --version
Si ves algo como:
PHPUnit 11.0.0 by Sebastian Bergmann and contributors.
Está todo listo para el siguiente paso: escribir un test.
Acá hay unas cuantas posibilidades dependiendo de la naturaleza de tu aplicación pero, para dejar este post de un tamaño razonable voy a hacer unas cuantas suposiciones (Si querés explorar algún caso más particular dejame un comentario).
Vamos a imaginar que querés testear una clase llamada EmailValidator que, oh sorpresa, se encarga de determinar si un string constituye o no, un correo válido.
Voy a poner el foco en el test, con lo cual, no me importa en este momento si EmailValidator hace lo que tiene que hacer o no, lo que me importa es poder escrbir un test que me permita verificarlo en todo momento.
Tu primer test con phpUnit
Como dije antes, voy a hacer unas cuantas suposiciones. Una de ellas es que tu proyecto está estructurado de un modo similar a:
/src
--/EmailValidator.php
/tests
composer.json
Y que el archivo composer.json define algún tipo de autoloading, con lo cual, un archivo tests/EmailValidatorTest.php que se va así debería funcionar:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
class EmailValidatorTest extends TestCase
{
#[Test]
public function do_something(): void {
$this->assertTrue(true);
}
}
Este primer test, como te habrás dado cuenta, es tautológico, no te preocupes, no lo vamos a dejar así, es un paso temporal para validar que el framework de testing está bien configurado.
Ejecutalo con:
./vendor/bin/phpunit tests
Y si ves algo como
PHPUnit 11.4.2 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.0RC1
. 1 / 1 (100%)
Time: 00:00.003, Memory: 6.00 MB
OK (1 test, 1 assertion)
Está todo en orden y podés pasar a la lógica de la verificación.
Ahora bien… ¿qué debería validar el test? Bueno… esto puede ponerse algo filosófico, por ahora lo dejo bien concreto: lo que debe verificar es que EmailValidator responde «sí» (o true) cuando se le pide validar un email y «no» (o false) cuando el parámetro es un string no válido como email.
En otras palabras: el test debe asegurar, con un umbral razonable de confianza, que la clase EmailValidator es capaz de reconocer direcciones de correo electrónico o, tal vez, es capaz de determinar si un string es o no un correo electrónico.
En todo caso, el test podría escribirse de este modo:
<?php
declare(strict_types=1);
use App\EmailValidator;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
class EmailValidatorTest extends TestCase
{
#[Test]
public function should_determine_whether_a_string_is_a_valid_email(): void {
$validator = new EmailValidator();
$this->assertTrue($validator->isValid("mauro.chojrin@leewayweb.com"));
}
}
Y, al ejecutarlo, lo esperable es que pase. Salvo, por supuesto, que el EmailValidator no esté correctamente implementado, algo que, para este ejemplo, asumiré que no es el caso.
Bien, con esto tenemos cubierto el caso positivo (Al menos uno de ellos), faltaría cubrir el caso negativo, es decir, que el EmailValidator sabe reconocer falsos correos.
Para eso podríamos hacer algo como:
<?php
declare(strict_types=1);
use App\EmailValidator;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
class EmailValidatorTest extends TestCase
{
#[Test]
public function should_determine_whether_a_string_is_a_valid_email(): void {
$validator = new EmailValidator();
$this->assertTrue($validator->isValid("mauro.chojrin@leewayweb.com"));
$this->assertFalse($validator->isValid("mauro.chojrin.leewayweb.com"));
}
}
No está necesariamente mal tener más de un assertion por test aunque, personalmente, preferiría separar los datos de la lógica del test.
Para eso, la recomendación es usar un test parametrizado:
<?php
declare(strict_types=1);
use App\EmailValidator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
class EmailValidatorTest extends TestCase
{
public static function dataProvider(): array
{
return [
[ "mauro.chojrin@leewayweb.com", true ],
[ "mauro.chojrin.leewayweb.com", false ],
];
}
#[Test]
#[DataProvider("dataProvider")]
public function should_determine_whether_a_string_is_a_valid_email(string $candidate, bool $isEmail): void {
$validator = new EmailValidator();
$this->assertEquals($isEmail, $validator->isValid($candidate));
}
}
De esta forma, cuando quieras agregar casos de prueba bastará con modificar el método dataProvider, por ejemplo así:
Y así podés seguir construyendo más y más casos de prueba hasta que te sientas seguro de que la implementación de la clase cumple sus requerimientos.
Claro que hay mucho (¡mucho!) más para aprender pero bueno… por algún lado hay que empezar, ¿no?
Por si tenés problemas en el camino
Algunas cosas que pueden fallarte en el camino y que son simples de resolver:
Faltan extensiones de php
phpUnit requiere de estas extensiones:
ext-dom
ext-mbstring
Dependiendo de tu ambiente de trabajo es posible que no se encuentren disponibles apenas arrancás. Si este es el caso, las podrás instalar usando el manejador de paquetes de tu sistema operativo.
No se reconocen las clases productivas
Es posible que la primera vez que ejecutes tu código te encuentres con un problema como este:
PHPUnit 11.4.2 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.0RC1
Configuration: /home/mauro/Code/phpunit101/phpunit.xml
EE 2 / 2 (100%)
Time: 00:00.006, Memory: 8.00 MB
There were 2 errors:
1) EmailValidatorTest::should_determine_whether_a_string_is_a_valid_email with data set #0 ('mauro.chojrin@leewayweb.com', true)
Error: Class "App\EmailValidator" not found
/home/mauro/Code/phpunit101/tests/EmailValidatorTest.php:23
2) EmailValidatorTest::should_determine_whether_a_string_is_a_valid_email with data set #1 ('mauro.chojrin.leewayweb.com', false)
Error: Class "App\EmailValidator" not found
/home/mauro/Code/phpunit101/tests/EmailValidatorTest.php:23
ERRORS!
Tests: 2, Assertions: 0, Errors: 2.
Si eso pasa, verificá cómo está configurado tu autoloading en tu archivo composer.json. Debería verse así:
Es hora de instalar alguna que otra librería. Sin dudarlo un segundo arranca la seguidilla de composer require.
Escribes algo de código, realizas tus pruebas, todo listo para hacer un commit.
git add .
git commit -m "Initial commit"
Y de pronto… algo llama tu atención.
¿Por qué se están agregando dos archivos de composer?
Más específicamente, ¿por qué el composer.jsony el composer.lock?
¿Acaso el .lock no se genera automáticamente al ejecutar composer install?
Es más, dando una rápida mirada se ve que el .lock pesa mucho más (¡muchísimo más!) que el .json:
¿Es realmente necesario engordar el repo con este archivo?
Respuesta corta: sí.
¿Querés saber por qué?
Te lo explico a continuación.
composer.json vs. composer.lock
Ambos archivos tienen formato json, así que, por este lado no pasa el tema.
¿Qué tiene composer.lock que no tenga composer.json?
Si abrís ambos archivos notarás que son parecidos, pero no iguales.
En principio, en el composer.json se almacenan los patrones de dependencias, mientras que en el composer.lock se almacenan las dependencias con sus versiones exactas (Más las dependencias de esas dependencias y otro montón de información).
En otras palabras, en el archivo composer.json se almacena la mínima información necesaria para generar el composer.lock
Ya sé, ya sé, todavía no respondí a la pregunta: si puedo generar el composer.lock a partir del composer.json… ¿para qué quiero comitear el primero?
La respuesta radica en el hecho de que un patrón de dependencias puede resolverse con muchas opciones diferentes.
Por ejemplo,
"doctrine/common": "^3.4"
Podría satisfacerse con doctrine/common en sus versiones :
3.4.0
3.4.1
3.4.2
3.4.3
3.4.4
3.4.5
Aunque no sería compatible con 3.3.* ni con 3.5.*.
El sistema de restricciones de versiones tiene sus vueltas pero si querés aprender más podés consultarlo directamente acá.
Y todo esto es importante porque…?
Porque, si hiciste tus pruebas con la versión x.y.z de una librería y luego, al momento de instalar la aplicación, sin que te des cuenta instalás la versión x.y.z+1 es posible que te encuentres con ese tipo de sorpresitas que a nadie le gustan.
De hecho, una de las razones más importantes que dieron origen a Composer es precisamente esta, asegurarte de que las versiones de las dependencias estén sincronizadas entre entornos.
Por eso el archivo se llama .lock, porque las dependencias están cerradas.
Así que, si pensabas ahorrarte algunos bytes en el repo… me temo que no estás de suerte en esta ocasión.
Cuando accedés usando el navegador todo funciona a las mil maravillas.
Está todo listo para ir a producción.
O casi.
Existen algunas pequeñas tareas que hay que hacer por fuera de la web. Limpiar archivos viejos… borrar las cuentas de usuario inactivas… lo típico, bah.
Si la URL a la que ingresás es http://localhost:8080… el host de la db ¿no debería ser localhost?
Probar no cuesta mucho… cambiás db por localhost y… ¡funciona!
Listo, vamos a producción.
mmm, mejor hacemos una última prueba del sitio, ¿no?
Boom. Error 500.
PHP Fatal error: Uncaught PDOException: SQLSTATE[HY000] [2002] No such file or directory in...
¿Cómo es posible? Si recién funcionaba…
¿Es que acaso es imposible hacer funcionar la web y el script de CLI sobre Docker?
No, claro que no.
Veámoslo paso a paso.
Qué pasa cuando se accede vía web
Cuando accedés a tu aplicación a través de la url http://localhost:8080, a pesar de que diga localhost, no es realmente tu computadora la que atiende esa petición (Bueno técnicamente sí lo es, pero a través de Docker).
El :8080 juega un papel muy importante.
Para que esto funcione, el puerto 8080 debe estar mapeado al puerto 80 del webserver que está corriendo en tu contenedor Docker.
Esto significa que, si bien tu computadora está escuchando a través del puerto 8080, lo que hace es re-enviar todo el tráfico recibido a través de él hacia el puerto 80 dentro del contenedor Docker configurado a tal efecto (Probablemente esto está definido en el archivo docker-compose.yml).
Distinto sería el caso si estuvieses usando el servidor incorporado a php (Es decir, si iniciaras tu aplicación vía php -S localhost:8080). En tal caso, la web y el CLI estarían usando el mismo entorno y, por lo tanto, no tendrías problemas.
Veamos ahora qué es lo que ocurre en el otro caso.
Qué pasa cuando se accede vía CLI
Lo primero que debés comprender es qué es exactamente lo que se ejecuta cuando hacés php my_script.php.
Ante todo, estás invocando al intérprete de php pasándole como argumento la cadena my_script.php.
Hasta aquí supongo que no hay nada muy novedoso, ¿cierto?
El problema comienza cuando tu script depende de configuraciones de entorno, como en este ejemplo.
Lo que ocurre es que, precisamente, el entorno de tu host es diferente del de los contenedores Docker. De eso se tratan los contenedores: de unidades de ejecución aisladas del host.
De hecho, Docker tiene su propio manejo de redes interno.
Esto quiere decir que el nombre db dentro del contenedor está asociado con una IP, mientras que fuera del entorno Docker no.
Cómo solucionar el problema
Ahora que tenés claro por dónde pasa el problema, la solución es ejectuar el script de PHP dentro del contenedor.
Tenés varias formas de hacerlo. Te comento rápidamente dos de ellas:
Con docker-compose
Si estás usando docker-compose podés ejecutar el siguiente comando:
docker-compose exec my_service php my_script.php
Suponiendo que el servicio donde está tu script está activo y se llama my_service, lo que verás en pantalla será el resultado de la ejecución de tu script.
Sin docker-compose
Si no usás docker-compose podés usar un comando del estilo de:
Este ejemplo es muy similar al de arriba. La principal diferencia es que en lugar de referirte a un servicio en ejecución, tenés que referirte directamente a un contenedor (my_container en este ejemplo) y tenés que montar los volúmenes en forma explícita.
Si el contenedor está corriendo el resultado que obtendrás será el mismo que el anterior.
Un pequeño consejo
Algo que puede ayudarte a evitar este tipo de situaciones es eliminar el php de tu host.
De esta forma, no vas a tener más opción que ejecutarlo dentro de Docker cuando así lo requieras.
Es cierto, es una opción algo extremista pero aún así puede resultarte útil ya que de esta forma siempre estarás seguro de que la versión de php que está utilizando tu script es exactamente la que esperás.
…me he bajado una imagen que contiene wordpress con letsencrypt, pero la versión de php que utiliza es la 5.6 y necesito actualizarla a la 7.2. ¿Hay alguna forma de modificar esto desde dentro o desde fuera del contenedor?
Qué dilema, ¿no?
La primera pregunta que se me ocurre es ¿realmente te bajaste una imágen y no un Dockerfile?
Por un rato asumiré que así es… después lo volvemos a revisar.
Así que, teniendo esta imagen podés construir contenedores pero… ¿si hacés la actualización y el contenedor se destruye? ¡Olvidate de la actualización! No suena muy divertido, ¿no? ¿Y entonces? ¿¿No hay salida??
Bueno… no tan rápido.
Un comando de Docker no muy usado (ni muy comentado honestamente) es el comando commit el cual puede venir bastante bien en esta situación.
Este comando permite crear una imagen basada en el estado actual de un contenedor. Es decir, es una forma de crear una imagen sin tener un Dockerfile.
En este hipotético escenario se podría hacer algo del estilo:
docker run -it wordpress:php5.6 bash
Y, una vez dentro, realizar la actualización siguiendo algún tutorial como este por ejemplo.
El problema, o mejor dicho, el primero de los problemas, es que esta versión de php es tan vieja que las imágenes que la contienen suelen estar basadas en versiones igual de viejas el sistema operativo. Esto implica que los repositorios ya quedaron obsoletos… en fin, que hacer esta actualización será un viajecito en sí mismo (Sí, te lo digo por experiencia después de haber probado unas 10 imágenes tratando de armar un ejemplo para este post).
Pero bueno… supongamos que, de alguna forma, te las arreglaste para actualizar el php.
Mientras el contenedor no se elimine, podrás usar docker ps -a para obtener algo como:
Y, una vez obtenido el ID del contenedor:
docker commit 9d50869ba044 wordpress:php5.6.muc
Esto te creará una nueva imagen para usar en tu sistema con la cual podrás crear un nuevo contenedor, por ejemplo:
docker run -it wordpress:php5.6.muc bash
Y ahora sí, al hacer php -v encontrarás la versión de php que necesitabas.
¡Genial! ¿No? Bueno… no exactamente.
Para empezar, la nueva versión de php no necesariamente será compatible con tu versión de WordPress… es más, lo más probable es que ese no sea el caso.
Aún si lo fuera… ¿vale la pena ponerse a actualizar la versión de PHP? La verdad es que lo veo bastante poco práctico.
Precisamente, la idea de usar Docker es crear contenedores desechables. ¿Quedó desactualizada la versión de php? Ningún problema, armemos una nueva imagen con la versión que se se requiere y listo.
De hecho, lo más probable es que lo que hayas descargado sea un Dockerfile más que una mera imagen.
Si ese es el caso, podrías hacer algo tan sencillo como modificar la imagen usada como base y re-construir la imagen.
Es decir, si el Dockerfile se ve algo así como:
# Base image
FROM php:5.6-apache
# Install the mysqli extension
RUN docker-php-ext-install mysqli
# Update repo
RUN apt-get update -y
# Install mysql-client
RUN apt-get install mysql-client -y
# Install wp-cli as wp
RUN curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && \
chmod +x wp-cli.phar && \
mv wp-cli.phar /usr/local/bin/wp
# Download and extract WordPress files on /var/www/html
RUN wp core download --allow-root
# Get args from docker-composer.yml
ARG WORDPRESS_DB_NAME
ARG WORDPRESS_DB_USER
ARG WORDPRESS_DB_PASSWORD
ARG WORDPRESS_DB_HOST
# Creating wp-config.php file on /var/www/html
RUN wp config create --dbname=${WORDPRESS_DB_NAME} --dbuser=${WORDPRESS_DB_USER} --dbpass=${WORDPRESS_DB_PASSWORD} --dbhost=${WORDPRESS_DB_HOST} --allow-root --skip-check
COPY entrypoint.sh /entrypoint.sh
# makes the script executable
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["apache2-foreground"]
Podrías cambiarlo por
# Base image
FROM php:7.2-apache
# Install the mysqli extension
RUN docker-php-ext-install mysqli
# Update repo
RUN apt-get update -y
# Install mysql-client
RUN apt-get install mysql-client -y
# Install wp-cli as wp
RUN curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && \
chmod +x wp-cli.phar && \
mv wp-cli.phar /usr/local/bin/wp
# Download and extract WordPress files on /var/www/html
RUN wp core download --allow-root
# Get args from docker-composer.yml
ARG WORDPRESS_DB_NAME
ARG WORDPRESS_DB_USER
ARG WORDPRESS_DB_PASSWORD
ARG WORDPRESS_DB_HOST
# Creating wp-config.php file on /var/www/html
RUN wp config create --dbname=${WORDPRESS_DB_NAME} --dbuser=${WORDPRESS_DB_USER} --dbpass=${WORDPRESS_DB_PASSWORD} --dbhost=${WORDPRESS_DB_HOST} --allow-root --skip-check
COPY entrypoint.sh /entrypoint.sh
# makes the script executable
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["apache2-foreground"]
Y ejecutar nuevamente docker build.
Ojo: ¡Esto no resuelve el problema de la incompatibilidad de la versión de PHP y de WordPress! Pero al menos te ahorra unos cuantos dolores de cabeza tratando de hacer la migración.
Mi consejo al final sería pasar todos los datos a volúmenes, buscar una imagen que tenga las versiones del software que querés usar y con esa imagen crear un nuevo contenedor donde montarlos.
Probablemente eso sea mucho más sencillo y eficiente.
¿Tu aplicación necesita generar algún gráfico usando GD? ¿O tal vez algo más común como parsear un XML?
Muchas de las funciones de bajo nivel de php están disponibles a través de extensiones que probablemente no se encuentren instaladas en tu imagen base.
¿Qué puedes hacer?
Las opciones son varias:
Buscar una nueva imagen base que sí las tenga instaladas
Descargar el código fuente, compilar e instalar manualmente las extensiones
En general, si usás una imagen basada en alguna de las imágenes oficiales de php, vas a tener acceso a este simpático script.
Para usarlo basta con invocarlo de esta forma:
docker-php-ext-install <EXTENSION>
Por ejemplo, para instalar la extensión zip se puede usar:
docker-php-ext-install zip
Es probable que, en algunas imágenes, esto genere un error debido a la falta de las librerías de base requeridas, en el caso de zip se trata de libzip-dev.
Para resolver este problema se requiere, previa a la invocación del script docker-php-ext-install, realizar la instalación correspondiente, por ejemplo usando:
apt install -y libzip-dev
Claro que, si esto se hace dentro de un contenedor en ejecución este cambio será efímero, es decir, si el contendor se destruye habrá que repetir todo el proceso.
Cómo persistir el cambio en la imagen
Lo mejor, si se pretende que el cambio persista más allá de la vida del contenedor en particular, es realizar estos cambios en el Dockerfile.
Algo así como:
FROM php:7.4.33-apache
LABEL authors="Mauro Chojrin <mauro.chojrin@leewayweb.com>"
RUN apt update && \
apt install -y libzip-dev && \
docker-php-ext-install zip
De esta forma, al realizar un build, la imagen resultante incluirá la extensión que se requiere y, por lo tanto, cualquier contenedor que la utilice también la tendrá disponible.
Una vez tienes dockerizada tu aplicación, lo siguiente que querrás hacer, como para terminar de dejar atrás el viejo XAMPP, será acceder a tu base de datos en forma gráfica, ¿cierto?.
Tendrás acceso a tu sitio a través de http://localhost:8888
Cada cambio que realices se reflejará automáticamente al recargar la página
Lo que faltaría sería poder ingresar a alguna dirección local (Por qué no http://localhost:9999) y ver algo como:
Como de costumbre, existen varias opciones para lograrlo. La que considero más conveniente es crear un nuevo contenedor que pueda ejecutar phpMyAdmin. Veámoslo.
phpMyAdmin en un contenedor nuevo
Básicamente, lo que se necesita es un nuevo contenedor que contenga un servidor web y pueda conectarse a la base de datos que tiene tu entorno.
En otras palabras, basta con ejecutar un comando del estilo:
docker run --name phpmyadmin -d --network dockerized_rukovoditel_342_default -p 9999:80 phpmyadmin
La red dockerized_rukovoditel_342_default corresponde a la red creada durante la ejecución de docker-compose up en mi ambiente, en el tuyo seguramente será algo diferente. En todo caso, siempre puedes usar docker network ls para averiguarlo.
Con esto ya tenemos lo suficiente como para ingresar a http://localhost:9999 y trabajar cómodamente con phpMyAdmin.
Claro que tener que usar este comando no es lo más práctico del mundo, ¿no?
Completemos el trabajo metiendo la nueva configuración dentro del docker-compose.yml.
phpMyAdmin en un contenedor nuevo manejado por docker-compose
Como podrás imaginar, sólo se trata de traducir la llamada directa al cliente de docker a la sintaxis YAML utilizada por docker-compose. Es decir, se trata de sumar esta definición a la sección services del archivo docker-compose.yml: