Blog

  • Cómo escribir pruebas unitarias para valores aleatorios en php

    Cómo escribir pruebas unitarias para valores aleatorios en php

    Te decidiste: llegó el momento de implementar phpUnit en proyecto. Emocionante, ¿no? Por fin vas a poder considerarte un desarrollador profesional.

    Ya instalaste las herramientas, te leiste unos cuantos tutoriales… todo listo.

    Los primeros tests no fueron tan complicados. Llegar al esperado verde costó un poco al comienzo pero lo sacaste.

    Y justo cuando la cosa se ponía interesante, te encontrás con:

    <?php
    
    class MyClass
    {
        public function testableMethod(int $param) : int
        {
            if (rand(1, 100) < 50) {
                return $param * 2;       
            } else {
                return $param * 3;
            }
        }
    }

    ¿Y ahora?

    ¿Cómo verificar que el método hace lo que tiene que hacer si el resultado depende de algo que no podés controlar?

    Está claro que TDD no funcionará para este caso… mejor dejar todo esto atrás y seguir trabajando como hasta ahora… al fin y al cabo, tan mal no te ha ido, ¿cierto?

    Si estás pensando esto, te propongo que sigas leyendo. Hay una salida a este dilema.

    La solución se basa en una característica muy popular, aunque muchas veces no muy comprendida, de la Programación Orientada a Objetos: la herencia.

    ¿Cómo? ¿Qué tiene que ver la herencia en todo esto? Veámoslo.

    Preparando el código PHP para las pruebas unitarias

    Tu primera aproximación a un test probablemente se verá similar a:

    <?php
    
    use PHPUnit\Framework\TestCase;
    
    use MyClass;
    
    class MyClassTest extends TestCase
    {
        public function testTestableMethod()
        {
            $sut = new MyClass();
            $this->assertEquals(..., $sut->testableMethod(5));
        }
    }

    El problema es, precisamente, con qué rellenar los .... Pues bien, ahí es donde viene la magia.

    ¿Qué tal si en lugar de testar directamente MyClass usaras una instancia de algo muy parecido a MyClass? Por ejemplo, una clase derivada de MyClass.

    Vamos por partes mejor.

    Comencemos por hacer a testableMethod un poco más test-friendly.

    Algo muy sencillo (y 100% seguro) de hacer es aislar la parte del método testableMethod que está impidiendo hacer el test.

    En este caso, el problema es la obtención del número aleatorio (rand(1, 100)), así que, el primer paso sería hacer una nueva versión del método testableMethod para que se vea así:

    public function testableMethod(int $param) : int
    {
        if ($this->getRandomNumber() < 50) {
            return $param * 2;
        } else {
            return $param * 3;
        }
    }

    Y el nuevo método getRandomNumber se vería así:

    protected function getRandomNumber(): int
    {
        return rand(1, 100);
    }

    Es claro que a nivel funcional no ha cambiado nada. Sin embargo, como veremos pronto, esta sutil diferencia es crucial para el desarrollo de la prueba unitaria.

    Un punto sumamente interesante es que este cambio está libre de riesgo en tanto que puede ser realizado en forma automática por un IDE.

    Continuemos entonces con el test.

    ¿Qué tal si creamos una nueva clase TestableMyClass que tenga exactamente el mismo comportamiento que MyClass, salvo por la forma de responder a getRandomNumber?

    class TestableMyClass extends MyClass
    {
        private int $randomNumber;
    
        /**
         * @param int $randomNumber
         */
        public function __construct(int $randomNumber) 
        {
            $this->randomNumber = $randomNumber;
        }
    
        /**
         * @return int
         */
        protected function getRandomNumber(): int
        {
            return $this->randomNumber;
        }
    }

    De esta forma tenemos una nueva implementación de MyClass que, en lugar de retornar números aleatorios, retornará un valor que nosotros controlamos y, de esa forma, podremos realizar el test que buscamos:

    class MyClassTest extends TestCase
    {
        /**
         * @dataProvider randomNumberProvider
         * @param int $baseNumber
         * @param int $multiplier
         * @param int $randomNumber
         * @return void
         */
        public function testTestableMethod(int $baseNumber, int $multiplier, int $randomNumber): void
        {
            $sut = new TestableMyClass($randomNumber);
    
            $this->assertEquals($baseNumber * $multiplier, $sut->testableMethod($baseNumber));
        }
    
        /**
         * @return int[][]
         */
        public function randomNumberProvider(): array
        {
            return [
                [ 1, 2, 2 ],
                [ 20, 2, 40 ],
                [ 51, 3, 153 ],
                [ 60, 3, 180 ],
            ];
        }
    }

    Y voilà! Tenemos un test que garantiza la correctitud del método.

    De hecho, para hacerlo todavía más interesante podríamos usar una clase que genere números aleatorios e inyectarla como colaborador de MyClass pero bueno… tema para otro post.

  • Integrar dos aplicaciones con un WebService SOAP

    Integrar dos aplicaciones con un WebService SOAP

    Una consulta que recibo bastante a menudo remite a cómo conectar dos sistemas dentro de una organización.

    Algunos ejemplos:

    Tengo la necesidad de conectar dos sistemas, uno de administración de stock y otro de asignaciones de bienes a personal. Lo que debería suceder son dos cosas:

    1. Sistema de administración hace compra de stock, por lo cual deberá informarle al otro sistema que debe actualizar las cantidades.

    2Sistema de asignación de bienes a personal hace entrega de un producto a un empleado, por lo cual deberá informarle al otro sistema que descuente del stock las cantidades otorgadas.

    El problema principal que tengo es que no hay posibilidad de modificar el sistema de asignación por lo cual tengo que desarrollar los web services intermedios para poder comunicarme con él a través de archivos planos.

    Tengo dos plataformas, las llamaré A y B.

    A es una nueva plataforma web entre nuestros clientes y nosotros, con un formulario para configurar un objeto. (con una series de atributos)

    B es el Product Data Management. Es la herramienta interna que usamos para gestionar estos objetos (crear/actualizar, etc…). Cada objeto también tiene una serie de atributos.

    La idea es de recuperar información de la plataforma A y usarla para generar/actualizar automáticamente un objeto en B, usando los web services de B.

    Creo que ya debe haber quedado claro que la problemática en todos los casos es similar.

    Conectar dos sistemas no es precisamente una tarea trivial.

    Más aún si están desarrollados usando diferentes tecnologías.

    Más aún si están hosteados en diferentes redes.

    En definitiva estamos en una de dos situaciones:

    • Un caso super sencillo en el que basta usar un recurso compartido (Un archivo, una base de datos, un repositorio de memoria, etc…).
    • Tenemos que pensar en una implementación basada en WebServices.

    Es más, mi recomendación es que, aunque hoy la solución pueda basarse en un esquema simple, vale la pena hacer el esfuerzo extra para preparar la conexión de ambos sistema para otros escenarios menos favorables.

    Al fin y al cabo, el esfuerzo tampoco será tan grande.

    Qué se necesita para conectar dos sistemas mediante SOAP

    Básicamente lo que se necesitará es implementar, al menos, un cliente y un servidor.

    Esto no necesariamente tendrá un impacto elevado en cuanto a cambios en la infraestructura.

    Ni siquiera tiene por qué suponer una modificiación sustancial en las aplicaciones aunque, para dar seguridad habría que ver qué tan bien estructurado está el código.

    En definitiva, lo que se necesitará es dotar a la aplicación receptora de la información de un mecanismo para exponer un webservice SOAP y a la emisora de uno para consumirlo.

    Esto puede sonar un poco contra-intuitivo, pero debe pensarse como que el receptor es quien brinda el servicio y el emisor quien lo utiliza a modo de cliente.

    Un ejemplo de conexión via SOAP

    Voy a tomar una versión simplificada del primer caso para realizar el ejemplo.

    Asumo que contamos con una base de datos con esta estructura:

    Sistema de compras
    Sistema de asignación de productos

    Dejo de lado una serie de detalles que, si bien son sumamente importantes, harían el ejemplo tan complejo que se perdería lo fundamental:

    1. No realizaré verificaciones de consistencia (De cantidades asignadas vs. compradas, de SKUs entre aplicaciones, etc…).
    2. Asumiré que la seguridad de la comunicación se maneja en alguna otra capa.

    Ahora sí, comencemos.

    Informando al sistema de asignación sobre nuevas compras

    Desde el lado del sistema de compras, lo que se necesita es enviar información al sistema de asignación, para lo cual, éste debe estar preparado para recibirlos.

    Para no entrar en un dilema de huevo-gallina, arranco por el lado del cliente asumiendo que el lado servidor está disponible y luego paso a mostrarlo.

    El código del cliente se podría parecer a esto:

    <?php
    
    require_once 'db.config.php';
    
    $mysqli = new mysqli($db_host, $db_user, $db_password, $db_name);
    
    $sql = "SELECT pu.quantity, pr.SKU FROM purchases pu INNER JOIN products pr ON pr.id = pu.product_id;";
    
    $reuslt = $mysqli->query($sql);
    
    $client = new SoapClient('purchases.wsdl');
    
    foreach ($reuslt->fetch_array(MYSQLI_ASSOC) as $purchaseRecord) {
        if ( 1 != $client->save_purchase(
            $purchaseRecord['SKU'],
            $purchaseRecord['quantity']
        ) ) {
             die ('Failed to save update of '.$purchaseRecord['SKU']);
        }
    }

    Este código podría ejecutarse dentro de una tarea cron, incorporarse como parte del proceso de registro de una compra o como respuesta a una acción específica del usuario.

    Todo dependerá del contexto general en el que operen los sistemas en cuestión.

    Asentando información de nuevas compras en el sistema de asignación

    Del lado del servidor (el sistema de asignación en este caso), encontraríamos un código similar a:

    <?php
    
    function save_purchase(string $SKU, int $quantity) : int
    {
        $sql = "UPDATE products SET stock = stock + ".$quantity." WHERE sku = '$SKU'";
    
        $config = require_once 'config.php';
    
    $mysqli = new mysqli($config['db_host'], $config['db_user'], $config['db_password'], $config['db_name']);
    
    
        return $mysqli->query($sql) ? 1: 0;
    }
    
    $server = new SoapServer("purchase.wsdl");
    
    $server->addFunction('save_purchase');
    $server->handle();

    Este código deberá estar alojado en alguna URL a la que pueda acceder el cliente, en este caso, el sistema de compras.

    Y, en ambos lados se encontrará el archivo de definición del webservice que se verá similar a:

    <!--
    Leeway
    2022-07-26
    A service to record new product purchases
      -->
    <definitions xmlns:tns="com.leewayweb.academy.wsdl" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
                 xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsd1="com.leewayweb.academy.xsd"
                 xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/" xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/"
                 xmlns="http://schemas.xmlsoap.org/wsdl/" name="A service to record new product purchases"
                 targetNamespace="com.leewayweb.academy.wsdl">
        <!--  definition of datatypes  -->
        <types>
            <schema xmlns="http://www.w3.org/2000/10/XMLSchema" targetNamespace="com.leewayweb.academy.xsd">
                <element name="SKU">
                    <complexType>
                        <all>
                            <element name="value" type="string"/>
                        </all>
                    </complexType>
                </element>
                <element name="quantity">
                    <complexType>
                        <all>
                            <element name="value" type="int"/>
                        </all>
                    </complexType>
                </element>
                <element name="result">
                    <complexType>
                        <all>
                            <element name="value" type="int"/>
                        </all>
                    </complexType>
                </element>
            </schema>
        </types>
        <!--  response messages  -->
        <message name="returns_result">
            <part name="result" type="xsd:result"/>
        </message>
        <!--  request messages  -->
        <message name="save_purchase">
            <part name="SKU" type="xsd:SKU"/>
            <part name="quantity" type="xsd:quantity"/>
        </message>
        <!--  server's services  -->
        <portType name="purchases">
            <operation name="save_purchase">
                <input message="tns:save_purchase"/>
                <output message="tns:returns_result"/>
            </operation>
        </portType>
        <!--  server encoding  -->
        <binding name="purchases_webservices" type="tns:purchases">
            <soap:binding style="rpc" transport="http://schemas.xmlsoap.org/soap/http"/>
            <operation name="save_purchase">
                <soap:operation soapAction="urn:xmethods-delayed-quotes#save_purchase"/>
                <input>
                    <soap:body use="encoded" namespace="urn:xmethods-delayed-quotes"
                               encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"/>
                </input>
                <output>
                    <soap:body use="encoded" namespace="urn:xmethods-delayed-quotes"
                               encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"/>
                </output>
            </operation>
        </binding>
        <!--  access to service provider  -->
        <service name="academy">
            <port name="academy_0" binding="purchases_webservices">
                <soap:address location="http://127.0.0.1/purchases.php"/>
            </port>
        </service>
    </definitions>

    Informando al sistema de compras sobre la insuficiencia de stock

    Ahora entonces toca hacer algo muy similar pero visto desde el otro lado:

    <?php
    
    $client = new SoapClient("stock.wsdl");
    
    $config = require_once 'config.php';
    
    $mysqli = new mysqli($config['db_host'], $config['db_user'], $config['db_password'], $config['db_name']);
    
    $sql = "SELECT SKU FROM products WHERE stock < minimum;";
    
    $result = $mysqli->query($sql);
    
    foreach ($result->fetch_array() as $product) {
        $product = $result->fetchArray();
        $client->generatePurchaseOrder($product['SKU']);
    }

    Este script podría, como vimos antes, ejecutarse periódicamente, o como resultado de una acción iniciada por el usuario (Lo que mejor se adapte a las necesidades del negocio).

    Generando la orden de compra

    Del lado del sistema de compras nos encontraremos con algo como:

    <?php
    
    function create_purchase_order(string $SKU) : int
    {
        $sql = "INSERT INTO purchase_orders (product_id, quantity) SELECT id, purchase_quantity FROM products WHERE SKU = '$SKU';";
    
        $config = require_once 'config.php';
        $mysqli = new mysqli($config['db_host'], $config['db_user'], $config['db_password'], $config['db_name']);
    
        return $mysqli->query($sql) ? 1: 0;
    }
    
    $server = new SoapServer("stock.wsdl");
    
    $server->addFunction('create_purchase_order');
    $server->handle();

    Y, no olvidar, el archivo WSDL:

    <?xml version="1.0" encoding ="utf-8"?>
    <!--
    Leeway
    2022-07-28
    Creates pruchase orders for products below critical point
     -->
    <definitions name="Creates pruchase orders for products below critical point"
                 targetNamespace="com.leewayweb.wsdl"
                 xmlns:tns="com.leewayweb.wsdl"
                 xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
                 xmlns:xsd="http://www.w3.org/2001/XMLSchema"
                 xmlns:xsd1="com.leewayweb.xsd"
                 xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/"
                 xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/"
                 xmlns="http://schemas.xmlsoap.org/wsdl/">
        <!-- definition of datatypes -->
        <types>
            <schema targetNamespace="com.leewayweb.xsd" xmlns="http://www.w3.org/2000/10/XMLSchema">
                <element name="SKU">
                    <complexType>
                        <all>
                            <element name="value" type="string"/>
                        </all>
                    </complexType>
                </element>
                <element name="resultcode">
                    <complexType>
                        <all>
                            <element name="value" type="int"/>
                        </all>
                    </complexType>
                </element>
            </schema>
        </types>
        <!-- response messages -->
        <message name='returns_resultcode'>
            <part name='resultcode' type='xsd:resultcode'/>
        </message>
        <!-- request messages -->
        <message name='create_purchase_order'>
            <part name='SKU' type='xsd:SKU'/>
        </message>
        <!-- server's services -->
        <portType name='purchase_orders'>
            <operation name='create_purchase_order'>
                <input message='tns:create_purchase_order'/>
                <output message='tns:returns_resultcode'/>
            </operation>
        </portType>
        <!-- server encoding -->
        <binding name='purchase_orders_webservices' type='tns:purchase_orders'>
            <soap:binding style='rpc' transport='http://schemas.xmlsoap.org/soap/http'/>
            <operation name='create_purchase_order'>
                <soap:operation soapAction='urn:xmethods-delayed-quotes#create_purchase_order'/>
                <input>
                    <soap:body use='encoded' namespace='urn:xmethods-delayed-quotes'
                               encodingStyle='http://schemas.xmlsoap.org/soap/encoding/'/>
                </input>
                <output>
                    <soap:body use='encoded' namespace='urn:xmethods-delayed-quotes'
                               encodingStyle='http://schemas.xmlsoap.org/soap/encoding/'/>
                </output>
            </operation>
        </binding>
        <!-- access to service provider -->
        <service name='academy'>
            <port name='academy_0' binding='purchase_orders_webservices'>
                <soap:address location='http://127.0.0.1./purchase_orders.php'/>
            </port>
        </service>
    </definitions>

    Resumiendo

    Integrar dos sistemas puede ser una tarea compleja.

    Mucho dependerá del conocimiento que se tenga sobre la organización de la información en cada uno de ellos, la infraestructura que les dé soporte y demás.

    Independientemente de las dificultades técnicas que esto suponga, la integración automática de sistemas de información supone para muchas empresas una mejora sustancial en la eficiencia de sus procesos de negocio (Imagina lo que significa trasladar inforamción manualmente de una base de datos a otra).

    Personalmente, antes de meter una capa extra de complejidad como la que supone utilizar SOAP intentaría diseñar una solución basada en REST.

    Claro que, muchas veces no es posible tomar esta decisión porque otra persona ya lo hizo por nosotros.

    En tal caso la solución pasará por utilizar las mejores herramientas de las que podemos disponer.

    Para este ejemplo usé este sitio para generar el WSDL ya que se trataba de servicios simples, pero para un caso más realista intentaría usar una herramienta como esta.

  • Consumir un WebService SOAP con certificado digital con PHP

    Consumir un WebService SOAP con certificado digital con PHP

    Una lectora del newsletter de Leeway Academy, me escribe lo siguiente:

    Necesito consumir un webservice con php utilizando certificado para autenticarse, recibí .pfx y lo convertí en .pem, obtuve cert.pem y key.pem.

    A primera vista parece algo sencillo, ¿cierto? Se trata de conectarse a un servidor del que ya sabemos su URL, tenemos los certificados… ¿qué podría salir mal?

    Claro que, como siempre que se trata de usar SOAP, las cosas no son tan fáciles como aparentan.

    Para empezar, hay que decidir qué herramientas usaremos para realizar la conexión (cURL, file_get_contents, Guzzle…).

    Después está el tema de cómo especificar el certificado… y por último, qué hacer con la respuesta del servidor una vez la hayamos obtenido.

    Porque, no olvidemos que, siendo un WebService SOAP, la respuesta es un XML. Pero no un XML cualquiera, un XML diseñado según las especificaciones de SOAP.

    Y, por si faltaba algo, lo más probable es que no se trate de una sola petición, no. Para poder hacer algo más que sólo conectarnos al servidor, seguramente tengamos que hacer unas cuantas.

    Juntemos todo eso y tenemos un buen dolor de cabeza por delante.

    Bueno… no necesariamente. Las cosas pueden ser bastante más sencillas si tenemos claro qué hacer.

    Empecemos aclarando algunos conceptos.

    Qué es un certificado digital

    Un certificado digital es un mecanismo que permite a una computadora asegurarse de que su contraparte en una comunicación digital es efectivamente quien dice ser.

    Más en concreto: cuando se realiza una comunicación web, típicamente existen dos actores, el cliente y el servidor.

    El cliente envía un mensaje codificado en HTTP y el servidor le responde de la misma forma.

    El problema es que, en ocasiones, puede haber algún intermediario no deseado (Lo que se conoce como ataque de man-in-the-middle):

    Cuando se debe enviar información sensible, es muy importante para el emisor poder determinar que quien recibe esa información es, efectivamente, quien dice ser.

    Y ¿cómo puede asegurarse la identidad de la contraparte? Precisamente, con un certificado digital.

    Los certificados digitales son emitidos por entidades en las que el emisor confía (Autoridades de Certificación), algo similar al DNI que otorgan los entes gubernamentales a las personas.

    De modo que, mediante criptografía, es posible determinar si un certificado presentado por una parte está avalado por alguna autoridad competente.

    Todo esto pasa de forma transparente cada vez que navegás hacia una página que comienza con https en lugar de http.

    Ese es el caso más común: como cliente no querés enviar tus datos de tarjeta de crédito a un servidor a menos que tengas la certeza de que estás comunicándote con quien creés que estás haciéndolo.

    Pues bien, lo mismo puede ocurrir a la inversa. Es decir, un servidor puede negarse a establecer comunicación con un cliente del cual no conoce su identidad.

    ¿Cómo puede el cliente dar fé de su identidad? Del mismo modo, ofreciéndole al servidor un certificado avalado por alguna autoridad en la que él confíe.

    Ese es el caso que estamos tratando en este artículo.

    Cómo especificar el certificado digital en una llamada SOAP

    Ahora sí, habiendo despejado los temas más teóricos, pasemos a la práctica.

    Asumiendo que contamos con el archivo de certificado (.pem) y el de clave privada (.key), podemos realizar la conexión directa, usando cURL, con el servidor de esta forma:

    $ch = curl_init(); 
    curl_setopt($ch, CURLOPT_URL, $url); 
    curl_setopt($ch, CURLOPT_VERBOSE, 1); 
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1); 
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 1); 
    curl_setopt($ch, CURLOPT_FAILONERROR, 1); 
    curl_setopt($ch, CURLOPT_SSLCERT, __DIR__ . '/client.crt'); 
    curl_setopt($ch, CURLOPT_SSLCERTTYPE, 'PEM'); 
    curl_setopt($ch, CURLOPT_SSLKEY, __DIR__ . '/client.key'); 
    curl_setopt($ch, CURLOPT_POST, 1);
    curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: text/xml'));
    curl_setopt($ch, CURLOPT_POSTFIELDS, $requestXml);
    $ret = curl_exec($ch);

    Algo bastante laborioso por cierto.

    Una alternativa ligeramente más sencilla es usar file_get_contents en combinación con stream_context_create para manejar la parte de los certificados:

    $ret = file_get_contents($url, false, stream_context_create(
    [
            'ssl' => [
                'local_cert' => __DIR__ . '/client.crt',
                'local_pk' => __DIR__ . '/client.key',
            ]
        ]
    );

    El problema es que nos encontraremos, a la vuelta, con algo como:

    <?xml version="1.0" encoding="UTF-8"?>
    <soapenv:Envelope
        xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
        <soapenv:Body>
            <deudasDeudorResponse soapenv:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
                <deudasDeudorReturn href="#id0"/>
            </deudasDeudorResponse>
            <multiRef id="id0" soapenc:root="0" soapenv:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xsi:type="ns1:DeudasDeudorClient"
                xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/"
                xmlns:ns1="urn:consultadeudasxinstWs.bcu.gub.uy">
                <deudas soapenc:arrayType="ns1:DeudaInstitucionClient[5]" xsi:type="soapenc:Array">
                    <deudas href="#id1"/>
                    <deudas href="#id2"/>
                    <deudas href="#id3"/>
                    <deudas href="#id4"/>
                    <deudas href="#id5"/></deudas>
                <deudor href="#id6"/>
                <periodo href="#id7"/>
                <tipoDeCambio href="#id8"/>
            </multiRef>        
        </soapenv:Body>
    </soapenv:Envelope>

    Es decir, tendremos que interpretar este XML para poder procesarlo efectivamente.

    Cómo interpretar el XML que retorna el WebService

    Si bien es perfectamente posible hacerlo una herramienta como SimpleXML o, directamente parseándolo mediante expresiones regulares, la verdad es que cualquiera de estos métodos es sumamente ineficiente y propenso a errores.

    De hecho, si el archivo contiene algo como <wsdl:import location="http://localhost:8080/soapservice/services/quoteService?wsdl=RandomQuote.wsdl" namespace="http://examples.javacodegeeks.com/"> vamos a tener que hacer otra llamada, otra vez especificando los parámetros de seguridad y así sucesivamente.

    Un modo mucho mejor es utilizar la clase SoapClient, la cual permite especificar, entre otras, cómo conectarse al servidor remoto:

    $client = new SoapClient($url,
            [
                'stream_context' => stream_context_create(
                    [
                        'ssl' => [
                            'local_cert' => __DIR__ . '/client.crt',
                            'local_pk' => __DIR__ . '/client.key',
                        ]
                    ]
                ),
            ]);

    A partir de aquí ya es posible ejecutar cualquier servicio disponible en el WebService o, si lo necesitás, ver qué operaciones te ofrece.

  • Cómo enviar un archivo a un WebService SOAP desde PHP

    Cómo enviar un archivo a un WebService SOAP desde PHP

    Henry, un suscriptor del newsletter de Leeway Academy, me envía este mensaje:

    Buenos dias.

    Soy desarrollador php pero tengo este problema ya que este wsdl (https://test.sunat.gob.pe:444/ol-ad-itseida-ws/ReceptorService.htm) no me da mucha info.

    Debo enviar un archivo xml tal cual, como archivo, con muchas etiquetas, en fin ya lo tengo resuelto. Una vez que tengo el ARCHIVO, cómo lo adjunto a este servicio para luego obtener el archivo de respuesta y leerlo?.

    No veo en los parametros de SoapClient cómo adjuntarlo o la forma de enviarlo.

    Si este es el formato correcto ( o no )

    $xml='<.......... ';  (TODAS LAS ETIQUETAS QUE NECESITO ENVIAR)
    
    $texto='<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ser="http://services.sigad.sunat.gob.pe">
       <soapenv:Header/>
       <soapenv:Body>
          <ser:recibirArchivo>
             <!--Optional:-->
             <numeroTransaccion>?</numeroTransaccion>
             <!--Optional:-->
             <informacionArchivo>$xml</informacionArchivo>
          </ser:recibirArchivo>
       </soapenv:Body>
    </soapenv:Envelope>'
    
    $texto = mb_convert_encoding($texto, "UTF-8");  //  CON Y SIN ESTA CONVERSION
    
    $location_URL = 'https://test.sunat.gob.pe:444/ol-ad-itseida-ws/ReceptorService.htm?wsdl';
    $action='urn:recibirArchivo';
    $options = array(
    // Stuff for development.
    'location' => $location_URL,
    // 'uri'      => 'https://test.sunat.gob.pe:444/ol-ad-itseida-ws/ReceptorService.htm',
    'uri'  => 'https://test.sunat.gob.pe:444/ol-ad-itseida-ws/ReceptorService.htm?wsdl',
    'username' => "XXXX",
    'password' =>"YYYY",
    'trace' => 1
    );

    AQUI ME ESTANCO

    envio: $order_return = $client->__doRequest($texto,$location_URL,$action,0);

    y recibo de respuesta

    <S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/">
       <S:Body>
          <ns0:Fault xmlns:ns0="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="http://www.w3.org/2003/05/soap-envelope">
             <faultcode>ns0:Server</faultcode>
             <faultstring>javax.xml.ws.soap.SOAPFaultException</faultstring>
          </ns0:Fault>
       </S:Body>
    </S:Envelope>

    Entonces no se la forma correcta de enviarlo.

    Saludos y por favor ayuda.

    Pasemos un poco en limpio el problema que tiene Henry.

    Para empezar, el WSDL al que se refiere es este:

    <?xml version='1.0' encoding='UTF-8'?>
    <!-- Published by JAX-WS RI (http://jax-ws.java.net). RI's version is JAX-WS RI 2.3.0-b170407.2038 svn-revision#2eaca54d17a59d265c6fe886b7fd0027836c766c. -->
    <!-- Generated by JAX-WS RI (http://jax-ws.java.net). RI's version is JAX-WS RI 2.3.0-b170407.2038 svn-revision#2eaca54d17a59d265c6fe886b7fd0027836c766c. -->
    <definitions
        xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"
        xmlns:wsp="http://www.w3.org/ns/ws-policy"
        xmlns:wsp1_2="http://schemas.xmlsoap.org/ws/2004/09/policy"
        xmlns:wsam="http://www.w3.org/2007/05/addressing/metadata"
        xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
        xmlns:tns="http://service.receptor.tecnologia.sunat.gob.pe/"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns="http://schemas.xmlsoap.org/wsdl/" targetNamespace="http://service.receptor.tecnologia.sunat.gob.pe/" name="ReceptorService.htm">
        <import namespace="http://services.sigad.sunat.gob.pe" location="https://test.sunat.gob.pe:444/ol-ad-itseida-ws/ReceptorService.htm?wsdl=1"/>
        <binding
            xmlns:ns1="http://services.sigad.sunat.gob.pe" name="ReceptorWebServiceServiceImplPortBinding" type="ns1:ReceptorService.htm">
            <soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
            <operation name="recibirArchivo">
                <soap:operation soapAction="urn:recibirArchivo"/>
                <input>
                    <soap:body use="literal"/>
                </input>
                <output>
                    <soap:body use="literal"/>
                </output>
                <fault name="Exception">
                    <soap:fault name="Exception" use="literal"/>
                </fault>
            </operation>
            <operation name="realizarConsulta">
                <soap:operation soapAction="urn:realizarConsulta"/>
                <input>
                    <soap:body use="literal"/>
                </input>
                <output>
                    <soap:body use="literal"/>
                </output>
                <fault name="Exception">
                    <soap:fault name="Exception" use="literal"/>
                </fault>
            </operation>
        </binding>
        <service name="ReceptorService.htm">
            <port name="ReceptorWebServiceServiceImplPort" binding="tns:ReceptorWebServiceServiceImplPortBinding">
                <soap:address location="https://test.sunat.gob.pe:444/ol-ad-itseida-ws/ReceptorService.htm"/>
            </port>
        </service>
    </definitions>

    Y según parece, lo que está intentando lograr Henry es enviar un archivo a un WebService de la agencia tributaria de Perú (Sunat).

    Nunca antes me había tocado tratar con este WS, de modo que, antes de pasar al tema específico, realicé algunas pruebas.

    Entender el WebService

    El primer paso para resolver un problema como este es comprender qué tenemos del otro lado.

    Nada mejor para empezar que usar una herramienta como cURL para descargar el archivo WSDL. Usando el comando curl https://test.sunat.gob.pe:444/ol-ad-itseida-ws/ReceptorService.htm\?wsdl obtuve:

    curl: (60) SSL certificate problem: unable to get local issuer certificate<br>More details here: https://curl.haxx.se/docs/sslcerts.html
    curl failed to verify the legitimacy of the server and therefore could not
    establish a secure connection to it. To learn more about this situation and
    how to fix it, please visit the web page mentioned above.

    No exactamente lo que esperaba… parece que me falta alguna verificación SSL.

    Para saltearme este chequeo agrego el modificador -k al comando original, quedando curl -k https://test.sunat.gob.pe:444/ol-ad-itseida-ws/ReceptorService.htm\?wsdl y ahí sí puedo descargar el WSDL y usar un simple script basado en SoapClient para ver qué me ofrece este servicio de un modo algo más simple:

    <?php
    
    if ($argc < 2) {
        die('Specify the WSDL URI');
    }
    
    $client = new SoapClient($argv[1],
        [
            'stream_context' => stream_context_create([
                    "ssl" => [
                        "verify_peer" => false,
                        "verify_peer_name" => false,
                    ]]),
        ]
    );
    echo "Functions available:" . PHP_EOL;
    echo "====================" . PHP_EOL;
    print_r($client->__getFunctions());

    Al ejecutar php describe_ws.php https://test.sunat.gob.pe:444/ol-ad-itseida-ws/ReceptorService.htm\?wsdl obtengo:

    Functions available:
    ====================
    Array
    (
        [0] => recibirArchivoResponse recibirArchivo(recibirArchivo $parameters)
        [1] => realizarConsultaResponse realizarConsulta(realizarConsulta $parameters)
    )

    Es decir, tenemos dos métodos disponibles para invocar: recibirArchivo y realizarConsulta.

    En particular, el que nos interesa en este caso es el primero.

    Ahora sería interesante ver qué son exactamente los tipos recibirArchivo y recibirArchivoResponse.

    Para ello le agregué un par de líneas al script:

    echo "Types available:" . PHP_EOL;
    echo "====================" . PHP_EOL;
    print_r($client->__getTypes());

    Y al correr nuevamente el scritp obtengo, además de lo que ya tenía:

    Types available:
    ====================
    Array
    (
        [0] => struct Exception {
     string message;
    }
        [1] => struct realizarConsulta {
     string parametrosConsulta;
    }
        [2] => struct realizarConsultaResponse {
     base64Binary realizarConsultaResultado;
     respuestaConsultaBean respuesta;
    }
        [3] => struct recibirArchivo {
     string numeroTransaccion;
     base64Binary informacionArchivo;
    }
        [4] => struct recibirArchivoResponse {
     acuseRecibo recibirArchivoResultado;
    }
        [5] => struct respuestaConsultaBean {
     string descripcion;
     string tipo;
    }
        [6] => struct acuseRecibo {
     string anhoEnvio;
     string documentoEmisor;
     string fechaRecepcion;
     string hashDocumento;
     errorAcuse listaErrores;
     errorAcuse listaWarning;
     string numeroOrden;
     string ticketEnvio;
    }
        [7] => struct errorAcuse {
     string codigo;
     string descripcion;
     string tipo;
    }
    )

    Bien, ahora vemos entonces que, para invocar al servicio de recibirArchivo se requiere enviar un string (numeroTransaccion) y un dato de tipo base64Binary (informacionArchivo).

    Esto ya me da alguna pista de lo que puede haber fallado en el caso de Henry… sigamos investigando.

    Qué es base64Binary

    Si vemos la definición en la especificación de SOAP encontramos lo siguiente:

    [Definition:]  base64Binary represents Base64-encoded arbitrary binary data. The ·value space· of base64Binary is the set of finite-length sequences of binary octets. For base64Binary data the entire binary stream is encoded using the Base64 Alphabet in [RFC 2045].

    Lo que esto significa en Español es que los datos del archivo deben ser enviados como una cadena codificada utilizando base64. De hecho, esto tiene bastante sentido ya que SOAP se monta sobre XML, un estándar basado en texto, de modo que algunos datos binarios podrían generar problemas.

    Envío de un archivo binario a un WebService SOAP

    Me temo que, como no tengo los datos de acceso al Sunat no voy a poder dar un ejemplo real pero dejaré uno que se parezca lo suficiente como para que pueda entenderse el mecanismo a utilizar.

    Armé un pequeño programita en Java basado en lo que se muestra en este artículo.

    Para correrlo uso este comando:

    java1.8 -Dfile.encoding=UTF-8 -jar ./base64_server.jar

    Y, a partir de ahí puedo consultar el WSDL a través de la URL http://localhost:9898/?wsdl, lo que me da:

    <!--  Published by JAX-WS RI (http://jax-ws.java.net). RI's version is JAX-WS RI 2.2.9-b130926.1035 svn-revision#5f6196f2b90e9460065a4c2f4e30e065b245e51e.  -->
    <!--  Generated by JAX-WS RI (http://jax-ws.java.net). RI's version is JAX-WS RI 2.2.9-b130926.1035 svn-revision#5f6196f2b90e9460065a4c2f4e30e065b245e51e.  -->
    <definitions
        xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"
        xmlns:wsp="http://www.w3.org/ns/ws-policy"
        xmlns:wsp1_2="http://schemas.xmlsoap.org/ws/2004/09/policy"
        xmlns:wsam="http://www.w3.org/2007/05/addressing/metadata"
        xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
        xmlns:tns="http://soap.leewayweb.com/"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns="http://schemas.xmlsoap.org/wsdl/" targetNamespace="http://soap.leewayweb.com/" name="FileTransfererImplService">
        <types>
            <xsd:schema>
                <xsd:import namespace="http://soap.leewayweb.com/" schemaLocation="http://localhost:9898/?xsd=1"/>
            </xsd:schema>
        </types>
        <message name="upload">
            <part name="parameters" element="tns:upload"/>
        </message>
        <message name="uploadResponse">
            <part name="parameters" element="tns:uploadResponse"/>
        </message>
        <message name="IOException">
            <part name="fault" element="tns:IOException"/>
        </message>
        <portType name="FileTransfererImpl">
            <operation name="upload">
                <input wsam:Action="http://soap.leewayweb.com/FileTransfererImpl/uploadRequest" message="tns:upload"/>
                <output wsam:Action="http://soap.leewayweb.com/FileTransfererImpl/uploadResponse" message="tns:uploadResponse"/>
                <fault message="tns:IOException" name="IOException" wsam:Action="http://soap.leewayweb.com/FileTransfererImpl/upload/Fault/IOException"/>
            </operation>
        </portType>
        <binding name="FileTransfererImplPortBinding" type="tns:FileTransfererImpl">
            <soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
            <operation name="upload">
                <soap:operation soapAction=""/>
                <input>
                    <soap:body use="literal"/>
                </input>
                <output>
                    <soap:body use="literal"/>
                </output>
                <fault name="IOException">
                    <soap:fault name="IOException" use="literal"/>
                </fault>
            </operation>
        </binding>
        <service name="FileTransfererImplService">
            <port name="FileTransfererImplPort" binding="tns:FileTransfererImplPortBinding">
                <soap:address location="http://localhost:9898/"/>
            </port>
        </service>
    </definitions>

    Y usando el mismo script que antes vemos que para invocarlo desde PHP tenemos los siguientes métodos:

    Functions available:
    ====================
    Array
    (
        [0] => uploadResponse upload(upload $parameters)
    )
    Types available:
    ====================
    Array
    (
        [0] => struct upload {
     string arg0;
     base64Binary arg1;
    }
        [1] => struct uploadResponse {
    }
        [2] => struct IOException {
     string message;
    }
    )

    Bastante parecido al caso que estoy analizando.

    Pues bien, lo que resta entonces es ver cómo enviar un archivo binario al servidor. Todo el truco pasa por enviar el contenido del archivo como una cadena de caracteres (Del encoding base64 se encarga SoapClient).

    Una función útil para esto es file_get_contents. Puede usarse de esta forma:

    <?php
    
    $client = new SoapClient('http://localhost:9898/?wsdl');
    
    $contents = file_get_contents($argv[1]);
    echo "Sending ".$contents.PHP_EOL;
    try {
    	$client->upload([
    		'arg0' => basename($argv[1]), 
    		'arg1' => $contents,
    	]);
    	echo "Sent!".PHP_EOL;
    } catch (Exception $e) {
    	var_dump($e);
    }

    Si se guarda el script como send_file.php puede ejecutarse con cualquier archivo binario mediante php send_file.php /ruta/al/archivo/binario y luego se podrá ver cómo se encuentra una copia en /ruta/al/programa/java/uploads.

    Si querés probarlo en tu computadora podés descargar el archivo .jar de acá.

    Advertencia: no usar con archivos grandes

    Este mecanismo puede funcionar bien bajo el supuesto de que los archivos sean pequeños. Si este no fuera el caso, las limitaciones de HTTP pueden volverse un cuello de botella difícil de superar.

    En todo caso, si buscas enviar archivos grandes no dejes de leer este artículo.

  • Cómo generar el WSDL de un Webservice PHP

    Un suscriptor del newsletter de Leeway Academy me envía el siguiente correo:

    Hola. muy bueno los mails, concuerdo con lo de los errores del otro día.

    Pero estoy atrapado en un problema.  Genere un Web service, me anda bárbaro, pero la gente que va a consumirlo en un sistema hecho en C# no puede procesar el WSDL que generé, me tiran errores.

    Lo estoy haciendo a mano.

    Hay alguna herramienta que se pueda usar?

    Muchas gracias.

    Ouch. Que temita que son los archivos WSDL, ¿no?

    Para dar un poquito más de contexto, se trata de un webservice de tipo SOAP desarrollado usando PHP.

    En general, el protocolo SOAP, aunque su nombre incluya la palabra Simple, dista bastante de serlo.

    Los XML que hay que enviar y recibir aportan bastante a la confusión general.

    Claro que, si se utilizan las herramientas adecuadas todo se vuelve mucho más sencillo.

    Yendo al problema que dio origen a este post, la clave está en «Lo estoy haciendo a mano.«.

    En línea general, es siempre preferible usar alguna librería ya hecha ya que, seguramente estará ampliamente testeada.

    En php disponemos de buenas herramientas para trabajar con XML pero para SOAP en particular pueden ser un poco rústicas. Mejor usar otra más específica: las clases provistas por la extensión Soap, en particular para este caso SoapServer.

    Con este par de herramientas tenemos cubierto el 80% de nuestras necesidades. Falta un detalle nomás: SoapServer no tiene la capacidad (Al menos hasta la versión 8.1 de php) de generar el archivo de definición del WebService: el WSDL.

    ¿Significa eso que tenemos que hacerlo a mano?

    Claro que es una posibilidad, se puede generar un archivo como este:

    <definitions name="Greetings" targetNamespace="http://localhost:8000/greetings_server.php" xmlns="http://schemas.xmlsoap.org/wsdl/" xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" xmlns:tns="http://localhost:8000/greetings_server.php" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap-enc="http://schemas.xmlsoap.org/soap/encoding/" xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/">
       <types>
          <xsd:schema targetNamespace="http://localhost:8000/greetings_server.php"/>
       </types>
       <portType name="GreetingsPort">
          <operation name="sayHello">
             <documentation>sayHello</documentation>
             <input message="tns:sayHelloIn"/>
             <output message="tns:sayHelloOut"/>
          </operation>
          <operation name="sayGoodBye">
             <documentation>sayGoodBye</documentation>
             <input message="tns:sayGoodByeIn"/>
             <output message="tns:sayGoodByeOut"/>
          </operation>
       </portType>
       <binding name="GreetingsBinding" type="tns:GreetingsPort">
          <soap:binding style="rpc" transport="http://schemas.xmlsoap.org/soap/http"/>
          <operation name="sayHello">
             <soap:operation soapAction="http://localhost:8000/greetings_server.php#sayHello"/>
             <input>
                <soap:body use="literal" namespace="http://localhost:8000/greetings_server.php"/>
             </input>
             <output>
                <soap:body use="literal" namespace="http://localhost:8000/greetings_server.php"/>
             </output>
          </operation>
          <operation name="sayGoodBye">
             <soap:operation soapAction="http://localhost:8000/greetings_server.php#sayGoodBye"/>
             <input>
                <soap:body use="literal" namespace="http://localhost:8000/greetings_server.php"/>
             </input>
             <output>
                <soap:body use="literal" namespace="http://localhost:8000/greetings_server.php"/>
             </output>
          </operation>
       </binding>
       <service name="GreetingsService">
          <port name="GreetingsPort" binding="tns:GreetingsBinding">
             <soap:address location="http://localhost:8000/greetings_server.php"/>
          </port>
       </service>
       <message name="sayHelloIn">
          <part name="name" type="xsd:string"/>
       </message>
       <message name="sayHelloOut">
          <part name="return" type="xsd:string"/>
       </message>
       <message name="sayGoodByeIn">
          <part name="name" type="xsd:string"/>
       </message>
       <message name="sayGoodByeOut">
          <part name="return" type="xsd:string"/>
       </message>
    </definitions>

    Y ciertamente el uso de SimpleXML será de gran ayuda… pero para hacerlo exitosamente habrá que conocer bien los detalles del protocolo SOAP y el manejo de Namespaces XML.

    ¿No existe acaso una librería que lo haga todo?

    Librerías para generar WSDL a partir de PHP

    Esa misma pregunta me hice yo, así que fui a buscar y encontré algunas opciones:

    Los probé todos (y algunos más que no recuerdo en este momento) y ninguno funcionaba al 100%.

    Algunos son tan viejos que ni tienen mantenimiento y han quedado obsoletos.

    Otros resolvían parcialmente el problema.

    Estaba a punto de darme por vencido cuando finalmente apareció una luz al final del túnel: la clase AutoDiscover del paquete SOAP de Laminas.

    A partir de ahí todo se volvió cuesta abajo.

    Uso de Laminas Soap\AutoDiscover

    Partiendo de este código que tenía para ejemplificar:

    <?php
    
    class GreetingsServer
    {
        function sayHello(string $name): string
        {
            return "Hello $name!";
        }
    
        function sayGoodBye(string $name): string
        {
            return "Goodbye $name!";
        }
    }
    
    $server = new SoapServer(__DIR__.'/grettings.wsdl');
    
    $server->setClass(GreetingServer::class);
    $server->handle();
    

    Le agregué la dependencia usando composer:

    composer require laminas/laminas-soap

    Luego se trata de discriminar el caso de que se esté solicitando el wsdl o no. Basta con un simple if:

    if (array_key_exists('wsdl', $_GET)) {
        header('Content-Type: application/wsdl+xml');
        die($wsdl);
    }

    Y ahora viene la parte interesante… ¿de dónde sale el contenido de $wsdl?

    Precisamente, de usar un objeto Laminas\Soap\AutoDiscover:

    $wsdl = (new AutoDiscover())
        ->setClass(GreetingsServer::class)
        ->setUri(SERVER_URI)
        ->setServiceName('Greetings')
        ->setOperationBodyStyle([
            'use' => 'literal'
        ])
        ->setBindingStyle(
            [
                'style' => 'rpc'
            ])
        ->generate()
        ->toXml();

    En el caso de que no se pida el wsdl (Es decir, que el QueryString sea diferente de ?wsdl), se debe crear el servidor usando un archivo ya generado:

    $server = new SoapServer(__DIR__ . '/greetings.wsdl');
    $server->setClass(GreetingsServer::class);
    $server->handle();

    ¿Cómo se genera ese archivo? Una forma muy simple es hacer un request a este mismo servicio y guardarlo en el mismo directorio:

    curl http://localhost:8000/greetings_server.php\?wsdl > greetings.wsdl

    En mi caso dice localhost:8000 porque es ahí donde está levantado el WebService pero esta URL debería cambiar según dónde tengas todo montado.

    Y listo, con esto se logra el bendito archivo WSDL que otros pueden consumir.

    Si querés ver el código completo podés descargarlo de GitHub.

    Ejemplo de clientes

    El ejemplo no estaría completo sin ver el cliente, ¿cierto?

    Empecemos por una versión en PHP:

    <?php
    
    $client = new SoapClient(
        'http://localhost:8000/greetings_server.php?wsdl',
        [
            'soap_version' => SOAP_1_2,
            'cache_wsdl' => WSDL_CACHE_NONE,
        ]);
    
    if ($argc === 1) {
        echo "Service description:" . PHP_EOL;
        echo "--------------------" . PHP_EOL;
        echo "Functions:" . PHP_EOL;
        print_r($client->__getFunctions());
        echo "=====" . PHP_EOL;
        echo "Types:" . PHP_EOL;
        print_r($client->__getTypes());
    } elseif ($argv[1] === "h") {
        echo $client->sayHello($argv[2]);
    } elseif ($argv[1] === "g") {
        echo $client->sayGoodBye($argv[2]);
    }

    Con este pequeño script de línea de comandos es posible ver la definición del webservice y ejecutar ambos servicios como si se tratara de llamadas locales.

    Y ahora, para ver un ejemplo algo más jugoso, veamos un script de python que consume este webservice:

    from zeep import Client
    import sys
    
    client = Client('http://localhost:8000/greetings_server.php?wsdl')
    
    cmd = sys.argv[1]
    
    if cmd == "h":
        print (client.service.sayHello(sys.argv[2]))
    else:
        print (client.service.sayGoodBye(sys.argv[2]))

    Usando el comando python3.8 client.py h Mauro obtenemos Hello Mauro! y con python3.8 client.py g Mauro obtenemos Goodbye Mauro!

    Y así vemos cómo es posible integrar dos aplicaciones desarrolladas en dos lenguajes diferentes usando un WebService SOAP.

    Cómo manejar el cambio de definición del WebService

    Es muy probable que, durante el desarrollo al menos, la definición del servicio cambie y, por lo tanto, el WSDL también debería cambiar.

    Teniendo en cuenta que el WSDL es un archivo estático, basta con refrescarlo cada vez que cambie el servicio (o con una periodicidad razonable).

    Esto se puede lograr a través de, entre otras:

    Ahora sí, dicho esto, se acabó el misterio de los archivos WSDL

  • Cómo garantizar un estándar de codificación en PHP

    Cómo garantizar un estándar de codificación en PHP

    ¿Alguna vez te tocó trabajar sobre el código de otras personas? Apuesto a que sí.

    A que es molesto encontrar cosas como esta:

    <?php
    
    if ($nombre== 'Pedro')
       echo 'Hola Pedro!';
    else{
       echo 'Tú no eres Pedro!';
    }
    
    if ('Juan' ==$nombre)
       echo 'Hola Juan!';
    else
       echo 'Tú no eres Juan!';

    ¿O no?

    O tal vez te parezca lo mismo que:

    <?php
    
    if ('Pedro' == $nombre) {
       echo 'Hola Pedro!';
    } else {
       echo 'Tú no eres Pedro!';
    }
    
    if ('Juan' == $nombre) {
       echo 'Hola Juan!';
    } else {
       echo 'Tú no eres Juan!';
    }

    Si ese es el caso, puedes dejar de leer este artículo, dudo que haya algo valioso para tí en lo que queda por delante.

    En mi caso, estoy convencido de que la calidad del código es tan importante como la de la funcionalidad de la aplicación que ese código sustenta.

    El caso es que ambos códigos son funcionalmente equivalentes, de modo que… ¿qué es lo que está mal ahí?

    El problema es que no se está utilizando un estándar de codificación.

    Qué es un estándar de codificación

    Un estándar de codificación es una serie de reglas que determinan cómo debe escribirse el código.

    El objetivo es lograr un código fácil de leer por otros humanos (para la computadora mientras funcione todo lo demás da igual).

    Un ejemplo de una regla como esta podría ser «todos los if llevan {} independientemente de que haya una línea dentro del bloque o más de una«.

    Por qué es importante usar un estándar de codificación

    Los estándares de codificación ayudan a disminuir el esfuerzo necesario para escribir el código: no se pierde tiempo tomando micro-decisiones como si poner o no poner las {} en cada situación.

    A la vez, el uso de un estándar de codificación hace más fácil la lectura del código escrito por diferentes personas, lo que hace más sencillo el mantenimiento del código a largo plazo.

    En definitiva, seguir un estándar de codificación permite disminuir la carga cognitiva que soportan los desarrolladores.

    Esta es la misma razón por la que no deberías escribir tu código en spanglish.

    Qué estándares de codificación existen en PHP

    El estándar más ampliamente aceptado actualmente es PSR-12 pero es bastante común que cada organización cree su propio estándar, esperablemente basado en alguno pre-existente, lo cual suele derivar en una situación como la que bien ilustra este cómic de xkcd:

    Cómo adaptar el código existente al estándar

    Una vez se ha definido el estándar a utilizar, no suele ser complejo implementarlo en el código que se generará a partir del momento actual.

    El desafío consiste en realizar las modificaciones requeridas al código pre-existente para que sea compatible con el estándar.

    Aquí existen básicamente dos caminos posibles:

    Opción 1: Manualmente

    Opción 2: Usando php-cs-fixer.

    Qué es php-cs-fixer

    php-cs-fixer es una herramienta de línea de comandos escrita en php por Fabien Potencier y Dariusz Rumiński cuyo objetivo es, precisamente, el de modificar código de modo de garantizar que cumpla con las reglas definidas en un estándar de codificación.

    Su uso es bastante simple, sólo requiere indicarle la ruta a la base de código sobre la que se trabajará y las reglas que se desea hacer cumplir.

    Ejemplo de uso de php-cs-fixer

    Partiendo de un código que no respeta el estándar PSR-12, usando este comando:

    ./php-cs-fixer fix -v

    Se obtiene el siguiente resultado:

    PHP CS Fixer 3.7.0 #StandWithUkraine️ by Fabien Potencier and Dariusz Ruminski.
    PHP runtime: 8.1.3
    Loaded config default from "/home/mauro/Code/car-rental-php/.php-cs-fixer.dist.php".
    Using cache file ".php-cs-fixer.cache".
    FSSSSSSFFFSSSFSSSSFSFSFFSFFFSSFFFFFFFFFFFFFFFFFFFF                                                                                                        50 / 50 (100%)
    Legend: ?-unknown, I-invalid file syntax (file ignored), S-skipped (cached or empty file), .-no changes, F-fixed, E-error
       1) www/test_db_pdo.php (braces, single_blank_line_at_eof)
       2) www/tests/_support/Helper/Functional.php (braces, blank_line_after_opening_tag)
       3) www/tests/_support/Helper/Acceptance.php (braces, blank_line_after_opening_tag)
       4) www/tests/_support/Helper/Unit.php (braces, blank_line_after_opening_tag)
       5) www/tests/acceptance/CarDetailsCest.php (no_spaces_inside_parenthesis)
       6) www/index.php (full_opening_tag)
       7) www/templates/components/navbar.php (braces)
       8) www/templates/profile.php (braces)
       9) www/templates/register.php (indentation_type, braces)
      10) www/templates/rentals.php (braces)
      11) www/templates/car.php (elseif, braces)
      12) www/templates/signin.php (braces)
      13) www/templates/rent.php (elseif, braces)
      14) www/templates/home.php (elseif, braces)
      15) www/classes/Router.php (class_definition, braces, single_blank_line_at_eof)
      16) www/classes/Utils.php (class_definition, braces, single_blank_line_at_eof)
      17) www/classes/db/UserService.php (class_definition, braces, array_syntax, ternary_operator_spaces, single_blank_line_at_eof)
      18) www/classes/db/Database.php (class_definition, braces, method_argument_space, single_blank_line_at_eof)
      19) www/classes/db/RentalService.php (class_definition, braces, array_syntax, single_blank_line_at_eof)
      20) www/classes/Renderer.php (class_definition, braces, array_syntax, single_blank_line_at_eof)
      21) www/classes/pages/Signin.php (elseif, class_definition, braces, array_syntax, single_blank_line_at_eof)
      22) www/classes/pages/Profile.php (class_definition, braces, no_spaces_inside_parenthesis, single_blank_line_at_eof)
      23) www/classes/pages/Register.php (elseif, class_definition, braces, array_syntax, single_blank_line_at_eof)
      24) www/classes/pages/CarDetails.php (class_definition, braces, single_blank_line_at_eof)
      25) www/classes/pages/Homepage.php (class_definition, braces, no_spaces_inside_parenthesis, single_blank_line_at_eof)
      26) www/classes/pages/Rentals.php (class_definition, braces, no_spaces_inside_parenthesis, single_blank_line_at_eof)
      27) www/classes/pages/BasicPage.php (class_definition, braces, visibility_required, single_blank_line_at_eof)
      28) www/classes/pages/NotFound.php (class_definition, braces, single_blank_line_at_eof)
      29) www/classes/pages/Rent.php (class_definition, braces, no_spaces_inside_parenthesis, array_syntax, single_blank_line_at_eof)
      30) www/classes/pages/Logout.php (class_definition, braces, single_blank_line_at_eof)
      31) www/phpinfo.php (blank_line_after_opening_tag, single_blank_line_at_eof)
      32) www/test_db.php (blank_line_after_opening_tag)
    
    Checked all files in 0.178 seconds, 14.000 MB memory used

    Donde puede verse qué archivos han sido modificados y qué regla se ha aplicado a cada uno.

    Cómo especificar qué reglas aplicar en php-cs-fixer

    Existen dos formas de especificar qué reglas se aplicarán en una corrida en particular:

    1. Parámetros al momento de ejecutar el script
    2. Mediante un archivo de configuración

    Independientemente de cuál sea el método elegido, las reglas pueden ser especificadas en forma explícita (una por una) o bien usando conjuntos pre-definidos.

    Las reglas y los conjuntos se diferencian porque los últimos tienen un nombre que comienza con @.

    En mi caso estoy usando este archivo de configuración (.php-cs-fixer.dist.php):

    <?php
    
    $finder = PhpCsFixer\Finder::create()
        ->in(__DIR__.'/www')
    ;
    
    $config = new PhpCsFixer\Config();
    return $config->setRules([
            '@PSR12' => true,
            'array_syntax' => [
                    'syntax' =>
                    'short'
            ],
            'full_opening_tag' => true,
        ])
        ->setFinder($finder)
    ;

    Esto quiere decir que las reglas que se aplicarán serán las siguientes:

    El código antes y después de php-cs-fixer

    Aquí puedes ver las diferencias que se generaron en algunos archivos después de la corrida de php-cs-fixer:

    git diff www/classes/Renderer.php

    diff --git a/www/classes/Renderer.php b/www/classes/Renderer.php
    index c5e2e0f..ac90cec 100644
    --- a/www/classes/Renderer.php
    +++ b/www/classes/Renderer.php
    @@ -1,15 +1,16 @@
     <?php
     
    -class Renderer {
    +class Renderer
    +{
    +    private static $injection = [];
     
    -    private static $injection = array();
    -
    -    public static function inject($key, $value){
    +    public static function inject($key, $value)
    +    {
             self::$injection[$key] = $value;
         }
     
    -    public static function render($contentFile, $variables = array()) {
    -
    +    public static function render($contentFile, $variables = [])
    +    {
             $contentFileFullPath = "../templates/" . $contentFile;
     
             // making sure passed in variables are in scope of the template
    @@ -28,20 +29,19 @@ class Renderer {
                 }
             }
     
    -    require_once("../templates/components/header.php");
    +        require_once("../templates/components/header.php");
     
    -    echo "\n<div class=\"container\">\n";
    +        echo "\n<div class=\"container\">\n";
     
    -    if (file_exists($contentFileFullPath)) {
    -        require_once($contentFileFullPath);
    -    } else {
    -        require_once("../templates/error.php");
    -    }
    +        if (file_exists($contentFileFullPath)) {
    +            require_once($contentFileFullPath);
    +        } else {
    +            require_once("../templates/error.php");
    +        }
     
    -    // close container div
    -    echo "</div>\n";
    +        // close container div
    +        echo "</div>\n";
     
    -    require_once("../templates/components/footer.php");
    +        require_once("../templates/components/footer.php");
    +    }
     }
    -
    -}
    \ No newline at end of file

    git diff www/templates/register.php

    diff --git a/www/templates/register.php b/www/templates/register.php
    index 1aa92ce..aab01a6 100644
    --- a/www/templates/register.php
    +++ b/www/templates/register.php
    @@ -1,6 +1,6 @@
     <div class="panel panel-default">
         <div class="panel-body">
    -        <?php if($loginInfo == 0) { ?>
    +        <?php if ($loginInfo == 0) { ?>
     
             <form class="form-horizontal" method="post" action="">
                 <fieldset>
    @@ -96,20 +96,20 @@
             </form>
             <br>
             <?php
    -            if(isset($errors)) {
    +            if (isset($errors)) {
                     foreach ($errors as $error) {
                         echo "<div class=\"alert alert-dismissible alert-danger fade in\">\n" .
    -  					"<button type=\"button\" class=\"close\" data-dismiss=\"alert\">&times;</button>\n" .
    -  					"$error\n" .
    -				    "</div>\n";
    +                    "<button type=\"button\" class=\"close\" data-dismiss=\"alert\">&times;</button>\n" .
    +                    "$error\n" .
    +                    "</div>\n";
                     }
                 }
     
    -            if(isset($success) && strlen($success) > 0) {
    +            if (isset($success) && strlen($success) > 0) {
                     echo "<div class=\"alert alert-dismissible alert-success fade in\">\n" .
    -  					"<button type=\"button\" class=\"close\" data-dismiss=\"alert\">&times;</button>\n" .
    -  					"$success\n" .
    -				"</div>\n";
    +                    "<button type=\"button\" class=\"close\" data-dismiss=\"alert\">&times;</button>\n" .
    +                    "$success\n" .
    +                "</div>\n";
                 }
             ?>

    git diff www/classes/db/UserService.php

    diff --git a/www/classes/db/UserService.php b/www/classes/db/UserService.php
    index 148bc00..1f5dd82 100644
    --- a/www/classes/db/UserService.php
    +++ b/www/classes/db/UserService.php
    @@ -2,9 +2,10 @@
     
     require_once('../classes/db/Database.php');
     
    -class User {
    -
    -    public static function isUserAdmin($id) {
    +class User
    +{
    +    public static function isUserAdmin($id)
    +    {
             $query = "SELECT _id FROM admins WHERE user_id = :id";
     
             $stmt = Database::getInstance()
    @@ -14,13 +15,15 @@ class User {
             $stmt->bindParam(":id", $id);
             $stmt->execute();
     
    -        if ($stmt->rowCount() > 0)
    +        if ($stmt->rowCount() > 0) {
                 return $stmt->fetchColumn();
    -        else
    +        } else {
                 return 0;
    +        }
         }
     
    -    public static function getUserInfo($id) {
    +    public static function getUserInfo($id)
    +    {
             $query = "SELECT first_name, last_name FROM user WHERE _id = :id";
     
             $stmt = Database::getInstance()
    @@ -33,7 +36,8 @@ class User {
             return $stmt->fetch(PDO::FETCH_ASSOC);
         }
     
    -    public static function getUserDetails($id) {
    +    public static function getUserDetails($id)
    +    {
             $query = "SELECT *, date_format(join_time, '%D %b %Y, %I:%i %p') as join_date  FROM user, address WHERE user._id = :id AND user.address_id = address._id";
     
             $stmt = Database::getInstance()
    @@ -46,7 +50,8 @@ class User {
             return $stmt->fetch(PDO::FETCH_ASSOC);
         }
     
    -    private static function userExists($id, $method) {
    +    private static function userExists($id, $method)
    +    {
             $query = "SELECT _id FROM user WHERE $method = :$method";
     
             $stmt = Database::getInstance()
    @@ -56,18 +61,21 @@ class User {
             $stmt->bindParam(":$method", $id);
             $stmt->execute();
     
    -        if ($stmt->rowCount() > 0)
    +        if ($stmt->rowCount() > 0) {
                 return $stmt->fetchColumn();
    -        else
    +        } else {
                 return 0;
    +        }
         }
     
    -    public static function doesUserExist($id) {
    +    public static function doesUserExist($id)
    +    {
             $_id = self::userExists($id, "username");
    -        return $_id>0?$_id:self::userExists($id, "email");
    +        return $_id>0 ? $_id : self::userExists($id, "email");
         }
     
    -    public static function verifyUser($id, $password) {
    +    public static function verifyUser($id, $password)
    +    {
             $query = "SELECT first_name, password FROM user WHERE _id = :id";
     
             $stmt = Database::getInstance()
    @@ -80,13 +88,14 @@ class User {
             if ($stmt->rowCount() > 0) {
                 $user = $stmt->fetch(PDO::FETCH_ASSOC);
     
    -            return password_verify($password, $user['password'])?$user['first_name']:false;
    +            return password_verify($password, $user['password']) ? $user['first_name'] : false;
             }
     
             return false;
         }
     
    -    public static function insertAddress($addressArray) {
    +    public static function insertAddress($addressArray)
    +    {
             $fields = ['street', 'city', 'state', 'country', 'zip'];
     
             $query = 'INSERT INTO address(' . implode(',', $fields) . ') VALUES(:' . implode(',:', $fields) . ')';
    @@ -95,7 +104,7 @@ class User {
                 ->getDb()
                 ->prepare($query);
     
    -        $prepared_array = array();
    +        $prepared_array = [];
             foreach ($fields as $field) {
                 $prepared_array[':'.$field] = @$addressArray[$field];
             }
    @@ -104,7 +113,8 @@ class User {
             return Database::getInstance()->getDb()->lastInsertId();
         }
     
    -    public static function insertUser($userArray) {
    +    public static function insertUser($userArray)
    +    {
             $fields = ['first_name', 'last_name', 'email', 'username', 'password', 'ph_no', 'gender', 'address_id'];
     
             $query = 'INSERT INTO user(' . implode(',', $fields) . ') VALUES(:' . implode(',:', $fields) . ')';
    @@ -112,7 +122,7 @@ class User {
             $db = Database::getInstance()->getDb();
             $stmt = $db->prepare($query);
     
    -        $prepared_array = array();
    +        $prepared_array = [];
             foreach ($fields as $field) {
                 $prepared_array[':'.$field] = @$userArray[$field];
             }
    @@ -135,5 +145,4 @@ class User {
     
             return $id;
         }
    -
    -}
    \ No newline at end of file
    +}

    A que se ve mejor que ir archivo por archivo aplicando cada regla, ¿cierto?

    Cómo garantizar la adhesión al estándar

    Por último, para garantizar que algo sucede, nada mejor que una buena automatización.

    En este caso, será cuestión de agregar la ejecución de php-cs-fixer a algún hook de git o al sistema de integración continua que se esté usando.

    No quedan excusas para no implementar un estándar de codificación en tus proyectos php.

  • Cómo manejar las excepciones en API Rest con Symfony

    Cómo manejar las excepciones en API Rest con Symfony

    Desarrollaste tu primer servicio web usando Symfony.

    Lo probaste por acá y por allá y funciona todo… salvo cuando no es así.

    Justo hubo un caso de esos muy extraños en que el registro que buscabas no tiene exactamente todos los campos solicitados… cosas que pasan en el desarrollo de software.

    Nada es grave, ¿no? Al final sólo se trata de enviar al cliente una respuesta adecuada para que sea él quien se encargue de resolver el problema y acompañarla del código de error HTTP correspondiente.

    A lo sumo, agregar una capa de Logging para poder analizar el tema cuando sea posible.

    La idea es bastante simple pero la pregunta es cómo hacerlo correctamente dentro aprovechando al máximo las capacidades del framework Symfony.

    La forma simple

    Para hacer algo rápido no se requiere mucho, basta con atrapar la excepción dentro del controlador en cuestión, generar una respuesta JSON, asignarle el código HTTP que corresponda y enviarla.

    Algo así:

    #[Route('/dangerous', name: 'dangerous_action', methods: ['GET'])]
        public function dangerousAction(Request $request): JsonResponse
        {
            try {
                $this->exceptionThrowerMethod();
            } catch (\Exception $exception) {
                
                $response = new JsonResponse([
                    'message' => $exception->getMessage(),
                    'data' => [],
                    'errors' => []
                ]);
                
                $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
                
                return $response;
            }
        }

    El problema es que esto habrá que replicarlo en cada acción (o en cada controlador al menos).

    Usando algunos elementos de Symfony es posible lograr una solución mucho más elegante y escalable.

    La forma correcta

    Si la API en cuestión cuenta con múltiples end-points es conveniente estandarizar el formato de las respuestas que se enviarán, sean estas exitosas o fallidas.

    El mejor modo de lograr esta homogeneidad es, como siempre que se trata de software, centralizar la funcionalidad común en un único componente.

    En este caso vale la pena contar con una clase como esta:

    <?php
    
    namespace App\Responses;
    
    class APIResponse extends \Symfony\Component\HttpFoundation\JsonResponse
    {
        /**
         * ApiResponse constructor.
         *
         * @param string $message
         * @param mixed  $data
         * @param array  $errors
         * @param int    $status
         * @param array  $headers
         * @param bool   $json
         */
        public function __construct(string $message, $data = null, array $errors = [], int $status = 200, array $headers = [], bool $json = false)
        {
            parent::__construct([
                'message' => $message,
                'data' => $data,
                'errors' => $errors,
            ], $status, $headers, $json);
        }
    }

    Con esto cuentas con lo necesario para estandarizar las respuestas a las peticiones de la API… pero el problema que se menciona en la sección anterior persiste: ¿cómo utilizar esta clase para responder a las excepciones?

    La respuesta: usando el sistema de eventos de Symfony.

    Cada excepción que no es atrapada explícitamente termina generando un evento de tipo kernel.exception, el cual puede ser atrapado por un EventListener como este:

    <?php
    
    namespace App\EventListener;
    
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
    use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
    
    class ExceptionListener
    {
        public function onKernelException(GetResponseForExceptionEvent $event)
        {
            $exception = $event->getException();
            $request   = $event->getRequest();
            
            $response = $this->createApiResponse($exception);
            $event->setResponse($response);
        }
        
        private function createApiResponse(\Exception $exception)
        {
            $statusCode = $exception instanceof HttpExceptionInterface ? $exception->getStatusCode() : Response::HTTP_INTERNAL_SERVER_ERROR;
            $errors     = [];
    
            return new APIResponse($exception->getMessage(), null, $errors, $statusCode);
        }
    }

    Con esta clase ya tienes definida la acción que debe tomarse ante una excepción y, combinándola con la clase definida en el paso anterior te aseguras de que la respuesta siempre tenga el mismo formato (Algo que los consumidores de tu API te agradecerán seguramente).

    Sólo falta un último paso: conectar esta clase con el EventDispatcher.

    Registrar un EventListener en Symfony

    Para lograr esto necesitas hacer un par de ajustes a la configuración de tu aplicación.

    Al final del archivo config/services.yaml debes agregar:

    App\EventListener\ExceptionListener:
            tags:
                - { name: kernel.event_listener, event: kernel.exception }

    Y ya está.

    Con esta configuración Symfony sabrá conectar el método onKernelException de tu clase App\EventListener\ExceptionListener.

    Si te quedan dudas de cómo es hace Symfony para establecer esta relación puedes leer este artículo.

    Para seguir investigando

    Si estás desarrollando o, mejor aún, pensando en desarrollar una API REST usando Symfony te sugiero que le des una mirada a API-Platform, seguro que te puede ahorrar muchos dolores de cabeza.

  • Cómo leer un archivo de Excel desde PHP

    Cómo leer un archivo de Excel desde PHP

    Referencias:

    • https://www.it-swarm-es.com/es/php/como-puedo-leer-archivos-.xls-excel-con-php/941255940/
    • https://forobeta.com/temas/ayuda-como-leer-archivo-de-excell-desde-php.675902/#post-5491417
    • https://foro.elhacker.net/buscador-t324525.0.html
    <?php
    
    use PhpOfficePhpSpreadsheetIOFactory;
    
    $spreadsheet = IOFactory::load('entrada.xls');
    $sheetData = $spreadsheet->getActiveSheet()->toArray(null, true, true, true);
    var_dump($sheetData);

    En este caso, la clase PhpOfficePhpSpreadsheetIOFactoryintentará «adivinar» el tipo de planilla de la que se trata (Algo bastante útil cuando tienes que tratar con diferentes tipos de planilla).

    ¿Cómo funciona esta adivinación? es bastante complejo… el punto es que puede fallar, con lo cual, si sabés exactamente el tipo de planilla que vas a usar, más vale usar un Reader específico:

    <?php
    
    use PhpOfficePhpSpreadsheetReaderXls;
    
    $reader = new Xls();
    $spreadsheet = $reader->load('entrada.xls');
    
    $sheetData = $spreadsheet->getActiveSheet()->toArray(null, true, true, true);
    var_dump($sheetData);
    

    Conclusión

    Como podrás ver en los ejemplos, es bastante sencillo realizar operaciones sobre una planilla Excel casi como si estuvieses escribiendo una macro.

    Además de lo dicho hasta aquí, la documentación de PHPSpreadSheet es un lujo (Sólo que está en Inglés).

  • Cómo enviar archivos grandes a través de WebServices

    Cómo enviar archivos grandes a través de WebServices

    Horacio, un ex-alumno, me escribió esta consulta:

    Hola profesor, tanto tiempo, sabe que tengo una pregunta , sabe que tengo que hacer un web service en php y queria saber que me conviene si soap o rest , son datos de gran tamaño , la idea es hacer una aplicación que permite cargar archivos xlsx(importar archivo) a una base de datos mysql (destino) y de ahí que lo consuma otro sistema web . Son archivos de gran tamaño.según su conocimiento que me recomienda?Aguardo respuesta.Saludos y gracias.

    El problema en que se encuentra Horacio radica en que los webservices, tanto REST como SOAP, están basados en el protocolo HTTP.

    Este protocolo se presta muy bien para el intercambio de texto, pero cuando se trata de datos, ya no resulta tan conveniente. Mucho menos si se trata del intercambio de muchos datos.

    ¿Cuál es el problema? Pues que para que los datos se envíen a través de HTTP, primero deben ser transformados a texto, lo cual agrega una sobrecarga de procesamiento y, a la vez, hace que la cantidad total de información a enviar sea mucho mayor.

    ¿Por qué sucede esto?

    Transferencia binaria versus transferencia textual

    La raíz del problema es la representación interna de los datos en la computadora.

    Un ejemplo muy simple: el número 1234567890 puede ser almacenado entero o como la secuencia de caracteres 1 2 3 4 5 6 7 8 9 0.

    En el primero de los casos, nos alcanzarían 31 bits para almacenarlo (230 = 1073741824 y 231=2147483648), mientras que en el segundo necesitaríamos 80 (Asumiendo 8 bits por cada caracter).

    Esto como para poner un poco en contexto por qué la transmisión binaria es más eficiente que la textual.

    Si multiplicamos esta diferencia de 49 bits por un gran conjunto de información a enviar pronto nos chocamos contra el límite establecido por el servidor web… seguramente lo que le ocurrió a Horacio.

    ¿Cuál podría ser la solución?

    Cambiar la configuración del servidor web

    En algunos escenarios, especialmente si se tiene la capacidad y posibilidad de alterar la configuración del servidor web, se podría relajar un poco el criterio para permitir operaciones HTTP más pesadas.

    Esta solución no es la óptima principalmente por dos motivos:

    1. No soluciona el problema de la eficiencia
    2. Siempre podrá aparecer un archivo lo suficientemente grande como para superar los nuevos límites y se estaría nuevamente en la situación inicial

    ¿REST o SOAP?

    Respecto de la pregunta de si conviene más usar REST o SOAP diría que da lo mismo.

    El problema es usar HTTP para realizar el envío y, tanto REST como SOAP depende de él.

    La solución pasa por desligar el envío del archivo respecto del aviso a la contraparte.

    Más en concreto, se trata de dividir el problema en tres partes:

    1. Dejar el archivo disponible para que la contraparte lo acceda
    2. Informar a la contraparte dónde está alojado tal archivo para que pueda accederlo
    3. Desde el receptor del mensaje realizar la descarga

    Sólo el paso 2 debería depender de un servicio web.

    Los pasos 1 y 3 pueden realizarse usando diversas opciones de almacenamiento de archivos binarios. Lo importante es que luego pueda realizarse la descarga sin usar HTTP.

    Un ejemplo usando FTP

    En esta sección se puede ver un ejemplo de solución usando un servidor FTP.

    client.php

    <?php
    
    if (!($remoteServer = ftp_connect(getenv('FTP_HOST')))) {
        die('Not connected :(' . PHP_EOL);
    }
    echo 'Connected!' . PHP_EOL;
    if (!ftp_login($remoteServer, getenv('FTP_USER'), getenv('FTP_PWD'))) {
        die('Wrong credentials' . PHP_EOL);
    }
    echo 'Login succesful!' . PHP_EOL;
    $remoteFullPath = getenv('REMOTE_BASE_PATH') . '/' . basename($argv[1]);
    if (!ftp_put($remoteServer, $remoteFullPath, $argv[1], FTP_BINARY)) {
        die('Upload failed');
    }
    
    echo 'File ' . $argv[1] . ' uploaded!'.PHP_EOL;
    
    ftp_close($remoteServer);
    
    $ch = curl_init(getenv('SERVER_URL') . '/upload');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => [
            'path' => $remoteFullPath
        ],
        CURLOPT_RETURNTRANSFER => true,
    ]);
    
    $response = curl_exec($ch);
    if (200 !== curl_getinfo($ch, CURLINFO_RESPONSE_CODE)) {
        die('Webservice call failed: ' . curl_error($ch));
    }
    curl_close($ch);
    echo 'Message sent!' . PHP_EOL;

    server.php:

    <?php
    
    error_log('Request received!' . PHP_EOL);
    if ('post' === strtolower($_SERVER['REQUEST_METHOD'])) {
        $remotePath = $_POST['path'];
        error_log('Downloading ' . $remotePath . PHP_EOL);
        if (!($remoteServer = ftp_connect(getenv('FTP_HOST')))) {
            http_response_code(500);
            trigger_error('Not connected :(' . PHP_EOL);
            die;
        }
        trigger_error('Connected!' . PHP_EOL);
        if (!ftp_login($remoteServer, getenv('FTP_USER'), getenv('FTP_PWD'))) {
            http_response_code(500);
            trigger_error('Wrong credentials' . PHP_EOL);
            die;
        }
        trigger_error('Login succesful!' . PHP_EOL);
        if (!ftp_get($remoteServer, __DIR__ . '/' . basename($remotePath), $remotePath)) {
            trigger_error('Download of file '.$remotePath.' failed' . PHP_EOL);
            http_response_code(500);
            die;
        }
        trigger_error('Sucessuflly downloaded '.$remotePath.PHP_EOL);
    }

    Notas:

    1. Para hacer más genérica la solución todos los datos específicos están guardardos en variables de entorno.
    2. En este caso el cliente se bloquea hasta que el servidor realice la descarga. En un caso más realista esto no debería ser así. El servidor debería guardar los datos de la descarga y emitir un acuse de recibo para que el cliente pueda continuar y eventualmente realizar la descarga.
  • Cómo ejecutar varias versiones de PHP en mismo NginX

    Cómo ejecutar varias versiones de PHP en mismo NginX

    Trabajando con un cliente me encontré ante este escenario:

    En un mismo servidor (Una instancia de AWS) había una aplicación desarrollada usando CodeIgniter y otra basada en Symfony.

    Al momento de comenzar mi intervención ambas aplicaciones compartían un mismo servidor web (NginX) y el binario de php para ejecutar algunos scripts de línea de comandos.

    El problema era que la versión de CodeIgniter en la que se había realizado el desarrollo (2.2.6) no podía actualizarse a la más reciente (4.1.5) sin re-escribir casi por completo la aplicación.

    Mientras tanto, la aplicación Symfony fue siguiendo la evolución del framework bastante de cerca.

    Para continuar el mantenimiento de la aplicación Symfony, y aprovechar nuevas caracterísitcas de rendimiento y seguridad, se hacía conveniente actualizar la versión de PHP y, difícilmente este cambio sería soportado por la aplicación vieja.

    La mejor solución sería darle a cada aplicación su propio entorno de ejecución de modo de que ninguna se vuelva un cuello de botella para la otra.

    La decisión fue mantener ambas versiones del lenguaje disponibles y que cada sitio utilizara la que le correspondiera (Al menos hasta definir qué hacer con la aplicación CodeIgniter).

    Pensé en pasar ambas a Docker y no descarto hacerlo más adelante pero, por el momento, me pareció demasiado esfuerzo para poco beneficio.

    Para tener dos versiones diferentes de PHP ejecutando en un mismo servidor es necesario:

    1. Instalar la nueva versión
    2. Modificar la configuración del WebServer
    3. Modificar la configuración de los cronjobs

    Cómo instalar una nueva versión de PHP

    El servidor en cuestión es un Ubuntu, de modo que instalar una nueva versión de PHP (8.0 en mi caso) requiere estos comandos:

    sudo add-apt-repository ppa:ondrej/php
    sudo apt install php8.0

    Con esto queda instalado el binario de php en su versión 8, luego hay que tener en cuenta instalar las extensiones que sean necesarias.

    Una vez finalizado el proceso, con este comando:

    update-alternatives --display php

    Vemos que se ha instalado la nueva versión:

    php - manual mode
      link best version is /usr/bin/php8.0
      link currently points to /usr/bin/php7.3
      link php is /usr/bin/php
      slave php.1.gz is /usr/share/man/man1/php.1.gz
    /usr/bin/php7.3 - priority 73
      slave php.1.gz: /usr/share/man/man1/php7.3.1.gz
    /usr/bin/php8.0 - priority 80
      slave php.1.gz: /usr/share/man/man1/php8.0.1.gz

    Notá como el comando php es un link simbólico que está alojado en /usr/bin/php y apunta a /usr/bin/php7.3. Es por eso que php -v muestra:

    PHP 7.3.19-1+ubuntu18.04.1+deb.sury.org+1 (cli) (built: Jun 12 2020 07:48:30) ( NTS )
    Copyright (c) 1997-2018 The PHP Group
    Zend Engine v3.3.19, Copyright (c) 1998-2018 Zend Technologies
        with Zend OPcache v7.3.19-1+ubuntu18.04.1+deb.sury.org+1, Copyright (c) 1999-2018, by Zend Technologies

    Es decir, si queremos asegurarnos de estar ejecutando la versión 8.0 el comando que nos da más certeza es /usr/bin/php8.0 -v:

    PHP 8.0.13 (cli) (built: Nov 22 2021 09:50:24) ( NTS )
    Copyright (c) The PHP Group
    Zend Engine v4.0.13, Copyright (c) Zend Technologies
        with Zend OPcache v8.0.13, Copyright (c), by Zend Technologies 

    Perfecto. Tengamos esto en cuenta para dentro de un rato cuando veamos lo que pasa con los cronjobs.

    Cómo especificar la versión de PHP para cada sitio

    Para este punto viene muy bien el hecho de estar usando NginX como servidor web. A diferencia de Apache que puede tener PHP incorporado, NginX se limita a servir contenido estático y delega la ejecución de PHP en otro proceso (php-fpm).

    Si miramos el archivo de configuración de cada sitio veremos algo como:

    server {
        ...
        location ~ ^/index\.php(/|$) {
            fastcgi_split_path_info ^(.+\.php)(/.*)$;
            fastcgi_pass unix:/var/run/php/php7.3-fpm.sock;
            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    
            fastcgi_intercept_errors off;
            fastcgi_buffer_size 16k;
            fastcgi_buffers 4 16k;
        }
    
        ...
    }

    En particular la línea que nos interesa en este caso es:

    fastcgi_pass unix:/var/run/php/php7.3-fpm.sock;

    Aquí se está definiendo el canal de comunicación entre el servidor web y el proceso que atenderá las peticiones php.

    De modo que lo que debemos cambiar es el 7.3 por 8.0 en el archivo que corresponde a la aplicación más nueva.

    Claro que, para que esto funcione debemos contar con php8.0-fpm.

    Para instalarlo puede usarse el comando:

    sudo apt install php8.0-fpm

    Y con eso ya contaremos con el proceso FastCGI que necesitamos.

    Por supuesto que, antes de aplicar estos cambios en producción es prudente realizar pruebas en un ambiente controlado. Asumiendo que ya se realizaron dichas pruebas y se corrigieron eventuales inconvenientes, con los cambios realizados la aplicación web está lista.

    Resta ver qué hacer con las tareas programadas y/o scripts de línea de comandos que se utilicen.

    En este caso lo que hay son algunas tareas programadas vía cron.

    Cómo especificar la versión de PHP para cada cronjob

    Usando el comando crontab -l obtenemos algo como:

    # Edit this file to introduce tasks to be run by cron.
    # 
    # Each task to run has to be defined through a single line
    # indicating with different fields when the task will be run
    # and what command to run for the task
    # 
    # To define the time you can provide concrete values for
    # minute (m), hour (h), day of month (dom), month (mon),
    # and day of week (dow) or use '*' in these fields (for 'any').# 
    # Notice that tasks will be started based on the cron's system
    # daemon's notion of time and timezones.
    # 
    # Output of the crontab jobs (including errors) is sent through
    # email to the user the crontab file belongs to (unless redirected).
    # 
    # For example, you can run a backup of all your user accounts
    # at 5 a.m every week with:
    # 0 5 * * 1 tar -zcf /var/backups/home.tgz /home/
    # 
    # For more information see the manual pages of crontab(5) and cron(8)
    # 
    # m h  dom mon dow   command
    
    MAILTO=mauro.chojrin@leewayweb.com
    0 6 1 2,5,8,11 * /usr/bin/php /home/ubuntu/followapp/bin/console app:agents:send-transaction-summary

    Esto significa que todos los 1 de los meses de Febrero, Mayo, Agosto y Noviembre a las 6:00 AM se ejecuta el script PHP /home/ubuntu/followapp/bin/console pasándole como argumentos app:agents:send-transaction-summary.

    El script es el que Symfony dispone para la ejecución de comandos diseñados utilizando el framework.

    El argumento especifica cuál de todos los comandos disponibles es el que se desea ejecutar (En este caso se trata de uno diseñado a efectos de enviar a los vendedores un resumen de las transacciones realizadas durante el trimestre anterior).

    Y la parte de la línea que dice /usr/bin/php es la que especifica cuál será el binario que deberá invocarse cuando sea el momento indicado. En este caso se trata del binario de PHP.

    Claro que, en este servidor donde hay más de una versión de PHP, esta definición puede resultar ambigua (A los ojos humanos claro, para la computadora está muy claro que /usr/bin/php es un alias de /usr/bin/php7.3).

    Lo conveniente para evitar confusiones es hacer explícita la versión del intérprete que se requiere, dejando la línea del crontab de esta forma:

    0 6 1 2,5,8,11 * /usr/bin/php /home/ubuntu/followapp/bin/console app:agents:send-transaction-summary

    Y ahora sí, con este último cambio está todo listo para tener ambas versiones conviviendo en armonía.

    Unas últimas notas

    Como habrás podido apreciar, no es realmente complejo lograr esta configuración, sin embargo, siendo que el servidor ya se encuentra virtualizado, lo más conveniente sería tener una instancia separada para cada aplicación.

    La desventaja de este enfoque es que, además de los costos propios de la infraestructura, la administración puede hacerse un poco más compleja.

    Lo que debe evaluarse para decidir es el riesgo de que una aplicación falle y, al competir por los recursos con la otra, deje fuera de línea un sitio que, en principio, podría continuar operativo.