diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 0000000..4498799 --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,21 @@ +# API Server - Regole e Skill Set + +Queste regole definiscono il comportamento per tutti gli sviluppi futuri su questa repository, al fine di mantenere l'architettura pulita e scalabile. + +1. **Dependency Injection Obbligatoria (IoC)** + Non utilizzare mai chiamate a metodi statici per accedere a servizi o configurazioni (es. evitare `Config::get()` o `HttpClient::get()`). + Tutte le dipendenze devono essere iniettate tramite costruttore. Il container in `bootstrap.php` provvederà all'autowiring automatico. + +2. **Namespace e Struttura dei Controller** + Tutti i nuovi Controller devono essere posizionati all'interno della cartella relativa alla loro versione (es. `src/Controllers/V1/`) e devono avere il namespace corretto (es. `namespace Controllers\V1;`). Questo permette al `Router` di mapparli automaticamente partendo dalle route versionate (es. `/v1/risorsa`). + +3. **Risposte HTTP Standard** + Non utilizzare mai funzioni di output diretto (come `echo`, `print` o `header()`) all'interno dei Controller. + Usa sempre l'oggetto `Response` (iniettato come parametro o generato internamente) e chiama il metodo `$res->json($payload, $statusCode)` per uniformare l'output. + +4. **Gestione Errori e Sicurezza** + Non includere mai stack trace o dettagli sensibili (segreti, stringhe di connessione) nelle risposte JSON d'errore o nei log generici. + Lascia che le eccezioni vengano catturate dal Global Exception Handler o usa il `LoggerInterface` per tracciare i problemi a livello server. + +5. **Validazione dell'Input** + Assicurati sempre di validare l'input proveniente da `$req->body()` o dai parametri URL prima di processarlo con la business logic applicativa. (Consigliato l'uso di DTO). diff --git a/bootstrap.php b/bootstrap.php index 1fa339e..1298d85 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -15,26 +15,40 @@ use Services\ExternalApiService; $container = new Container(); +$config = new Config(); +$container->singleton(Config::class, function() use ($config) { + return $config; +}); + +$container->singleton(\Core\HttpClient::class, function() { + return new \Core\HttpClient(); +}); + // Rate limiter driver configurabile via config or env: 'file' or 'memory' -$rlDriver = Config::get('rate_limiter_driver', 'file'); +$rlDriver = $config->get('rate_limiter_driver', 'file'); if ($rlDriver === 'memory') { $container->singleton(RateLimiterInterface::class, function($c) { - $requests = (int) \Core\Config::get('rate_limit_requests', 100); - $window = (int) \Core\Config::get('rate_limit_window_seconds', 60); + $config = $c->make(Config::class); + $requests = (int) $config->get('rate_limit_requests', 100); + $window = (int) $config->get('rate_limit_window_seconds', 60); return new InMemoryRateLimiter($requests, $window); }); } else { $container->singleton(RateLimiterInterface::class, function($c) { - $dir = Config::get('rate_limit_storage_dir', sys_get_temp_dir() . '/api_rate_limit'); - return new FileRateLimiter($dir); + $config = $c->make(Config::class); + $dir = $config->get('rate_limit_storage_dir', sys_get_temp_dir() . '/api_rate_limit'); + $requests = (int) $config->get('rate_limit_requests', 100); + $window = (int) $config->get('rate_limit_window_seconds', 60); + return new FileRateLimiter($dir, $requests, $window); }); } // Logger binding as PSR LoggerInterface $container->singleton(LoggerInterface::class, function($c) { + $config = $c->make(Config::class); // LoggerFactory::create returns a Monolog\Logger instance - return LoggerFactory::create(Config::get('log_ident', 'api')); + return LoggerFactory::create($config->get('log_ident', 'api')); }); // External API service binding diff --git a/docs/APM-050525-0947-1550.pdf b/docs/APM-050525-0947-1550.pdf new file mode 100644 index 0000000..be2acbc Binary files /dev/null and b/docs/APM-050525-0947-1550.pdf differ diff --git a/public/index.php b/public/index.php index 36a1804..f276ed9 100644 --- a/public/index.php +++ b/public/index.php @@ -8,6 +8,22 @@ use Core\RateLimiter\RateLimiterInterface; use Psr\Log\LoggerInterface; $logger = $container->make(LoggerInterface::class); + +set_exception_handler(function (\Throwable $e) use ($logger) { + $logger->error('Uncaught Exception: ' . $e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString() + ]); + + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode([ + 'error' => 'Internal Server Error', + 'message' => 'An unexpected error occurred.' + ]); + exit; +}); + $limiter = $container->make(RateLimiterInterface::class); $router = new Router($container); // vedi nota: router può ricevere container diff --git a/src/Controllers/ExampleController.php b/src/Controllers/ExampleController.php index d8680a1..b502f1f 100644 --- a/src/Controllers/ExampleController.php +++ b/src/Controllers/ExampleController.php @@ -6,14 +6,24 @@ use Core\Response; use Core\Config; use Core\HttpClient; -class ExampleController { +class ExampleController +{ + private $config; + private $httpClient; - public function test(Request $req, Response $res) { + public function __construct(Config $config, HttpClient $httpClient) + { + $this->config = $config; + $this->httpClient = $httpClient; + } - $url = Config::get('external_api_base_url') . '/status'; + public function test(Request $req, Response $res) + { - $response = HttpClient::get($url, [ - 'Authorization' => 'Bearer ' . Config::secret('external_api_token') + $url = $this->config->get('external_api_base_url') . '/status'; + + $response = $this->httpClient->get($url, [ + 'Authorization' => 'Bearer ' . $this->config->secret('external_api_token') ]); return $res->json([ diff --git a/src/Core/Config.php b/src/Core/Config.php index 07de903..3f9c2ac 100644 --- a/src/Core/Config.php +++ b/src/Core/Config.php @@ -2,36 +2,36 @@ namespace Core; class Config { - private static $config; - private static $secrets; + private $config; + private $secrets; - private static function loadConfig() { - if (!self::$config) { + private function loadConfig() { + if ($this->config === null) { $file = __DIR__ . '/../../config/config.php'; - self::$config = file_exists($file) ? require $file : []; + $this->config = file_exists($file) ? require $file : []; } } - private static function loadSecrets() { - if (!self::$secrets) { + private function loadSecrets() { + if ($this->secrets === null) { $file = __DIR__ . '/../../config/secrets.php'; - self::$secrets = file_exists($file) ? require $file : []; + $this->secrets = file_exists($file) ? require $file : []; } } - public static function get($key, $default = null) { - self::loadConfig(); + public function get($key, $default = null) { + $this->loadConfig(); if (getenv(strtoupper($key)) !== false) { return getenv(strtoupper($key)); } - return self::$config[$key] ?? $default; + return $this->config[$key] ?? $default; } - public static function secret($key, $default = null) { - self::loadSecrets(); + public function secret($key, $default = null) { + $this->loadSecrets(); if (getenv(strtoupper($key)) !== false) { return getenv(strtoupper($key)); } - return self::$secrets[$key] ?? $default; + return $this->secrets[$key] ?? $default; } } diff --git a/src/Core/ElixFormsException.php b/src/Core/ElixFormsException.php new file mode 100644 index 0000000..d77df53 --- /dev/null +++ b/src/Core/ElixFormsException.php @@ -0,0 +1,8 @@ +client === null) { + $this->client = new Client([ 'timeout' => 10.0, 'http_errors' => false ]); } - return self::$client; + return $this->client; } - public static function get(string $url, array $headers = []) { - try { - $resp = self::client()->request('GET', $url, ['headers' => $headers]); - return [ - 'status' => $resp->getStatusCode(), - 'body' => json_decode($resp->getBody()->getContents(), true) - ]; - } catch (RequestException $e) { - return ['status' => 500, 'body' => null, 'error' => $e->getMessage()]; - } + public function get(string $url, array $headers = []) { + return $this->request('GET', $url, null, $headers); } - public static function post(string $url, $data = null, array $headers = []) { + public function post(string $url, $data = null, array $headers = []) { + return $this->request('POST', $url, $data, $headers); + } + + private function request(string $method, string $url, $data, array $headers) { try { $options = ['headers' => $headers]; - if ($data !== null) $options['json'] = $data; - $resp = self::client()->request('POST', $url, $options); + if ($data !== null) { + $options['json'] = $data; + } + + $resp = $this->client()->request($method, $url, $options); + + $status = $resp->getStatusCode(); + $bodyRaw = $resp->getBody()->getContents(); + $contentType = $resp->getHeaderLine('Content-Type'); + + $isJson = stripos($contentType, 'application/json') !== false; + return [ - 'status' => $resp->getStatusCode(), - 'body' => json_decode($resp->getBody()->getContents(), true) + 'status' => $status, + 'headers' => $resp->getHeaders(), + 'body' => $bodyRaw, + 'is_json' => $isJson, + 'json' => $isJson ? json_decode($bodyRaw, true) : null ]; + } catch (RequestException $e) { - return ['status' => 500, 'body' => null, 'error' => $e->getMessage()]; + return [ + 'status' => 500, + 'headers' => [], + 'body' => null, + 'is_json' => false, + 'json' => null, + 'error' => $e->getMessage() + ]; } } } diff --git a/src/Core/LoggerFactory.php b/src/Core/LoggerFactory.php index 6707247..78d7a0a 100644 --- a/src/Core/LoggerFactory.php +++ b/src/Core/LoggerFactory.php @@ -8,7 +8,7 @@ use Monolog\Formatter\JsonFormatter; class LoggerFactory { public static function create(string $name = null): Logger { - $ident = $name ?? Config::get('log_ident', 'api'); + $ident = $name ?? 'api'; $logger = new Logger($ident); $handler = new SyslogHandler($ident, LOG_USER); $handler->setFormatter(new JsonFormatter()); diff --git a/src/Core/RateLimiter/FileRateLimiter.php b/src/Core/RateLimiter/FileRateLimiter.php index a60a1dd..36cb5fc 100644 --- a/src/Core/RateLimiter/FileRateLimiter.php +++ b/src/Core/RateLimiter/FileRateLimiter.php @@ -8,13 +8,13 @@ class FileRateLimiter implements RateLimiterInterface { private $requests; private $window; - public function __construct(string $storageDir = null) { + public function __construct(string $storageDir = null, int $requests = 100, int $window = 60) { $this->dir = $storageDir ?? sys_get_temp_dir() . '/api_rate_limit'; if (!is_dir($this->dir)) { mkdir($this->dir, 0700, true); } - $this->requests = (int) Config::get('rate_limit_requests', 100); - $this->window = (int) Config::get('rate_limit_window_seconds', 60); + $this->requests = $requests; + $this->window = $window; } private function fileForKey(string $key): string { diff --git a/src/Services/ElixFormsAuthenticationClient.php b/src/Services/ElixFormsAuthenticationClient.php new file mode 100644 index 0000000..0d3410a --- /dev/null +++ b/src/Services/ElixFormsAuthenticationClient.php @@ -0,0 +1,80 @@ +config = $config; + $this->httpClient = $httpClient; + } + + /** + * 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 { + $baseUrl = rtrim($this->config->get('elixforms_api_base_url'), '/'); + $url = $baseUrl . '/eF/services/api/authentication/login/v1'; + + $data = [ + 'username' => $username, + 'password' => $password + ]; + + // L'API supporta application/json + $response = $this->httpClient->post($url, $data); + + if ($response['status'] !== 200) { + throw new ElixFormsException("Errore durante il login elixForms. HTTP Status: " . $response['status']); + } + + if (!$response['is_json'] || empty($response['json'])) { + throw new ElixFormsException("Risposta non valida dal server elixForms: atteso JSON."); + } + + $json = $response['json']; + + // Estraiamo il token dalla struttura complessa + if (isset($json['value']['authToken'])) { + return $json['value']['authToken']; + } + + throw new ElixFormsException("authToken non trovato nella risposta del login elixForms."); + } + + /** + * 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 { + $baseUrl = rtrim($this->config->get('elixforms_api_base_url'), '/'); + $url = $baseUrl . '/eF/services/api/authentication/' . urlencode($username) . '/logout/v1'; + + $headers = [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/x-www-form-urlencoded' + ]; + + $response = $this->httpClient->post($url, null, $headers); + + if ($response['status'] !== 200) { + throw new ElixFormsException("Errore durante il logout elixForms. HTTP Status: " . $response['status']); + } + + return true; + } +} diff --git a/src/Services/ExternalApiService.php b/src/Services/ExternalApiService.php index df07aa4..f0292a8 100644 --- a/src/Services/ExternalApiService.php +++ b/src/Services/ExternalApiService.php @@ -4,11 +4,22 @@ namespace Services; use Core\Config; use Core\HttpClient; -class ExternalApiService { - public function getStatus() { - $url = rtrim(Config::get('external_api_base_url'), '/') . '/status'; - $token = Config::secret('external_api_token'); +class ExternalApiService +{ + private $config; + private $httpClient; + + public function __construct(Config $config, HttpClient $httpClient) + { + $this->config = $config; + $this->httpClient = $httpClient; + } + + public function getStatus() + { + $url = rtrim($this->config->get('external_api_base_url'), '/') . '/status'; + $token = $this->config->secret('external_api_token'); $headers = ['Authorization' => 'Bearer ' . $token]; - return HttpClient::get($url, $headers); + return $this->httpClient->get($url, $headers); } } diff --git a/tests/Unit/RateLimiterTest.php b/tests/Unit/RateLimiterTest.php index 35d2ce4..b608eb2 100644 --- a/tests/Unit/RateLimiterTest.php +++ b/tests/Unit/RateLimiterTest.php @@ -25,9 +25,10 @@ final class RateLimiterTest extends TestCase { } public function testBlocksWhenExceeded(): void { - $limiter = new FileRateLimiter($this->dir); + $requests = 5; + $limiter = new FileRateLimiter($this->dir, $requests, 60); $key = 'test-client-2'; - $requests = (int) \Core\Config::get('rate_limit_requests', 5); + for ($i = 0; $i < $requests; $i++) { $this->assertTrue($limiter->allow($key)); }