From eb882db0c88692fec2eab64c0d684eb9592fbafc Mon Sep 17 00:00:00 2001 From: kj Date: Mon, 7 Sep 2026 18:00:06 -0300 Subject: [PATCH] sync: 2026-09-07 70b1016 refactor(autoload): remove global exception handler a534613 refactor(request): propagate validation failures as exceptions 5f3fc16 feat(router): add error boundary with exception callback 9dbfd7d feat: add named parameter injection to resolve() --- autoload.php | 5 -- src/Libs/Request.php | 45 ++++++++-------- src/Libs/Router.php | 122 +++++++++++++++++++++++++++++++----------- src/Libs/Synapsis.php | 19 +++++-- 4 files changed, 126 insertions(+), 65 deletions(-) diff --git a/autoload.php b/autoload.php index 9765698..59f3712 100644 --- a/autoload.php +++ b/autoload.php @@ -12,8 +12,3 @@ spl_autoload_register(function ($className) { require_once $file; } }); - -set_exception_handler(function ($exception) { - echo "Uncaught exception: " , $exception->getMessage(), "\n"; - echo $exception->getTraceAsString(); -}); diff --git a/src/Libs/Request.php b/src/Libs/Request.php index c82ec00..8c347ad 100644 --- a/src/Libs/Request.php +++ b/src/Libs/Request.php @@ -2,6 +2,8 @@ namespace Libs; +use Exception; + /** * Request - DuckBrain * @@ -59,17 +61,20 @@ class Request extends Neuron } // Run configured validations - if (!$this->validate()) { - exit(); - } + $this->validate(); } /** * Starts the configured validation. * - * @return bool + * On failure the single error message is built with Validator::message() + * and handed to onInvalid(), which throws by default: failures travel up + * as exceptions instead of answering HTTP here. + * + * @return void + * @throws Exception When a configured rule fails. */ - public function validate(): bool + public function validate(): void { $actual = match ($_SERVER['REQUEST_METHOD']) { 'POST', 'PUT', 'PATCH', 'DELETE' => $this->{strtolower($_SERVER['REQUEST_METHOD'])}, @@ -88,7 +93,7 @@ class Request extends Neuron Validator::validateList(static::getRules(), $this->get) && Validator::validateList(static::rules(), $body) ) { - return true; + return; } $error = Validator::message( @@ -98,7 +103,6 @@ class Request extends Neuron ); static::onInvalid($error); - return false; } /** @@ -157,27 +161,20 @@ class Request extends Neuron /** * Function to execute when an invalid value has been detected. * - * Always answers with a single error and HTTP 422. The representation is - * negotiated from the request's Accept header: JSON when the client asks - * for application/json, plain text otherwise. + * The default implementation always throws a generic \Exception carrying + * the single error message and HTTP 422 as its code; the framework's + * exception boundary (Router::apply) renders the response. Override it to + * throw a more specific exception type instead. The never return type is + * the contract: an override that returned would let the request continue + * with invalid data, so PHP rejects such an override at compile time. * * @param string $error * - * @return void + * @return never + * @throws Exception */ - public function onInvalid(string $error): void + public function onInvalid(string $error): never { - http_response_code(422); - - $accept = $_SERVER['HTTP_ACCEPT'] ?? ''; - - if (str_contains($accept, 'application/json')) { - header('Content-Type: application/json'); - print(json_encode(['error' => $error])); - - return; - } - - print($error); + throw new Exception($error, 422); } } diff --git a/src/Libs/Router.php b/src/Libs/Router.php index edcbff5..af85b66 100644 --- a/src/Libs/Router.php +++ b/src/Libs/Router.php @@ -50,7 +50,7 @@ class Router * * @var callable $notFoundCallback */ - public static $notFoundCallback = 'Libs\Router::defaultNotFound'; + public static $notFoundCallback = Router::defaultNotFound(...); /** * Default callback function for when @@ -64,6 +64,54 @@ class Router echo '

Error 404 - Page Not Found

'; } + /** + * The callback function to be executed when the router's boundary + * catches an exception thrown anywhere in the matched route chain. + * It receives the exception as a named argument: the handler must + * declare its parameter as $exception (typeable as \Throwable). + * + * @var callable $exceptionCallback + */ + public static $exceptionCallback = Router::defaultException(...); + + /** + * Default callback function for exception responses. + * + * The HTTP status comes from the exception's code only when it is an + * integer in the 400-599 range; anything else (0, arbitrary codes, the + * SQLSTATE string carried by PDOException) answers 500. The body is + * negotiated from the request's Accept header: JSON when the client asks + * for application/json, plain text otherwise. The trace is serialized + * from getTraceAsString() because json_encode() of a Throwable yields an + * empty object: its properties are protected. + * + * @param \Throwable $exception + * + * @return void + */ + public static function defaultException(\Throwable $exception): void + { + $code = $exception->getCode(); + http_response_code(is_int($code) && $code >= 400 && $code <= 599 ? $code : 500); + + $accept = $_SERVER['HTTP_ACCEPT'] ?? ''; + + if (str_contains($accept, 'application/json')) { + header('Content-Type: application/json'); + print(json_encode([ + 'error' => $exception->getMessage(), + 'exception' => get_class($exception), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => explode(PHP_EOL, $exception->getTraceAsString()), + ])); + + return; + } + + print($exception->getMessage() . PHP_EOL . $exception->getTraceAsString()); + } + /** * __construct */ @@ -363,50 +411,60 @@ class Router /** * Applies the route configuration. * + * This method is the framework's error boundary: any \Throwable thrown + * while running the matched route's callback chain, while printing the + * returned data, or while resolving the not-found callback is caught + * here and rendered through $exceptionCallback. A failing handler is + * deliberately left uncaught (double failure falls back to PHP itself). + * * @param string|null $path (optional) Path to use. If not defined, it detects the current path. * * @return void */ public static function apply(?string $path = null): void { - $path = $path ?? static::currentPath(); - $routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method - 'POST' => static::$post, - 'PUT' => static::$put, - 'PATCH' => static::$patch, - 'DELETE' => static::$delete, - default => static::$get - }; + try { + $path = $path ?? static::currentPath(); + $routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method + 'POST' => static::$post, + 'PUT' => static::$put, + 'PATCH' => static::$patch, + 'DELETE' => static::$delete, + default => static::$get + }; - foreach ($routers as $router) { // Checks all routers to see if they match the current path - if (preg_match_all('/^' . $router['path'] . '\/?$/si', $path, $matches, PREG_PATTERN_ORDER)) { - unset($matches[0]); + foreach ($routers as $router) { // Checks all routers to see if they match the current path + if (preg_match_all('/^' . $router['path'] . '\/?$/si', $path, $matches, PREG_PATTERN_ORDER)) { + unset($matches[0]); - // Checking and storing the variable parameters of the route - if (isset($matches[1])) { - static::$params = new Neuron(); - foreach ($matches as $index => $match) { - $paramName = $router['paramNames'][$index - 1]; - static::$params->{$paramName} = urldecode($match[0]); + // Checking and storing the variable parameters of the route + if (isset($matches[1])) { + static::$params = new Neuron(); + foreach ($matches as $index => $match) { + $paramName = $router['paramNames'][$index - 1]; + static::$params->{$paramName} = urldecode($match[0]); + } } - } - // Processes the callback queue - foreach (array_reverse($router['callback']) as $callback) { - $data = Synapsis::resolve($callback); - } + // Processes the callback queue + foreach (array_reverse($router['callback']) as $callback) { + $data = Synapsis::resolve($callback); + } - // By default, prints as JSON if something is returned - if (isset($data)) { - header('Content-Type: application/json'); - print(json_encode($data)); - } + // By default, prints as JSON if something is returned + if (isset($data)) { + header('Content-Type: application/json'); + print(json_encode($data)); + } - return; + return; + } } - } - // If no router matches, call $notFoundCallBack - Synapsis::resolve(static::$notFoundCallback); + // If no router matches, call $notFoundCallBack + Synapsis::resolve(static::$notFoundCallback); + } catch (\Throwable $exception) { + Synapsis::resolve(static::$exceptionCallback, ['exception' => $exception]); + } } } diff --git a/src/Libs/Synapsis.php b/src/Libs/Synapsis.php index 6f0b8d7..616769e 100644 --- a/src/Libs/Synapsis.php +++ b/src/Libs/Synapsis.php @@ -65,12 +65,14 @@ class Synapsis /** * Resolves and injects dependencies for a callable and returns its result. * - * @param callable $action + * @param callable $action + * @param array $named Associative array of values injected into the callable's + * parameters by exact name match. Consumed in resolveParameterValues(). * * @return mixed * @throws Exception If an unhandled callable type is provided. */ - public static function resolve(callable $action): mixed + public static function resolve(callable $action, array $named = []): mixed { if ($action instanceof Closure) { // If it's an anonymous function $reflectionCallback = new ReflectionFunction($action); @@ -90,7 +92,7 @@ class Synapsis // Get the parameters return call_user_func_array( $action, - static::resolveParameterValues($reflectionCallback->getParameters()) + static::resolveParameterValues($reflectionCallback->getParameters(), $named) ); } @@ -132,11 +134,15 @@ class Synapsis * Resolves parameter values by injecting dependencies. * * @param array $parameters + * @param array $named + * Values injected into parameters whose name matches the key, + * taking precedence over optional defaults and DI resolution. + * Keys matching no parameter are ignored. * * @return array * @throws Exception If a primitive parameter does not have a default value. */ - public static function resolveParameterValues(array $parameters): array + public static function resolveParameterValues(array $parameters, array $named = []): array { $values = []; foreach ($parameters as $parameter) { @@ -144,6 +150,11 @@ class Synapsis continue; } + if (array_key_exists($parameter->getName(), $named)) { // Named values win over defaults and DI + $values[] = $named[$parameter->getName()]; + continue; + } + if ($parameter->isOptional()) { // Always use the default value first $values[] = $parameter->getDefaultValue(); continue;