From 250ee892dca0c6d38da81a9d7284ab8390d05853 Mon Sep 17 00:00:00 2001 From: Pier-Paolo Mammi Date: Mon, 13 Jul 2026 12:05:43 +0200 Subject: [PATCH] add initial solution (made with copilot) --- .gitignore | 4 + .htaccess.txt | 3 + bootstrap.php | 50 +++++++ composer.json | 24 ++++ config/config.php.template | 10 ++ config/secrets.php.template | 6 + public/index.php | 33 +++++ run_tests.sh | 1 + src/Controllers/ExampleController.php | 23 +++ src/Controllers/v1/UsersController.php | 26 ++++ src/Core/Config.php | 37 +++++ src/Core/Container.php | 133 ++++++++++++++++++ src/Core/HttpClient.php | 45 ++++++ src/Core/LoggerFactory.php | 19 +++ src/Core/RateLimiter/FileRateLimiter.php | 84 +++++++++++ src/Core/RateLimiter/InMemoryRateLimiter.php | 42 ++++++ src/Core/RateLimiter/RateLimiterInterface.php | 7 + src/Core/Request.php | 20 +++ src/Core/Response.php | 11 ++ src/Core/Router.php | 57 ++++++++ src/Helpers/Json.php | 109 ++++++++++++++ src/Services/ExternalApiService.php | 14 ++ struttura-soluzione.txt | 53 +++++++ tests/Unit/RateLimiterTest.php | 36 +++++ tests/Unit/SomeControllerTest.php | 13 ++ 25 files changed, 860 insertions(+) create mode 100644 .gitignore create mode 100644 .htaccess.txt create mode 100644 bootstrap.php create mode 100644 composer.json create mode 100644 config/config.php.template create mode 100644 config/secrets.php.template create mode 100644 public/index.php create mode 100644 run_tests.sh create mode 100644 src/Controllers/ExampleController.php create mode 100644 src/Controllers/v1/UsersController.php create mode 100644 src/Core/Config.php create mode 100644 src/Core/Container.php create mode 100644 src/Core/HttpClient.php create mode 100644 src/Core/LoggerFactory.php create mode 100644 src/Core/RateLimiter/FileRateLimiter.php create mode 100644 src/Core/RateLimiter/InMemoryRateLimiter.php create mode 100644 src/Core/RateLimiter/RateLimiterInterface.php create mode 100644 src/Core/Request.php create mode 100644 src/Core/Response.php create mode 100644 src/Core/Router.php create mode 100644 src/Helpers/Json.php create mode 100644 src/Services/ExternalApiService.php create mode 100644 struttura-soluzione.txt create mode 100644 tests/Unit/RateLimiterTest.php create mode 100644 tests/Unit/SomeControllerTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4084cfc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/config/config.php +/config/secrets.php +/vendor/ +/tests/_output/ diff --git a/.htaccess.txt b/.htaccess.txt new file mode 100644 index 0000000..b58fcbd --- /dev/null +++ b/.htaccess.txt @@ -0,0 +1,3 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteRule ^ public/index.php [QSA,L] diff --git a/bootstrap.php b/bootstrap.php new file mode 100644 index 0000000..1fa339e --- /dev/null +++ b/bootstrap.php @@ -0,0 +1,50 @@ +singleton(RateLimiterInterface::class, function($c) { + $requests = (int) \Core\Config::get('rate_limit_requests', 100); + $window = (int) \Core\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); + }); +} + +// Logger binding as PSR LoggerInterface +$container->singleton(LoggerInterface::class, function($c) { + // LoggerFactory::create returns a Monolog\Logger instance + return LoggerFactory::create(Config::get('log_ident', 'api')); +}); + +// External API service binding +$container->singleton(ExternalApiService::class, function($c) { + return new ExternalApiService(); +}); + +// If you have other services, bind them here, for example: +// $container->singleton(SomeService::class, function($c) { +// return new SomeService($c->make(LoggerInterface::class), ...); +// }); + +return $container; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..eaae99d --- /dev/null +++ b/composer.json @@ -0,0 +1,24 @@ +{ + "name": "yourorg/api", + "require": { + "php": ">=7.4", + "guzzlehttp/guzzle": "^7.0", + "monolog/monolog": "^2.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "autoload": { + "psr-4": { + "Core\\": "src/Core/", + "Controllers\\": "src/Controllers/", + "Services\\": "src/Services/", + "Helpers\\": "src/Helpers/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + } +} \ No newline at end of file diff --git a/config/config.php.template b/config/config.php.template new file mode 100644 index 0000000..701a592 --- /dev/null +++ b/config/config.php.template @@ -0,0 +1,10 @@ + 'https://api.example.com', + 'rate_limiter_driver' => 'file', // o 'memory' + // configuration for file-based rate limiter + 'rate_limit_storage_dir' => sys_get_temp_dir() . '/api_rate_limit', + // configuration for memory-based rate limiter + 'rate_limit_requests' => 100, + 'rate_limit_window_seconds' => 60, +]; diff --git a/config/secrets.php.template b/config/secrets.php.template new file mode 100644 index 0000000..babdcd4 --- /dev/null +++ b/config/secrets.php.template @@ -0,0 +1,6 @@ + 'myUser', + 'external_api_password' => 'myPass', + 'external_api_token' => 'mySecretToken', +]; diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..36a1804 --- /dev/null +++ b/public/index.php @@ -0,0 +1,33 @@ +make(LoggerInterface::class); +$limiter = $container->make(RateLimiterInterface::class); + +$router = new Router($container); // vedi nota: router può ricevere container + +// register routes (path without version prefix) +$router->get('/users', 'UsersController@index'); +$router->post('/users', 'UsersController@create'); +$router->get('/example', 'ExampleController@test'); + +$request = new Request(); +$response = new Response(); + +// Rate limiting by IP address +$key = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; +if (!$limiter->allow($key)) { + $retry = $limiter->getRetryAfter($key); + header('Retry-After: ' . $retry); + $logger->warning('Rate limit exceeded', ['ip' => $key]); + $response->json(['error' => 'Too Many Requests'], 429); +} + +$logger->info('Dispatching request', ['path' => $request->path(), 'method' => $request->method()]); +$router->dispatch($request, $response); \ No newline at end of file diff --git a/run_tests.sh b/run_tests.sh new file mode 100644 index 0000000..3d7f6ad --- /dev/null +++ b/run_tests.sh @@ -0,0 +1 @@ +./vendor/bin/phpunit --bootstrap vendor/autoload.php tests/Unit/RateLimiterTest.php diff --git a/src/Controllers/ExampleController.php b/src/Controllers/ExampleController.php new file mode 100644 index 0000000..d8680a1 --- /dev/null +++ b/src/Controllers/ExampleController.php @@ -0,0 +1,23 @@ + 'Bearer ' . Config::secret('external_api_token') + ]); + + return $res->json([ + 'external_api_response' => $response + ]); + } +} diff --git a/src/Controllers/v1/UsersController.php b/src/Controllers/v1/UsersController.php new file mode 100644 index 0000000..e876f9b --- /dev/null +++ b/src/Controllers/v1/UsersController.php @@ -0,0 +1,26 @@ +externalService = $externalService; + } + + public function index(Request $req, Response $res) { + return $res->json([ + 'users' => ['Mario', 'Luigi'] + ]); + } + + public function create(Request $req, Response $res) { + $data = $req->body(); + return $res->json([ + 'created' => $data + ], 201); + } +} diff --git a/src/Core/Config.php b/src/Core/Config.php new file mode 100644 index 0000000..07de903 --- /dev/null +++ b/src/Core/Config.php @@ -0,0 +1,37 @@ + */ + private $bindings = []; + + /** @var array */ + private $instances = []; + + /** + * Bind an abstract name (interface or key) to a concrete implementation. + * $concrete can be: + * - a callable: function(Container $c) { return new Foo(); } + * - a string class name: 'App\\Foo' + * + * @param string $abstract + * @param callable|string $concrete + * @return void + */ + public function bind(string $abstract, $concrete): void { + $this->bindings[$abstract] = $concrete; + } + + /** + * Bind as singleton. The first resolved instance is cached and returned afterwards. + * + * @param string $abstract + * @param callable|string $concrete + * @return void + */ + public function singleton(string $abstract, $concrete): void { + $this->bindings[$abstract] = $concrete; + // mark as singleton with null placeholder + $this->instances[$abstract] = null; + } + + /** + * Resolve an abstract to an instance. + * + * @param string $abstract + * @return mixed + * @throws \Exception + */ + public function make(string $abstract) { + // return existing singleton instance if already created + if (array_key_exists($abstract, $this->instances) && $this->instances[$abstract] !== null) { + return $this->instances[$abstract]; + } + + if (!isset($this->bindings[$abstract])) { + // if no binding, try to instantiate the abstract directly if it's a class + if (class_exists($abstract)) { + $object = $this->build($abstract); + } else { + throw new \Exception("No binding found for [{$abstract}]"); + } + } else { + $concrete = $this->bindings[$abstract]; + + if (is_callable($concrete)) { + // factory receives the container + $object = $concrete($this); + } elseif (is_string($concrete) && class_exists($concrete)) { + $object = $this->build($concrete); + } else { + throw new \Exception("Invalid binding for [{$abstract}]"); + } + } + + // if abstract was registered as singleton, cache the instance + if (array_key_exists($abstract, $this->instances)) { + $this->instances[$abstract] = $object; + } + + return $object; + } + + /** + * Build an instance of the given class resolving constructor dependencies. + * + * @param string $class + * @return object + * @throws \Exception + */ + private function build(string $class) { + $reflector = new \ReflectionClass($class); + + if (!$reflector->isInstantiable()) { + throw new \Exception("Class {$class} is not instantiable"); + } + + $constructor = $reflector->getConstructor(); + if (is_null($constructor)) { + return new $class(); + } + + $params = $constructor->getParameters(); + $dependencies = []; + + foreach ($params as $param) { + $type = $param->getType(); + + // If parameter has a class/interface type, resolve it from container + if ($type && !$type->isBuiltin()) { + $depClass = $type->getName(); + $dependencies[] = $this->make($depClass); + continue; + } + + // If default value is available, use it + if ($param->isDefaultValueAvailable()) { + $dependencies[] = $param->getDefaultValue(); + continue; + } + + // Cannot resolve the dependency + $name = $param->getName(); + throw new \Exception("Unresolvable dependency [\${$name}] in class {$class}"); + } + + return $reflector->newInstanceArgs($dependencies); + } +} diff --git a/src/Core/HttpClient.php b/src/Core/HttpClient.php new file mode 100644 index 0000000..39968e3 --- /dev/null +++ b/src/Core/HttpClient.php @@ -0,0 +1,45 @@ + 10.0, + 'http_errors' => false + ]); + } + return self::$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 static function post(string $url, $data = null, array $headers = []) { + try { + $options = ['headers' => $headers]; + if ($data !== null) $options['json'] = $data; + $resp = self::client()->request('POST', $url, $options); + return [ + 'status' => $resp->getStatusCode(), + 'body' => json_decode($resp->getBody()->getContents(), true) + ]; + } catch (RequestException $e) { + return ['status' => 500, 'body' => null, 'error' => $e->getMessage()]; + } + } +} diff --git a/src/Core/LoggerFactory.php b/src/Core/LoggerFactory.php new file mode 100644 index 0000000..6707247 --- /dev/null +++ b/src/Core/LoggerFactory.php @@ -0,0 +1,19 @@ +setFormatter(new JsonFormatter()); + $logger->pushHandler($handler); + $logger->pushProcessor(new UidProcessor()); + return $logger; + } +} diff --git a/src/Core/RateLimiter/FileRateLimiter.php b/src/Core/RateLimiter/FileRateLimiter.php new file mode 100644 index 0000000..a60a1dd --- /dev/null +++ b/src/Core/RateLimiter/FileRateLimiter.php @@ -0,0 +1,84 @@ +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); + } + + private function fileForKey(string $key): string { + return $this->dir . '/rl_' . md5($key) . '.json'; + } + + public function allow(string $key): bool { + $file = $this->fileForKey($key); + $now = time(); + + $data = ['tokens' => $this->requests, 'last' => $now]; + if (file_exists($file)) { + $fp = fopen($file, 'c+'); + if (!$fp) return true; + flock($fp, LOCK_EX); + $contents = stream_get_contents($fp); + rewind($fp); + $data = $contents ? json_decode($contents, true) : $data; + + // refill tokens + $elapsed = $now - ($data['last'] ?? $now); + $rate = $this->requests / $this->window; + $refill = floor($elapsed * $rate); + $data['tokens'] = min($this->requests, ($data['tokens'] ?? $this->requests) + $refill); + $data['last'] = $now; + + if ($data['tokens'] > 0) { + $data['tokens']--; + ftruncate($fp, 0); + fwrite($fp, json_encode($data)); + fflush($fp); + flock($fp, LOCK_UN); + fclose($fp); + return true; + } else { + ftruncate($fp, 0); + fwrite($fp, json_encode($data)); + fflush($fp); + flock($fp, LOCK_UN); + fclose($fp); + return false; + } + } else { + $fp = fopen($file, 'w'); + if (!$fp) return true; + flock($fp, LOCK_EX); + $data = ['tokens' => $this->requests - 1, 'last' => $now]; + fwrite($fp, json_encode($data)); + fflush($fp); + flock($fp, LOCK_UN); + fclose($fp); + return true; + } + } + + public function getRetryAfter(string $key): int { + $file = $this->fileForKey($key); + if (!file_exists($file)) return 0; + $data = json_decode(file_get_contents($file), true); + $tokens = $data['tokens'] ?? 0; + if ($tokens > 0) return 0; + $last = $data['last'] ?? time(); + $elapsed = time() - $last; + $remaining = max(0, $this->window - $elapsed); + return $remaining; + } +} diff --git a/src/Core/RateLimiter/InMemoryRateLimiter.php b/src/Core/RateLimiter/InMemoryRateLimiter.php new file mode 100644 index 0000000..8a8fa18 --- /dev/null +++ b/src/Core/RateLimiter/InMemoryRateLimiter.php @@ -0,0 +1,42 @@ +requests = $requests; + $this->window = $window; + } + + public function allow(string $key): bool { + $now = time(); + if (!isset($this->state[$key])) { + $this->state[$key] = ['tokens' => $this->requests - 1, 'last' => $now]; + return true; + } + $data = $this->state[$key]; + $elapsed = $now - $data['last']; + $rate = $this->requests / $this->window; + $refill = floor($elapsed * $rate); + $data['tokens'] = min($this->requests, $data['tokens'] + $refill); + $data['last'] = $now; + if ($data['tokens'] > 0) { + $data['tokens']--; + $this->state[$key] = $data; + return true; + } + $this->state[$key] = $data; + return false; + } + + public function getRetryAfter(string $key): int { + if (!isset($this->state[$key])) return 0; + $data = $this->state[$key]; + if ($data['tokens'] > 0) return 0; + $elapsed = time() - $data['last']; + return max(0, $this->window - $elapsed); + } +} diff --git a/src/Core/RateLimiter/RateLimiterInterface.php b/src/Core/RateLimiter/RateLimiterInterface.php new file mode 100644 index 0000000..37ca9b9 --- /dev/null +++ b/src/Core/RateLimiter/RateLimiterInterface.php @@ -0,0 +1,7 @@ +container = $container; + } + + public function register($method, $path, $handler) { + $this->routes[$method][$path] = $handler; + } + + public function get($path, $handler) { $this->register('GET', $path, $handler); } + public function post($path, $handler) { $this->register('POST', $path, $handler); } + + public function dispatch(Request $req, Response $res) { + $method = $req->method(); + $path = $req->path(); + + // extract version prefix /v1/... + if (preg_match('#^/v([0-9]+)(/.*)?$#', $path, $m)) { + $version = $m[1]; + $pathWithoutVersion = $m[2] ?? '/'; + } else { + $version = '1'; + $pathWithoutVersion = $path; + } + + // try exact route with versioned namespace + $routeKey = $pathWithoutVersion; + if (!isset($this->routes[$method][$routeKey])) { + return $res->json(['error' => 'Not found'], 404); + } + + $handler = $this->routes[$method][$routeKey]; + // handler can be 'UsersController@index' or 'Controllers\\UsersController@index' + if (strpos($handler, '@') !== false) { + list($class, $function) = explode('@', $handler); + // if class not namespaced, prefix with Controllers\V{n}\ + if (strpos($class, '\\') === false) { + $class = "Controllers\\V{$version}\\" . $class; + } + } else { + return $res->json(['error' => 'Invalid handler'], 500); + } + + if (!class_exists($class)) { + return $res->json(['error' => 'Controller not found'], 500); + } + + // snippet inside Router::dispatch + $controller = $this->container->make($class); + return $controller->$function($req, $res); + } +} diff --git a/src/Helpers/Json.php b/src/Helpers/Json.php new file mode 100644 index 0000000..dc49983 --- /dev/null +++ b/src/Helpers/Json.php @@ -0,0 +1,109 @@ +error('JSON encode error', ['error' => $e->getMessage()]); + } + // fallback minimale: non esporre dettagli sensibili + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: application/json; charset=utf-8'); + } + echo json_encode(['error' => 'Internal Server Error']); + } + + exit; + } + + /** + * Invia un errore JSON standardizzato. + * + * @param string|array $message + * @param int $status + * @param LoggerInterface|null $logger + * @return void + */ + public static function sendError($message = 'Bad Request', int $status = 400, ?LoggerInterface $logger = null): void + { + $payload = [ + 'error' => is_array($message) ? $message : ['message' => $message] + ]; + + if ($logger) { + $logger->warning('API error response', ['status' => $status, 'payload' => $payload]); + } + + self::send($payload, $status); + } + + /** + * Legge e decodifica il body JSON della richiesta. + * + * @param bool $assoc + * @param int $maxBytes limite in byte per proteggere da payload troppo grandi + * @return mixed|null + */ + public static function readJsonBody(bool $assoc = true, int $maxBytes = 1048576) + { + $raw = file_get_contents('php://input'); + + if ($raw === false || $raw === '') { + return null; + } + + if (strlen($raw) > $maxBytes) { + throw new \RuntimeException('Payload too large', 413); + } + + try { + return json_decode($raw, $assoc, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \InvalidArgumentException('Invalid JSON payload', 400); + } + } + + /** + * Risposta per errori di validazione con formato coerente. + * + * @param array $errors mappa campo => messaggi + * @param int $status + * @return void + */ + public static function validationError(array $errors, int $status = 422): void + { + $payload = [ + 'error' => 'validation_failed', + 'details' => $errors + ]; + self::send($payload, $status); + } +} diff --git a/src/Services/ExternalApiService.php b/src/Services/ExternalApiService.php new file mode 100644 index 0000000..df07aa4 --- /dev/null +++ b/src/Services/ExternalApiService.php @@ -0,0 +1,14 @@ + 'Bearer ' . $token]; + return HttpClient::get($url, $headers); + } +} diff --git a/struttura-soluzione.txt b/struttura-soluzione.txt new file mode 100644 index 0000000..272e60f --- /dev/null +++ b/struttura-soluzione.txt @@ -0,0 +1,53 @@ +/var/www/html/api/ +│ +├── public/ +│ └── index.php +│ +├── src/ +│ ├── Controllers/ +│ │ ├── V1/ +│ │ │ └── UsersController.php +│ │ └── ExampleController.php +│ │ +│ ├── Services/ +│ │ └── ExternalApiService.php +│ │ +│ ├── Core/ +│ │ ├── Router.php +│ │ ├── Request.php +│ │ ├── Response.php +│ │ ├── Config.php +│ │ ├── HttpClient.php +│ │ ├── LoggerFactory.php +│ │ └── RateLimiter/ +│ │ ├── RateLimiterInterface.php +│ │ ├── FileRateLimiter.php +│ │ └── InMemoryRateLimiter.php +│ │ +│ └── Helpers/ +│ └── Json.php +│ +├── config/ +│ ├── config.php +│ ├── config.php.template +│ ├── secrets.php +│ └── secrets.php.template +│ +├── tests/ +│ └── Unit/ +│ └── RateLimiterTest.php +│ +├── composer.json +└── .gitignore + + +Piccoli accorgimenti operativi +Permessi: la cartella di storage per il rate limiter deve essere scrivibile dall’utente Apache (www-data o apache). + +Sicurezza: non loggare mai i segreti; filtra i campi sensibili prima di loggare. + +Performance: file‑based va bene per carichi moderati; se il traffico cresce, sostituisci FileRateLimiter con una soluzione in memoria/distribuita. + +Error handling: centralizza gestione eccezioni e ritorna JSON coerente con codici HTTP. + +Versioning: quando aggiungi V2, crea src/Controllers/V2/... e registra le rotte nello stesso modo; il router selezionerà la versione corretta. \ No newline at end of file diff --git a/tests/Unit/RateLimiterTest.php b/tests/Unit/RateLimiterTest.php new file mode 100644 index 0000000..35d2ce4 --- /dev/null +++ b/tests/Unit/RateLimiterTest.php @@ -0,0 +1,36 @@ +dir = sys_get_temp_dir() . '/api_rate_limit_test'; + if (is_dir($this->dir)) { + array_map('unlink', glob("$this->dir/*")); + } else { + mkdir($this->dir, 0700, true); + } + } + + public function testAllowsRequestsUnderLimit(): void { + $limiter = new FileRateLimiter($this->dir); + $key = 'test-client'; + $allowed = 0; + for ($i = 0; $i < 5; $i++) { + if ($limiter->allow($key)) $allowed++; + } + $this->assertGreaterThan(0, $allowed); + } + + public function testBlocksWhenExceeded(): void { + $limiter = new FileRateLimiter($this->dir); + $key = 'test-client-2'; + $requests = (int) \Core\Config::get('rate_limit_requests', 5); + for ($i = 0; $i < $requests; $i++) { + $this->assertTrue($limiter->allow($key)); + } + $this->assertFalse($limiter->allow($key)); + } +} diff --git a/tests/Unit/SomeControllerTest.php b/tests/Unit/SomeControllerTest.php new file mode 100644 index 0000000..9521f5b --- /dev/null +++ b/tests/Unit/SomeControllerTest.php @@ -0,0 +1,13 @@ +assertTrue($limiter->allow('client1')); + $this->assertTrue($limiter->allow('client1')); + $this->assertFalse($limiter->allow('client1')); + } +}