Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/Phaseolies/Database/Entity/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@ public function makeHidden(array $attributes): self
}

/**
* Number of jsonSerialize() calls in progress (nested relations included).
* Number of JSON serializations in progress (nested relations included).
*
* @var int
*/
Expand All @@ -719,18 +719,31 @@ public function makeHidden(array $attributes): self
* @return array
*/
public function jsonSerialize(): array
{
return self::serializingJson(fn() => $this->toArray());
}

/**
* Run a callback that produces JSON, so loaded relations use their own jsonSerialize()
*
* This also covers a jsonSerialize() override that calls toArray() itself.
*
* @param callable $callback
* @return mixed
*/
public static function serializingJson(callable $callback): mixed
{
self::$jsonSerializationDepth++;

try {
return $this->toArray();
return $callback();
} finally {
self::$jsonSerializationDepth--;
}
}

/**
* Serialize a related model for toArray(): through jsonSerialize() while
* Serialize a related model for toArray(): through jsonSerialize() while producing JSON.
*
* @param Model $model
* @return array
Expand Down
45 changes: 40 additions & 5 deletions src/Phaseolies/Error/Handlers/JsonErrorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
namespace Phaseolies\Error\Handlers;

use Throwable;
use Phaseolies\Http\Exceptions\HttpException;
use Phaseolies\Http\Exceptions\HttpResponseException;
use Phaseolies\Http\Response;
use Phaseolies\Error\JsonErrorRenderer;
use Phaseolies\Error\Contracts\ErrorHandlerInterface;

Expand All @@ -23,18 +25,51 @@ public function handle(Throwable $exception): void
return;
}

$this->buildResponse($exception)->prepare(request())->send();
}

/**
* Build the JSON error response for an exception.
*
* @param Throwable $exception
* @return Response
*/
protected function buildResponse(Throwable $exception): Response
{
$renderer = new JsonErrorRenderer();
if ($exception instanceof HttpResponseException) {
$responseErrors = $exception->getValidationErrors();

$statusCode = (int) $exception->getStatusCode() ?: 500;
$response = $renderer->render($exception, $statusCode, $responseErrors);
} else {
$statusCode = (int) $exception->getCode() ?: 500;
$response = $renderer->render($exception, $statusCode);

return $renderer->render($exception, $statusCode, $responseErrors);
}

$response = $renderer->render($exception, $this->resolveStatusCode($exception));

if ($exception instanceof HttpException) {
$response->withHeaders($exception->getHeaders());
}

$response->prepare(request())->send();
return $response;
}

/**
* The HTTP status for an exception. Any other exception's code can be any
* number, so it only counts when it is an HTTP error status.
*
* @param Throwable $exception
* @return int
*/
protected function resolveStatusCode(Throwable $exception): int
{
if ($exception instanceof HttpException) {
return $exception->getStatusCode();
}

$statusCode = (int) $exception->getCode();

return $statusCode >= 400 && $statusCode < 600 ? $statusCode : 500;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/Phaseolies/Helpers/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,9 @@ function bcrypt(string $plainText): string
* Retrieves the old input value for a given key from the session.
*
* @param mixed $key
* @return string|null
* @return mixed
*/
function old($key): ?string
function old($key): mixed
{
return MessageBag::old($key);
}
Expand Down
3 changes: 1 addition & 2 deletions src/Phaseolies/Http/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ class Request
self::METHOD_PATCH,
self::METHOD_PURGE,
self::METHOD_TRACE,
self::METHOD_ANY,
];

/**
Expand Down Expand Up @@ -437,7 +436,7 @@ public function isValidMethod(): bool
{
$method = strtoupper((string) $this->method);

return $method !== self::METHOD_ANY && in_array($method, self::VALID_HTTP_METHODS, true);
return in_array($method, self::VALID_HTTP_METHODS, true);
}

public function __toString(): string
Expand Down
9 changes: 8 additions & 1 deletion src/Phaseolies/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Phaseolies\Http\Controllers\Controller;
use Phaseolies\Error\JsonErrorRenderer;
use Phaseolies\Database\Entity\Builder;
use Phaseolies\Database\Entity\Model;

class Response implements HttpStatus
{
Expand Down Expand Up @@ -643,6 +644,12 @@ public static function dispatchHttpException(HttpException $exception): void

http_response_code($statusCode);

foreach ($exception->getHeaders() as $name => $value) {
if (!headers_sent()) {
header($name . ': ' . implode(', ', (array) $value));
}
}

self::renderErrorPage($statusCode, $message);
}

Expand Down Expand Up @@ -1633,7 +1640,7 @@ public static function toSerializable(mixed $content): mixed
*/
protected function encodeJsonContent(mixed $content, int $options = 0): string
{
return json_encode(static::toSerializable($content), $options);
return Model::serializingJson(fn() => json_encode(static::toSerializable($content), $options));
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Phaseolies/Http/Response/RedirectResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ public function to(string $url, int $statusCode = 302, array $headers = [], ?boo
public function back($status = 302, $headers = [], string|bool|null $fallback = false): RedirectResponse
{
$fallbackUrl = is_string($fallback) && $fallback !== '' ? $fallback : '/';
$url = request()->headers->get('referer') ?? $fallbackUrl;
$url = request()->headers->get('referer') ?: $fallbackUrl;

$this->setStatusCode($status)->withHeaders($headers);
$this->setTargetUrl($url);
Expand Down
4 changes: 3 additions & 1 deletion src/Phaseolies/Http/Response/Stream/StreamedJsonResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Phaseolies\Http\Response\Stream;

use Phaseolies\Database\Entity\Model;
use Phaseolies\Http\Response\JsonResponse;
use Phaseolies\Http\Response\Stream\StreamedResponse;

Expand Down Expand Up @@ -93,7 +94,8 @@ private function streamData(mixed $data, int $jsonEncodingOptions, int $keyEncod
return;
}

echo json_encode($data ?? new \stdClass(), $jsonEncodingOptions);
// Only the encoding: a generator's own code still sees plain toArray().
echo Model::serializingJson(fn() => json_encode($data ?? new \stdClass(), $jsonEncodingOptions));
}

private function streamArray(array $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
Expand Down
57 changes: 40 additions & 17 deletions src/Phaseolies/Http/Support/ValidationRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,11 @@ protected function isInteger(array $input, string $fieldName): bool
{
$value = $input[$fieldName] ?? '';

// filter_var() ignores surrounding whitespace, which is not part of an integer.
if (is_string($value) && $value !== trim($value)) {
return false;
}

return filter_var($value, FILTER_VALIDATE_INT) !== false;
}

Expand Down Expand Up @@ -588,7 +593,9 @@ protected function isDateValid(array $input, string $fieldName): bool
{
$date = $input[$fieldName] ?? '';

return strtotime($date) !== false;
// strtotime() rolls impossible dates over (February 30 becomes March 2);
// date_parse() reports them as a warning.
return strtotime($date) !== false && date_parse($date)['warning_count'] === 0;
}

/**
Expand Down Expand Up @@ -869,7 +876,7 @@ protected function isEmptyFieldRequired(array $input, string $fieldName): bool
return empty($value);
}

return $value === '';
return is_string($value) && trim($value) === '';
}

/**
Expand All @@ -882,7 +889,7 @@ protected function isEmptyFieldRequired(array $input, string $fieldName): bool
*/
protected function isLessThanMin(array $input, string $fieldName, int $value): bool
{
return strlen($input[$fieldName]) < $value;
return mb_strlen((string) $input[$fieldName]) < $value;
}

/**
Expand All @@ -895,7 +902,7 @@ protected function isLessThanMin(array $input, string $fieldName, int $value): b
*/
protected function isMoreThanMax(array $input, string $fieldName, int $value): bool
{
return strlen($input[$fieldName]) > $value;
return mb_strlen((string) $input[$fieldName]) > $value;
}

/**
Expand Down Expand Up @@ -987,7 +994,7 @@ protected function _removeUnderscore(string $string): string
*/
protected function isAlpha(array $input, string $fieldName): bool
{
return (bool) preg_match('/^[a-zA-Z]+$/', $input[$fieldName] ?? '');
return (bool) preg_match('/^[a-zA-Z]+\z/', $input[$fieldName] ?? '');
}

/**
Expand All @@ -999,7 +1006,7 @@ protected function isAlpha(array $input, string $fieldName): bool
*/
protected function isAlphaNum(array $input, string $fieldName): bool
{
return (bool) preg_match('/^[a-zA-Z0-9]+$/', $input[$fieldName] ?? '');
return (bool) preg_match('/^[a-zA-Z0-9]+\z/', $input[$fieldName] ?? '');
}

/**
Expand All @@ -1011,7 +1018,7 @@ protected function isAlphaNum(array $input, string $fieldName): bool
*/
protected function isAlphaDash(array $input, string $fieldName): bool
{
return (bool) preg_match('/^[a-zA-Z0-9_\-]+$/', $input[$fieldName] ?? '');
return (bool) preg_match('/^[a-zA-Z0-9_\-]+\z/', $input[$fieldName] ?? '');
}

/**
Expand All @@ -1023,7 +1030,10 @@ protected function isAlphaDash(array $input, string $fieldName): bool
*/
protected function isNumeric(array $input, string $fieldName): bool
{
return is_numeric($input[$fieldName] ?? '');
$value = $input[$fieldName] ?? '';

// is_numeric() ignores surrounding whitespace, which is not part of a number.
return is_numeric($value) && (!is_string($value) || $value === trim($value));
}

/**
Expand Down Expand Up @@ -1133,6 +1143,19 @@ protected function isDifferent(array $input, string $fieldName, string $otherFie
return ($input[$fieldName] ?? '') !== ($input[$otherField] ?? '');
}

/**
* The form of a value to compare with a rule's list, which holds only strings.
*
* A JSON body sends numbers as numbers, and 1 must match "1".
*
* @param mixed $value
* @return mixed
*/
protected function listValue(mixed $value): mixed
{
return is_int($value) || is_float($value) ? (string) $value : $value;
}

/**
* Check if the field value is one of the allowed values
*
Expand All @@ -1145,7 +1168,7 @@ protected function isInList(array $input, string $fieldName, string $ruleValue):
{
$list = array_map('trim', explode(',', $ruleValue));

return in_array($input[$fieldName] ?? '', $list, true);
return in_array($this->listValue($input[$fieldName] ?? ''), $list, true);
}

/**
Expand All @@ -1160,7 +1183,7 @@ protected function isNotInList(array $input, string $fieldName, string $ruleValu
{
$list = array_map('trim', explode(',', $ruleValue));

return !in_array($input[$fieldName] ?? '', $list, true);
return !in_array($this->listValue($input[$fieldName] ?? ''), $list, true);
}

/**
Expand Down Expand Up @@ -1189,7 +1212,7 @@ protected function isPhone(array $input, string $fieldName): bool
{
$value = $input[$fieldName] ?? '';

return (bool) preg_match('/^\+?[0-9\s\-\(\)]{7,20}$/', $value);
return (bool) preg_match('/^\+?[0-9 \-\(\)]{7,20}\z/', $value);
}

/**
Expand All @@ -1204,7 +1227,7 @@ protected function isExactDigits(array $input, string $fieldName, int $digits):
{
$value = (string)($input[$fieldName] ?? '');

return (bool) preg_match('/^\d{' . $digits . '}$/', $value);
return (bool) preg_match('/^\d{' . $digits . '}\z/', $value);
}

/**
Expand All @@ -1219,7 +1242,7 @@ protected function isMinDigits(array $input, string $fieldName, int $min): bool
{
$value = (string)($input[$fieldName] ?? '');

return (bool) preg_match('/^\d+$/', $value) && strlen($value) >= $min;
return (bool) preg_match('/^\d+\z/', $value) && strlen($value) >= $min;
}

/**
Expand All @@ -1234,7 +1257,7 @@ protected function isMaxDigits(array $input, string $fieldName, int $max): bool
{
$value = (string)($input[$fieldName] ?? '');

return (bool) preg_match('/^\d+$/', $value) && strlen($value) <= $max;
return (bool) preg_match('/^\d+\z/', $value) && strlen($value) <= $max;
}

/**
Expand All @@ -1254,7 +1277,7 @@ protected function isExactSize(array $input, string $fieldName, int $size): bool
return count($value) === $size;
}

return strlen((string)$value) === $size;
return mb_strlen((string) $value) === $size;
}

/**
Expand Down Expand Up @@ -1295,7 +1318,7 @@ protected function isUuid(array $input, string $fieldName): bool
$value = $input[$fieldName] ?? '';

return (bool) preg_match(
'/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
'/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i',
$value
);
}
Expand Down Expand Up @@ -1360,7 +1383,7 @@ protected function isSlug(array $input, string $fieldName): bool
{
$value = $input[$fieldName] ?? '';

return (bool) preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $value);
return (bool) preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*\z/', $value);
}

/**
Expand Down
Loading
Loading