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;