Compare commits

..

13 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
11 changed files with 876 additions and 94 deletions

View File

@@ -12,8 +12,3 @@ spl_autoload_register(function ($className) {
require_once $file; require_once $file;
} }
}); });
set_exception_handler(function ($exception) {
echo "Uncaught exception: " , $exception->getMessage(), "\n";
echo $exception->getTraceAsString();
});

View File

@@ -176,6 +176,10 @@ class Model
$vars = json_encode(static::$dbQueryVariables); $vars = json_encode(static::$dbQueryVariables);
if ($resetQuery) {
static::resetQuery();
}
throw new Exception( throw new Exception(
"\nError at query to database.\n" . "\nError at query to database.\n" .
"Query: $query\n" . "Query: $query\n" .
@@ -1008,7 +1012,9 @@ class Model
public static function orderBy(string $value, string $order = 'ASC'): static public static function orderBy(string $value, string $order = 'ASC'): static
{ {
if ($value == "RAND") { 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(); return new static();
} }
@@ -1098,8 +1104,18 @@ class Model
public static function search(string $search, ?array $in = null): static public static function search(string $search, ?array $in = null): static
{ {
if ($in == null) { if ($in == null) {
$className = get_called_class(); $in = [];
$in = array_keys((new $className())->getVars()); $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); $search = static::bind($search);

View File

@@ -2,6 +2,8 @@
namespace Libs; namespace Libs;
use Exception;
/** /**
* Request - DuckBrain * Request - DuckBrain
* *
@@ -59,39 +61,48 @@ class Request extends Neuron
} }
// Run configured validations // Run configured validations
if (!$this->validate()) { $this->validate();
exit();
}
} }
/** /**
* Starts the configured validation. * 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']) { $actual = match ($_SERVER['REQUEST_METHOD']) {
'POST', 'PUT', 'PATCH', 'DELETE' => $this->{strtolower($_SERVER['REQUEST_METHOD'])}, 'POST', 'PUT', 'PATCH', 'DELETE' => $this->{strtolower($_SERVER['REQUEST_METHOD'])},
default => $this->get 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 ( if (
Validator::validateList(static::paramRules(), $this->params) && Validator::validateList(static::paramRules(), $this->params) &&
Validator::validateList(static::getRules(), $this->get) && 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 = Validator::message(
$error = static::messages()[Validator::$lastFailed]; Validator::$lastFailed,
} else { static::messages(),
$error = 'Error: validation failed of ' . preg_replace('/\./', ' as ', Validator::$lastFailed, 1); static::attributes()
} );
static::onInvalid($error); static::onInvalid($error);
return false;
} }
/** /**
@@ -134,16 +145,36 @@ class Request extends Neuron
return []; 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. * 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 * @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); throw new Exception($error, 422);
print($error);
} }
} }

View File

@@ -50,7 +50,7 @@ class Router
* *
* @var callable $notFoundCallback * @var callable $notFoundCallback
*/ */
public static $notFoundCallback = 'Libs\Router::defaultNotFound'; public static $notFoundCallback = 'Libs\Router::defaultNotFound';
/** /**
* Default callback function for when * 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>'; 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 * __construct
*/ */
@@ -136,7 +184,6 @@ class Router
public static function redirect(string $path): void public static function redirect(string $path): void
{ {
header('Location: ' . static::basePath() . ltrim($path, '/')); header('Location: ' . static::basePath() . ltrim($path, '/'));
exit;
} }
/** /**
@@ -363,50 +410,60 @@ class Router
/** /**
* Applies the route configuration. * 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. * @param string|null $path (optional) Path to use. If not defined, it detects the current path.
* *
* @return void * @return void
*/ */
public static function apply(?string $path = null): void public static function apply(?string $path = null): void
{ {
$path = $path ?? static::currentPath(); try {
$routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method $path = $path ?? static::currentPath();
'POST' => static::$post, $routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method
'PUT' => static::$put, 'POST' => static::$post,
'PATCH' => static::$patch, 'PUT' => static::$put,
'DELETE' => static::$delete, 'PATCH' => static::$patch,
default => static::$get 'DELETE' => static::$delete,
}; default => static::$get
};
foreach ($routers as $router) { // Checks all routers to see if they match the current path 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)) { if (preg_match_all('/^' . $router['path'] . '\/?$/si', $path, $matches, PREG_PATTERN_ORDER)) {
unset($matches[0]); unset($matches[0]);
// Checking and storing the variable parameters of the route // Checking and storing the variable parameters of the route
if (isset($matches[1])) { if (isset($matches[1])) {
static::$params = new Neuron(); static::$params = new Neuron();
foreach ($matches as $index => $match) { foreach ($matches as $index => $match) {
$paramName = $router['paramNames'][$index - 1]; $paramName = $router['paramNames'][$index - 1];
static::$params->{$paramName} = urldecode($match[0]); static::$params->{$paramName} = urldecode($match[0]);
}
} }
}
// Processes the callback queue // Processes the callback queue
foreach (array_reverse($router['callback']) as $callback) { foreach (array_reverse($router['callback']) as $callback) {
$data = Synapsis::resolve($callback); $data = Synapsis::resolve($callback);
} }
// By default, prints as JSON if something is returned // By default, prints as JSON if something is returned
if (isset($data)) { if (isset($data)) {
header('Content-Type: application/json'); header('Content-Type: application/json');
print(json_encode($data)); print(json_encode($data));
} }
return; return;
}
} }
}
// If no router matches, call $notFoundCallBack // If no router matches, call $notFoundCallBack
Synapsis::resolve(static::$notFoundCallback); Synapsis::resolve(static::$notFoundCallback);
} catch (\Throwable $exception) {
Synapsis::resolve(static::$exceptionCallback, ['exception' => $exception]);
}
} }
} }

View File

@@ -65,12 +65,14 @@ class Synapsis
/** /**
* Resolves and injects dependencies for a callable and returns its result. * 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 * @return mixed
* @throws Exception If an unhandled callable type is provided. * @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 if ($action instanceof Closure) { // If it's an anonymous function
$reflectionCallback = new ReflectionFunction($action); $reflectionCallback = new ReflectionFunction($action);
@@ -90,7 +92,7 @@ class Synapsis
// Get the parameters // Get the parameters
return call_user_func_array( return call_user_func_array(
$action, $action,
static::resolveParameterValues($reflectionCallback->getParameters()) static::resolveParameterValues($reflectionCallback->getParameters(), $named)
); );
} }
@@ -132,11 +134,15 @@ class Synapsis
* Resolves parameter values by injecting dependencies. * Resolves parameter values by injecting dependencies.
* *
* @param array<ReflectionParameter> $parameters * @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> * @return array<mixed>
* @throws Exception If a primitive parameter does not have a default value. * @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 = []; $values = [];
foreach ($parameters as $parameter) { foreach ($parameters as $parameter) {
@@ -144,6 +150,11 @@ class Synapsis
continue; 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 if ($parameter->isOptional()) { // Always use the default value first
$values[] = $parameter->getDefaultValue(); $values[] = $parameter->getDefaultValue();
continue; continue;

View File

@@ -7,27 +7,49 @@ use Exception;
/** /**
* Validator - DuckBrain * Validator - DuckBrain
* *
* Complementary library to the Request library. * Complementary library to the Request library. Simplifies value verification.
* Simplifies value verification. * It can validate both individual rules and batches (see validateList()).
* *
* It has the ability to verify both individual rules and in batches. * Model: a batch stops at the FIRST failing rule and records it in
* $lastFailed as "field.rule[:args]"; there is no error bag. Use message() to
* turn a $lastFailed into a human message with the :attribute placeholder
* (Request::validate() already does this for its single HTTP 422 error).
* *
* |----------+--------------------------------------------------------| * Rules are a pipe-separated list per field, e.g. "required|email", with
* | Rule | Description | * optional arguments after a colon. The file rules (file/image/mimes) mark a
* |----------+--------------------------------------------------------| * field as a "file field"; in that case min/max/between/size measure kilobytes
* | not | Negates the next rule. Ex: not:float | * per upload and required/nullable act on uploads. Files are checked against
* | exists | Is required; must be defined and can be empty | * their real, sniffed MIME type - never the file name or the client type.
* | required | Is required; must be defined and not empty |
* | number | Is numeric |
* | int | Is an integer |
* | float | Is a float |
* | bool | Is a boolean |
* | email | Is an email |
* | enum | Is in a list of values. Ex: enum:admin,user,guest |
* | url | Is a valid URL |
* |----------+--------------------------------------------------------|
* *
* Rule lists are separated by |, e.g., required|email * |---------------+---------------+---------------------------------------------|
* | Rule | Arguments | Description |
* |---------------+---------------+---------------------------------------------|
* | not | :rule | Negates the following rule. Ex: not:float |
* | exists | | Must be defined (may be empty) |
* | required | | Must be defined and not empty |
* | nullable | | Allows empty; skips the field's other rules |
* | string | | Must be a string |
* | array | | Must be an array |
* | number | | Must be numeric |
* | int | | Must be an integer |
* | float | | Must be a float |
* | bool | | Must be a boolean |
* | email | | Must be a valid email address |
* | url | | Must be a valid URL |
* | date | | Must be a parseable date |
* | regex | :pattern | Must match a PCRE pattern (with delimiters) |
* | in | :a,b,c | Must be one of the listed values |
* | min | :n | Length/number/count min (files: KB) |
* | max | :n | Length/number/count max (files: KB) |
* | between | :min,:max | Value within range (files: KB per upload) |
* | size | :n | Exact length/number/count (files: KB) |
* | confirmed | | Must match the '<field>_confirmation' field |
* | required_with | :fields | Required when a listed field is present |
* | required_if | :field,:value | Required when a field equals a value |
* | file | | Value must be an uploaded file ($_FILES) |
* | image | | Uploaded file must be an image (real MIME) |
* | mimes | :ext,ext | Uploaded file's real MIME must match |
* |---------------+---------------+---------------------------------------------|
* *
* @author KJ * @author KJ
* @website https://kj2.me * @website https://kj2.me
@@ -52,6 +74,189 @@ class Validator
*/ */
private const DATA_AWARE_RULES = ['confirmed', 'required_with', 'required_if']; private const DATA_AWARE_RULES = ['confirmed', 'required_with', 'required_if'];
/**
* Rules whose mere presence marks a field as a "file field". When a field's
* rule list contains any of these, the shared rules below are routed to
* their file-specific variants (see checkRule / validateList).
*
* @var string[]
*/
private const FILE_MARKER_RULES = ['file', 'image', 'mimes'];
/**
* Shared rules that gain file semantics (kilobytes / upload presence) and
* are therefore dispatched to `file_<rule>()` for a file field.
*
* @var string[]
*/
private const FILE_AWARE_RULES = ['min', 'max', 'between', 'size', 'required', 'nullable'];
/**
* Default error message template for each rule, used by message() to build
* the text for the first failing rule.
*
* Supported placeholders:
* |------------+--------------------------------------------------|
* | Marker | Replaced with |
* |------------+--------------------------------------------------|
* | :attribute | attributes[campo] if given, else the field name |
* | :min | the min/between lower bound |
* | :max | the max/between upper bound |
* | :size | the size rule value |
* | :value | the required_if expected value |
* | :other | the required_if companion field / confirmation |
* | :values | a comma list (in, mimes, required_with) |
* |----------+----------------------------------------------------|
*
* @var array<string,string>
*/
public static array $messageTemplates = [
'not' => 'The :attribute is not valid.',
'exists' => 'The :attribute field is missing.',
'required' => 'The :attribute field is required.',
'number' => 'The :attribute must be a number.',
'int' => 'The :attribute must be an integer.',
'float' => 'The :attribute must be a float.',
'bool' => 'The :attribute must be a boolean.',
'string' => 'The :attribute must be a string.',
'array' => 'The :attribute must be an array.',
'email' => 'The :attribute must be a valid email address.',
'url' => 'The :attribute must be a valid URL.',
'in' => 'The selected :attribute is invalid. Allowed: :values.',
'min' => 'The :attribute must be at least :min.',
'max' => 'The :attribute must not be greater than :max.',
'between' => 'The :attribute must be between :min and :max.',
'size' => 'The :attribute must be exactly :size.',
'regex' => 'The :attribute format is invalid.',
'date' => 'The :attribute is not a valid date.',
'confirmed' => 'The :attribute confirmation does not match.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_if' => 'The :attribute field is required when :other is :value.',
'nullable' => 'The :attribute field may be null.',
'file' => 'The :attribute must be a file.',
'image' => 'The :attribute must be an image.',
'mimes' => 'The :attribute must be a file of type: :values.',
];
/**
* Known MIME types per file extension, used by the mimes rule to compare
* the detected (real) MIME type of an upload against the allowed list.
*
* An extension maps to a set because different systems report slightly
* different types for the same format (e.g. "jpg" => image/jpeg/pjpeg).
* Public so applications can add formats without touching the library.
*
* @var array<string,string[]>
*/
public static array $mimeTypes = [
'jpg' => ['image/jpeg', 'image/pjpeg'],
'jpeg' => ['image/jpeg', 'image/pjpeg'],
'png' => ['image/png'],
'gif' => ['image/gif'],
'bmp' => ['image/bmp', 'image/x-ms-bmp'],
'webp' => ['image/webp'],
'svg' => ['image/svg+xml'],
'ico' => ['image/vnd.microsoft.icon', 'image/x-icon'],
'tif' => ['image/tiff'],
'tiff' => ['image/tiff'],
'pdf' => ['application/pdf'],
'txt' => ['text/plain'],
'csv' => ['text/plain', 'text/csv', 'application/csv'],
'json' => ['application/json'],
'html' => ['text/html'],
'xml' => ['text/xml', 'application/xml', 'text/plain'],
'mp3' => ['audio/mpeg', 'audio/mp3'],
'wav' => ['audio/wav', 'audio/x-wav'],
'ogg' => ['audio/ogg', 'application/ogg'],
'flac' => ['audio/flac', 'audio/x-flac'],
'mp4' => ['video/mp4'],
'm4a' => ['audio/mp4', 'video/mp4'],
'webm' => ['video/webm'],
'mov' => ['video/quicktime'],
'avi' => ['video/x-msvideo', 'video/avi'],
'zip' => ['application/zip', 'application/x-zip', 'application/x-zip-compressed'],
'gz' => ['application/gzip', 'application/x-gzip'],
'tar' => ['application/x-tar'],
'rar' => ['application/vnd.rar', 'application/x-rar-compressed', 'application/rar'],
'7z' => ['application/x-7z-compressed'],
'doc' => ['application/msword'],
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
'xls' => ['application/vnd.ms-excel'],
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
'ppt' => ['application/vnd.ms-powerpoint'],
'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
];
/**
* Builds the human-readable message for a single failed validation.
*
* Pure helper: it only reads its arguments and never reads nor writes
* $lastFailed or any other state. Request::validate() uses it to produce
* the single error message it sends, forwarding its messages() and
* attributes() maps; it can also be called directly by controllers.
*
* Resolution order for the message text:
* 1. $messages[$lastFailed] override by full "field.rule:args"
* 2. $messages["field.rule"] override by "field.rule" (no args)
* 3. $messageTemplates[rule] default for the rule
* Placeholders are then substituted in whichever text was chosen.
*
* @param string $lastFailed The "field.rule[:args]" produced by validateList().
* @param array $messages Optional per-field overrides.
* @param array $attributes Optional human field names ("field" => "Field").
*
* @return string
*/
public static function message(string $lastFailed, array $messages = [], array $attributes = []): string
{
$separator = strpos($lastFailed, '.');
if ($separator === false) {
return 'The :attribute is invalid.';
}
$target = substr($lastFailed, 0, $separator);
$ruleStr = substr($lastFailed, $separator + 1);
[$rule, $rawArguments] = static::parseRule($ruleStr);
$text = $messages[$lastFailed]
?? $messages[$target . '.' . $rule]
?? (static::$messageTemplates[$rule] ?? 'The :attribute is invalid.');
$replace = [
':attribute' => $attributes[$target] ?? $target,
];
$args = static::splitArguments($rule, $rawArguments);
switch ($rule) {
case 'min':
$replace[':min'] = $args[0] ?? '';
break;
case 'max':
$replace[':max'] = $args[0] ?? '';
break;
case 'between':
$replace[':min'] = $args[0] ?? '';
$replace[':max'] = $args[1] ?? '';
break;
case 'size':
$replace[':size'] = $args[0] ?? '';
break;
case 'in':
case 'mimes':
case 'required_with':
$replace[':values'] = implode(', ', $args);
break;
case 'required_if':
$replace[':other'] = $args[0] ?? '';
$replace[':value'] = $args[1] ?? '';
break;
}
return strtr($text, $replace);
}
/** /**
* Validates a list of rules against the properties of an object. * Validates a list of rules against the properties of an object.
* *
@@ -65,13 +270,23 @@ class Validator
foreach ($rulesList as $target => $rules) { foreach ($rulesList as $target => $rules) {
$rules = preg_split('/\|/', $rules); $rules = preg_split('/\|/', $rules);
$value = $haystack->{$target}; $value = $haystack->{$target};
$ruleNames = array_map(static fn ($r) => static::parseRule($r)[0], $rules);
$isFileField = array_intersect($ruleNames, self::FILE_MARKER_RULES) !== [];
if (in_array('nullable', $rules, true) && static::isEmpty($value)) { if (in_array('nullable', $ruleNames, true)) {
continue; $empty = $isFileField
? !static::hasUploadedFile($value)
: static::isEmpty($value);
if ($empty) {
continue;
}
} }
$ruleValidator = $isFileField ? 'checkFileRule' : 'checkRule';
foreach ($rules as $rule) { foreach ($rules as $rule) {
if (static::checkRule($value, $rule, $target, $haystack)) { if (static::$ruleValidator($value, $rule, $target, $haystack)) {
continue; continue;
} }
static::$lastFailed = $target . '.' . $rule; static::$lastFailed = $target . '.' . $rule;
@@ -90,7 +305,7 @@ class Validator
* arguments legitimately contain ':' or ',' - such as regex or mimes - * arguments legitimately contain ':' or ',' - such as regex or mimes -
* are preserved intact. The caller decides how to split the arguments. * are preserved intact. The caller decides how to split the arguments.
* *
* @param string $rule The rule to parse. Ex: "regex:/^a,b$/" or "enum:a,b". * @param string $rule The rule to parse. Ex: "regex:/^a,b$/" or "in:a,b".
* *
* @return array A two element array: [name, rawArguments]. When the rule * @return array A two element array: [name, rawArguments]. When the rule
* has no parameters, rawArguments is an empty string. * has no parameters, rawArguments is an empty string.
@@ -107,22 +322,81 @@ class Validator
} }
/** /**
* Checks if a rule is met. * Checks if a scalar rule is met.
* *
* @param mixed $subject The value to verify. * @param mixed $subject The value to verify.
* @param string $rule The rule to test. * @param string $rule The rule to test.
* @param string|null $field Optional name of the field being validated. * @param string|null $field Optional name of the field being validated
* Required by "data-aware" rules to locate sibling fields. * (used by "data-aware" rules to locate siblings).
* @param mixed $haystack Optional full data set (Neuron or array) the * @param mixed $haystack Optional full data set (Neuron or array).
* subject belongs to. Also used by data-aware rules.
* *
* @return bool * @return bool
* @throws Exception If the rule is not callable. * @throws Exception If the rule is not callable.
*/ */
public static function checkRule(mixed $subject, string $rule, ?string $field = null, mixed $haystack = null): bool public static function checkRule(
{ mixed $subject,
string $rule,
?string $field = null,
mixed $haystack = null
): bool {
[$name, $rawArguments] = static::parseRule($rule); [$name, $rawArguments] = static::parseRule($rule);
return static::callRuleMethod($name, $rawArguments, $subject, $field, $haystack);
}
/**
* Variant of checkRule() for file fields.
*
* Shares the same dispatch but reroutes the size-based and presence rules
* (see FILE_AWARE_RULES) to their `file_*` counterparts, so min/max/between/
* size work on kilobytes and required works on uploads. Marker rules
* (file/image/mimes) and everything else run unchanged.
*
* @param mixed $subject The value to verify (a $_FILES entry).
* @param string $rule The rule to test.
* @param string|null $field Name of the field being validated.
* @param mixed $haystack The full data set (Neuron or array).
*
* @return bool
* @throws Exception If the rule is not callable.
*/
public static function checkFileRule(
mixed $subject,
string $rule,
?string $field = null,
mixed $haystack = null
): bool {
[$name, $rawArguments] = static::parseRule($rule);
if (in_array($name, self::FILE_AWARE_RULES, true)) {
$name = 'file_' . $name;
}
return static::callRuleMethod($name, $rawArguments, $subject, $field, $haystack);
}
/**
* Resolves a rule name to its method and invokes it with the right arguments.
*
* Data-aware rules receive the field name and haystack; every other rule gets
* just the subject plus its comma-split arguments.
*
* @param string $name The resolved rule/method name.
* @param string $rawArguments Raw argument string from parseRule().
* @param mixed $subject The value being validated.
* @param string|null $field Field name (for data-aware rules).
* @param mixed $haystack Full data set (for data-aware rules).
*
* @return bool
* @throws Exception If the rule is not callable.
*/
private static function callRuleMethod(
string $name,
string $rawArguments,
mixed $subject,
?string $field,
mixed $haystack
): bool {
$method = [static::class, $name]; $method = [static::class, $name];
if (in_array($name, self::DATA_AWARE_RULES, true)) { if (in_array($name, self::DATA_AWARE_RULES, true)) {
@@ -145,7 +419,7 @@ class Validator
* Splits a rule's raw argument string into the arguments to pass to the * Splits a rule's raw argument string into the arguments to pass to the
* rule's method (the subject is not included). * rule's method (the subject is not included).
* *
* Most rules take a comma-separated list of values (e.g. "enum:a,b"). * Most rules take a comma-separated list of values (e.g. "in:a,b").
* A few rules receive an argument that must be kept intact because it can * A few rules receive an argument that must be kept intact because it can
* legitimately contain commas or colons: "regex" (a pattern) and "not" * legitimately contain commas or colons: "regex" (a pattern) and "not"
* (a sub-rule that is itself parsed recursively). An empty argument string * (a sub-rule that is itself parsed recursively). An empty argument string
@@ -443,6 +717,7 @@ class Validator
return static::readField($haystack, $field . '_confirmation') === $subject; return static::readField($haystack, $field . '_confirmation') === $subject;
} }
// phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps
/** /**
* Requires the field only when at least one of the listed fields is present. * Requires the field only when at least one of the listed fields is present.
* *
@@ -535,11 +810,187 @@ class Validator
* *
* @return bool * @return bool
*/ */
public static function enum(mixed $subject, ...$values): bool public static function in(mixed $subject, ...$values): bool
{ {
return in_array($subject, $values); return in_array($subject, $values);
} }
/**
* Checks that the value contains at least one successfully uploaded file.
*
* Presence is enforced by this rule itself: if nothing was uploaded (or the
* value is not a file descriptor) it fails. Make a file field optional with
* the `nullable` rule. An upload that errored (size limit, partial, ...) also
* fails.
*
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
*
* @return bool
*/
public static function file(mixed $subject): bool
{
$uploads = static::asFile($subject);
if ($uploads === null) {
return false;
}
$present = false;
foreach ($uploads as $upload) {
if ($upload['error'] === UPLOAD_ERR_NO_FILE) {
continue;
}
$present = true;
if ($upload['error'] !== UPLOAD_ERR_OK || $upload['name'] === '') {
return false;
}
}
return $present;
}
/**
* Checks that every uploaded file is an image (by its real MIME type).
*
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
*
* @return bool
*/
public static function image(mixed $subject): bool
{
return static::everyUploadedMime(
$subject,
static fn (string $mime): bool => str_starts_with($mime, 'image/')
);
}
/**
* Checks that every uploaded file matches one of the allowed extensions.
*
* The extension list is translated to the set of MIME types it stands for
* (via $mimeTypes) and compared against each file's real, sniffed MIME type
* - never the client-declared type nor the file name.
*
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
* @param string ...$extensions Allowed extensions, e.g. "jpg", "png".
*
* @return bool
*/
public static function mimes(mixed $subject, ...$extensions): bool
{
$allowed = [];
foreach ($extensions as $extension) {
foreach (static::$mimeTypes[strtolower(trim($extension))] ?? [] as $mime) {
$allowed[$mime] = true;
}
}
$allowedTypes = array_keys($allowed);
return static::everyUploadedMime(
$subject,
static fn (string $mime): bool => in_array($mime, $allowedTypes, true)
);
}
/**
* File-field variant of `min`: every uploaded file must be at least $min KB.
*
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
* @param mixed $min Minimum size in kilobytes.
*
* @return bool
*/
public static function file_min(mixed $subject, mixed $min): bool
{
$min = (float) $min;
return static::everyUploadedSize($subject, static fn (float $kb): bool => $kb >= $min);
}
/**
* File-field variant of `max`: every uploaded file must be at most $max KB.
*
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
* @param mixed $max Maximum size in kilobytes.
*
* @return bool
*/
public static function file_max(mixed $subject, mixed $max): bool
{
$max = (float) $max;
return static::everyUploadedSize($subject, static fn (float $kb): bool => $kb <= $max);
}
/**
* File-field variant of `between`: every uploaded file must be between $min
* and $max kilobytes.
*
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
* @param mixed $min Lower bound in KB.
* @param mixed $max Upper bound in KB.
*
* @return bool
*/
public static function file_between(mixed $subject, mixed $min, mixed $max): bool
{
$min = (float) $min;
$max = (float) $max;
return static::everyUploadedSize(
$subject,
static fn (float $kb): bool => $kb >= $min && $kb <= $max
);
}
/**
* File-field variant of `size`: every uploaded file must be exactly $size KB.
*
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
* @param mixed $size Exact size in kilobytes.
*
* @return bool
*/
public static function file_size(mixed $subject, mixed $size): bool
{
$size = (float) $size;
return static::everyUploadedSize($subject, static fn (float $kb): bool => $kb === $size);
}
/**
* File-field variant of `required`: at least one file must be uploaded.
*
* @param mixed $subject The value to check (a $_FILES entry, single or multi).
*
* @return bool
*/
public static function file_required(mixed $subject): bool
{
return static::hasUploadedFile($subject);
}
/**
* File-field marker for `nullable`.
*
* As a rule it always passes; the skip decision for a file field lives in
* validateList(), which treats a file field as empty when no upload came in
* (see hasUploadedFile()).
*
* @param mixed $subject The value to check.
*
* @return bool Always true.
*/
public static function file_nullable(mixed $subject): bool
{
return true;
}
/** /**
* Reads a field from a data set that may be a Neuron object or an array. * Reads a field from a data set that may be a Neuron object or an array.
* *
@@ -570,7 +1021,9 @@ class Validator
* *
* Matches the conventional definition: null, an empty string or an empty * Matches the conventional definition: null, an empty string or an empty
* array. Note that, unlike PHP's empty(), "0" and 0 are NOT treated as * array. Note that, unlike PHP's empty(), "0" and 0 are NOT treated as
* empty so they cannot silently bypass a required-style rule. * empty so they cannot silently bypass a required-style rule. File fields
* have their own emptiness predicate (hasUploadedFile) used by the file
* routing in validateList(); this helper stays scalar.
* *
* @param mixed $value The value to test. * @param mixed $value The value to test.
* *
@@ -580,4 +1033,223 @@ class Validator
{ {
return $value === null || $value === '' || $value === []; return $value === null || $value === '' || $value === [];
} }
/**
* Detects and normalizes a file upload value into a list of descriptors.
*
* A value is treated as an uploaded file when it is an array with the shape
* of a $_FILES entry, i.e. it defines at least the `name`, `tmp_name` and
* `error` keys. Both single uploads and multi-file uploads (input name="x[]",
* where every key is an array) are supported. Each returned descriptor always
* carries name, type, tmp_name, error and size with safe defaults, so the
* file rules can rely on every key. Non-file values return null.
*
* @param mixed $subject The value to inspect.
*
* @return array|null A list of normalized descriptors, or null when not a file.
*/
private static function asFile(mixed $subject): ?array
{
if (
!is_array($subject) ||
!isset($subject['name'], $subject['tmp_name'], $subject['error'])
) {
return null;
}
if (is_array($subject['name'])) {
return static::asFileList($subject);
}
return [[
'name' => (string) $subject['name'],
'type' => (string) ($subject['type'] ?? ''),
'tmp_name' => (string) $subject['tmp_name'],
'error' => (int) $subject['error'],
'size' => (int) ($subject['size'] ?? 0),
]];
}
/**
* Zips a multi-file $_FILES entry (parallel arrays) into a list of single
* file descriptors. The number of files is taken from the `name` array and
* the other arrays are read index by index with safe defaults.
*
* @param array $subject The raw multi-file $_FILES entry.
*
* @return array The list of normalized descriptors.
*/
private static function asFileList(array $subject): array
{
$names = array_values($subject['name']);
$read = static function ($key, int $index) {
$values = is_array($key) ? array_values($key) : [];
return $values[$index] ?? null;
};
$files = [];
foreach ($names as $index => $name) {
$files[] = [
'name' => (string) $name,
'type' => (string) ($read($subject['type'] ?? [], $index) ?? ''),
'tmp_name' => (string) ($read($subject['tmp_name'], $index) ?? ''),
'error' => (int) ($read($subject['error'], $index) ?? UPLOAD_ERR_NO_FILE),
'size' => (int) ($read($subject['size'] ?? [], $index) ?? 0),
];
}
return $files;
}
/**
* Determines the real MIME type of an uploaded file by sniffing its content
* with finfo, ignoring the client-declared `type` and the file `name`.
*
* Returns null when the file cannot be inspected (empty/missing path or the
* fileinfo extension is unavailable).
*
* @param array $descriptor A normalized file descriptor from asFile().
*
* @return string|null
*/
private static function realMime(array $descriptor): ?string
{
$path = $descriptor['tmp_name'] ?? '';
if ($path === '' || !is_file($path) || !class_exists(\finfo::class)) {
return null;
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($path);
return $mime === false ? null : $mime;
}
/**
* Applies a MIME predicate to every uploaded file of a value.
*
* Presence is enforced: fails when the value is not a file descriptor, when
* no upload was actually provided, when a present upload errored, or when a
* present upload's real (sniffed) MIME type is unknown or does not satisfy
* $check. Make a file field optional with the `nullable` rule.
*
* @param mixed $subject The value to inspect.
* @param callable $check Predicate receiving the real MIME type: fn(string): bool.
*
* @return bool
*/
private static function everyUploadedMime(mixed $subject, callable $check): bool
{
$uploads = static::asFile($subject);
if ($uploads === null) {
return false;
}
$present = false;
foreach ($uploads as $upload) {
if ($upload['error'] === UPLOAD_ERR_NO_FILE) {
continue;
}
$present = true;
if ($upload['error'] !== UPLOAD_ERR_OK || $upload['name'] === '') {
return false;
}
$mime = static::realMime($upload);
if ($mime === null || $check($mime) === false) {
return false;
}
}
return $present;
}
/**
* Sizes (in KB) of the present uploads of a value, or null when it is not a
* file. Absent uploads are skipped, so a field with no file yields an empty
* list. Division is forced to float so exact-KB sizes compare cleanly.
*
* @param mixed $subject The value to measure.
*
* @return float[]|null
*/
private static function fileSizesKb(mixed $subject): ?array
{
$uploads = static::asFile($subject);
if ($uploads === null) {
return null;
}
$sizes = [];
foreach ($uploads as $upload) {
if ($upload['error'] === UPLOAD_ERR_NO_FILE || $upload['name'] === '') {
continue;
}
$sizes[] = (float) ($upload['size'] / 1024);
}
return $sizes;
}
/**
* Checks that the value is a file and that every present upload satisfies
* $check. Fails when the value is not a file descriptor; an empty set of
* present uploads passes (presence belongs to the file rules).
*
* @param mixed $subject The value to inspect.
* @param callable $check Predicate receiving a size in KB: fn(float): bool.
*
* @return bool
*/
private static function everyUploadedSize(mixed $subject, callable $check): bool
{
$sizes = static::fileSizesKb($subject);
if ($sizes === null) {
return false;
}
foreach ($sizes as $kb) {
if ($check($kb) === false) {
return false;
}
}
return true;
}
/**
* Checks whether a value carries at least one successfully uploaded file.
*
* @param mixed $subject The value to inspect.
*
* @return bool
*/
private static function hasUploadedFile(mixed $subject): bool
{
$uploads = static::asFile($subject);
if ($uploads === null) {
return false;
}
foreach ($uploads as $upload) {
if ($upload['error'] !== UPLOAD_ERR_NO_FILE && $upload['name'] !== '') {
return true;
}
}
return false;
}
} }