switch to Guzzle module for HTTP calls
This commit is contained in:
@@ -139,7 +139,7 @@ QueryParamHelper::getDecodedTextFromQuery('COL0002'); // ?string (testo URL-deco
|
||||
|
||||
### `HttpClient.php` (Utility Richieste HTTP REST)
|
||||
|
||||
Classe orientata agli oggetti per effettuare chiamate HTTP server-to-server sicure verso Web Service esterni (es. endpoint contratti):
|
||||
Adapter applicativo basato su Guzzle per effettuare chiamate HTTP server-to-server sicure verso Web Service esterni (es. endpoint contratti). Accetta un `GuzzleHttp\\ClientInterface` opzionale nel costruttore per consentire mocking e test senza traffico di rete:
|
||||
|
||||
```php
|
||||
use ElixForms\Common\HttpClient;
|
||||
|
||||
@@ -28,6 +28,7 @@ temp
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
.phpunit.result.cache
|
||||
|
||||
# OSX
|
||||
.DS_Store
|
||||
|
||||
+13
-1
@@ -3,11 +3,23 @@
|
||||
"description": "Custom web pages PHP per elixForms",
|
||||
"license": "proprietary",
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
"php": ">=8.0",
|
||||
"guzzlehttp/guzzle": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ElixForms\\Common\\": "php/common/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit tests"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2474
-3
File diff suppressed because it is too large
Load Diff
+62
-76
@@ -4,24 +4,32 @@ declare(strict_types=1);
|
||||
|
||||
namespace ElixForms\Common;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
|
||||
/**
|
||||
* Client HTTP robusto per effettuare chiamate REST esterne.
|
||||
* Utilizza l'estensione cURL di PHP ed implementa best practices per
|
||||
* la gestione degli header, dell'autenticazione e degli errori.
|
||||
* Adapter applicativo basato su Guzzle per la gestione uniforme di
|
||||
* header, autenticazione, timeout ed errori delle chiamate esterne.
|
||||
*/
|
||||
class HttpClient
|
||||
{
|
||||
private array $defaultHeaders = [];
|
||||
private ClientInterface $client;
|
||||
private array $defaultHeaders;
|
||||
private ?string $username = null;
|
||||
private ?string $password = null;
|
||||
private int $timeout = 10;
|
||||
|
||||
/**
|
||||
* @param array $defaultHeaders Header predefiniti per ogni richiesta
|
||||
* @param array<string, string> $defaultHeaders Header predefiniti per ogni richiesta
|
||||
*/
|
||||
public function __construct(array $defaultHeaders = [])
|
||||
public function __construct(array $defaultHeaders = [], ?ClientInterface $client = null)
|
||||
{
|
||||
$this->defaultHeaders = $defaultHeaders;
|
||||
$this->client = $client ?? new Client();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +47,10 @@ class HttpClient
|
||||
*/
|
||||
public function setTimeout(int $seconds): self
|
||||
{
|
||||
if ($seconds <= 0) {
|
||||
throw new \InvalidArgumentException('Il timeout deve essere maggiore di zero.');
|
||||
}
|
||||
|
||||
$this->timeout = $seconds;
|
||||
return $this;
|
||||
}
|
||||
@@ -47,19 +59,16 @@ class HttpClient
|
||||
* Esegue una richiesta HTTP GET.
|
||||
*
|
||||
* @param string $url URL della richiesta
|
||||
* @param array $queryParams Parametri query aggiuntivi
|
||||
* @param array $headers Header specifici per questa richiesta
|
||||
* @param array<string, mixed> $queryParams Parametri query aggiuntivi
|
||||
* @param array<string, string> $headers Header specifici per questa richiesta
|
||||
* @return string Risposta in formato testuale
|
||||
* @throws \RuntimeException In caso di errore curl o codice di stato non 2xx
|
||||
* @throws \RuntimeException In caso di errore HTTP o di trasporto
|
||||
*/
|
||||
public function get(string $url, array $queryParams = [], array $headers = []): string
|
||||
{
|
||||
if (!empty($queryParams)) {
|
||||
$separator = (strpos($url, '?') === false) ? '?' : '&';
|
||||
$url .= $separator . http_build_query($queryParams);
|
||||
}
|
||||
$options = $queryParams === [] ? [] : [RequestOptions::QUERY => $queryParams];
|
||||
|
||||
return $this->request($url, 'GET', null, $headers);
|
||||
return $this->request($url, 'GET', $headers, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,87 +76,64 @@ class HttpClient
|
||||
*
|
||||
* @param string $url URL della richiesta
|
||||
* @param mixed $data Dati da inviare nel body (array, stringa o JSON)
|
||||
* @param array $headers Header specifici per questa richiesta
|
||||
* @param array<string, string> $headers Header specifici per questa richiesta
|
||||
* @return string Risposta in formato testuale
|
||||
* @throws \RuntimeException In caso di errore curl o codice di stato non 2xx
|
||||
* @throws \RuntimeException In caso di errore HTTP o di trasporto
|
||||
*/
|
||||
public function post(string $url, $data, array $headers = []): string
|
||||
{
|
||||
return $this->request($url, 'POST', $data, $headers);
|
||||
$options = is_array($data)
|
||||
? [RequestOptions::FORM_PARAMS => $data]
|
||||
: [RequestOptions::BODY => $data];
|
||||
|
||||
return $this->request($url, 'POST', $headers, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Metodo interno per eseguire la richiesta tramite cURL.
|
||||
* @param array<string, string> $headers
|
||||
* @param array<string, mixed> $requestOptions
|
||||
*/
|
||||
private function request(string $url, string $method, $data = null, array $headers = []): string
|
||||
private function request(string $url, string $method, array $headers, array $requestOptions = []): string
|
||||
{
|
||||
$ch = curl_init();
|
||||
if ($ch === false) {
|
||||
throw new \RuntimeException('Impossibile inizializzare cURL.');
|
||||
}
|
||||
$options = [
|
||||
RequestOptions::HEADERS => array_merge($this->defaultHeaders, $headers),
|
||||
RequestOptions::TIMEOUT => $this->timeout,
|
||||
RequestOptions::CONNECT_TIMEOUT => $this->timeout,
|
||||
RequestOptions::ALLOW_REDIRECTS => ['max' => 3],
|
||||
RequestOptions::VERIFY => true,
|
||||
RequestOptions::HTTP_ERRORS => true,
|
||||
];
|
||||
|
||||
// Configura le opzioni cURL di base
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->timeout);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_MAXREDIRS, 3);
|
||||
|
||||
// Disabilita la verifica SSL in ambiente di sviluppo locale se necessario,
|
||||
// ma di default è attiva per sicurezza.
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
|
||||
// Gestione metodo HTTP
|
||||
$method = strtoupper($method);
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
if ($data !== null) {
|
||||
if (is_array($data)) {
|
||||
$postData = http_build_query($data);
|
||||
} else {
|
||||
$postData = $data;
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
|
||||
}
|
||||
} elseif ($method !== 'GET') {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
}
|
||||
|
||||
// Costruisci gli header
|
||||
$mergedHeaders = array_merge($this->defaultHeaders, $headers);
|
||||
$formattedHeaders = [];
|
||||
foreach ($mergedHeaders as $name => $value) {
|
||||
$formattedHeaders[] = "{$name}: {$value}";
|
||||
}
|
||||
|
||||
if (!empty($formattedHeaders)) {
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $formattedHeaders);
|
||||
}
|
||||
|
||||
// Autenticazione Basic
|
||||
if ($this->username !== null && $this->password !== null) {
|
||||
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||
curl_setopt($ch, CURLOPT_USERPWD, "{$this->username}:{$this->password}");
|
||||
$options[RequestOptions::AUTH] = [$this->username, $this->password];
|
||||
}
|
||||
|
||||
// Esegui la richiesta
|
||||
$response = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
// curl_close non è più necessario in PHP 8.0+ ed è deprecato in PHP 8.5+
|
||||
|
||||
if ($errno !== 0) {
|
||||
throw new \RuntimeException("Errore cURL durante la chiamata a {$url}: [{$errno}] {$error}");
|
||||
try {
|
||||
$response = $this->client->request(
|
||||
strtoupper($method),
|
||||
$url,
|
||||
array_replace($options, $requestOptions)
|
||||
);
|
||||
} catch (RequestException $exception) {
|
||||
$response = $exception->getResponse();
|
||||
if ($response !== null) {
|
||||
throw new \RuntimeException(
|
||||
"Richiesta fallita con codice di stato HTTP {$response->getStatusCode()}.",
|
||||
0,
|
||||
$exception
|
||||
);
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Errore di connessione al servizio esterno.', 0, $exception);
|
||||
} catch (GuzzleException $exception) {
|
||||
throw new \RuntimeException('Errore durante la chiamata al servizio esterno.', 0, $exception);
|
||||
}
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
if ($statusCode < 200 || $statusCode >= 300) {
|
||||
throw new \RuntimeException("Richiesta fallita con codice di stato HTTP {$statusCode}.");
|
||||
}
|
||||
|
||||
return (string)$response;
|
||||
return (string)$response->getBody();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use ElixForms\Common\HttpClient;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class HttpClientTest extends TestCase
|
||||
{
|
||||
public function testGetUsesQueryHeadersAuthenticationAndTimeout(): void
|
||||
{
|
||||
$history = [];
|
||||
$stack = HandlerStack::create(new MockHandler([new Response(200, [], '{"ok":true}')]));
|
||||
$stack->push(Middleware::history($history));
|
||||
|
||||
$client = new HttpClient(
|
||||
['Accept' => 'application/json', 'X-Default' => 'default'],
|
||||
new Client(['handler' => $stack])
|
||||
);
|
||||
$client->setBasicAuth('user', 'secret')->setTimeout(5);
|
||||
|
||||
$result = $client->get(
|
||||
'https://example.test/resource',
|
||||
['term' => 'Mario Rossi'],
|
||||
['X-Default' => 'override']
|
||||
);
|
||||
|
||||
self::assertSame('{"ok":true}', $result);
|
||||
self::assertCount(1, $history);
|
||||
self::assertSame('GET', $history[0]['request']->getMethod());
|
||||
self::assertSame('term=Mario%20Rossi', $history[0]['request']->getUri()->getQuery());
|
||||
self::assertSame('application/json', $history[0]['request']->getHeaderLine('Accept'));
|
||||
self::assertSame('override', $history[0]['request']->getHeaderLine('X-Default'));
|
||||
self::assertSame(['user', 'secret'], $history[0]['options']['auth']);
|
||||
self::assertSame(5, $history[0]['options']['timeout']);
|
||||
self::assertSame(5, $history[0]['options']['connect_timeout']);
|
||||
}
|
||||
|
||||
public function testPostEncodesArrayAsFormData(): void
|
||||
{
|
||||
$history = [];
|
||||
$stack = HandlerStack::create(new MockHandler([new Response(200, [], 'saved')]));
|
||||
$stack->push(Middleware::history($history));
|
||||
$client = new HttpClient([], new Client(['handler' => $stack]));
|
||||
|
||||
self::assertSame('saved', $client->post('https://example.test/resource', ['name' => 'Mario Rossi']));
|
||||
self::assertSame('POST', $history[0]['request']->getMethod());
|
||||
self::assertSame('name=Mario+Rossi', (string)$history[0]['request']->getBody());
|
||||
}
|
||||
|
||||
public function testHttpErrorsAreExposedWithoutResponseBody(): void
|
||||
{
|
||||
$client = new HttpClient([], new Client([
|
||||
'handler' => HandlerStack::create(new MockHandler([
|
||||
new Response(503, [], 'sensitive upstream response'),
|
||||
])),
|
||||
]));
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Richiesta fallita con codice di stato HTTP 503.');
|
||||
|
||||
$client->get('https://example.test/resource');
|
||||
}
|
||||
|
||||
public function testTimeoutMustBePositive(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
(new HttpClient())->setTimeout(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user