Categoría: Cómo hacer para…

Estos artículos te explicarán cómo resolver problemas específicos usando PHP

  • Cómo agregar extensiones PHP a una imagen Docker

    Cómo agregar extensiones PHP a una imagen Docker

    ¿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:

    1. Buscar una nueva imagen base que sí las tenga instaladas
    2. Descargar el código fuente, compilar e instalar manualmente las extensiones
    3. Usar el script que viene con las imágenes oficiales de php

    Supongo que la elección es clara, ¿no?

    Cómo usar docker-php-ext-install

    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.

  • Cómo ejecutar phpMyAdmin en Docker

    Cómo ejecutar phpMyAdmin en Docker

    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?.

    Asumiré que tienes un archivo docker-compose.yml similar a este:

    version: '3.8'
    services:
      db:
        image: 'mysql:5.7.42-debian'
        environment:
          - MYSQL_ROOT_PASSWORD=root
          - MYSQL_DATABASE=ruko
          - MYSQL_USER=ruko
          - MYSQL_PASSWORD=ruko
        restart: always
        volumes:
          - 'db-data:/var/lib/mysql'
      webserver:
        image: 'ruko_dev'
        user: 'www-data'
        build:
          context: '.'
        restart: always
        ports:
          - '8888:80'
        volumes:
          - './app/:/var/www/html/'
    volumes:
      db-data: {}

    Con lo cual, al hacer docker-compose up:

    • 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.

    Qué mejor para ello que usar una imagen estándar de phpMyAdmin, ¿no?

    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:

      pma:
        image: 'phpmyadmin'
        ports:
          - '9999:80'
        restart: always

    Para probarlo todo basta con ejecutar docker-compose down y docker-compose up.

  • ¿Cómo cambiar el upload_max_file_size desde el Dockerfile?

    ¿Cómo cambiar el upload_max_file_size desde el Dockerfile?

    Que tiempos aquellos en que el tamaño los archivos que se subían vía HTTP se medía en KB, ¿no? Qué fácil era la vida por entonces.

    Pero bueno… el mundo avanza y con él la necesidad de compartir nuestras fotos en alta calidad se hace presente.

    En ese contexto, tener un upload máximo de 2 MB puede convertirse en un gran limitante.

    Y, casualmente, el tamaño máximo por defecto del upload_max_filesize de PHP es de… 2 MB.

    En un ambiente de desarrollo normal modificar esto es fácil, ¿no?

    Basta con:

    1. Ejecutar el comando php --ini
    2. Abrir el archivo referido en Configuration File (php.ini)
    3. Buscar la línea upload_max_filesize = 2M
    4. Modificarla por un valor acorde a nuestras necesidades (Por ejemplo upload_max_filesize => 10M)
    5. Reiniciar el servidor (o php-fpm)

    Pero si estamos usando un contenedor Docker las cosas no son tan simples.

    Es decir, hacer el cambio en sí no presenta una dificultad especial:

    docker exec -it 7b8da0fb9892 bash
    apt update
    apt install -y vim
    vim /usr/local/etc/php/php.ini

    Modificar el valor de la configuración, guardar y listo.

    El problema es que… apenas el contenedor se destruya, el cambio se fue con él y estamos otra vez donde empezamos.

    Es todo una cuestión de imagen

    La solución basada en el Dockerfile pasa por tener, dentro de la imagen que se construya, una definición del php.ini que contenga los cambios que necesitamos.

    ¿Cómo lograr eso? Pues se trata de tener, dentro de nuestro proyecto, nuestro propio php.ini y meterlo dentro de la imagen durante el build.

    En concreto, supongamos que partimos de un Dockerfile que se ve así:

    FROM php:7.4-apache
    
    LABEL authors="Mauro Chojrin <mauro.chojrin@leewayweb.com>"
    
    RUN apt-get update && \
        apt-get install -y \
            libpng-dev \
            libzip-dev
    
    RUN docker-php-ext-install gd && \
        docker-php-ext-install zip && \
        docker-php-ext-install mysqli
    
    ADD app /var/www/html

    Con esto podemos crear una imagen para luego levantar un contenedor con un Apache que podrá procesar php para responder a las peticiones.

    En este ejemplo, el código (El contenido del directorio /var/www/html dentro del contenedor) será el que se encuentre dentro del directorio app de nuestro proyecto.

    Lo que toca hacer es agregar un archivo php.ini dentro del proyecto e indicar a Docker que lo agregue a la imagen durante el build:

    FROM php:7.4-apache
    
    LABEL authors="Mauro Chojrin <mauro.chojrin@leewayweb.com>"
    
    RUN apt-get update && \
        apt-get install -y \
            libpng-dev \
            libzip-dev
    
    RUN docker-php-ext-install gd && \
        docker-php-ext-install zip && \
        docker-php-ext-install mysqli
    
    ADD app /var/www/html
    
    ADD php.ini /usr/local/etc/php/

    ¿Qué debería contener el archivo php.ini? Si queremos asegurarnos de que sólo se modifique el upload_max_filesize necesitamos empezar por un archivo que contenga la configuración presente dentro del contenedor.

    Cómo obtener el archivo original

    Lo primero que podemos probar es buscar el archivo dentro del contenedor1:

    docker exec -it 7b8da0fb9892 php --ini

    Esto dará un resultado similar a:

    Configuration File (php.ini) Path: /usr/local/etc/php
    Loaded Configuration File:         (none)
    Scan for additional .ini files in: /usr/local/etc/php/conf.d
    Additional .ini files parsed:      /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini

    Si tuviera un archivo lo copiaría usando:

    docker cp 7b8da0fb9892:/usr/local/etc/php/php.ini .

    Pero, como en mi caso me indica que el archivo de configuración cargado es ninguno, tendré que probar otra cosa.

    Lo que haré será crear en el directorio del proyecto un pequeño script llamado dump_php_ini.php:

    <?php 
    $s = []
    foreach(ini_get_all() as $k=>$v) {
         if ($v['local_value'] || $v['global_value']) {
            $s[] = sprintf("%s = %s", $k, $v['local_value']?$v['local_value']:$v['global_value']);
        }
    }
    
    echo join("\n", $s);

    Y luego ejecutar este comando:

    docker run -v $(pwd):/var/www/html -it php:7.4-apache php dump_php_ini.php > php.ini

    Con eso tengo el archivo que contiene la configuración exacta que se está usando actualmente.

    Y ahora sí, puedo editarlo, hacer la modificación que se requiere y volver a construir la imagen usando:

    docker build . -t my_apache_php

    A partir de aquí, todos los contenedores que sean creados usando este Dockerfile tendrán la configuración deseada y los visitantes del sitio podrán compartir sus fotos sin inconvenientes.

    1. Cambia el ID del contenedor por el tuyo ↩︎
  • ¿Cómo direccionar un dominio a un contenedor Docker?

    ¿Cómo direccionar un dominio a un contenedor Docker?

    Tenés tu entorno local funcionando perfecto con Docker.

    Llevar la imagen al hosting y crear el contenedor no es un problema pero, cuando querés que los clientes vean el sitio… ¿qué les tenés que pasar? ¿No sería genial poder pasarles una URL con tu propio dominio?

    Veamos qué necesitarías para hacerlo.

    1. Un servidor donde alojar tus contenedores
    2. Un dominio que puedas direccionar a tu servidor
    3. La configuración de DNS
    4. La configuración del webserver dentro de Docker

    Asumiré que los puntos 1, 2 y 3 los tenés resueltos y me concentraré en el punto 4.

    Cómo configurar Docker para que responda a un dominio único

    Si se trata de un único contenedor corriendo en el servidor no es necesario hacer demasiado. Basta con asegurarse de que el contenedor esté conectado al puerto 80 del servidor.

    Por ejemplo, si se inicia en un servidor un contenedor usando algo como:

    docker run --restart=always -p  80:80 imagen -d

    Será suficiente para dejar el contenedor corriendo en background en forma constante.

    Con una configuración estándar de Apache como por ejemplo:

    <VirtualHost *:80>
    	# The ServerName directive sets the request scheme, hostname and port that
    	# the server uses to identify itself. This is used when creating
    	# redirection URLs. In the context of virtual hosts, the ServerName
    	# specifies what hostname must appear in the request's Host: header to
    	# match this virtual host. For the default virtual host (this file) this
    	# value is not decisive as it is used as a last resort host regardless.
    	# However, you must set it for any further virtual host explicitly.
    	ServerName www.example.com
    
    	ServerAdmin webmaster@localhost
    	DocumentRoot /var/www/html
    
    	# Available loglevels: trace8, ..., trace1, debug, info, notice, warn,
    	# error, crit, alert, emerg.
    	# It is also possible to configure the loglevel for particular
    	# modules, e.g.
    	#LogLevel info ssl:warn
    
    	ErrorLog ${APACHE_LOG_DIR}/error.log
    	CustomLog ${APACHE_LOG_DIR}/access.log combined
    
    	# For most configuration files from conf-available/, which are
    	# enabled or disabled at a global level, it is possible to
    	# include a line for only one particular virtual host. For example the
    	# following line enables the CGI configuration for this host only
    	# after it has been globally disabled with "a2disconf".
    	#Include conf-available/serve-cgi-bin.conf
    </VirtualHost>
    
    # vim: syntax=apache ts=4 sw=4 sts=4 sr noet

    Es suficiente.

    En definitiva, casi que es más difícil lograr que el contenedor no quede asociado al dominio que lo contrario.

    Cómo configurar Docker para que responda a diferentes dominios

    Distinto, y más interesante, es el caso de querer alojar en un mismo servidor varios sitios diferentes, cada uno con su propio contenedor.

    Para lograr eso se requiere algo más de maña a nivel de infraestructura.

    Un webserver como Apache permite, por ejemplo, usar múltiples hosts virtuales (VirtualHost) pero, obviamente, el puerto 80 del servidor es uno solo. Esto quiere decir que hay un único proceso escuchando en el puerto 80 y, de todo el tráfico que se recibe a través de él, es su responsabilidad decidir cómo responder.

    Para que a cada sitio le llegue el tráfico que le corresponde, y sólo ese tráfico, es necesario definir algún tipo de mapeo entre la petición que recibe el webserver y el host virtual que lo atenderá.

    Una forma de hacer esto es asociar diferentes hosts a diferentes puertos.

    <VirtualHost *:80>
    	...
    </VirtualHost>
    <VirtualHost *:81>
    	...
    </VirtualHost>
    <VirtualHost *:82>
    	...
    </VirtualHost>

    Claro que, si la idea es que a todos los sitios se acceda a través del puerto estándar, esta opción queda descartada.

    Otra opción disponible es desambiguar por IP:

    <VirtualHost 172.21.0.1:80>
    	...
    </VirtualHost>
    <VirtualHost 137.184.122.59:80>
    	...
    </VirtualHost>
    <VirtualHost 93.184.216.34:80>
    	...
    </VirtualHost>

    Esto puede funcionar si hay más de una IP pública asociada al servidor.

    Lo más común, sin embargo, es desambiguar a través del ServerName:

    <VirtualHost *:80>
    	ServerName dominio1.com
    </VirtualHost>
    <VirtualHost *:80>
    	ServerName dominio2.com
    </VirtualHost>
    <VirtualHost *:80>
    	ServerName dominio3.com
    </VirtualHost>

    En este esquema, Apache determinará qué VirtualHost es el requerido analizando la URL. Un request de tipo http://dominio1.com será atendido siguiendo la primera definción, http://dominio2.com la segunda y http://dominio3.com la tercera.

    El primer paso es claro: definir un VirtualHost por cada dominio que se quiera usar.

    Segundo paso: conectar el tráfico del host al contenedor que le corresponde.

    Para lograr esto es necesario contar con el módulo proxy de Apache. En el caso de Ubuntu basta con el comando sudo a2enmod proxy_http; sudo service apache2 restart para tenerlo todo listo.

    Luego, por cada sitio que se quiera servir, se debe crear la configuración del VirtualHost:

    <VirtualHost *:80>
        ServerName dominio1.com
        ProxyPass / http://127.0.0.1:5000/
        ProxyPassReverse / http://127.0.0.1:5000/
    </VirtualHost>

    De esta forma, todo el tráfico que se reciba a través de http://dominio1.com será redirigido automáticamente hacia el puerto 5000 dentro del mismo servidor (De ahí el 127.0.0.1).

    Por último, para cerrar el círculo se necesita contar con un contenedor Docker escuchando en el puerto seleccionado, en este caso, el 5000. Con esto será suficiente para la prueba.

    docker run -p 5000:80 -v $(pwd):/var/www/html php:7.4-apache

    Asumiendo que el directorio actual contiene un archivo como este:

    <?php
    
    echo phpversion();

    Al abrir el navegador en http://dominio1.com/ se verá algo como:

    Si aún no tenés los dominios apuntados al servidor o querés hacer pruebas en tu entorno local basta con que modifiques tu archivo /etc/hosts apuntando todos los dominios a 127.0.0.1.

    El caso del segundo dominio será prácticamente igual:

    <VirtualHost *:80>
        ServerName dominio2.com
        ProxyPass / http://127.0.0.1:5001/
        ProxyPassReverse / http://127.0.0.1:5001/
    </VirtualHost>

    La diferencia está en el puerto. El contenedor para el segundo dominio deberá estar escuchando a un puerto diferente en el host, pero éste deberá estar mapeado al puerto 80 dentro del contenedor. Por ejemplo:

    docker run -p 5001:80 -v $(pwd):/var/www/html php:8.2-apache

    Y, nuevamente, al abrir el navegador en http://dominio2.com verás:

    Y así continúa por cada uno de los dominios que necesites apuntar

  • 8 razones por las que falla una aplicación PHP

    Es una historia que se repite una y otra vez: el sistema funciona perfecto en tu XAMPP pero cuando lo subís al hosting misteriosamente nadie puede registrarse ni logearse.

    Es el mismo codigo en el webserver y en localhost, no entiendo por qué da error en uno sí y otro no

    Dentro de xampp no me presenta problema, sólo en el hosting

    Estoy trabajando con laravel y estoy tratando de generar un sitemap.xml automatico, pero cuando lo genero no me lo guarda en la carpeta publica, solo logre que me lo guarde en el raiz del cpanel y no puedo hacer que me lo genere dentro del public_html

    ¿Qué pasó? El código es exactamente el mismo, lo viste funcionar con tus propios ojos.

    La única diferencia es que la URL, en lugar de empezar por http://localhost empieza por http://dominio.com, ¿cierto?

    Me temo que las diferencias pueden ir bastante más allá de eso.

    Para evitarte unas cuantas vueltas y dolores de cabeza te voy a contar las razones más frecuentes que llevan a este comportamiento.

    Intentaré ir de lo más frecuente hacia lo menos.

    Aquí voy.

    La versión de PHP

    La primera gran dependencia que toda aplicación php tiene es el propio intérprete.

    Si desarrollás en forma local tenés instalado algún paquete que permite transformar esto:

    <html>
      <body><p><?php echo "Hola mundo!"; ?></p></body>
    </html>

    En

    <html>
      <body><p>Hola mundo!"</p></body>
    </html>

    Generalmente esta responsabilidad le corresponde al servidor web que tenés instalado en tu computadora (¿XAMPP tal vez?).

    En tu hosting hay otro servidor web con una configuración similar pero no necesariamente idéntica.

    PHP es un lenguaje que está en constante evolución, año a año salen nuevas versiones y, si bien se hace un esfuerzo muy grande por mantener la compatibilidad con versiones viejas, esto no siempre se logra.

    Es por eso que algunas cosas que funcionan en una versión dejan de funcionar en las siguientes.

    En conclusión: lo primero que deberías verificar es que tu versión de PHP local es igual a la que tiene tu hosting.

    El archivo php.ini

    Además del intérprete, php dispone de un archivo de configuración especial: php.ini.

    En este archivo se definen ciertos parámetros que determinan detalles de cómo el intérprete realiza esa transformación.

    Las variables que pueden configurarse a través de este archivo son ciertamente muchas. Algunas son más críticas que otras.

    Un ejemplo muy simple es el de la directiva short_open_tag. Si su valor es "1" php no tendrá problemas en interpretar algo como:

    <html>
      <body><p><? echo "Hola mundo!"; ?></p></body>
    </html>

    Sin embargo, si este no es el caso, php dejará el texto tal como está, con lo cual, en lugar de transformarlo al HTML que esperás, el visitante de tu sitio verá el código.

    En conclusión: si las cosas no funcionan vale la pena comparar los cotenidos de tu php.ini y los de tu hosting.

    Las extensiones php

    PHP como tal trae una cantidad de funcionalidad incorporada pero es muy común que ciertas funciones se dejen como opcionales por razones de eficiencia.

    Para esos casos en los que se necesitan funcionalidades extras, php cuenta con el mecanismo de extensiones.

    Si tu aplicación depende de alguna en particular, por ejemplo si necesita generar gráficos, es muy probable que requiera de la presencia de una extensión en particular en el ambiente de ejecución.

    Si tu hosting no tiene instalada dicha extensión las cosas no van a funcionar como lo esperás.

    En conclusión: verificá si tu aplicación requiere alguna extensión especial y, en tal caso, asegurate de que esté presente en tu hosting.

    Los permisos

    Para que una aplicación php pueda ejecutarse es preciso que el usuario que ejecuta el servidor web tenga ciertos permisos sobre el sistema de archivos.

    Como mínimo, debe ser capaz de leer los archivos .php que componen tu aplicación.

    En muchos casos también será necesario que pueda escribir en ciertos directorios.

    En conclusión: validá que el usuario que ejecuta el webserver tenga los permisos necesarios para realizar las tareas que debe.

    La topología de red

    Típicamente cuando desarrollas en forma local la red donde tu aplicación vive es bastante sencillita: sólo existe localhost.

    Esto quiere decir que todos los servicios de los que depende son accesibles a través de la dirección de red 127.0.0.1 (O el nombre localhost, lo que es lo mismo).

    Más en concreto, si tu aplicación usa un MySQL como base de datos, es casi seguro que en algún lugar de tu código habrá una línea que se parece a:

    $link = mysqli_connect("127.0.0.1", "mi_usuario", "mi_contraseña", "mi_bd");

    Y esto funcionará sin problemas.

    El tema es que, en un entorno de hosting compartido donde hay muchos sitios hosteados en una misma computadora, es muy común que los servicios estén distribuidos en diferentes nodos de la red del proveedor.

    Esto quiere decir que, al intentar conectar al servidor de MySQL a través de la dirección 127.0.0.1 se producirá un fallo.

    En conclusión: verifica que las conexiones a servicios satélites se estén realizando utilizando las direcciones que correspnden a la red de tu hosting.

    Las rutas absolutas

    Un problema similar al anterior se da cuando se utilizan rutas absolutas.

    Por ejemplo:

    <?php
    
    require_once '/var/www/html/libreria.php';

    Puede funcionar en tu computadora porque existe en tu disco un archivo llamado libreria.php en un directorio /var/www/html/

    Si bien es posible que dicho directorio exista también en tu hosting, es bastante probable que no sea así.

    Si ese archivo está dentro del árbol de directorios de tu aplicación, una mejor forma de lograr el mismo objetivo es utilizar rutas relativas:

    <?php
    
    require_once __DIR__.'/libreria.php';

    En conclusión: usá rutas relativas en lugar de absolutas.

    La configuración del Apache

    Otro elemento que puede afectar negativamente al funcionamiento de la aplicación es la configuración del servidor web. No es que necesariamente se trate a Apache pero lo más probable es que así sea, especialmente en un ambiente de hosting compartido.

    Estas directivas afectan la forma en que el servidor web atiende los pedidos de tus visitantes.

    Muchas aplicaciones php dependen de ciertas configuraciones existentes en el archivo .htaccess (Reglas de re-escritura por ejemplo).

    Para que esto funcione, el webserver debe tener habilitado el procesamiento de este tipo de archivos

    En conclusión: valida que la configuración de tu Apache coincida con la de tu hosting.

    El sistema operativo

    El último de los sospechosos, que muchas veces es pasado por alto, es el sistema operativo sobre el que está montado el servidor.

    Si estás desarrollando en Windows y tu hosting es un Linux hay buenas chances de que no todo funcione a la primera.

    La diferencia para referenciar archivos (Cosas como c:\progams\file.php vs. /var/www/html/file.php) o el hecho de que para Linux un archivo llamado file.php no es el mismo que File.php mientras que para Windows sí son sólo algunas de las sutiles diferencias que pueden darte la idea de que el sistema está listo para subirse cuando en realidad estás a punto de abrir la caja de Pandora.

    En conclusión: verificá que tu código no dependa de características particulares de un sistema operativo.

    Cómo usar esta información

    A lo largo de este post te conté las razones que más comúnmente hacen que un despliegue de una aplicación PHP sea más traumático de lo que debería.

    Ahora que las conocés te recomiendo que las revises nuevamente y las uses a modo de lista de verificación para planificar tus próximas entregas.

  • 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