Compare commits

18 Commits
Author SHA1 Message Date
kj 3b6da45b01 refactor(validator): rename enum rule and method to in 2026-09-14 16:48:02 -03:00
kj b7260b5199 refactor(Router): use string callable syntax for default callbacks 2026-09-08 12:08:32 -03:00
kj 1f29622914 refactor(router): remove exit statement from redirect method 2026-09-07 20:00:15 -03:00
kj eb882db0c8 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()
2026-09-07 18:00:18 -03:00
kj 9ba43e3f25 sync: 2026-09-05
25db49f fix(model): reset query state when database query fails
9cb41d5 fix(model): use RANDOM() for non-MySQL databases in orderBy
2026-09-05 16:49:10 -03:00
kj 54e96ba4bb sync: first sync with the new develop branch
a4611e8 chore(src): rename .keep files to .gitkeep
2026-09-05 14:21:52 -03:00
kj 9027f36748 refactor(model): use reflection for search field discovery 2026-09-05 00:39:16 -03:00
kj 2ae9ef39f3 feat(request): negotiate error response frmat based on Accept header 2026-09-03 18:23:42 -03:00
kj ee0db0307e docs(Validator): Expand documentation with complete rules reference 2026-09-03 18:22:52 -03:00
kj 44cbe6bbdc feat(validator): add file upload validation rules 2026-09-03 18:07:54 -03:00
kj 31728af351 docs: Clarify phpdoc 2026-09-03 16:12:54 -03:00
kj 4845d14ef5 chore(validator): suppress PHPCS rule for non-camel-case method 2026-09-03 15:59:48 -03:00
kj aca4e732ed feat(validation): Add human-readable validation error messages 2026-09-03 15:59:36 -03:00
kj f475c9125f feat(Validator): Add confirmed, required_with, and required_if rules 2026-09-03 15:03:28 -03:00
kj 4fe7314389 feat(Validator): Add regex and date validation methods 2026-09-03 14:42:15 -03:00
kj 1f366830bc feat(validation): add type and size-based validation methods
Add string, array, min, max, between, and size validators to the
Validator class. A private measure helper centralizes size semantics
for numbers (value), strings (length), and arrays (element count).
2026-09-03 14:35:02 -03:00
kj 15df7c8d96 refactor(Validator): improve rule argument parsing 2026-09-03 14:20:02 -03:00
kj b8dd1fd0f6 refactor(model): remove default 'id' from dbIgnoreSave list 2026-06-19 16:08:29 -03:00
11 changed files with 1248 additions and 95 deletions
-5
View File
@@ -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();
});
+20 -4
View File
@@ -31,7 +31,7 @@ class Model
/**
* @var array Attributes to ignore when saving to the database.
*/
protected static array $dbIgnoreSave = ['id'];
protected static array $dbIgnoreSave = [];
/**
* @var array Attributes that should be explicitly saved, even if private/protected.
@@ -176,6 +176,10 @@ class Model
$vars = json_encode(static::$dbQueryVariables);
if ($resetQuery) {
static::resetQuery();
}
throw new Exception(
"\nError at query to database.\n" .
"Query: $query\n" .
@@ -1008,7 +1012,9 @@ class Model
public static function orderBy(string $value, string $order = 'ASC'): static
{
if ($value == "RAND") {
static::$dbQuery['orderBy'] = 'RAND()';
$random = static::db()->getAttribute(PDO::ATTR_DRIVER_NAME) == 'mysql' ? 'RAND()' : 'RANDOM()';
static::$dbQuery['orderBy'] = $random;
return new static();
}
@@ -1098,8 +1104,18 @@ class Model
public static function search(string $search, ?array $in = null): static
{
if ($in == null) {
$className = get_called_class();
$in = array_keys((new $className())->getVars());
$in = [];
$reflection = new \ReflectionClass(get_called_class());
foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
if (!in_array($property->getName(), static::$dbIgnoreSave)) {
$in[] = static::camelCaseToSnakeCase($property->getName());
}
}
}
if (empty($in)) {
return new static();
}
$search = static::bind($search);
+48 -17
View File
@@ -2,6 +2,8 @@
namespace Libs;
use Exception;
/**
* Request - DuckBrain
*
@@ -59,39 +61,48 @@ 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'])},
default => $this->get
};
// Merge uploaded files ($_FILES) into the body data set so the file rules
// (file/image/mimes and the routed min/max/required...) can validate
// uploads through a Request. $this->post is left untouched.
$body = empty($_FILES)
? $actual
: new Neuron(array_merge(get_object_vars($actual), $_FILES));
if (
Validator::validateList(static::paramRules(), $this->params) &&
Validator::validateList(static::getRules(), $this->get) &&
Validator::validateList(static::rules(), $actual)
Validator::validateList(static::rules(), $body)
) {
return true;
return;
}
if (isset(static::messages()[Validator::$lastFailed])) {
$error = static::messages()[Validator::$lastFailed];
} else {
$error = 'Error: validation failed of ' . preg_replace('/\./', ' as ', Validator::$lastFailed, 1);
}
$error = Validator::message(
Validator::$lastFailed,
static::messages(),
static::attributes()
);
static::onInvalid($error);
return false;
}
/**
@@ -134,16 +145,36 @@ class Request extends Neuron
return [];
}
/**
* Human-readable names for the fields.
*
* Forwarded to Validator::message() so the :attribute marker in the error
* text can be replaced with a friendlier name (e.g. "edad" => "la edad").
*
* @return array
*/
public function attributes(): array
{
return [];
}
/**
* Function to execute when an invalid value has been detected.
*
* 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);
print($error);
throw new Exception($error, 422);
}
}
+90 -33
View File
@@ -50,7 +50,7 @@ class Router
*
* @var callable $notFoundCallback
*/
public static $notFoundCallback = 'Libs\Router::defaultNotFound';
public static $notFoundCallback = 'Libs\Router::defaultNotFound';
/**
* Default callback function for when
@@ -64,6 +64,54 @@ class Router
echo '<h2 style="text-align: center;margin: 25px 0px;">Error 404 - Page Not Found</h2>';
}
/**
* 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 = 'Libs\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
*/
@@ -136,7 +184,6 @@ class Router
public static function redirect(string $path): void
{
header('Location: ' . static::basePath() . ltrim($path, '/'));
exit;
}
/**
@@ -363,50 +410,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]);
}
}
}
+15 -4
View File
@@ -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<string, mixed> $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<ReflectionParameter> $parameters
* @param array<string, mixed> $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<mixed>
* @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;
+1075 -32
View File
File diff suppressed because it is too large Load Diff