Compare commits

..
11 Commits
Author SHA1 Message Date
pierpaolo.mammi 33ff4d9577 major refactor
- add request status to search instance API
- use JSend-type response in elixForms clients
- readjust namespaces
- add more unit tests (manual HTTP requests too)
2026-07-24 14:56:25 +02:00
pierpaolo.mammi ef53ee8364 update logger to stdout/stderr 2026-07-24 12:43:29 +02:00
pierpaolo.mammi 8f1fbbfeef fix xdebug 2026-07-23 23:07:21 +02:00
pierpaolo.mammi ad6fa5ef2a more optimizations 2026-07-22 17:01:18 +02:00
pierpaolo.mammi 6e3665696c cleanup login function 2026-07-22 16:15:00 +02:00
pierpaolo.mammi 99a4f2787b rename function for better readability 2026-07-22 16:14:45 +02:00
pierpaolo.mammi 5ae01c3038 change field lookup logic 2026-07-22 16:14:00 +02:00
pierpaolo.mammi 90851908b7 optimize global calls 2026-07-22 16:13:07 +02:00
pierpaolo.mammi 8c61b69320 fix api urls 2026-07-22 16:11:45 +02:00
pierpaolo.mammi 48c120ab01 optimize sprintf calls 2026-07-22 15:57:32 +02:00
pierpaolo.mammi ea8fbaebfe update composer package name 2026-07-22 12:55:35 +02:00
22 changed files with 568 additions and 348 deletions
+1 -1
View File
@@ -3,6 +3,6 @@ FROM php:8.5.8-cli
RUN pecl install xdebug && docker-php-ext-enable xdebug RUN pecl install xdebug && docker-php-ext-enable xdebug
WORKDIR /app WORKDIR /app
EXPOSE 8000 9013 EXPOSE 8000
CMD ["php", "-S", "0.0.0.0:8000", "-t", "public", "public/index.php"] CMD ["php", "-S", "0.0.0.0:8000", "-t", "public", "public/index.php"]
+2 -1
View File
@@ -5,5 +5,6 @@ xdebug.mode=develop,debug
xdebug.start_with_request=yes xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal xdebug.client_host=host.docker.internal
xdebug.client_port=9013 xdebug.client_port=9013
xdebug.log_level=0 xdebug.log=/tmp/xdebug.log
xdebug.log_level=7
xdebug.idekey=VSCODE xdebug.idekey=VSCODE
+1
View File
@@ -8,6 +8,7 @@
"name": "API - Listen for Xdebug", "name": "API - Listen for Xdebug",
"type": "php", "type": "php",
"request": "launch", "request": "launch",
"hostname": "0.0.0.0",
"port": 9013, "port": 9013,
"pathMappings": { "pathMappings": {
"/app": "${workspaceFolder}" "/app": "${workspaceFolder}"
+5 -5
View File
@@ -12,9 +12,9 @@ use Api\Core\RateLimiter\RateLimiterInterface;
use Api\Core\RateLimiter\FileRateLimiter; use Api\Core\RateLimiter\FileRateLimiter;
use Api\Core\RateLimiter\InMemoryRateLimiter; use Api\Core\RateLimiter\InMemoryRateLimiter;
use Api\Core\Log\LoggerFactory; use Api\Core\Log\LoggerFactory;
use ElixForms\ElixFormsClient; use ElixForms\Clients\ElixFormsApiClient;
use ElixForms\Auth\ElixFormsApiClient; use ElixForms\Clients\ElixFormsAuthenticationClient;
use ElixForms\Auth\ElixFormsAuthenticationClient; use ElixForms\Clients\ElixFormsGenericClient;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
$container = new Container(); $container = new Container();
@@ -63,10 +63,10 @@ $container->singleton(LoggerInterface::class, function($c) {
}); });
// ElixForms client binding // ElixForms client binding
$container->singleton(ElixFormsClient::class, function($c) { $container->singleton(ElixFormsGenericClient::class, function($c) {
$config = $c->make(Config::class); $config = $c->make(Config::class);
$baseUrl = $config->get('elixforms_api_base_url'); $baseUrl = $config->get('elixforms_api_base_url');
return new ElixFormsClient($baseUrl); return new ElixFormsGenericClient($baseUrl, null);
}); });
$container->singleton(ElixFormsAuthenticationClient::class, function($c) { $container->singleton(ElixFormsAuthenticationClient::class, function($c) {
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "yourorg/api", "name": "elixforms-web-services/api",
"require": { "require": {
"php": ">=7.4", "php": ">=7.4",
"guzzlehttp/guzzle": "^7.0", "guzzlehttp/guzzle": "^7.0",
+1 -1
View File
@@ -16,4 +16,4 @@ services:
- ./.docker/php/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini - ./.docker/php/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini
#environment: #environment:
# XDEBUG_MODE: debug # XDEBUG_MODE: debug
# XDEBUG_CONFIG: client_host=host.docker.internal client_port=9003 start_with_request=yes # XDEBUG_CONFIG: client_host=host.docker.internal client_port=9013 start_with_request=yes
+25 -9
View File
@@ -15,12 +15,11 @@ Il progetto contiene già i file necessari per il debug:
- `.docker/php/Dockerfile` - container PHP con Xdebug - `.docker/php/Dockerfile` - container PHP con Xdebug
- `docker-compose.yml` - configurazione Docker - `docker-compose.yml` - configurazione Docker
- `.vscode/launch.json` - configurazione di avvio VS Code - `.vscode/launch.json` - configurazione di avvio VS Code
- `.vscode/settings.json` - impostazioni VS Code
Il container espone: Il servizio usa:
- **porta `8000`** per HTTP - **porta `8000`** per HTTP
- **porta `9003`** per Xdebug - **porta host `9013`** per la connessione in uscita di Xdebug verso VS Code
## 2. Avvia il container ## 2. Avvia il container
@@ -39,7 +38,7 @@ http://localhost:8000
## 3. Avvia il debug in VS Code ## 3. Avvia il debug in VS Code
1. Apri la sezione **Run and Debug** in VS Code (Ctrl+Shift+D) 1. Apri la sezione **Run and Debug** in VS Code (Ctrl+Shift+D)
2. Seleziona la configurazione: **Debug PHP in Docker** 2. Seleziona la configurazione: **API - Listen for Xdebug**
3. Premi **F5** per avviare il debugger 3. Premi **F5** per avviare il debugger
## 4. Imposta un breakpoint e testa ## 4. Imposta un breakpoint e testa
@@ -56,12 +55,15 @@ http://localhost:8000
http://localhost:8000/api/users http://localhost:8000/api/users
``` ```
3. Oppure usa curl con il trigger Xdebug: 3. Esegui la stessa richiesta con curl:
```bash ```bash
curl -H "XDEBUG_TRIGGER: 1" http://localhost:8000/api/users curl http://localhost:8000/api/users
``` ```
`xdebug.start_with_request=yes` avvia il tentativo di connessione per ogni
richiesta, quindi non serve aggiungere `XDEBUG_TRIGGER`.
Il debugger dovrebbe fermarsi sul breakpoint e permetterti di ispezionare le variabili. Il debugger dovrebbe fermarsi sul breakpoint e permetterti di ispezionare le variabili.
## 5. Risoluzione dei problemi ## 5. Risoluzione dei problemi
@@ -69,13 +71,14 @@ Il debugger dovrebbe fermarsi sul breakpoint e permetterti di ispezionare le var
### VS Code non si ferma sul breakpoint ### VS Code non si ferma sul breakpoint
- Verifica che Xdebug sia abilitato nel container - Verifica che Xdebug sia abilitato nel container
- Controlla che la configurazione `client_host` nel Dockerfile punti a `host.docker.internal` - Controlla che `xdebug.client_host` in `.docker/php/xdebug.ini` punti a
- Verifica che la porta 9003 sia disponibile e non bloccata dal firewall `host.docker.internal`
- Verifica che la porta 9013 sia disponibile e non bloccata dal firewall
### Il container non si avvia ### Il container non si avvia
- Assicurati che Docker Desktop sia in esecuzione - Assicurati che Docker Desktop sia in esecuzione
- Verifica che le porte 8000 e 9003 non siano già in uso - Verifica che la porta HTTP 8000 non sia già in uso
- Controlla i log di Docker per errori specifici - Controlla i log di Docker per errori specifici
### "Failed to fetch" dalle richieste del frontend ### "Failed to fetch" dalle richieste del frontend
@@ -84,6 +87,19 @@ Il debugger dovrebbe fermarsi sul breakpoint e permetterti di ispezionare le var
- Controlla che `allowed_origins` in `config/config.php` includa l'origine del frontend - Controlla che `allowed_origins` in `config/config.php` includa l'origine del frontend
- Testa con l'endpoint di diagnostica `/cors-check` - Testa con l'endpoint di diagnostica `/cors-check`
### Diagnostica avanzata dei breakpoint
Il log Xdebug è scritto nel container in `/tmp/xdebug.log`. Per analizzare anche
la registrazione e la risoluzione dei breakpoint, imposta temporaneamente
`xdebug.log_level=10` in `.docker/php/xdebug.ini`, riavvia il container e usa:
```bash
docker exec elixforms-ws tail -f /tmp/xdebug.log
```
Al termine ripristina `xdebug.log_level=7`, perché il livello 10 produce molti
dettagli per ogni riga eseguita.
## Note ## Note
- La procedura di debug influisce solo sulla sessione VS Code, il server continua a funzionare normalmente - La procedura di debug influisce solo sulla sessione VS Code, il server continua a funzionare normalmente
-19
View File
@@ -1,19 +0,0 @@
@term=MA
GET http://localhost:8000/api/dipendenti/cerca?term={{term}}
Authorization: Basic elixforms_ws:password123
X-API-Key: myApiAccessToken
###
@term=ID
GET http://localhost:8000/api/contratti/cerca?term={{term}}&cod_fis=MMMPPL74T17E463A
Authorization: Basic elixforms_ws:password123
X-API-Key: myApiAccessToken
###
@term=ID
GET http://localhost:8000/contratti/cerca?term={{term}}
+56 -25
View File
@@ -4,9 +4,8 @@ namespace Api\Controllers\V1;
use Api\Core\Config; use Api\Core\Config;
use Api\Core\Request; use Api\Core\Request;
use Api\Core\Response; use Api\Core\Response;
use ElixForms\Auth\ElixFormsApiClient; use ElixForms\Clients\ElixFormsApiClient;
use ElixForms\Auth\ElixFormsAuthenticationClient; use ElixForms\Clients\ElixFormsAuthenticationClient;
use ElixForms\Exceptions\ElixFormsException;
class ElixFormsController class ElixFormsController
{ {
@@ -30,27 +29,51 @@ class ElixFormsController
$moduleTag = $this->requiredString($query, 'moduleTag'); $moduleTag = $this->requiredString($query, 'moduleTag');
$fieldName = $this->requiredString($query, 'fieldName'); $fieldName = $this->requiredString($query, 'fieldName');
$fieldValue = $this->requiredString($query, 'fieldValue'); $fieldValue = $this->requiredString($query, 'fieldValue');
$requestStatuses = $this->requiredString($query, 'requestStatuses');
$exportGroup = $this->optionalNonEmptyString($query, 'exportGroup', 'API'); $exportGroup = $this->optionalNonEmptyString($query, 'exportGroup', 'API');
if ($moduleTag === null || $fieldName === null || $fieldValue === null || $exportGroup === null) { if ($moduleTag === null || $fieldName === null || $fieldValue === null || $exportGroup === null) {
return $res->json([ return $res->json([
'error' => 'moduleTag, fieldName e fieldValue sono obbligatori; exportGroup, se specificato, deve essere una stringa non vuota.' 'status' => 'fail',
'data' => [
'parameters' => 'moduleTag, fieldName e fieldValue sono obbligatori; exportGroup, se specificato, deve essere una stringa non vuota.',
],
], 400); ], 400);
} }
$username = $this->config->secret('elixforms_api_username'); $username = $this->config->secret('elixforms_api_username');
$password = $this->config->secret('elixforms_api_password'); $password = $this->config->secret('elixforms_api_password');
if (!is_string($username) || trim($username) === '' || !is_string($password) || $password === '') { if (!\is_string($username) || trim($username) === '' || !\is_string($password) || $password === '') {
throw new ElixFormsException('Credenziali elixForms non configurate.'); return $res->json([
'status' => 'error',
'message' => 'Credenziali elixForms non configurate.',
'code' => 500,
], 500);
} }
$token = $this->authenticationClient->login($username, $password); $login = $this->authenticationClient->login($username, $password);
$instances = $this->apiClient->lookupByStatus($moduleTag, $token, $username); if ($login['status'] !== 'success') {
return $this->respondWithJSendFailure($res, $login);
}
$token = $login['data']['authToken'];
$instancesResponse = $this->apiClient->lookupByStatus(
$moduleTag,
$token,
$username,
$requestStatuses
);
if ($instancesResponse['status'] !== 'success') {
return $this->respondWithJSendFailure($res, $instancesResponse);
}
$instances = $instancesResponse['data'];
$matchingInstances = []; $matchingInstances = [];
foreach ($instances as $instance) { foreach ($instances as $instance) {
if (!is_array($instance)) { if (!\is_array($instance)) {
continue; continue;
} }
@@ -59,35 +82,43 @@ class ElixFormsController
continue; continue;
} }
$exportTags = $this->apiClient->getExportTags( $exportTagsResponse = $this->apiClient->getExportTags(
$requestId, $requestId,
$moduleTag, $moduleTag,
$token, $token,
$username, $username,
$exportGroup $exportGroup
); );
if ($exportTagsResponse['status'] !== 'success') {
return $this->respondWithJSendFailure($res, $exportTagsResponse);
}
foreach ($exportTags as $exportTag) { $exportTags = $exportTagsResponse['data'];
if (!is_array($exportTag) || !isset($exportTag['name'])) {
continue;
}
$name = (string) $exportTag['name']; $exportTagsByName = array_column($exportTags, null, 'name');
$value = isset($exportTag['value']) ? (string) $exportTag['value'] : ''; if (!\array_key_exists($fieldName, $exportTagsByName)) {
continue;
}
if (strcasecmp($name, $fieldName) === 0 && stripos($value, $fieldValue) !== false) { $exportTag = $exportTagsByName[$fieldName];
$matchingInstances[] = $instance; $value = isset($exportTag['value']) ? (string) $exportTag['value'] : '';
break;
} if (stripos($value, $fieldValue) !== false) {
$matchingInstances[] = $instance;
} }
} }
return $res->json($matchingInstances); return $res->json(['status' => 'success', 'data' => $matchingInstances]);
}
private function respondWithJSendFailure(Response $response, array $payload)
{
return $response->json($payload, $payload['status'] === 'fail' ? 400 : 502);
} }
private function requiredString(array $values, string $key): ?string private function requiredString(array $values, string $key): ?string
{ {
if (!isset($values[$key]) || !is_string($values[$key])) { if (!isset($values[$key]) || !\is_string($values[$key])) {
return null; return null;
} }
@@ -98,11 +129,11 @@ class ElixFormsController
private function optionalNonEmptyString(array $values, string $key, string $default): ?string private function optionalNonEmptyString(array $values, string $key, string $default): ?string
{ {
if (!array_key_exists($key, $values)) { if (!\array_key_exists($key, $values)) {
return $default; return $default;
} }
if (!is_string($values[$key])) { if (!\is_string($values[$key])) {
return null; return null;
} }
@@ -114,7 +145,7 @@ class ElixFormsController
private function requestId(array $instance) private function requestId(array $instance)
{ {
foreach (['idDomanda', 'requestId', 'idRequest'] as $key) { foreach (['idDomanda', 'requestId', 'idRequest'] as $key) {
if (isset($instance[$key]) && (is_int($instance[$key]) || is_string($instance[$key]))) { if (isset($instance[$key]) && (\is_int($instance[$key]) || \is_string($instance[$key]))) {
return $instance[$key]; return $instance[$key];
} }
} }
+3 -3
View File
@@ -53,7 +53,7 @@ class Container {
*/ */
public function make(string $abstract) { public function make(string $abstract) {
// return existing singleton instance if already created // return existing singleton instance if already created
if (array_key_exists($abstract, $this->instances) && $this->instances[$abstract] !== null) { if (\array_key_exists($abstract, $this->instances) && $this->instances[$abstract] !== null) {
return $this->instances[$abstract]; return $this->instances[$abstract];
} }
@@ -70,7 +70,7 @@ class Container {
if (is_callable($concrete)) { if (is_callable($concrete)) {
// factory receives the container // factory receives the container
$object = $concrete($this); $object = $concrete($this);
} elseif (is_string($concrete) && class_exists($concrete)) { } elseif (\is_string($concrete) && class_exists($concrete)) {
$object = $this->build($concrete); $object = $this->build($concrete);
} else { } else {
throw new \Exception("Invalid binding for [{$abstract}]"); throw new \Exception("Invalid binding for [{$abstract}]");
@@ -78,7 +78,7 @@ class Container {
} }
// if abstract was registered as singleton, cache the instance // if abstract was registered as singleton, cache the instance
if (array_key_exists($abstract, $this->instances)) { if (\array_key_exists($abstract, $this->instances)) {
$this->instances[$abstract] = $object; $this->instances[$abstract] = $object;
} }
+12 -4
View File
@@ -2,7 +2,8 @@
namespace Api\Core\Log; namespace Api\Core\Log;
use Monolog\Logger; use Monolog\Logger;
use Monolog\Handler\SyslogHandler; use Monolog\Handler\FilterHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Processor\UidProcessor; use Monolog\Processor\UidProcessor;
use Monolog\Formatter\JsonFormatter; use Monolog\Formatter\JsonFormatter;
@@ -10,9 +11,16 @@ class LoggerFactory {
public static function create(?string $name): Logger { public static function create(?string $name): Logger {
$ident = $name ?? 'api'; $ident = $name ?? 'api';
$logger = new Logger($ident); $logger = new Logger($ident);
$handler = new SyslogHandler($ident, LOG_USER); $formatter = new JsonFormatter();
$handler->setFormatter(new JsonFormatter());
$logger->pushHandler($handler); $stdoutHandler = new StreamHandler('php://stdout', Logger::DEBUG);
$stdoutHandler->setFormatter($formatter);
$logger->pushHandler(new FilterHandler($stdoutHandler, Logger::DEBUG, Logger::WARNING));
$stderrHandler = new StreamHandler('php://stderr', Logger::ERROR);
$stderrHandler->setFormatter($formatter);
$logger->pushHandler($stderrHandler);
$logger->pushProcessor(new UidProcessor()); $logger->pushProcessor(new UidProcessor());
return $logger; return $logger;
} }
+1 -1
View File
@@ -55,7 +55,7 @@ class Json
public static function sendError($message = 'Bad Request', int $status = 400, ?LoggerInterface $logger = null): void public static function sendError($message = 'Bad Request', int $status = 400, ?LoggerInterface $logger = null): void
{ {
$payload = [ $payload = [
'error' => is_array($message) ? $message : ['message' => $message] 'error' => \is_array($message) ? $message : ['message' => $message]
]; ];
if ($logger) { if ($logger) {
-132
View File
@@ -1,132 +0,0 @@
<?php
namespace ElixForms\Auth;
use Api\Core\HttpClient;
use ElixForms\Exceptions\ElixFormsException;
class ElixFormsApiClient
{
private string $baseUrl;
private HttpClient $httpClient;
public function __construct(string $baseUrl, HttpClient $httpClient)
{
$this->baseUrl = rtrim($baseUrl, '/');
$this->httpClient = $httpClient;
}
/**
* Restituisce le istanze del modulo, opzionalmente filtrate per stato.
* I flag di ElixFormsRequestStatus possono essere combinati con l'operatore |.
*
* @return array<int,array<string,mixed>>
*/
public function lookupByStatus(
string $moduleTag,
string $authToken,
string $username,
?int $status = null
): array
{
$queryParts = [];
if ($status !== null) {
foreach (ElixFormsRequestStatus::toApiValues($status) as $statusValue) {
$queryParts[] = 'requestStatus=' . rawurlencode($statusValue);
}
}
$queryParts[] = 'moduleTag=' . rawurlencode($moduleTag);
$url = $this->baseUrl . '/eF/api/request/lookup/by-status?' . implode('&', $queryParts);
$payload = $this->getJson($url, $authToken, $username, 'LookupByStatus');
$requests = $payload['value']['requests'] ?? [];
if (!is_array($requests)) {
throw new ElixFormsException('Risposta LookupByStatus non valida: requests deve essere un array.');
}
return array_values($requests);
}
/**
* @param int|string $requestId
* @return array<int,array{name?:mixed,value?:mixed}>
*/
public function getExportTags(
$requestId,
string $moduleTag,
string $authToken,
string $username,
string $exportGroup = 'API'
): array
{
$exportGroup = trim($exportGroup);
if ($exportGroup === '') {
throw new \InvalidArgumentException('exportGroup deve essere una stringa non vuota.');
}
$url = sprintf(
'%s/eF/services/api/request/%s/view/_DEFAULT/exportTags/get/v1?moduleTag=%s&exportGroup=%s',
$this->baseUrl,
rawurlencode((string) $requestId),
rawurlencode($moduleTag),
rawurlencode($exportGroup)
);
$payload = $this->getJson($url, $authToken, $username, 'GetExportTags');
$exportTags = $payload['value']['exportTags'] ?? [];
if (!is_array($exportTags)) {
throw new ElixFormsException('Risposta GetExportTags non valida: exportTags deve essere un array.');
}
return array_values($exportTags);
}
private function getJson(
string $url,
string $authToken,
string $username,
string $operation
): array {
$response = $this->httpClient->get($url, [
'Authorization' => 'Bearer ' . $authToken,
'Accept' => 'application/json',
'x-requested-with' => 'XMLHttpRequest',
'x-api-username' => $username,
]);
$status = $response['status'] ?? 500;
if ($status !== 200) {
throw new ElixFormsException(sprintf(
'Errore durante %s. HTTP Status: %d',
$operation,
$status
));
}
$payload = $response['json'] ?? null;
$jsonError = JSON_ERROR_NONE;
if (!is_array($payload) && isset($response['body']) && is_string($response['body'])) {
$payload = json_decode($response['body'], true);
$jsonError = json_last_error();
}
if (!is_array($payload) || $jsonError !== JSON_ERROR_NONE) {
throw new ElixFormsException(sprintf(
'Risposta non valida da %s: atteso JSON.',
$operation
));
}
$globalStatus = $payload['value']['globalStatus'] ?? null;
if ($globalStatus === 'ERROR') {
$description = $payload['value']['description'] ?? 'errore non specificato';
throw new ElixFormsException(sprintf(
'%s ha restituito un errore: %s',
$operation,
$description
));
}
return $payload;
}
}
@@ -1,87 +0,0 @@
<?php
namespace ElixForms\Auth;
use ElixForms\Exceptions\ElixFormsException;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
class ElixFormsAuthenticationClient {
private $baseUrl;
private $httpClient;
public function __construct(string $baseUrl, ?Client $httpClient = null) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->httpClient = $httpClient ?? new Client(['timeout' => 10.0, 'http_errors' => false]);
}
/**
* Esegue il login verso l'API di elixForms.
*
* @param string $username
* @param string $password
* @return string Il token di autenticazione (authToken)
* @throws ElixFormsException Se le credenziali sono errate o c'è un errore server
*/
public function login(string $username, string $password): string {
$url = $this->baseUrl . '/eF/services/api/authentication/login/v1';
try {
$response = $this->httpClient->post($url, [
'form_params' => [
'username' => $username,
'password' => $password
]
]);
$status = $response->getStatusCode();
if ($status !== 200) {
throw new ElixFormsException("Errore durante il login elixForms. HTTP Status: " . $status);
}
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($json)) {
throw new ElixFormsException("Risposta non valida dal server elixForms: atteso JSON.");
}
if (isset($json['value']['authToken'])) {
return $json['value']['authToken'];
}
throw new ElixFormsException("authToken non trovato nella risposta del login elixForms.");
} catch (GuzzleException $e) {
throw new ElixFormsException("Errore di connessione al server elixForms: " . $e->getMessage(), 0, $e);
}
}
/**
* Effettua il logout invalidando il token sul server elixForms.
*
* @param string $username
* @param string $token
* @return bool True se il logout ha successo
* @throws ElixFormsException Se c'è un errore durante il logout
*/
public function logout(string $username, string $token): bool {
$url = $this->baseUrl . '/eF/services/api/authentication/' . urlencode($username) . '/logout/v1';
try {
$response = $this->httpClient->post($url, [
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/x-www-form-urlencoded'
]
]);
$status = $response->getStatusCode();
if ($status !== 200) {
throw new ElixFormsException("Errore durante il logout elixForms. HTTP Status: " . $status);
}
return true;
} catch (GuzzleException $e) {
throw new ElixFormsException("Errore di connessione al server elixForms: " . $e->getMessage(), 0, $e);
}
}
}
@@ -0,0 +1,151 @@
<?php
namespace ElixForms\Clients;
use Api\Core\HttpClient;
use ElixForms\Enums\ElixFormsRequestStatus;
class ElixFormsApiClient
{
private string $baseUrl;
private HttpClient $httpClient;
public function __construct(string $baseUrl, HttpClient $httpClient)
{
$this->baseUrl = rtrim($baseUrl, '/');
$this->httpClient = $httpClient;
}
/**
* Restituisce le istanze del modulo, opzionalmente filtrate per stato.
* Gli stati sono separati da virgole e diventano query parameter requestStatus ripetuti.
*
* @return array{status:string,data?:array<int,array<string,mixed>>,message?:string,code?:int}
*/
public function lookupByStatus(
string $moduleTag,
string $authToken,
string $username,
?string $requestStatuses = null
): array
{
$queryParts = [];
try {
foreach (ElixFormsRequestStatus::fromQueryParameter($requestStatuses) as $statusValue) {
$queryParts[] = 'requestStatus=' . rawurlencode($statusValue);
}
} catch (\InvalidArgumentException $exception) {
return $this->fail(['requestStatuses' => $exception->getMessage()]);
}
$queryParts[] = 'moduleTag=' . rawurlencode($moduleTag);
$url = "{$this->baseUrl}/api/request/lookup/by-status?" . implode('&', $queryParts);
$payload = $this->getJsonAsArray($url, $authToken, $username, 'LookupByStatus');
if ($payload['status'] !== 'success') {
return $payload;
}
$payload = $payload['data'];
$requests = $payload['value']['requests'] ?? [];
if (!\is_array($requests)) {
return $this->error('Risposta LookupByStatus non valida: requests deve essere un array.');
}
return $this->success(array_values($requests));
}
/**
* @param int|string $requestId
* @return array{status:string,data?:array<int,array{name?:mixed,value?:mixed}>,message?:string,code?:int}
*/
public function getExportTags(
$requestId,
string $moduleTag,
string $authToken,
string $username,
string $exportGroup = 'API'
): array
{
$exportGroup = trim($exportGroup);
if ($exportGroup === '') {
return $this->fail(['exportGroup' => 'deve essere una stringa non vuota.']);
}
$url = \sprintf(
'%s/services/api/request/%s/view/_DEFAULT/exportTags/get/v1?moduleTag=%s&exportGroup=%s',
$this->baseUrl,
rawurlencode((string) $requestId),
rawurlencode($moduleTag),
rawurlencode($exportGroup)
);
$payload = $this->getJsonAsArray($url, $authToken, $username, 'GetExportTags');
if ($payload['status'] !== 'success') {
return $payload;
}
$payload = $payload['data'];
$exportTags = $payload['value']['exportTags'] ?? [];
if (!\is_array($exportTags)) {
return $this->error('Risposta GetExportTags non valida: exportTags deve essere un array.');
}
return $this->success(array_values($exportTags));
}
private function getJsonAsArray(
string $url,
string $authToken,
string $username,
string $operation
): array {
$response = $this->httpClient->get($url, [
'Authorization' => "Bearer {$authToken}",
'Accept' => 'application/json',
'x-requested-with' => 'XMLHttpRequest',
'x-api-username' => $username,
]);
$status = (int) ($response['status'] ?? 500);
if ($status !== 200) {
return $this->error(\sprintf('Errore durante %s.', $operation), $status);
}
$payload = $response['json'] ?? null;
$jsonError = JSON_ERROR_NONE;
if (!\is_array($payload) && isset($response['body']) && \is_string($response['body'])) {
$payload = json_decode($response['body'], true);
$jsonError = json_last_error();
}
if (!\is_array($payload) || $jsonError !== JSON_ERROR_NONE) {
return $this->error(\sprintf('Risposta non valida da %s: atteso JSON.', $operation));
}
$globalStatus = $payload['value']['globalStatus'] ?? null;
if ($globalStatus === 'ERROR') {
$description = $payload['value']['description'] ?? 'errore non specificato';
return $this->fail([
'operation' => $operation,
'description' => $description,
]);
}
return $this->success($payload);
}
private function success(array $data): array
{
return ['status' => 'success', 'data' => $data];
}
private function fail(array $data): array
{
return ['status' => 'fail', 'data' => $data];
}
private function error(string $message, int $code = 502): array
{
return ['status' => 'error', 'message' => $message, 'code' => $code];
}
}
@@ -0,0 +1,100 @@
<?php
namespace ElixForms\Clients;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
class ElixFormsAuthenticationClient {
private $baseUrl;
private $httpClient;
public function __construct(string $baseUrl, ?Client $httpClient = null) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->httpClient = $httpClient ?? new Client(['timeout' => 10.0, 'http_errors' => false]);
}
/**
* Esegue il login verso l'API di elixForms.
*
* @param string $username
* @param string $password
* @return array{status:string,data?:array{authToken:string},message?:string,code?:int}
*/
public function login(string $username, string $password): array {
$url = "{$this->baseUrl}/services/api/authentication/login/v1";
try {
$response = $this->httpClient->post($url, [
'headers' => [
'x-requested-with' => 'XMLHttpRequest',
'Content-Type' => 'application/json'
],
'json' => [
'username' => $username,
'password' => $password
]
]);
$status = $response->getStatusCode();
if ($status !== 200) {
return $this->error('Errore durante il login elixForms.', $status);
}
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($json)) {
return $this->error('Risposta non valida dal server elixForms: atteso JSON.');
}
if (isset($json['value']['authToken'])) {
return $this->success(['authToken' => $json['value']['authToken']]);
}
return $this->fail(['authToken' => 'non trovato nella risposta del login elixForms.']);
} catch (GuzzleException $e) {
return $this->error('Errore di connessione al server elixForms.');
}
}
/**
* Effettua il logout invalidando il token sul server elixForms.
*
* @param string $username
* @param string $token
* @return array{status:string,data?:array{loggedOut:bool},message?:string,code?:int}
*/
public function logout(string $username, string $token): array {
$url = "{$this->baseUrl}/services/api/authentication/" . urlencode($username) . '/logout/v1';
try {
$response = $this->httpClient->post($url, [
'headers' => [
'Authorization' => "Bearer {$token}",
'Content-Type' => 'application/x-www-form-urlencoded'
]
]);
$status = $response->getStatusCode();
if ($status !== 200) {
return $this->error('Errore durante il logout elixForms.', $status);
}
return $this->success(['loggedOut' => true]);
} catch (GuzzleException $e) {
return $this->error('Errore di connessione al server elixForms.');
}
}
private function success(array $data): array {
return ['status' => 'success', 'data' => $data];
}
private function fail(array $data): array {
return ['status' => 'fail', 'data' => $data];
}
private function error(string $message, int $code = 502): array {
return ['status' => 'error', 'message' => $message, 'code' => $code];
}
}
@@ -1,12 +1,11 @@
<?php <?php
namespace ElixForms; namespace ElixForms\Clients;
use ElixForms\Auth\ElixFormsAuthenticationClient as AuthClient; use ElixForms\Clients\ElixFormsAuthenticationClient;
use ElixForms\Exceptions\ElixFormsException;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
class ElixFormsClient class ElixFormsGenericClient
{ {
private string $baseUrl; private string $baseUrl;
private Client $httpClient; private Client $httpClient;
@@ -17,9 +16,9 @@ class ElixFormsClient
$this->httpClient = $httpClient ?? new Client(['timeout' => 10.0, 'http_errors' => false]); $this->httpClient = $httpClient ?? new Client(['timeout' => 10.0, 'http_errors' => false]);
} }
public function auth(): AuthClient public function auth(): ElixFormsAuthenticationClient
{ {
return new AuthClient($this->baseUrl, $this->httpClient); return new ElixFormsAuthenticationClient($this->baseUrl, $this->httpClient);
} }
public function request(string $method, string $path, ?array $body, array $headers = []): array public function request(string $method, string $path, ?array $body, array $headers = []): array
@@ -38,15 +37,31 @@ class ElixFormsClient
$contentType = $response->getHeaderLine('Content-Type'); $contentType = $response->getHeaderLine('Content-Type');
$isJson = stripos($contentType, 'application/json') !== false; $isJson = stripos($contentType, 'application/json') !== false;
return [ $data = [
'status' => $status, 'status' => $status,
'headers' => $response->getHeaders(), 'headers' => $response->getHeaders(),
'body' => $bodyRaw, 'body' => $bodyRaw,
'is_json' => $isJson, 'is_json' => $isJson,
'json' => $isJson ? json_decode($bodyRaw, true) : null, 'json' => $isJson ? json_decode($bodyRaw, true) : null,
]; ];
if ($status < 200 || $status >= 300) {
return $this->error('Richiesta a elixForms non riuscita.', $status);
}
return $this->success($data);
} catch (GuzzleException $e) { } catch (GuzzleException $e) {
throw new ElixFormsException('Errore di connessione al server elixForms: ' . $e->getMessage(), 0, $e); return $this->error('Errore di connessione al server elixForms.');
} }
} }
private function success(array $data): array
{
return ['status' => 'success', 'data' => $data];
}
private function error(string $message, int $code = 502): array
{
return ['status' => 'error', 'message' => $message, 'code' => $code];
}
} }
@@ -1,5 +1,5 @@
<?php <?php
namespace ElixForms\Auth; namespace ElixForms\Enums;
use InvalidArgumentException; use InvalidArgumentException;
@@ -28,7 +28,7 @@ final class ElixFormsRequestStatus
/** /**
* @return array<int,string> * @return array<int,string>
*/ */
public static function toApiValues(int $status): array public static function toApiValues(?int $status = self::ALL): array
{ {
if ($status <= 0 || ($status & ~self::ALL) !== 0) { if ($status <= 0 || ($status & ~self::ALL) !== 0) {
throw new InvalidArgumentException('La combinazione di stati elixForms non è valida.'); throw new InvalidArgumentException('La combinazione di stati elixForms non è valida.');
@@ -43,4 +43,31 @@ final class ElixFormsRequestStatus
return $values; return $values;
} }
/**
* @return array<int,string>
*/
public static function fromQueryParameter(?string $requestStatuses): array
{
if ($requestStatuses === null || trim($requestStatuses) === '') {
return [];
}
$allowedValues = array_values(self::API_VALUES);
$statuses = [];
foreach (explode(',', $requestStatuses) as $requestStatus) {
$requestStatus = trim($requestStatus);
if ($requestStatus === '') {
continue;
}
if (!in_array($requestStatus, $allowedValues, true)) {
throw new InvalidArgumentException('Lo stato elixForms richiesto non è valido.');
}
$statuses[] = $requestStatus;
}
return array_values(array_unique($statuses));
}
} }
+17
View File
@@ -0,0 +1,17 @@
# Sample requests to try out authorization logic
###
# Authorization OK
@term=MA
GET http://localhost:8000/api/dipendenti/cerca?term={{term}}
Authorization: Basic elixforms_ws:password123
X-API-Key: myApiAccessToken
###
# Should return 401 Unauthorized
@term=MA
GET http://localhost:8000/api/dipendenti/cerca?term={{term}}
@@ -0,0 +1,26 @@
# Sample requests to trigger web service
###
# TAG not found
GET http://localhost:8000/elixforms/instances/search?moduleTag=ANTANI&fieldName=responsabileScientifico.codiceFiscale&fieldValue=MMM
###
# No data found (choose a TAG with very few instances...)
GET http://localhost:8000/elixforms/instances/search?moduleTag=RequestForm_EDILIZIA_RDA_MANUTENZIONE&fieldName=responsabileScientifico.codiceFiscale&fieldValue=MMM
###
# With one status
GET http://localhost:8000/elixforms/instances/search?requestStatuses=PROCESSED&moduleTag=RequestForm_EDILIZIA_RDA_MANUTENZIONE&fieldName=responsabileScientifico.codiceFiscale&fieldValue=MMM
###
# With two statuses (comma-separated)
GET http://localhost:8000/elixforms/instances/search?requestStatuses=PROCESSED,SUBMITTED&moduleTag=RequestForm_EDILIZIA_RDA_MANUTENZIONE&fieldName=responsabileScientifico.codiceFiscale&fieldValue=MMM
###
# With wrong status
GET http://localhost:8000/elixforms/instances/search?requestStatuses=UNKNOWN&moduleTag=RequestForm_EDILIZIA_RDA_MANUTENZIONE&fieldName=responsabileScientifico.codiceFiscale&fieldValue=MMM
+67 -16
View File
@@ -2,8 +2,7 @@
namespace Tests\Unit; namespace Tests\Unit;
use Api\Core\HttpClient; use Api\Core\HttpClient;
use ElixForms\Auth\ElixFormsApiClient; use ElixForms\Clients\ElixFormsApiClient;
use ElixForms\Auth\ElixFormsRequestStatus;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class ElixFormsApiClientTest extends TestCase final class ElixFormsApiClientTest extends TestCase
@@ -32,16 +31,17 @@ final class ElixFormsApiClientTest extends TestCase
}; };
$client = new ElixFormsApiClient('https://example.test', $httpClient); $client = new ElixFormsApiClient('https://example.test', $httpClient);
$requests = $client->lookupByStatus('MODULO TEST', 'token', 'user'); $response = $client->lookupByStatus('MODULO TEST', 'token', 'user');
self::assertSame([['requestId' => 123]], $requests); self::assertSame('success', $response['status']);
self::assertSame([['requestId' => 123]], $response['data']);
self::assertStringNotContainsString('requestStatus=', $httpClient->url); self::assertStringNotContainsString('requestStatus=', $httpClient->url);
self::assertStringContainsString('moduleTag=MODULO%20TEST', $httpClient->url); self::assertStringContainsString('moduleTag=MODULO%20TEST', $httpClient->url);
self::assertSame('Bearer token', $httpClient->headers['Authorization']); self::assertSame('Bearer token', $httpClient->headers['Authorization']);
self::assertSame('user', $httpClient->headers['x-api-username']); self::assertSame('user', $httpClient->headers['x-api-username']);
} }
public function testLookupByStatusExpandsCombinedFlags(): void public function testLookupByStatusExpandsCommaSeparatedStatuses(): void
{ {
$httpClient = new class extends HttpClient { $httpClient = new class extends HttpClient {
public string $url = ''; public string $url = '';
@@ -67,7 +67,7 @@ final class ElixFormsApiClientTest extends TestCase
'MODULO', 'MODULO',
'token', 'token',
'user', 'user',
ElixFormsRequestStatus::IN_PROGRESS | ElixFormsRequestStatus::PROCESSED 'IN_PROGRESS, PROCESSED'
); );
self::assertStringContainsString('requestStatus=IN_PROGRESS', $httpClient->url); self::assertStringContainsString('requestStatus=IN_PROGRESS', $httpClient->url);
@@ -75,11 +75,39 @@ final class ElixFormsApiClientTest extends TestCase
self::assertStringContainsString('requestStatus=PROCESSED', $httpClient->url); self::assertStringContainsString('requestStatus=PROCESSED', $httpClient->url);
} }
public function testRequestStatusRejectsUnknownFlags(): void public function testLookupByStatusOmitsStatusesWhenTheValueIsEmpty(): void
{ {
$this->expectException(\InvalidArgumentException::class); $httpClient = new class extends HttpClient {
public string $url = '';
ElixFormsRequestStatus::toApiValues(8); public function get(string $url, array $headers = [])
{
$this->url = $url;
return [
'status' => 200,
'json' => ['value' => ['globalStatus' => 'OK', 'requests' => []]],
];
}
};
$client = new ElixFormsApiClient('https://example.test', $httpClient);
$client->lookupByStatus('MODULO', 'token', 'user', ' ');
self::assertStringNotContainsString('requestStatus=', $httpClient->url);
}
public function testLookupByStatusReturnsFailForAnUnknownStatus(): void
{
$httpClient = new HttpClient();
$client = new ElixFormsApiClient('https://example.test', $httpClient);
$response = $client->lookupByStatus('MODULO', 'token', 'user', 'UNKNOWN');
self::assertSame([
'status' => 'fail',
'data' => ['requestStatuses' => 'Lo stato elixForms richiesto non è valido.'],
], $response);
} }
public function testGetExportTagsSupportsVendorJsonContentTypeResponses(): void public function testGetExportTagsSupportsVendorJsonContentTypeResponses(): void
@@ -107,14 +135,15 @@ final class ElixFormsApiClientTest extends TestCase
}; };
$client = new ElixFormsApiClient('https://example.test/', $httpClient); $client = new ElixFormsApiClient('https://example.test/', $httpClient);
$tags = $client->getExportTags(42, 'MODULO', 'token', 'user'); $response = $client->getExportTags(42, 'MODULO', 'token', 'user');
self::assertSame('success', $response['status']);
self::assertSame( self::assertSame(
[['name' => 'contratto.id', 'value' => 'ABC-123']], [['name' => 'contratto.id', 'value' => 'ABC-123']],
$tags $response['data']
); );
self::assertSame( self::assertSame(
'https://example.test/eF/services/api/request/42/view/_DEFAULT/exportTags/get/v1?moduleTag=MODULO&exportGroup=API', 'https://example.test/services/api/request/42/view/_DEFAULT/exportTags/get/v1?moduleTag=MODULO&exportGroup=API',
$httpClient->url $httpClient->url
); );
} }
@@ -146,12 +175,34 @@ final class ElixFormsApiClientTest extends TestCase
self::assertStringContainsString('exportGroup=REPORT%20ORE', $httpClient->url); self::assertStringContainsString('exportGroup=REPORT%20ORE', $httpClient->url);
} }
public function testGetExportTagsRejectsAnEmptyExportGroup(): void public function testGetExportTagsReturnsFailForAnEmptyExportGroup(): void
{ {
$this->expectException(\InvalidArgumentException::class);
$httpClient = new HttpClient(); $httpClient = new HttpClient();
$client = new ElixFormsApiClient('https://example.test', $httpClient); $client = new ElixFormsApiClient('https://example.test', $httpClient);
$client->getExportTags(42, 'MODULO', 'token', 'user', ' '); $response = $client->getExportTags(42, 'MODULO', 'token', 'user', ' ');
self::assertSame([
'status' => 'fail',
'data' => ['exportGroup' => 'deve essere una stringa non vuota.'],
], $response);
}
public function testLookupByStatusReturnsErrorForAnUnsuccessfulHttpResponse(): void
{
$httpClient = new class extends HttpClient {
public function get(string $url, array $headers = [])
{
return ['status' => 503];
}
};
$client = new ElixFormsApiClient('https://example.test', $httpClient);
$response = $client->lookupByStatus('MODULO', 'token', 'user');
self::assertSame([
'status' => 'error',
'message' => 'Errore durante LookupByStatus.',
'code' => 503,
], $response);
} }
} }
+30 -16
View File
@@ -5,8 +5,8 @@ use Api\Controllers\V1\ElixFormsController;
use Api\Core\Config; use Api\Core\Config;
use Api\Core\Request; use Api\Core\Request;
use Api\Core\Response; use Api\Core\Response;
use ElixForms\Auth\ElixFormsApiClient; use ElixForms\Clients\ElixFormsApiClient;
use ElixForms\Auth\ElixFormsAuthenticationClient; use ElixForms\Clients\ElixFormsAuthenticationClient;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class ElixFormsControllerTest extends TestCase final class ElixFormsControllerTest extends TestCase
@@ -18,9 +18,9 @@ final class ElixFormsControllerTest extends TestCase
{ {
} }
public function login(string $username, string $password): string public function login(string $username, string $password): array
{ {
return 'token'; return ['status' => 'success', 'data' => ['authToken' => 'token']];
} }
}; };
$apiClient = new class extends ElixFormsApiClient { $apiClient = new class extends ElixFormsApiClient {
@@ -32,14 +32,18 @@ final class ElixFormsControllerTest extends TestCase
string $moduleTag, string $moduleTag,
string $authToken, string $authToken,
string $username, string $username,
?int $status = null ?string $requestStatuses = null
): array ): array
{ {
return [ if ($requestStatuses !== 'IN_PROGRESS,PROCESSED') {
throw new \RuntimeException('requestStatuses non inoltrato al client.');
}
return ['status' => 'success', 'data' => [
['idDomanda' => 10], ['idDomanda' => 10],
['requestId' => 20], ['requestId' => 20],
['idRequest' => 30], ['idRequest' => 30],
]; ]];
} }
public function getExportTags( public function getExportTags(
@@ -54,16 +58,22 @@ final class ElixFormsControllerTest extends TestCase
throw new \RuntimeException('exportGroup non inoltrato al client.'); throw new \RuntimeException('exportGroup non inoltrato al client.');
} }
$values = [ $tags = [
10 => 'Nessuna corrispondenza', 10 => [
20 => 'Il contratto ABC-123 è presente', 'name' => 'contratto.altro',
30 => 'Altro valore', 'value' => 'Il contratto ABC-123 è presente',
],
20 => [
'name' => 'contratto.id',
'value' => 'Il contratto ABC-123 è presente',
],
30 => [
'name' => 'contratto.id',
'value' => 'Altro valore',
],
]; ];
return [[ return ['status' => 'success', 'data' => [$tags[$requestId]]];
'name' => 'contratto.id',
'value' => $values[$requestId],
]];
} }
}; };
$config = new class extends Config { $config = new class extends Config {
@@ -79,6 +89,7 @@ final class ElixFormsControllerTest extends TestCase
'moduleTag' => 'MODULO', 'moduleTag' => 'MODULO',
'fieldName' => 'contratto.id', 'fieldName' => 'contratto.id',
'fieldValue' => 'abc-123', 'fieldValue' => 'abc-123',
'requestStatuses' => 'IN_PROGRESS,PROCESSED',
'exportGroup' => ' CUSTOM ', 'exportGroup' => ' CUSTOM ',
]; ];
} }
@@ -91,7 +102,10 @@ final class ElixFormsControllerTest extends TestCase
self::fail('La risposta avrebbe dovuto interrompere il flusso del test.'); self::fail('La risposta avrebbe dovuto interrompere il flusso del test.');
} catch (CapturedResponseException $exception) { } catch (CapturedResponseException $exception) {
self::assertSame(200, $exception->status); self::assertSame(200, $exception->status);
self::assertSame([['requestId' => 20]], $exception->payload); self::assertSame([
'status' => 'success',
'data' => [['requestId' => 20]],
], $exception->payload);
} }
} }