add initial solution (made with copilot)

This commit is contained in:
2026-07-13 12:05:43 +02:00
commit 250ee892dc
25 changed files with 860 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/config/config.php
/config/secrets.php
/vendor/
/tests/_output/
+3
View File
@@ -0,0 +1,3 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ public/index.php [QSA,L]
+50
View File
@@ -0,0 +1,50 @@
<?php
// bootstrap.php
// Posizione consigliata: project_root/bootstrap.php
require_once __DIR__ . '/vendor/autoload.php';
use Core\Container;
use Core\Config;
use Core\RateLimiter\RateLimiterInterface;
use Core\RateLimiter\FileRateLimiter;
use Core\RateLimiter\InMemoryRateLimiter;
use Psr\Log\LoggerInterface;
use Core\LoggerFactory;
use Services\ExternalApiService;
$container = new Container();
// Rate limiter driver configurabile via config or env: 'file' or 'memory'
$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);
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;
+24
View File
@@ -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/"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
'external_api_base_url' => '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,
];
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'external_api_username' => 'myUser',
'external_api_password' => 'myPass',
'external_api_token' => 'mySecretToken',
];
+33
View File
@@ -0,0 +1,33 @@
<?php
$container = require __DIR__ . '/../bootstrap.php';
use Core\Router;
use Core\Request;
use Core\Response;
use Core\RateLimiter\RateLimiterInterface;
use Psr\Log\LoggerInterface;
$logger = $container->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);
+1
View File
@@ -0,0 +1 @@
./vendor/bin/phpunit --bootstrap vendor/autoload.php tests/Unit/RateLimiterTest.php
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Controllers;
use Core\Request;
use Core\Response;
use Core\Config;
use Core\HttpClient;
class ExampleController {
public function test(Request $req, Response $res) {
$url = Config::get('external_api_base_url') . '/status';
$response = HttpClient::get($url, [
'Authorization' => 'Bearer ' . Config::secret('external_api_token')
]);
return $res->json([
'external_api_response' => $response
]);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Controllers;
use Core\Request;
use Core\Response;
class UsersController {
private $externalService;
public function __construct(\Services\ExternalApiService $externalService) {
$this->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);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace Core;
class Config {
private static $config;
private static $secrets;
private static function loadConfig() {
if (!self::$config) {
$file = __DIR__ . '/../../config/config.php';
self::$config = file_exists($file) ? require $file : [];
}
}
private static function loadSecrets() {
if (!self::$secrets) {
$file = __DIR__ . '/../../config/secrets.php';
self::$secrets = file_exists($file) ? require $file : [];
}
}
public static function get($key, $default = null) {
self::loadConfig();
if (getenv(strtoupper($key)) !== false) {
return getenv(strtoupper($key));
}
return self::$config[$key] ?? $default;
}
public static function secret($key, $default = null) {
self::loadSecrets();
if (getenv(strtoupper($key)) !== false) {
return getenv(strtoupper($key));
}
return self::$secrets[$key] ?? $default;
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace Core;
/**
* Simple service container / dependency injector.
*
* - bind($abstract, $concrete): registra una factory, callable o nome classe.
* - singleton($abstract, $concrete): come bind ma mantiene l'istanza.
* - make($abstract): risolve e restituisce l'istanza.
*
* Nota: usa reflection per risolvere le dipendenze del costruttore.
*/
class Container {
/** @var array<string,mixed> */
private $bindings = [];
/** @var array<string,mixed> */
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);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Core;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
class HttpClient {
private static $client;
private static function client() {
if (!self::$client) {
self::$client = new Client([
'timeout' => 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()];
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Core;
use Monolog\Logger;
use Monolog\Handler\SyslogHandler;
use Monolog\Processor\UidProcessor;
use Monolog\Formatter\JsonFormatter;
class LoggerFactory {
public static function create(string $name = null): Logger {
$ident = $name ?? Config::get('log_ident', 'api');
$logger = new Logger($ident);
$handler = new SyslogHandler($ident, LOG_USER);
$handler->setFormatter(new JsonFormatter());
$logger->pushHandler($handler);
$logger->pushProcessor(new UidProcessor());
return $logger;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Core\RateLimiter;
use Core\Config;
class FileRateLimiter implements RateLimiterInterface {
private $dir;
private $requests;
private $window;
public function __construct(string $storageDir = null) {
$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);
}
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;
}
}
@@ -0,0 +1,42 @@
<?php
namespace Core\RateLimiter;
class InMemoryRateLimiter implements RateLimiterInterface {
private $requests;
private $window;
private $state = [];
public function __construct(int $requests = 100, int $window = 60) {
$this->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);
}
}
@@ -0,0 +1,7 @@
<?php
namespace Core\RateLimiter;
interface RateLimiterInterface {
public function allow(string $key): bool;
public function getRetryAfter(string $key): int;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Core;
class Request {
public function method() {
return $_SERVER['REQUEST_METHOD'];
}
public function path() {
return strtok($_SERVER['REQUEST_URI'], '?');
}
public function body() {
return json_decode(file_get_contents('php://input'), true);
}
public function query() {
return $_GET;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Core;
class Response {
public function json($data, $status = 200) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Core;
class Router {
private $routes = [];
public function __construct(\Core\Container $container) {
$this->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);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace Helpers;
use Psr\Log\LoggerInterface;
class Json
{
/**
* Invia una risposta JSON coerente.
*
* @param mixed $data
* @param int $status
* @param bool $pretty
* @param LoggerInterface|null $logger opzionale per loggare errori di encoding
* @return void
*/
public static function send($data, int $status = 200, bool $pretty = false, ?LoggerInterface $logger = null): void
{
if (!headers_sent()) {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
}
$options = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
if ($pretty) {
$options |= JSON_PRETTY_PRINT;
}
try {
$json = json_encode($data, $options | JSON_THROW_ON_ERROR);
echo $json;
} catch (\JsonException $e) {
if ($logger) {
$logger->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);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
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');
$headers = ['Authorization' => 'Bearer ' . $token];
return HttpClient::get($url, $headers);
}
}
+53
View File
@@ -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 dallutente Apache (www-data o apache).
Sicurezza: non loggare mai i segreti; filtra i campi sensibili prima di loggare.
Performance: filebased 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.
+36
View File
@@ -0,0 +1,36 @@
<?php
use PHPUnit\Framework\TestCase;
use Core\RateLimiter\FileRateLimiter;
final class RateLimiterTest extends TestCase {
private $dir;
protected function setUp(): void {
$this->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));
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
use PHPUnit\Framework\TestCase;
use Core\RateLimiter\RateLimiterInterface;
use Core\RateLimiter\InMemoryRateLimiter;
final class SomeControllerTest extends TestCase {
public function testRateLimitedPath() {
$limiter = new InMemoryRateLimiter(2, 60);
$this->assertTrue($limiter->allow('client1'));
$this->assertTrue($limiter->allow('client1'));
$this->assertFalse($limiter->allow('client1'));
}
}