diff --git a/MILESTONES.md b/MILESTONES.md index 84dba23..5e5c50c 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -131,7 +131,7 @@ Phred supports REST and JSON:API via env setting; batteries-included defaults, s * Establish testing structure with Codeception (unit, integration, API suites). * Add fixtures/factories via Faker for examples. * PHPStan level selection and baseline; code style via php-cs-fixer ruleset. - * Pre‑commit hooks (e.g., GrumPHP) optional. + * Pre‑commit hooks (e.g., GrumPHP) or custom git hooks for staged files. * Define TestRunnerInterface and a Codeception adapter; otherwise, state tests are run via Composer script only. * Acceptance: * `composer test` runs green across suites; static analysis passes. diff --git a/composer.json.bak b/composer.json.bak index 081e697..8dc1d19 100644 --- a/composer.json.bak +++ b/composer.json.bak @@ -1,57 +1,5 @@ { - "name": "getphred/phred", - "description": "Phred Framework", - "type": "project", - "require": { - "php": "^8.2", - "crell/api-problem": "^3.7", - "filp/whoops": "^2.15", - "getphred/eyrie": "dev-main", - "getphred/flagpole": "dev-main", - "getphred/pairity": "dev-main", - "laravel/serializable-closure": "^1.3", - "lcobucci/jwt": "^5.2", - "league/flysystem": "^3.24", - "middlewares/cors": "^0.4.0", - "monolog/monolog": "^3.5", - "nyholm/psr7": "^1.8", - "nyholm/psr7-server": "^1.1", - "php-di/php-di": "^7.0", - "relay/relay": "^2.1", - "symfony/console": "^7.0", - "vlucas/phpdotenv": "^5.6", - "zircote/swagger-php": "^4.8" - }, - "require-dev": { - "codeception/codeception": "^5.1", - "codeception/module-asserts": "^3.0", - "codeception/module-phpbrowser": "^3.0", - "codeception/module-rest": "^3.3", - "fakerphp/faker": "^1.23", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5" - }, "autoload": { - "psr-4": { - "Phred\\": "src/", - "App\\": "app/", - "Modules\\": "modules/", - "Pairity\\": "vendor/getphred/pairity/src/", - "Project\\Modules\\Blog\\": "modules/Blog/", - "Project\\Modules\\StubTest\\": "modules/StubTest/" - } - }, - "autoload-dev": { - "psr-4": { - "Phred\\Tests\\": "tests/" - } - }, - "bin": [ - "phred" - ], - "config": { - "allow-plugins": { - "php-http/discovery": true - } + "psr-4": [] } -} +} \ No newline at end of file diff --git a/src/Http/Kernel.php b/src/Http/Kernel.php index f8d9654..9b8cf1c 100644 --- a/src/Http/Kernel.php +++ b/src/Http/Kernel.php @@ -58,13 +58,32 @@ final class Kernel $corsSettings->enableAllMethodsAllowed(); $corsSettings->enableAllHeadersAllowed(); - $middleware = [ + $middleware = []; + if (filter_var($config->get('APP_DEBUG', false), FILTER_VALIDATE_BOOLEAN)) { + $middleware[] = new class extends Middleware\Middleware { + public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface + { + self::$timings = []; // Reset timings for each request in debug mode + $response = $handler->handle($request); + $timings = self::getTimings(); + if (!empty($timings)) { + $encoded = json_encode($timings, JSON_UNESCAPED_SLASHES); + if ($encoded) { + $response = $response->withHeader('X-Phred-Timings', $encoded); + } + } + return $response; + } + }; + } + + $middleware = array_merge($middleware, [ // Security headers new Middleware\Security\SecureHeadersMiddleware($config), // CORS new \Middlewares\Cors(\Neomerx\Cors\Analyzer::instance($corsSettings)), new Middleware\ProblemDetailsMiddleware( - $config->get('APP_DEBUG', 'false') === 'true', + filter_var($config->get('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOLEAN), null, null, filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN) @@ -74,7 +93,8 @@ final class Kernel new Middleware\ContentNegotiationMiddleware(), new Middleware\RoutingMiddleware($this->dispatcher, $psr17), new Middleware\DispatchMiddleware($psr17), - ]; + ]); + $relay = new Relay($middleware); return $relay->handle($request); } diff --git a/src/Http/Middleware/ContentNegotiationMiddleware.php b/src/Http/Middleware/ContentNegotiationMiddleware.php index 7d4c7ee..47413dd 100644 --- a/src/Http/Middleware/ContentNegotiationMiddleware.php +++ b/src/Http/Middleware/ContentNegotiationMiddleware.php @@ -23,14 +23,17 @@ class ContentNegotiationMiddleware extends Middleware public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $cfg = $this->config ?? new DefaultConfig(); - $format = strtolower((string) $cfg->get('API_FORMAT', $cfg->get('api.format', 'rest'))); + $format = $this->profileSelf(function () use ($request) { + $cfg = $this->config ?? new DefaultConfig(); + $format = strtolower((string) $cfg->get('API_FORMAT', $cfg->get('api.format', 'rest'))); - // Optional: allow Accept header to override when JSON:API is explicitly requested - $neg = $this->negotiator ?? new DefaultErrorFormatNegotiator(); - if ($neg->apiFormat($request) === 'jsonapi') { - $format = 'jsonapi'; - } + // Optional: allow Accept header to override when JSON:API is explicitly requested + $neg = $this->negotiator ?? new DefaultErrorFormatNegotiator(); + if ($neg->apiFormat($request) === 'jsonapi') { + $format = 'jsonapi'; + } + return $format; + }); return $handler->handle($request->withAttribute(self::ATTR_API_FORMAT, $format)); } diff --git a/src/Http/Middleware/Middleware.php b/src/Http/Middleware/Middleware.php index 93c0066..af4a4fb 100644 --- a/src/Http/Middleware/Middleware.php +++ b/src/Http/Middleware/Middleware.php @@ -10,8 +10,48 @@ use Psr\Http\Server\RequestHandlerInterface; abstract class Middleware implements MiddlewareInterface { + /** @var array */ + protected static array $timings = []; + abstract public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface; + /** + * Wrap a handler and measure its execution time. + */ + protected function profile(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $start = microtime(true); + try { + return $handler->handle($request); + } finally { + $duration = microtime(true) - $start; + self::$timings[static::class] = (self::$timings[static::class] ?? 0) + $duration; + } + } + + /** + * Simple profiler for middleware that don't need to wrap the handler. + */ + protected function profileSelf(callable $callback): mixed + { + $start = microtime(true); + try { + return $callback(); + } finally { + $duration = microtime(true) - $start; + self::$timings[static::class] = (self::$timings[static::class] ?? 0) + $duration; + } + } + + /** + * Get all recorded timings. + * @return array + */ + public static function getTimings(): array + { + return self::$timings; + } + protected function json(array $data, int $status = 200): ResponseInterface { $response = new \Nyholm\Psr7\Response($status, ['Content-Type' => 'application/json']); diff --git a/src/Http/Middleware/ProblemDetailsMiddleware.php b/src/Http/Middleware/ProblemDetailsMiddleware.php index 4f51634..5e0afad 100644 --- a/src/Http/Middleware/ProblemDetailsMiddleware.php +++ b/src/Http/Middleware/ProblemDetailsMiddleware.php @@ -36,7 +36,7 @@ class ProblemDetailsMiddleware extends Middleware public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { try { - return $handler->handle($request); + return $this->profile($request, $handler); } catch (Throwable $e) { $useProblem = $this->shouldUseProblemDetails(); $format = $this->determineApiFormat($request); diff --git a/src/Http/Middleware/Security/SecureHeadersMiddleware.php b/src/Http/Middleware/Security/SecureHeadersMiddleware.php index 2d7e44c..0536dd7 100644 --- a/src/Http/Middleware/Security/SecureHeadersMiddleware.php +++ b/src/Http/Middleware/Security/SecureHeadersMiddleware.php @@ -20,7 +20,7 @@ final class SecureHeadersMiddleware extends Middleware public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $response = $handler->handle($request); + $response = $this->profile($request, $handler); // Standard security headers $response = $response->withHeader('X-Content-Type-Options', 'nosniff') diff --git a/src/Http/Support/DefaultErrorFormatNegotiator.php b/src/Http/Support/DefaultErrorFormatNegotiator.php index 9923994..0690e23 100644 --- a/src/Http/Support/DefaultErrorFormatNegotiator.php +++ b/src/Http/Support/DefaultErrorFormatNegotiator.php @@ -17,6 +17,7 @@ final class DefaultErrorFormatNegotiator implements ErrorFormatNegotiatorInterfa public function wantsHtml(ServerRequest $request): bool { $accept = $request->getHeaderLine('Accept'); - return $accept === '' || str_contains($accept, 'text/html'); + // Only return true if text/html is explicitly mentioned and is likely the preferred format + return str_contains($accept, 'text/html'); } } diff --git a/src/Support/Config.php b/src/Support/Config.php index edc5dad..4dd6960 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -90,4 +90,12 @@ final class Config // non-dotted: try exact file key return self::$store[$key] ?? null; } + + /** + * Clear the config store (useful for tests). + */ + public static function clear(): void + { + self::$store = null; + } } diff --git a/src/commands/create_command.php b/src/commands/create_command.php new file mode 100644 index 0000000..e416561 --- /dev/null +++ b/src/commands/create_command.php @@ -0,0 +1,67 @@ + [ + 'mode' => 'argument', + 'required' => true, + 'description' => 'Command name (e.g., hello:world)', + ], + '--description' => [ + 'mode' => 'option', + 'valueRequired' => true, + 'description' => 'Optional command description.', + ], + ]; + + public function handle(Input $input, Output $output): int + { + $name = trim((string) $input->getArgument('name')); + $description = $input->getOption('description') ?: 'Custom CLI command.'; + + if ($name === '') { + $output->writeln('Command name is required.'); + return 1; + } + + $root = getcwd(); + $commandsDir = $root . '/console/commands'; + + if (!is_dir($commandsDir)) { + @mkdir($commandsDir, 0777, true); + } + + // Convert name to StudlyCase for filename, e.g., hello:world -> HelloWorld.php + $filename = str_replace([':', '-', '_'], ' ', $name); + $filename = str_replace(' ', '', ucwords($filename)) . '.php'; + $path = $commandsDir . '/' . $filename; + + if (file_exists($path)) { + $output->writeln("Command file '$filename' already exists."); + return 1; + } + + $stub = file_get_contents(dirname(__DIR__) . '/stubs/command.stub'); + $code = strtr($stub, [ + '{{namespace}}' => '', // Global namespace for console/commands by default or project specific? + // bin/phred uses anonymous class require, so namespace is optional but good for structure. + '{{command}}' => $name, + '{{description}}' => $description, + ]); + + // Remove empty namespace line if present + $code = str_replace("namespace ;\n\n", "", $code); + + file_put_contents($path, $code); + $output->writeln("created console/commands/$filename"); + + return 0; + } +}; diff --git a/src/stubs/command.stub b/src/stubs/command.stub new file mode 100644 index 0000000..6bfb010 --- /dev/null +++ b/src/stubs/command.stub @@ -0,0 +1,23 @@ + ['mode' => 'argument', 'required' => true, 'description' => '...'], + // '--force' => ['mode' => 'flag', 'description' => '...'], + ]; + + public function handle(Input $input, Output $output): int + { + $output->writeln('{{command}} works!'); + return 0; + } +}; diff --git a/tests/Feature/SecurityTest.php b/tests/Feature/SecurityTest.php index 2127074..5877289 100644 --- a/tests/Feature/SecurityTest.php +++ b/tests/Feature/SecurityTest.php @@ -32,4 +32,20 @@ class SecurityTest extends TestCase $this->assertEquals('http://example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); } + + public function testProfilingHeadersPresentInDebug(): void + { + putenv('APP_DEBUG=true'); + $_ENV['APP_DEBUG'] = 'true'; + $_SERVER['APP_DEBUG'] = 'true'; + \Phred\Support\Config::clear(); + $kernel = new Kernel(); + $request = new ServerRequest('GET', '/_phred/health'); + + $response = $kernel->handle($request); + + $this->assertTrue($response->hasHeader('X-Phred-Timings')); + $timings = json_decode($response->getHeaderLine('X-Phred-Timings'), true); + $this->assertIsArray($timings); + } } diff --git a/tests/Feature/StubCompilationTest.php b/tests/Feature/StubCompilationTest.php new file mode 100644 index 0000000..a3f83b9 --- /dev/null +++ b/tests/Feature/StubCompilationTest.php @@ -0,0 +1,65 @@ +findStubs($stubRoot); + + foreach ($stubs as $stubPath) { + $content = file_get_contents($stubPath); + $compiled = $this->interpolateStub($content); + + $tmpFile = tempnam(sys_get_temp_dir(), 'stub_test_'); + file_put_contents($tmpFile, $compiled); + + $output = []; + $returnVar = 0; + exec("php -l " . escapeshellarg($tmpFile) . " 2>&1", $output, $returnVar); + + $this->assertSame(0, $returnVar, "Syntax error in stub: $stubPath\n" . implode("\n", $output)); + + unlink($tmpFile); + } + } + + private function findStubs(string $dir): array + { + $files = []; + $items = scandir($dir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') continue; + $path = $dir . '/' . $item; + if (is_dir($path)) { + $files = array_merge($files, $this->findStubs($path)); + } elseif (str_ends_with($item, '.stub')) { + $files[] = $path; + } + } + return $files; + } + + private function interpolateStub(string $content): string + { + $replacements = [ + '{{namespace}}' => 'App\\Test', + '{{viewNamespace}}' => 'App\\Views\\TestView', + '{{class}}' => 'TestClass', + '{{name}}' => 'TestName', + '{{moduleName}}' => 'TestModule', + '{{template}}' => 'test_template', + '{{params}}' => '$request', + '{{body}}' => 'return $request;', + '{{useView}}' => 'use App\\Views\\TestView;', + '$dollar' => '$', // For some stubs that use $dollar + ]; + + return strtr($content, $replacements); + } +}