Test Failed
Push — master ( e6f4b5...c3cccf )
by
unknown
15:56
created

WsdlConsumer   A

Complexity

Total Complexity 24

Size/Duplication

Total Lines 371
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 123
c 1
b 0
f 0
dl 0
loc 371
ccs 0
cts 140
cp 0
rs 10
wmc 24

7 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 6 1
A sendRequest() 0 24 2
A getToken() 0 52 4
A getSeed() 0 12 3
A getWsdlUri() 0 26 3
A createSoapClientOptions() 0 39 2
B callServiceFunction() 0 106 9
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * LibreDTE: Biblioteca PHP (Núcleo).
7
 * Copyright (C) LibreDTE <https://www.libredte.cl>
8
 *
9
 * Este programa es software libre: usted puede redistribuirlo y/o modificarlo
10
 * bajo los términos de la Licencia Pública General Affero de GNU publicada por
11
 * la Fundación para el Software Libre, ya sea la versión 3 de la Licencia, o
12
 * (a su elección) cualquier versión posterior de la misma.
13
 *
14
 * Este programa se distribuye con la esperanza de que sea útil, pero SIN
15
 * GARANTÍA ALGUNA; ni siquiera la garantía implícita MERCANTIL o de APTITUD
16
 * PARA UN PROPÓSITO DETERMINADO. Consulte los detalles de la Licencia Pública
17
 * General Affero de GNU para obtener una información más detallada.
18
 *
19
 * Debería haber recibido una copia de la Licencia Pública General Affero de
20
 * GNU junto a este programa.
21
 *
22
 * En caso contrario, consulte <http://www.gnu.org/licenses/agpl.html>.
23
 */
24
25
namespace libredte\lib\Core\Sii\HttpClient;
26
27
use libredte\lib\Core\Service\PathManager;
28
use libredte\lib\Core\Signature\Certificate;
29
use libredte\lib\Core\Signature\SignatureException;
30
use libredte\lib\Core\Signature\SignatureGenerator;
31
use libredte\lib\Core\Xml\XmlConverter;
32
use libredte\lib\Core\Xml\XmlDocument;
33
use SoapClient;
34
use SoapFault;
35
36
/**
37
 * Clase para consumir los servicios web SOAP del SII.
38
 */
39
class WsdlConsumer
40
{
41
    /**
42
     * Certificado digital.
43
     *
44
     * @var Certificate
45
     */
46
    private Certificate $certificate;
47
48
    /**
49
     * Configuración de la conexión al SII.
50
     *
51
     * @var ConnectionConfig
52
     */
53
    private ConnectionConfig $config;
54
55
    /**
56
     * Constructor de la clase que consume servicios web mediante WSDL del SII.
57
     *
58
     * @param Certificate $certificate
59
     * @param ConnectionConfig $config
60
     */
61
    public function __construct(
62
        Certificate $certificate,
63
        ConnectionConfig $config,
64
    ) {
65
        $this->certificate = $certificate;
66
        $this->config = $config;
67
    }
68
69
    /**
70
     * Método para obtener el token de la sesión en el SII.
71
     *
72
     * Primero se obtiene una semilla, luego se firma la semilla con el
73
     * certificado digital y con esta semilla firmada se hace la solicitud del
74
     * token al SII.
75
     *
76
     * Referencia: http://www.sii.cl/factura_electronica/autenticacion.pdf
77
     *
78
     * WSDL producción: https://palena.sii.cl/DTEWS/GetTokenFromSeed.jws?WSDL
79
     * WSDL certificación: https://maullin.sii.cl/DTEWS/GetTokenFromSeed.jws?WSDL
80
     *
81
     * @return string Token para autenticación en SII.
82
     * @throws SiiClientException En caso de error.
83
     */
84
    public function getToken(): string
85
    {
86
        // Obtener semilla.
87
        $semilla = $this->getSeed();
88
89
        // Crear solicitud del token con la semilla, parámetro getTokenRequest
90
        // de la función getToken() en el servicio web GetTokenFromSeed.
91
        $xmlRequest = XmlConverter::arrayToXml([
92
            'getToken' => [
93
                'item' => [
94
                    'Semilla' => $semilla,
95
                ],
96
            ],
97
        ]);
98
99
        // Firmar el XML de la solicitud del token.
100
        try {
101
            $xmlRequestSigned = SignatureGenerator::signXML(
102
                $xmlRequest,
103
                $this->certificate
104
            );
105
        } catch (SignatureException $e) {
106
            throw new SiiClientException(sprintf(
107
                'No fue posible firmar getToken. %s',
108
                $e->getMessage()
109
            ));
110
        }
111
112
        // Realizar la solicitud del token al SII.
113
        $xmlResponse = $this->sendRequest(
114
            'GetTokenFromSeed',
115
            'getToken',
116
            ['pszXml' => $xmlRequestSigned]
117
        );
118
119
        // Extraer respuesta de la solicitud del token.
120
        $response = XmlConverter::xmlToArray($xmlResponse);
121
        $estado = $response['SII:RESPUESTA']['SII:RESP_HDR']['ESTADO'] ?? null;
122
        $token = $response['SII:RESPUESTA']['SII:RESP_BODY']['TOKEN'] ?? null;
123
124
        // Validar respuesta de la solicitud del token.
125
        if ($estado !== '00' || $token === null) {
126
            $glosa = $response['SII:RESPUESTA']['SII:RESP_HDR']['GLOSA'] ?? null;
127
            throw new SiiClientException(sprintf(
128
                'No fue posible obtener el token para autenticar en el SII al usuario %s. %s',
129
                $this->certificate->getId(),
130
                $glosa
131
            ));
132
        }
133
134
        // Entregar el token obtenido desde el SII para la sesión.
135
        return $token;
136
    }
137
138
    /**
139
     * Obtiene una semilla desde el SII para luego usarla en la obtención del
140
     * token para la autenticación.
141
     *
142
     * Este es el único servicio web que se puede llamar sin utilizar el
143
     * certificado digital. Es de libre consumo y se usa para obtener la
144
     * semilla necesaria para luego, usando el certificado, obtener un token
145
     * válido para la sesión en el SII.
146
     *
147
     * Nota: la semilla tiene una validez de 2 minutos.
148
     *
149
     * WSDL producción: https://palena.sii.cl/DTEWS/CrSeed.jws?WSDL
150
     * WSDL certificación: https://maullin.sii.cl/DTEWS/CrSeed.jws?WSDL
151
     *
152
     * @return int La semilla si se logró obtener.
153
     * @throws SiiClientException En caso de error.
154
     */
155
    public function getSeed(): int
156
    {
157
        $xmlResponse = $this->sendRequest('CrSeed', 'getSeed');
158
        $response = XmlConverter::xmlToArray($xmlResponse);
159
        $estado = $response['SII:RESPUESTA']['SII:RESP_HDR']['ESTADO'] ?? null;
160
        $semilla = $response['SII:RESPUESTA']['SII:RESP_BODY']['SEMILLA'] ?? null;
161
162
        if ($estado !== '00' || $semilla === null) {
163
            throw new SiiClientException('No fue posible obtener la semilla.');
164
        }
165
166
        return (int) $semilla;
167
    }
168
169
    /**
170
     * Método para obtener el XML del WSDL (Web Services Description Language)
171
     * del servicio del SII que se desea consumir.
172
     *
173
     * @param string $servicio Servicio para el cual se desea obtener su WSDL.
174
     * @return string Ubicación del WSDL del servicio según el ambiente que
175
     * esté configurado. Entrega, normalmente, un archivo local para un WSDL
176
     * del ambiente de certificación y siempre una URL para un WSDL del
177
     * ambiente de producción.
178
     */
179
    public function getWsdlUri(string $servicio): string
180
    {
181
        $ambiente = $this->config->getAmbiente();
182
183
        // Algunos WSDL del ambiente de certificación no funcionan tal cual los
184
        // provee SII. Lo anterior ya que apuntan a un servidor llamado
185
        // nogal.sii.cl el cual no es accesible desde Internet. Posiblemente es
186
        // un servidor local del SII para desarrollo. Así que LibreDTE tiene
187
        // para el ambiente de certificación WSDL modificados para funcionar
188
        // con el servidor de pruebas (maullin.sii.cl). Estos WSDL se usan
189
        // siempre al solicitar el WSDL del ambiente de certificación.
190
        // Cambios basados en: http://stackoverflow.com/a/28464354/3333009
191
        if ($ambiente === ConnectionConfig::CERTIFICACION) {
192
            $servidor = $this->config->getServidor();
193
            $wsdl = PathManager::getWsdlPath($servidor, $servicio);
194
            if ($wsdl !== null) {
195
                return $wsdl;
196
            }
197
        }
198
199
        // Los WSDL para el ambiente de producción son directamente los
200
        // proporcionados por el SII y que están definidos en la configuración.
201
        // Si por cualquier motivo un WSDL de un servicio para el ambiente de
202
        // certificación no existe localmente en LibreDTE, también se entregará
203
        // el WSDL oficial del SII.
204
        return $this->config->getWsdl($servicio);
205
    }
206
207
    /**
208
     * Realiza una solicitud a un servicio web del SII mediante el uso de WSDL.
209
     *
210
     * Este método prepara y normaliza los datos recibidos y llama al método
211
     * que realmente hace la consulta al SII: callServiceFunction().
212
     *
213
     * @param string $service Nombre del servicio del SII que se consumirá.
214
     * @param string $function Nombre de la función que se ejecutará en el
215
     * servicio web del SII.
216
     * @param array|int $args Argumentos que se pasarán al servicio web.
217
     * @param int|null $retry Intentos que se realizarán como máximo para
218
     * obtener respuesta.
219
     * @return XmlDocument Documento XML con la respuesta del servicio web.
220
     * @throws SiiClientException En caso de error.
221
     */
222
    public function sendRequest(
223
        string $service,
224
        string $function,
225
        array|int $args = [],
226
        ?int $retry = null
227
    ): XmlDocument {
228
        // Revisar si se pasó en $args el valor de $retry.
229
        // @scrutinizer ignore-type-check
230
        if (is_int($args)) {
0 ignored issues
show
introduced by
The condition is_int($args) is always false.
Loading history...
231
            $retry = $args;
232
            $args = [];
233
        }
234
235
        // Si no se especificó $retry se obtiene el valor por defecto.
236
        $retry = max(0, min(
237
            $retry ?? $this->config->getReintentos(),
238
            ConnectionConfig::REINTENTOS
239
        ));
240
241
        // Definir el WSDL que se debe utilizar.
242
        $wsdl = $this->getWsdlUri($service);
243
244
        // Realizar la llamada a la función en el servicio web del SII.
245
        return $this->callServiceFunction($wsdl, $function, $args, $retry);
246
    }
247
248
    /**
249
     * Ejecuta una función en un servicio web del SII mediante el uso de WSDL.
250
     *
251
     * @param string $wsdl El WSDL del servicio web donde está la función.
252
     * @param string $function Nombre de la función que se ejecutará,
253
     * @param array $args Argumentos que se pasarán al servicio web.
254
     * @param int $retry Intentos que se realizarán como máximo.
255
     * @return XmlDocument Documento XML con la respuesta del servicio web.
256
     * @throws SiiClientException En caso de error.
257
     */
258
    private function callServiceFunction(
259
        string $wsdl,
260
        string $function,
261
        array $args,
262
        int $retry
263
    ): XmlDocument {
264
        // Definir las opciones para consumir el servicio web.
265
        $soapClientOptions = $this->createSoapClientOptions();
266
267
        // Preparar cliente SOAP.
268
        try {
269
            $soap = new SoapClient($wsdl, $soapClientOptions);
270
        } catch (SoapFault $e) {
271
            $message = $e->getMessage();
272
            if (
273
                isset($e->getTrace()[0]['args'][1])
274
                && is_string($e->getTrace()[0]['args'][1])
275
            ) {
276
                $message .= ': ' . $e->getTrace()[0]['args'][1];
277
            }
278
            throw new SiiClientException(sprintf(
279
                'Ocurrió un error al crear el cliente SOAP para la API del SII con el WSDL %s. %s',
280
                $wsdl,
281
                $message
282
            ));
283
        }
284
285
        // Argumentos adicionales para la llamada al servicio web SOAP mediante
286
        // __soapCall().
287
        $options = null;
288
289
        // En el WSDL indicadas como soap:header.
290
        $requestHeaders = [];
291
292
        // Si el SII enviase cabeceras SOAP devuelta.
293
        $responseHeaders = [];
294
295
        // Para almacenar la respuesta de la llamada a la API SOAP del SII.
296
        $responseBody = null;
297
298
        // Para ir almacenando los errores, si existen, de cada intento.
299
        $errors = [];
300
301
        // Ejecutar la función que se ha solicitado del servicio web a través
302
        // del cliente SOAP preparado previamente.
303
        // Se realizarán $retry intentos de consulta. O sea, si $retry > 0 se
304
        // hará una consulta más $retry - 1 reintentos.
305
        for ($i = 0; $i < $retry; $i++) {
306
            try {
307
                // Se realiza la llamada a la función en el servicio web.
308
                $responseBody = $soap->__soapCall(
309
                    $function,
310
                    $args,
311
                    $options,
312
                    $requestHeaders,
313
                    $responseHeaders
314
                );
315
                // Si la llamada no falló (no hubo excepción), se rompe el
316
                // ciclo de reintentos.
317
                break;
318
            } catch (SoapFault $e) {
319
                $message = $e->getMessage();
320
                if (
321
                    isset($e->getTrace()[0]['args'][1])
322
                    && is_string($e->getTrace()[0]['args'][1])
323
                ) {
324
                    $message .= ': ' . $e->getTrace()[0]['args'][1];
325
                }
326
                $errors[] = sprintf(
327
                    'Error al ejecutar la función %s en el servicio web SOAP del SII ($i = %d): %s',
328
                    $function,
329
                    $i,
330
                    $message
331
                );
332
                $responseBody = null;
333
                // El reitento será con "exponential backoff", por lo que se
334
                // hace una pausa de 0.2 * $retry segundos antes de volver a
335
                // intentar llamar a la función del servicio web.
336
                usleep(200000 * $retry);
337
            }
338
        }
339
340
        // Si la respuesta es `null` significa que ninguno de los intentos de
341
        // llamadas a la función del servicio web fue exitoso.
342
        if ($responseBody === null) {
343
            throw new SiiClientException(sprintf(
344
                'No se obtuvo respuesta de la función %s del servicio web SOAP del SII después de %d intentos. %s',
345
                $function,
346
                $retry,
347
                implode(' ', $errors)
348
            ));
349
        }
350
351
        // El SII indica que la respuesta que envía es:
352
        //   Content-Type: text/xml;charset=utf-8
353
        // Sin embargo, parece que indica que es UTF-8 pero envía contenido
354
        // codificado con ISO-8859-1 (que es lo esperable del SII). Lo que hace
355
        // que los caracteres especiales se obtengan como "�", por lo cual se
356
        // reemplazan en la respuesta por "?" para que sea "un poco" más
357
        // legible la respuesta del SII.
358
        $responseBody = str_replace(['�', '�'], '?', $responseBody);
359
360
        // Entregar el resultado como un documento XML.
361
        $xmlDocument = new XmlDocument();
362
        $xmlDocument->loadXML($responseBody);
363
        return $xmlDocument;
364
    }
365
366
    /**
367
     * Define las opciones para consumir el servicio web del SII mediante SOAP.
368
     *
369
     * @return array Arreglo con las opciones para SoapClient.
370
     */
371
    private function createSoapClientOptions(): array
372
    {
373
        // Configuración de caché para SOAP.
374
        ini_set('soap.wsdl_cache_enabled', 3600);
375
        ini_set('soap.wsdl_cache_ttl', 3600);
376
377
        // Opciones base.
378
        $options = [
379
            'encoding' => 'ISO-8859-1',
380
            //'trace' => true, // Permite usar __getLastResponse().
381
            'exceptions' => true, // Lanza SoapFault en caso de error.
382
            'cache_wsdl' => WSDL_CACHE_MEMORY, // WSDL_CACHE_DISK o WSDL_CACHE_MEMORY.
383
            'keep_alive' => false,
384
            'stream_context' => [
385
                'http' => [
386
                    'header' => [
387
                        'User-Agent: Mozilla/5.0 (compatible; PROG 1.0; +https://www.libredte.cl)',
388
                    ],
389
                ],
390
            ],
391
        ];
392
393
        // Si no se debe verificar el certificado SSL del servidor del SII se
394
        // asigna al "stream_context" dicha configuración.
395
        if (!$this->config->getVerificarSsl()) {
396
            $options['stream_context']['ssl'] = [
397
                'verify_peer' => false,
398
                'verify_peer_name' => false,
399
                'allow_self_signed' => true,
400
            ];
401
        }
402
403
        // Crear el "stream context" verdadero con las opciones definidas.
404
        $options['stream_context'] = stream_context_create(
405
            $options['stream_context']
406
        );
407
408
        // Retornar las opciones para el SoapClient.
409
        return $options;
410
    }
411
}
412