From 9c5e089573210c52dfff2b409463c657c10d1fe8 Mon Sep 17 00:00:00 2001 From: Pier-Paolo Mammi Date: Tue, 21 Jul 2026 20:43:36 +0200 Subject: [PATCH] add route to search instances by field value --- bootstrap.php | 17 ++ public/index.php | 19 +-- .../Controllers/V1/ElixFormsController.php | 124 ++++++++++++++ src/ElixForms/Auth/ElixFormsApiClient.php | 132 +++++++++++++++ .../Auth/ElixFormsAuthenticationClient.php | 2 +- src/ElixForms/Auth/ElixFormsRequestStatus.php | 46 +++++ tests/Unit/ElixFormsApiClientTest.php | 157 ++++++++++++++++++ tests/Unit/ElixFormsControllerTest.php | 147 ++++++++++++++++ 8 files changed, 632 insertions(+), 12 deletions(-) create mode 100644 src/Api/Controllers/V1/ElixFormsController.php create mode 100644 src/ElixForms/Auth/ElixFormsApiClient.php create mode 100644 src/ElixForms/Auth/ElixFormsRequestStatus.php create mode 100644 tests/Unit/ElixFormsApiClientTest.php create mode 100644 tests/Unit/ElixFormsControllerTest.php diff --git a/bootstrap.php b/bootstrap.php index 656a5b4..a510008 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -13,6 +13,8 @@ use Api\Core\RateLimiter\FileRateLimiter; use Api\Core\RateLimiter\InMemoryRateLimiter; use Api\Core\Log\LoggerFactory; use ElixForms\ElixFormsClient; +use ElixForms\Auth\ElixFormsApiClient; +use ElixForms\Auth\ElixFormsAuthenticationClient; use Psr\Log\LoggerInterface; $container = new Container(); @@ -67,6 +69,21 @@ $container->singleton(ElixFormsClient::class, function($c) { return new ElixFormsClient($baseUrl); }); +$container->singleton(ElixFormsAuthenticationClient::class, function($c) { + $config = $c->make(Config::class); + + return new ElixFormsAuthenticationClient($config->get('elixforms_api_base_url')); +}); + +$container->singleton(ElixFormsApiClient::class, function($c) { + $config = $c->make(Config::class); + + return new ElixFormsApiClient( + $config->get('elixforms_api_base_url'), + $c->make(HttpClient::class) + ); +}); + // If you have other services, bind them here, for example: // $container->singleton(SomeService::class, function($c) { // return new SomeService($c->make(LoggerInterface::class), ...); diff --git a/public/index.php b/public/index.php index dadb41f..6ab6ca6 100644 --- a/public/index.php +++ b/public/index.php @@ -16,7 +16,7 @@ set_exception_handler(function (\Throwable $e) use ($logger) { 'exception' => $e, 'trace' => $e->getTraceAsString() ]); - + http_response_code(500); header('Content-Type: application/json'); echo json_encode([ @@ -40,7 +40,7 @@ $corsConfig = $container->make(\Api\Core\Config::class)->get('cors', []); if (!empty($corsConfig['enabled'])) { $origin = $corsManager->getOrigin(); $requestOrigin = $_SERVER['HTTP_ORIGIN'] ?? 'none'; - + // Log CORS request details for debugging $logger->debug('CORS request received', [ 'request_origin' => $requestOrigin, @@ -49,10 +49,10 @@ if (!empty($corsConfig['enabled'])) { 'method' => $_SERVER['REQUEST_METHOD'], 'path' => $request->path() ]); - + // Apply CORS headers to all responses $corsManager->applyHeaders(); - + // Handle preflight OPTIONS requests if ($corsManager->isPreflightRequest()) { $corsManager->handlePreflight(); @@ -63,7 +63,7 @@ if (!empty($corsConfig['enabled'])) { if ($request->path() === '/cors-check' && $request->method() === 'GET') { $corsManager = $container->make(CorsManager::class); $corsConfig = $container->make(\Api\Core\Config::class)->get('cors', []); - + $response->json([ 'cors_enabled' => !empty($corsConfig['enabled']), 'request_origin' => $_SERVER['HTTP_ORIGIN'] ?? null, @@ -87,12 +87,9 @@ if (strpos($request->path(), '/api/') === 0) { } // register routes (path without version prefix) -// $router->get('/users/index', 'UsersController@index'); -// $router->post('/users/create', 'UsersController@create'); -// $router->get('/example/test', 'ExampleController@test'); -// $router->get('/dipendenti/cerca', 'DipendentiController@search'); -$router->get('/api/contratti/cerca', 'ContrattiController@search'); -$router->get('/api/dipendenti/cerca', 'DipendentiController@search'); +$router->get('/contratti/cerca', 'ContrattiController@search'); +$router->get('/dipendenti/cerca', 'DipendentiController@search'); +$router->get('/elixforms/instances/search', 'ElixFormsController@searchInstances'); // Rate limiting by IP address $key = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; diff --git a/src/Api/Controllers/V1/ElixFormsController.php b/src/Api/Controllers/V1/ElixFormsController.php new file mode 100644 index 0000000..624adcf --- /dev/null +++ b/src/Api/Controllers/V1/ElixFormsController.php @@ -0,0 +1,124 @@ +authenticationClient = $authenticationClient; + $this->apiClient = $apiClient; + $this->config = $config; + } + + public function searchInstances(Request $req, Response $res) + { + $query = $req->query(); + $moduleTag = $this->requiredString($query, 'moduleTag'); + $fieldName = $this->requiredString($query, 'fieldName'); + $fieldValue = $this->requiredString($query, 'fieldValue'); + $exportGroup = $this->optionalNonEmptyString($query, 'exportGroup', 'API'); + + if ($moduleTag === null || $fieldName === null || $fieldValue === null || $exportGroup === null) { + return $res->json([ + 'error' => 'moduleTag, fieldName e fieldValue sono obbligatori; exportGroup, se specificato, deve essere una stringa non vuota.' + ], 400); + } + + $username = $this->config->secret('elixforms_api_username'); + $password = $this->config->secret('elixforms_api_password'); + + if (!is_string($username) || trim($username) === '' || !is_string($password) || $password === '') { + throw new ElixFormsException('Credenziali elixForms non configurate.'); + } + + $token = $this->authenticationClient->login($username, $password); + $instances = $this->apiClient->lookupByStatus($moduleTag, $token, $username); + $matchingInstances = []; + + foreach ($instances as $instance) { + if (!is_array($instance)) { + continue; + } + + $requestId = $this->requestId($instance); + if ($requestId === null) { + continue; + } + + $exportTags = $this->apiClient->getExportTags( + $requestId, + $moduleTag, + $token, + $username, + $exportGroup + ); + + foreach ($exportTags as $exportTag) { + if (!is_array($exportTag) || !isset($exportTag['name'])) { + continue; + } + + $name = (string) $exportTag['name']; + $value = isset($exportTag['value']) ? (string) $exportTag['value'] : ''; + + if (strcasecmp($name, $fieldName) === 0 && stripos($value, $fieldValue) !== false) { + $matchingInstances[] = $instance; + break; + } + } + } + + return $res->json($matchingInstances); + } + + private function requiredString(array $values, string $key): ?string + { + if (!isset($values[$key]) || !is_string($values[$key])) { + return null; + } + + $value = trim($values[$key]); + + return $value === '' ? null : $value; + } + + private function optionalNonEmptyString(array $values, string $key, string $default): ?string + { + if (!array_key_exists($key, $values)) { + return $default; + } + + if (!is_string($values[$key])) { + return null; + } + + $value = trim($values[$key]); + + return $value === '' ? null : $value; + } + + private function requestId(array $instance) + { + foreach (['idDomanda', 'requestId', 'idRequest'] as $key) { + if (isset($instance[$key]) && (is_int($instance[$key]) || is_string($instance[$key]))) { + return $instance[$key]; + } + } + + return null; + } +} diff --git a/src/ElixForms/Auth/ElixFormsApiClient.php b/src/ElixForms/Auth/ElixFormsApiClient.php new file mode 100644 index 0000000..914d3e1 --- /dev/null +++ b/src/ElixForms/Auth/ElixFormsApiClient.php @@ -0,0 +1,132 @@ +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> + */ + 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 + */ + 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; + } +} diff --git a/src/ElixForms/Auth/ElixFormsAuthenticationClient.php b/src/ElixForms/Auth/ElixFormsAuthenticationClient.php index 1181ac9..5b02f5f 100644 --- a/src/ElixForms/Auth/ElixFormsAuthenticationClient.php +++ b/src/ElixForms/Auth/ElixFormsAuthenticationClient.php @@ -9,7 +9,7 @@ class ElixFormsAuthenticationClient { private $baseUrl; private $httpClient; - public function __construct(string $baseUrl, Client $httpClient = null) { + public function __construct(string $baseUrl, ?Client $httpClient = null) { $this->baseUrl = rtrim($baseUrl, '/'); $this->httpClient = $httpClient ?? new Client(['timeout' => 10.0, 'http_errors' => false]); } diff --git a/src/ElixForms/Auth/ElixFormsRequestStatus.php b/src/ElixForms/Auth/ElixFormsRequestStatus.php new file mode 100644 index 0000000..2ab0d71 --- /dev/null +++ b/src/ElixForms/Auth/ElixFormsRequestStatus.php @@ -0,0 +1,46 @@ + 'IN_PROGRESS', + self::SUBMITTED => 'SUBMITTED', + self::PROCESSED => 'PROCESSED', + ]; + + private function __construct() + { + } + + /** + * @return array + */ + public static function toApiValues(int $status): array + { + if ($status <= 0 || ($status & ~self::ALL) !== 0) { + throw new InvalidArgumentException('La combinazione di stati elixForms non è valida.'); + } + + $values = []; + foreach (self::API_VALUES as $flag => $apiValue) { + if (($status & $flag) === $flag) { + $values[] = $apiValue; + } + } + + return $values; + } +} diff --git a/tests/Unit/ElixFormsApiClientTest.php b/tests/Unit/ElixFormsApiClientTest.php new file mode 100644 index 0000000..29af2ed --- /dev/null +++ b/tests/Unit/ElixFormsApiClientTest.php @@ -0,0 +1,157 @@ +url = $url; + $this->headers = $headers; + + return [ + 'status' => 200, + 'json' => [ + 'value' => [ + 'globalStatus' => 'OK', + 'requests' => [['requestId' => 123]], + ], + ], + ]; + } + }; + $client = new ElixFormsApiClient('https://example.test', $httpClient); + + $requests = $client->lookupByStatus('MODULO TEST', 'token', 'user'); + + self::assertSame([['requestId' => 123]], $requests); + self::assertStringNotContainsString('requestStatus=', $httpClient->url); + self::assertStringContainsString('moduleTag=MODULO%20TEST', $httpClient->url); + self::assertSame('Bearer token', $httpClient->headers['Authorization']); + self::assertSame('user', $httpClient->headers['x-api-username']); + } + + public function testLookupByStatusExpandsCombinedFlags(): void + { + $httpClient = new class extends HttpClient { + public string $url = ''; + + 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', + ElixFormsRequestStatus::IN_PROGRESS | ElixFormsRequestStatus::PROCESSED + ); + + self::assertStringContainsString('requestStatus=IN_PROGRESS', $httpClient->url); + self::assertStringNotContainsString('requestStatus=SUBMITTED', $httpClient->url); + self::assertStringContainsString('requestStatus=PROCESSED', $httpClient->url); + } + + public function testRequestStatusRejectsUnknownFlags(): void + { + $this->expectException(\InvalidArgumentException::class); + + ElixFormsRequestStatus::toApiValues(8); + } + + public function testGetExportTagsSupportsVendorJsonContentTypeResponses(): void + { + $httpClient = new class extends HttpClient { + public string $url = ''; + + public function get(string $url, array $headers = []) + { + $this->url = $url; + + return [ + 'status' => 200, + 'body' => json_encode([ + 'value' => [ + 'globalStatus' => 'OK', + 'exportTags' => [ + ['name' => 'contratto.id', 'value' => 'ABC-123'], + ], + ], + ]), + 'json' => null, + ]; + } + }; + $client = new ElixFormsApiClient('https://example.test/', $httpClient); + + $tags = $client->getExportTags(42, 'MODULO', 'token', 'user'); + + self::assertSame( + [['name' => 'contratto.id', 'value' => 'ABC-123']], + $tags + ); + self::assertSame( + 'https://example.test/eF/services/api/request/42/view/_DEFAULT/exportTags/get/v1?moduleTag=MODULO&exportGroup=API', + $httpClient->url + ); + } + + public function testGetExportTagsUsesTheProvidedExportGroup(): void + { + $httpClient = new class extends HttpClient { + public string $url = ''; + + public function get(string $url, array $headers = []) + { + $this->url = $url; + + return [ + 'status' => 200, + 'json' => [ + 'value' => [ + 'globalStatus' => 'OK', + 'exportTags' => [], + ], + ], + ]; + } + }; + $client = new ElixFormsApiClient('https://example.test', $httpClient); + + $client->getExportTags(42, 'MODULO', 'token', 'user', 'REPORT ORE'); + + self::assertStringContainsString('exportGroup=REPORT%20ORE', $httpClient->url); + } + + public function testGetExportTagsRejectsAnEmptyExportGroup(): void + { + $this->expectException(\InvalidArgumentException::class); + + $httpClient = new HttpClient(); + $client = new ElixFormsApiClient('https://example.test', $httpClient); + $client->getExportTags(42, 'MODULO', 'token', 'user', ' '); + } +} diff --git a/tests/Unit/ElixFormsControllerTest.php b/tests/Unit/ElixFormsControllerTest.php new file mode 100644 index 0000000..88f6640 --- /dev/null +++ b/tests/Unit/ElixFormsControllerTest.php @@ -0,0 +1,147 @@ + 10], + ['requestId' => 20], + ['idRequest' => 30], + ]; + } + + public function getExportTags( + $requestId, + string $moduleTag, + string $authToken, + string $username, + string $exportGroup = 'API' + ): array + { + if ($exportGroup !== 'CUSTOM') { + throw new \RuntimeException('exportGroup non inoltrato al client.'); + } + + $values = [ + 10 => 'Nessuna corrispondenza', + 20 => 'Il contratto ABC-123 è presente', + 30 => 'Altro valore', + ]; + + return [[ + 'name' => 'contratto.id', + 'value' => $values[$requestId], + ]]; + } + }; + $config = new class extends Config { + public function secret($key, $default = null) + { + return $key === 'elixforms_api_username' ? 'user' : 'password'; + } + }; + $request = new class extends Request { + public function query() + { + return [ + 'moduleTag' => 'MODULO', + 'fieldName' => 'contratto.id', + 'fieldValue' => 'abc-123', + 'exportGroup' => ' CUSTOM ', + ]; + } + }; + $response = new CapturingResponse(); + $controller = new ElixFormsController($authenticationClient, $apiClient, $config); + + try { + $controller->searchInstances($request, $response); + self::fail('La risposta avrebbe dovuto interrompere il flusso del test.'); + } catch (CapturedResponseException $exception) { + self::assertSame(200, $exception->status); + self::assertSame([['requestId' => 20]], $exception->payload); + } + } + + public function testSearchInstancesRejectsMissingParameters(): void + { + $authenticationClient = new class extends ElixFormsAuthenticationClient { + public function __construct() + { + } + }; + $apiClient = new class extends ElixFormsApiClient { + public function __construct() + { + } + }; + $config = new Config(); + $request = new class extends Request { + public function query() + { + return ['moduleTag' => 'MODULO']; + } + }; + $controller = new ElixFormsController($authenticationClient, $apiClient, $config); + + try { + $controller->searchInstances($request, new CapturingResponse()); + self::fail('La risposta avrebbe dovuto interrompere il flusso del test.'); + } catch (CapturedResponseException $exception) { + self::assertSame(400, $exception->status); + } + } +} + +final class CapturingResponse extends Response +{ + public function json($data, $status = 200) + { + throw new CapturedResponseException($data, $status); + } +} + +final class CapturedResponseException extends \RuntimeException +{ + public $payload; + public int $status; + + public function __construct($payload, int $status) + { + parent::__construct('Response captured'); + $this->payload = $payload; + $this->status = $status; + } +}