Browse Source

refactor error handling strategy and fix CLI double output

michelphp 11 hours ago
parent
commit
8ecbc484e2

+ 2 - 2
functions/helpers_array.php

@@ -58,9 +58,9 @@ if (!function_exists('array_group_by')) {
         $result = [];
         foreach ($array as $value) {
             $group = $value;
-            if (is_array( $value)) {
+            if (is_array($value)) {
                 $group = $value[$key];
-            }elseif (is_object($value)) {
+            } elseif (is_object($value)) {
                 $group = $value->$key;
             }
             $result[$group][] = $value;

+ 17 - 2
functions/helpers_string.php

@@ -55,10 +55,25 @@ if (!function_exists('str_contains')) {
     }
 }
 
+if (!function_exists('human_readable_bytes')) {
+    function human_readable_bytes(int $size, int $precision = 2): string
+    {
+        if ($size <= 0) {
+            return '0 B';
+        }
+        $base = log($size, 1024);
+        $suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
+        $class = (int) floor($base);
+        return round(pow(1024, $base - $class), $precision) . ' ' . $suffixes[$class];
+    }
+}
+
 if (!function_exists('_m_convert')) {
+    /**
+     * @deprecated Use human_readable_bytes instead
+     */
     function _m_convert($size): string
     {
-        $unit = array('B', 'KB', 'MB', 'GB', 'TB', 'PB');
-        return @round($size / pow(1024, ($i = floor(log($size, 1024)))), 2) . ' ' . $unit[$i];
+        return human_readable_bytes((int) $size);
     }
 }

+ 18 - 26
src/App.php

@@ -4,8 +4,6 @@ declare(strict_types=1);
 
 namespace Michel\Framework\Core;
 
-use Michel\Resolver\Option;
-use Michel\Resolver\OptionsResolver;
 use Psr\Container\ContainerInterface;
 use Psr\Http\Message\ResponseFactoryInterface;
 use Psr\Http\Message\ServerRequestFactoryInterface;
@@ -24,30 +22,24 @@ final class App
 
     private function __construct(array $options)
     {
-        $resolver = new OptionsResolver([
-            Option::mixed('server_request')->validator(static function ($value) {
-                return $value instanceof \Closure;
-            }),
-            Option::mixed('server_request_factory')->validator(static function ($value) {
-                return $value instanceof \Closure;
-            }),
-            Option::mixed('response_factory')->validator(static function ($value) {
-                return $value instanceof \Closure;
-            }),
-            Option::mixed('container')->validator(static function ($value) {
-                return $value instanceof \Closure;
-            }),
-            Option::array('custom_environments')->validator(static function (array $value) {
-                $environmentsFiltered = array_filter($value, function ($value) {
-                    return is_string($value) === false;
-                });
-                if ($environmentsFiltered !== []) {
-                    throw new \InvalidArgumentException('custom_environments array values must be string only');
-                }
-                return true;
-            })->setOptional([]),
-        ]);
-        $this->options = $resolver->resolve($options);
+        $required = ['server_request', 'server_request_factory', 'response_factory', 'container'];
+        foreach ($required as $key) {
+            if (!isset($options[$key]) || !$options[$key] instanceof \Closure) {
+                 throw new \InvalidArgumentException(sprintf('The option "%s" is required and must be a Closure.', $key));
+            }
+        }
+        if (isset($options['custom_environments'])) {
+             $environmentsFiltered = array_filter($options['custom_environments'], function ($value) {
+                return is_string($value) === false;
+            });
+            if ($environmentsFiltered !== []) {
+                throw new \InvalidArgumentException('custom_environments array values must be string only');
+            }
+        } else {
+            $options['custom_environments'] = [];
+        }
+
+        $this->options = $options;
     }
 
     public static function initWithPath(string $path): void

+ 68 - 10
src/BaseKernel.php

@@ -10,9 +10,11 @@ use Michel\Framework\Core\Debug\DebugDataCollector;
 use Michel\Framework\Core\ErrorHandler\ErrorHandler;
 use Michel\Framework\Core\ErrorHandler\ExceptionHandler;
 use Michel\Framework\Core\Handler\RequestHandler;
+use Michel\Framework\Core\Http\Exception\HttpException;
 use Michel\Framework\Core\Http\Exception\HttpExceptionInterface;
 use InvalidArgumentException;
-use Michel\Framework\Core\Routing\ControllerFinder;
+use Michel\Framework\Core\Finder\ControllerFinder;
+use Michel\Package\PackageInterface;
 use Psr\Container\ContainerInterface;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
@@ -37,7 +39,7 @@ use function sprintf;
 abstract class BaseKernel
 {
     private const DEFAULT_ENV = 'prod';
-    public const VERSION = '1.0.0-alpha';
+    public const VERSION = '0.0.1-alpha';
     public const NAME = 'MICHEL';
     private const DEFAULT_ENVIRONMENTS = [
         'dev',
@@ -74,7 +76,11 @@ abstract class BaseKernel
             $request = $request->withAttribute('debug_collector', $this->debugDataCollector);
 
             $requestHandler = new RequestHandler($this->container, $this->middlewareCollection);
-            return $requestHandler->handle($request);
+            $response =  $requestHandler->handle($request);
+            if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 600) {
+                throw new HttpException($response->getStatusCode(), $response->getReasonPhrase());
+            }
+            return $response;
         } catch (Throwable $exception) {
             if (!$exception instanceof HttpExceptionInterface) {
                 $this->logException($exception, $request);
@@ -149,8 +155,8 @@ abstract class BaseKernel
             throw new InvalidArgumentException('The log dir is empty, please set it in the Kernel.');
         }
 
-        if (!is_dir($logDir)) {
-            @mkdir($logDir, 0777, true);
+        if (!is_dir($logDir) && !mkdir($logDir, 0777, true) && !is_dir($logDir)) {
+            throw new \RuntimeException(sprintf('Directory "%s" was not created', $logDir));
         }
         if ($logFile === null) {
             $logFile = $this->getEnv() . '.log';
@@ -201,19 +207,19 @@ abstract class BaseKernel
 
     private function configureErrorHandling(): void
     {
+        ini_set("log_errors", '1');
+        ini_set("error_log", $this->getLogDir() . '/error_log.log');
+
         if ($this->getEnv() === 'dev') {
             ErrorHandler::register();
             return;
         }
-        ini_set("log_errors", '1');
-        ini_set("error_log", $this->getLogDir() . '/error_log.log');
 
         ini_set("display_startup_errors", '0');
         ini_set("display_errors", '0');
         ini_set("html_errors", '0');
-        ini_set("track_errors", '0');
 
-        error_reporting(E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR);
+        error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
     }
 
     private function configureTimezone(): void
@@ -237,7 +243,7 @@ abstract class BaseKernel
 
     private function loadDependencies(): void
     {
-        list($services, $parameters, $listeners, $routes, $commands, $packages, $controllers) = (new Dependency($this))->load();
+        list($services, $parameters, $listeners, $routes, $commands, $packages, $controllers) = $this->loadDependenciesConfiguration();
         $definitions = array_merge(
             $parameters,
             $services,
@@ -271,6 +277,58 @@ abstract class BaseKernel
         unset($services, $parameters, $listeners, $routes, $commands, $packages, $controllers, $definitions);
     }
 
+    private function loadDependenciesConfiguration(): array
+    {
+        $services = $this->loadConfigurationIfExists('services.php');
+        $parameters = $this->loadParameters();
+        $listeners = $this->loadConfigurationIfExists('listeners.php');
+        $routes = $this->loadConfigurationIfExists('routes.php');
+        $commands = $this->loadConfigurationIfExists('commands.php');
+        $controllers = $this->loadConfigurationIfExists('controllers.php');
+        $packages = $this->getPackages();
+        foreach ($packages as $package) {
+            $services = array_merge($package->getDefinitions(), $services);
+            $parameters = array_merge($package->getParameters(), $parameters);
+            $listeners = array_merge_recursive($package->getListeners(), $listeners);
+            $routes = array_merge($package->getRoutes(), $routes);
+            $commands = array_merge($package->getCommandSources(), $commands);
+            $controllers = array_merge($package->getControllerSources(), $controllers);
+        }
+
+        return [$services, $parameters, $listeners, $routes, $commands, $packages, $controllers];
+    }
+
+    /**
+     * @return array<PackageInterface>
+     */
+    private function getPackages(): array
+    {
+        $packagesName = $this->loadConfigurationIfExists('packages.php');
+        $packages = [];
+        foreach ($packagesName as $packageName => $envs) {
+            if (!in_array($this->getEnv(), $envs)) {
+                continue;
+            }
+            $packages[] = new $packageName();
+        }
+        return $packages;
+    }
+
+    private function loadParameters(): array
+    {
+        $parameters = $this->loadConfigurationIfExists('parameters.php');
+        $parameters['michel.environment'] = $this->getEnv();
+        $parameters['michel.debug'] = $this->isDebug();
+        $parameters['michel.project_dir'] = $this->getProjectDir();
+        $parameters['michel.cache_dir'] = $this->getCacheDir();
+        $parameters['michel.logs_dir'] = $this->getLogDir();
+        $parameters['michel.config_dir'] = $this->getConfigDir();
+        $parameters['michel.public_dir'] = $this->getPublicDir();
+        $parameters['michel.current_cache'] = $this->getEnv() === 'dev' ? null : $this->getCacheDir();
+
+        return $parameters;
+    }
+
     private static function getAvailableEnvironments(): array
     {
         return array_unique(array_merge(self::DEFAULT_ENVIRONMENTS, App::getCustomEnvironments()));

+ 0 - 73
src/Dependency.php

@@ -1,73 +0,0 @@
-<?php
-
-namespace Michel\Framework\Core;
-
-use Michel\Package\PackageInterface;
-
-final class Dependency
-{
-
-    private BaseKernel $baseKernel;
-
-    public function __construct(BaseKernel $baseKernel)
-    {
-        $this->baseKernel = $baseKernel;
-    }
-    public function load(): array
-    {
-        $services = $this->loadConfigurationIfExists('services.php');
-        $parameters = $this->loadParameters('parameters.php');
-        $listeners = $this->loadConfigurationIfExists('listeners.php');
-        $routes = $this->loadConfigurationIfExists('routes.php');
-        $commands = $this->loadConfigurationIfExists('commands.php');
-        $controllers = $this->loadConfigurationIfExists('controllers.php');
-        $packages = $this->getPackages();
-        foreach ($packages as $package) {
-            $services = array_merge($package->getDefinitions(), $services);
-            $parameters = array_merge($package->getParameters(), $parameters);
-            $listeners = array_merge_recursive($package->getListeners(), $listeners);
-            $routes = array_merge($package->getRoutes(), $routes);
-            $commands = array_merge($package->getCommandSources(), $commands);
-            $controllers = array_merge($package->getControllerSources(), $controllers);
-        }
-
-        return [$services, $parameters, $listeners, $routes, $commands, $packages, $controllers];
-    }
-
-    /**
-     * @return array<PackageInterface>
-     */
-    private function getPackages(): array
-    {
-        $packagesName = $this->loadConfigurationIfExists('packages.php');
-        $packages = [];
-        foreach ($packagesName as $packageName => $envs) {
-            if (!in_array($this->baseKernel->getEnv(), $envs)) {
-                continue;
-            }
-            $packages[] = new $packageName();
-        }
-        return $packages;
-    }
-
-    private function loadConfigurationIfExists(string $fileName): array
-    {
-        return $this->baseKernel->loadConfigurationIfExists($fileName);
-    }
-
-    private function loadParameters(string $fileName): array
-    {
-        $parameters = $this->loadConfigurationIfExists($fileName);
-
-        $parameters['michel.environment'] = $this->baseKernel->getEnv();
-        $parameters['michel.debug'] = $this->baseKernel->isDebug();
-        $parameters['michel.project_dir'] = $this->baseKernel->getProjectDir();
-        $parameters['michel.cache_dir'] = $this->baseKernel->getCacheDir();
-        $parameters['michel.logs_dir'] = $this->baseKernel->getLogDir();
-        $parameters['michel.config_dir'] = $this->baseKernel->getConfigDir();
-        $parameters['michel.public_dir'] = $this->baseKernel->getPublicDir();
-        $parameters['michel.current_cache'] = $this->baseKernel->getEnv() === 'dev' ? null : $this->baseKernel->getCacheDir();
-
-        return $parameters;
-    }
-}

+ 22 - 1
src/ErrorHandler/ErrorHandler.php

@@ -3,6 +3,8 @@
 namespace Michel\Framework\Core\ErrorHandler;
 
 use ErrorException;
+use Throwable;
+use function error_reporting;
 use function in_array;
 use function set_error_handler;
 use const E_DEPRECATED;
@@ -14,13 +16,14 @@ final class ErrorHandler
 
     public static function register(): self
     {
-        \error_reporting(E_ALL);
+        error_reporting(E_ALL);
         ini_set("display_errors", '0');
         ini_set("display_startup_errors", '0');
         ini_set('html_errors', (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') ? '0' : '1');
 
         $handler = new self();
         set_error_handler($handler);
+        set_exception_handler([$handler, 'handleException']);
         return $handler;
     }
 
@@ -37,9 +40,27 @@ final class ErrorHandler
         throw new ErrorException($message, 0, $level, $file, $line);
     }
 
+    public function handleException(Throwable $exception): void
+    {
+        $message = sprintf(
+            "Uncaught Exception: %s\nIn file: %s:%d\nStack trace:\n%s\n",
+            $exception->getMessage(),
+            $exception->getFile(),
+            $exception->getLine(),
+            $exception->getTraceAsString()
+        );
+
+        if (ini_get('error_log')) {
+            error_log($message);
+        }
+        echo $message;
+        exit(1);
+    }
+
     public function clean(): void
     {
         restore_error_handler();
+        restore_exception_handler();
     }
 
     /**

+ 1 - 0
src/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php

@@ -35,6 +35,7 @@ final class HtmlErrorRenderer
     public function __invoke(HttpExceptionInterface $exception): ResponseInterface
     {
         $response = $this->responseFactory->createResponse($exception->getStatusCode());
+        $response = $response->withHeader('Content-Type', 'text/html');
         if ($this->isDebug() === false) {
             $template = $this->findTemplate($exception->getStatusCode());
             if ($template !== null) {

+ 16 - 16
src/ErrorHandler/ExceptionHandler.php

@@ -6,8 +6,6 @@ use Michel\Framework\Core\ErrorHandler\ErrorRenderer\HtmlErrorRenderer;
 use Michel\Framework\Core\ErrorHandler\ErrorRenderer\JsonErrorRenderer;
 use Michel\Framework\Core\Http\Exception\HttpException;
 use Michel\Framework\Core\Http\Exception\HttpExceptionInterface;
-use Michel\Resolver\Option;
-use Michel\Resolver\OptionsResolver;
 use Psr\Http\Message\ResponseFactoryInterface;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
@@ -21,20 +19,22 @@ class ExceptionHandler
     public function __construct(ResponseFactoryInterface $responseFactory, array $options = [])
     {
         $this->responseFactory = $responseFactory;
-        $resolver = (new OptionsResolver(
-            [
-                Option::bool("debug", false),
-                Option::mixed("json_response", new JsonErrorRenderer($this->responseFactory, $options['debug']))
-                    ->validator(static function ($value) {
-                        return is_callable($value);
-                    }),
-                Option::mixed("html_response", new HtmlErrorRenderer($this->responseFactory, $options['debug']))
-                    ->validator(static function ($value) {
-                        return is_callable($value);
-                    }),
-            ]
-        ));
-        $this->options = $resolver->resolve($options);
+        
+        $debug = $options['debug'] ?? false;
+        
+        if (!isset($options['json_response'])) {
+            $options['json_response'] = new JsonErrorRenderer($this->responseFactory, $debug);
+        } elseif (!is_callable($options['json_response'])) {
+            throw new \InvalidArgumentException('Option "json_response" must be callable.');
+        }
+
+        if (!isset($options['html_response'])) {
+            $options['html_response'] = new HtmlErrorRenderer($this->responseFactory, $debug);
+        } elseif (!is_callable($options['html_response'])) {
+             throw new \InvalidArgumentException('Option "html_response" must be callable.');
+        }
+
+        $this->options = $options;
     }
 
     public function render(ServerRequestInterface $request, Throwable $exception): ResponseInterface

+ 1 - 1
src/Routing/ControllerFinder.php → src/Finder/ControllerFinder.php

@@ -1,6 +1,6 @@
 <?php
 
-namespace Michel\Framework\Core\Routing;
+namespace Michel\Framework\Core\Finder;
 
 use Michel\Framework\Core\Controller\Controller;
 

+ 8 - 3
tests/ControllerFinderTest.php

@@ -2,7 +2,7 @@
 
 namespace Test\Michel\Framework\Core;
 
-use Michel\Framework\Core\Routing\ControllerFinder;
+use Michel\Framework\Core\Finder\ControllerFinder;
 use Michel\UniTester\TestCase;
 use Test\Michel\Framework\Core\Controller\SampleControllerTest;
 use Test\Michel\Framework\Core\Controller\UserControllerTest;
@@ -50,10 +50,15 @@ class ControllerFinderTest extends TestCase
             $controllers = (new ControllerFinder([$targetDir], $cacheDir))->findControllerClasses();
             $this->assertCount(2, $controllers);
             $this->assertTrue(file_exists($fileCache));
-            $this->assertEquals([
+
+            $classes = require $fileCache;
+            $needles = [
                 SampleControllerTest::class,
                 UserControllerTest::class,
-            ], require $fileCache);
+            ];
+            rsort($classes);
+            rsort($needles);
+            $this->assertEquals($needles, $classes);
 
             $controllers = (new ControllerFinder([$targetDir], $cacheDir))->findControllerClasses();
             $this->assertCount(2, $controllers);