From ac2923f9c992e7826a6e753c3353d931c116fcf0 Mon Sep 17 00:00:00 2001 From: techmahedy Date: Wed, 4 Nov 2026 16:48:58 +0600 Subject: [PATCH 1/5] fix(http): make request/response handling consistent end to end --- phpstan-baseline.neon | 306 ------------- src/Phaseolies/Application.php | 10 +- src/Phaseolies/Http/Request.php | 430 ++++-------------- src/Phaseolies/Http/Response.php | 217 ++++----- .../Http/Response/BaseJsonResponse.php | 231 ---------- .../Http/Response/RedirectResponse.php | 24 +- .../Response/Stream/BinaryFileResponse.php | 13 +- .../Response/Stream/StreamedJsonResponse.php | 3 + .../Http/Response/Stream/StreamedResponse.php | 15 +- src/Phaseolies/Http/ResponseFactory.php | 51 +-- .../Support/InteractsWithContentTypes.php | 2 + .../Http/Support/RequestAbortion.php | 31 +- src/Phaseolies/Http/Support/RequestHelper.php | 44 +- src/Phaseolies/Http/Support/RequestParser.php | 85 +--- src/Phaseolies/Http/Validation/Rule.php | 32 +- src/Phaseolies/Support/Facades/Response.php | 141 +++--- src/Phaseolies/Support/Router.php | 105 +++-- .../Router/InteractsWithCurrentRouter.php | 4 +- tests/Application/ApplicationTest.php | 23 + tests/Requests/AllRequestInputTest.php | 175 +++++++ tests/ResponseTest.php | 117 +++++ tests/Router/RouterTest.php | 55 +++ tests/Session/DispatchSessionTest.php | 4 +- 23 files changed, 786 insertions(+), 1332 deletions(-) delete mode 100644 src/Phaseolies/Http/Response/BaseJsonResponse.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6fe6d97a..1edfea03 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1770,312 +1770,6 @@ parameters: count: 1 path: src/Phaseolies/Support/Facades/DB.php - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: array\)\: Unexpected token "\\n \* ", expected type at offset 2932 on line 58$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 1633 on line 30$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 1667 on line 31$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 1708 on line 32$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 1886 on line 36$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 1927 on line 37$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 2897 on line 57$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3103 on line 61$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3142 on line 62$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3182 on line 63$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3222 on line 64$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3253 on line 65$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3291 on line 66$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3328 on line 67$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 661 on line 14$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 695 on line 15$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: int\)\: Unexpected token "\\n \* ", expected type at offset 2068 on line 40$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: int\)\: Unexpected token "\\n \* ", expected type at offset 433 on line 10$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 1020 on line 21$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 1747 on line 33$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 1785 on line 34$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2103 on line 41$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2863 on line 56$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: string\)\: Unexpected token "\\n \* ", expected type at offset 1252 on line 25$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: string\)\: Unexpected token "\\n \* ", expected type at offset 804 on line 17$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\?\\DateTimeInterface \$date\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2219 on line 43$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\?string \$body\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 52 on line 2$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(HttpException \$exception\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 1217 on line 24$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(Request \$request\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 3067 on line 60$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(Request \$request\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 856 on line 18$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\\DateTimeInterface \$date\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2036 on line 39$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(array \$headers\)\: Response\)\: Unexpected token "\\n \* ", expected type at offset 573 on line 12$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(array \$options\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2820 on line 55$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(int \$seconds\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2553 on line 50$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(int \$seconds\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2606 on line 51$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(int \$statusCode\)\: string\)\: Unexpected token "\\n \* ", expected type at offset 980 on line 20$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(int \$targetLevel, bool \$flush\)\: void\)\: Unexpected token "\\n \* ", expected type at offset 1145 on line 23$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(int \$value\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2303 on line 45$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(int \$value\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2357 on line 46$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(int \$value\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2419 on line 47$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(int \$value\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 2473 on line 48$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(string \$charset\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 224 on line 6$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(string \$version\)\: static\)\: Unexpected token "\\n \* ", expected type at offset 757 on line 16$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(static getBody\(\)\: \?string\)\: Unexpected token "\\n \* ", expected type at offset 89 on line 3$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(static getCharset\(\)\: \?string\)\: Unexpected token "\\n \* ", expected type at offset 264 on line 7$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(static getDate\(\)\: \?\\DateTimeImmutable\)\: Unexpected token "\\n \* ", expected type at offset 1976 on line 38$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(static getEtag\(\)\: \?string\)\: Unexpected token "\\n \* ", expected type at offset 2700 on line 53$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(static getExpires\(\)\: \?\\DateTimeImmutable\)\: Unexpected token "\\n \* ", expected type at offset 2155 on line 42$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(static getLastModified\(\)\: \?\\DateTimeImmutable\)\: Unexpected token "\\n \* ", expected type at offset 2663 on line 52$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(static getMaxAge\(\)\: \?int\)\: Unexpected token "\\n \* ", expected type at offset 2255 on line 44$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - - - message: '#^PHPDoc tag @method has invalid value \(static getTtl\(\)\: \?int\)\: Unexpected token "\\n \* ", expected type at offset 2506 on line 49$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Facades/Response.php - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: array\)\: Unexpected token "\\n \* ", expected type at offset 529 on line 12$#' identifier: phpDoc.parseError diff --git a/src/Phaseolies/Application.php b/src/Phaseolies/Application.php index 14aa21d7..d758fb29 100644 --- a/src/Phaseolies/Application.php +++ b/src/Phaseolies/Application.php @@ -11,6 +11,7 @@ use Phaseolies\Http\Request; use Phaseolies\Http\Contracts\GatewayInterface; use Phaseolies\Http\Exceptions\HttpException; +use Phaseolies\Http\Exceptions\BadRequestHttpException; use Phaseolies\Http\Exceptions\HttpResponseException; use Phaseolies\Session\Contracts\CommittableSession; use Phaseolies\Error\ErrorHandler; @@ -1519,16 +1520,15 @@ protected function typeAllowsNull(\ReflectionType $type): bool * * @param Request $request * @return Response + * @throws BadRequestHttpException */ public function handle(Request $request): Response { - $response = $this->router->resolve($this, $request); - - if (!$response instanceof Response) { - $response = $response->setBody((string) $response); + if ($bodyParseError = $request->getBodyParseError()) { + throw new BadRequestHttpException($bodyParseError->getMessage(), $bodyParseError); } - return $response; + return $this->router->resolve($this, $request); } /** diff --git a/src/Phaseolies/Http/Request.php b/src/Phaseolies/Http/Request.php index 8a914d8b..cd08b6ed 100644 --- a/src/Phaseolies/Http/Request.php +++ b/src/Phaseolies/Http/Request.php @@ -101,6 +101,19 @@ class Request self::METHOD_ANY, ]; + /** + * Methods whose request body is parsed from the raw input stream. + */ + private const BODY_PARSEABLE_METHODS = [ + self::METHOD_PUT, + self::METHOD_DELETE, + self::METHOD_PATCH, + self::METHOD_POST, + self::METHOD_ANY, + self::METHOD_HEAD, + self::METHOD_OPTIONS, + ]; + /** * @var string[] */ @@ -132,8 +145,8 @@ class Request protected ?array $languages = null; protected ?array $charsets = null; protected ?array $encodings = null; - private bool $isIisRewrite = false; protected array $routeParams = []; + protected ?\RuntimeException $bodyParseError = null; /** * Creates a Request from explicit parameter values. @@ -173,26 +186,7 @@ public function __construct( $this->content = $content; $this->requestUri = $this->getPath(); $this->baseUrl = base_url(); - $this->method = $this->method(); - $this->format = null; - $this->languages = null; - $this->charsets = null; - $this->encodings = null; - } - - /** - * Initializes headers from the $_SERVER superglobal. - * - * @return void - */ - protected function initializeHeaders(): void - { - foreach ($this->server->all() as $key => $value) { - if (str_starts_with($key, "HTTP_")) { - $header = str_replace("_", "-", substr($key, 5)); - $this->headers->set($header, $value); - } - } + $this->getMethod(); } /** @@ -215,53 +209,47 @@ protected function parseRequestBody(): array { $request = $this->request->all() + $this->query->all(); - $contentType = $this->server->get("CONTENT_TYPE", ""); - $requestMethod = strtoupper($this->server->get("REQUEST_METHOD", "GET")); + $contentType = (string) $this->server->get("CONTENT_TYPE", ""); + $requestMethod = $this->getRealMethod(); + + $isJson = str_starts_with($contentType, "application/json"); + $isUrlEncoded = str_starts_with($contentType, "application/x-www-form-urlencoded"); + $isMultipart = str_starts_with($contentType, "multipart/form-data"); if ( - str_starts_with($contentType, "application/json") && - in_array($requestMethod, ["PUT", "DELETE", "PATCH", "POST", "ANY", "HEAD", "OPTIONS"], true) + !($isJson || $isUrlEncoded || $isMultipart) || + !in_array($requestMethod, self::BODY_PARSEABLE_METHODS, true) ) { - $rawContent = $this->getContent(); + return $request; + } - if ($rawContent !== false) { - $jsonData = json_decode($rawContent, true); - if (json_last_error() === JSON_ERROR_NONE) { - $request = array_merge($request, $jsonData); - } else { - throw new \RuntimeException("Invalid JSON data in request body."); - } - } else { - throw new \RuntimeException("Unable to retrieve request content."); - } - } elseif ( - str_starts_with($contentType, "application/x-www-form-urlencoded") && - in_array($requestMethod, ["PUT", "DELETE", "PATCH", "POST", "ANY", "HEAD", "OPTIONS"], true) - ) { - $rawContent = $this->getContent(); - if ($rawContent !== false) { - parse_str($rawContent, $data); - $request = $data; - } else { - throw new \RuntimeException("Unable to retrieve request content."); + $rawContent = $this->getContent(); + + if (!is_string($rawContent)) { + throw new \RuntimeException("Unable to retrieve request content."); + } + + if ($isJson) { + if (trim($rawContent) === '') { + return $request; } - } elseif ( - str_starts_with($contentType, "multipart/form-data") && - in_array($requestMethod, ["PUT", "DELETE", "PATCH", "POST", "ANY", "HEAD", "OPTIONS"], true) - ) { - $rawContent = $this->getContent(); - if ($rawContent !== false) { - $request = array_merge($request, $this->parseMultipartFormData()); - } else { - throw new \RuntimeException("Unable to retrieve request content."); + + $jsonData = json_decode($rawContent, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException("Invalid JSON data in request body."); } + + return is_array($jsonData) ? array_merge($request, $jsonData) : $request; } - if (!$this->isValidRequest()) { - throw new \RuntimeException("Invalid request."); + if ($isUrlEncoded) { + parse_str($rawContent, $data); + + return array_merge($request, $data); } - return $request; + return array_merge($request, $this->parseMultipartFormData($rawContent)); } /** @@ -271,17 +259,17 @@ protected function parseRequestBody(): array * into individual parts, parses each part's headers, and collects the form field * values. * - * @return array Associative array of form fields and their values + * @param string $rawContent + * @return array */ - protected function parseMultipartFormData(): array + protected function parseMultipartFormData(string $rawContent): array { - $rawContent = $this->getContent(); - if ($rawContent === false || empty($rawContent)) { + if ($rawContent === '') { return []; } $data = []; - $boundary = $this->extractBoundary($this->headers->get('CONTENT_TYPE')); + $boundary = $this->extractBoundary($this->headers->get('CONTENT_TYPE', '')); if (!$boundary) { return []; @@ -291,7 +279,7 @@ protected function parseMultipartFormData(): array array_pop($parts); foreach ($parts as $part) { - if (empty(trim($part))) { + if (trim($part) === '' || !str_contains($part, "\r\n\r\n")) { continue; } @@ -429,51 +417,29 @@ public function getContent(bool $asResource = false) */ public function isValidMethod(): bool { - return in_array( - strtoupper($this->method), - [ - self::METHOD_GET, - self::METHOD_POST, - self::METHOD_PUT, - self::METHOD_PATCH, - self::METHOD_DELETE, - self::METHOD_OPTIONS, - self::METHOD_HEAD, - self::METHOD_TRACE, - self::METHOD_CONNECT, - self::METHOD_PURGE, - ], - true - ); + $method = strtoupper((string) $this->method); + + return $method !== self::METHOD_ANY && in_array($method, self::VALID_HTTP_METHODS, true); } /** - * The following methods are derived from code of the PHP Symfony Framework - * Validates the request based on trusted proxies and headers. + * Validates that a request coming from a trusted proxy carries every trusted header. * - * @return bool True if the request is valid, false otherwise. + * @return bool */ public function isValidRequest(): bool { - if (empty($this->trustedProxies)) { - // No trusted proxies, all requests are valid. - return true; - } - - $remoteAddress = $this->server->get("REMOTE_ADDR"); - - if (!in_array($remoteAddress, $this->trustedProxies, true)) { - // Request not from a trusted proxy. + if (!$this->isFromTrustedProxy()) { return true; } - if ($this->trustedHeaderSet === 0) { + if (self::$trustedHeaderSet <= 0) { // No trusted headers set, all requests are valid. return true; } foreach (self::TRUSTED_HEADERS as $headerBit => $headerName) { - if (($this->trustedHeaderSet & $headerBit) === $headerBit) { + if ((self::$trustedHeaderSet & $headerBit) === $headerBit) { if (!$this->server->has("HTTP_" . $headerName)) { // Required trusted header is missing. return false; @@ -612,7 +578,7 @@ public static function normalizeQueryString(?string $qs): string * * @return array The input data. */ - public function all(): ?array + public function all(): array { return $this->request->all(); } @@ -626,7 +592,6 @@ public function all(): ?array public function merge(array $input): self { $this->request->replace(array_merge($this->request->all(), $input)); - $this->input = []; return $this; } @@ -1154,151 +1119,13 @@ public function isMethodIdempotent(): bool } /** - * The following methods are derived from code of the PHP Symfony Framework - * The following methods are derived from code of the Zend Framework (1.10dev - 2010-01-24) - * - * Code subject to the new BSD license (https://framework.zend.com/license). - * - * Copyright (c) 2005-2010 Zend Technologies USA Inc. (https://www.zend.com/) - */ - protected function prepareRequestUri(): string - { - $requestUri = ''; - - if ($this->isIisRewrite() && '' != $this->server->get('UNENCODED_URL')) { - // IIS7 with URL Rewrite: make sure we get the unencoded URL (double slash problem) - $requestUri = $this->server->get('UNENCODED_URL'); - $this->server->remove('UNENCODED_URL'); - } elseif ($this->server->has('REQUEST_URI')) { - $requestUri = $this->server->get('REQUEST_URI'); - - if ('' !== $requestUri && '/' === $requestUri[0]) { - // To only use path and query remove the fragment. - if (false !== $pos = strpos($requestUri, '#')) { - $requestUri = substr($requestUri, 0, $pos); - } - } else { - // HTTP proxy reqs setup request URI with scheme and host [and port] + the URL path, - // only use URL path. - $uriComponents = parse_url($requestUri); - - if (isset($uriComponents['path'])) { - $requestUri = $uriComponents['path']; - } - - if (isset($uriComponents['query'])) { - $requestUri .= '?' . $uriComponents['query']; - } - } - } elseif ($this->server->has('ORIG_PATH_INFO')) { - // IIS 5.0, PHP as CGI - $requestUri = $this->server->get('ORIG_PATH_INFO'); - if ('' != $this->server->get('QUERY_STRING')) { - $requestUri .= '?' . $this->server->get('QUERY_STRING'); - } - $this->server->remove('ORIG_PATH_INFO'); - } - - // normalize the request URI to ease creating sub-requests from this request - $this->server->set('REQUEST_URI', $requestUri); - - return $requestUri; - } - - /** - * The following methods are derived from code of the PHP Symfony Framework - * Is this IIS with UrlRewriteModule? - * - * This method consumes, caches and removed the IIS_WasUrlRewritten env var, - * so we don't inherit it to sub-requests. - */ - private function isIisRewrite(): bool - { - if (1 === $this->server->getInt('IIS_WasUrlRewritten')) { - $this->isIisRewrite = true; - $this->server->remove('IIS_WasUrlRewritten'); - } - - return $this->isIisRewrite; - } - - /** - * The following methods are derived from code of the PHP Symfony Framework - * Returns the requested URI (path and query string). + * Returns the requested URI path (without the query string). * - * @return string The raw URI (i.e. not URI decoded) + * @return string The URL-decoded request path */ public function getRequestUri(): string { - return $this->requestUri ??= $this->prepareRequestUri(); - } - - /** - * The following methods are derived from code of the PHP Symfony Framework - * Prepares the base URL. - */ - protected function prepareBaseUrl(): string - { - $filename = basename($this->server->get('SCRIPT_FILENAME', '')); - - if (basename($this->server->get('SCRIPT_NAME', '')) === $filename) { - $baseUrl = $this->server->get('SCRIPT_NAME'); - } elseif (basename($this->server->get('PHP_SELF', '')) === $filename) { - $baseUrl = $this->server->get('PHP_SELF'); - } elseif (basename($this->server->get('ORIG_SCRIPT_NAME', '')) === $filename) { - $baseUrl = $this->server->get('ORIG_SCRIPT_NAME'); // 1and1 shared hosting compatibility - } else { - // Backtrack up the script_filename to find the portion matching - // php_self - $path = $this->server->get('PHP_SELF', ''); - $file = $this->server->get('SCRIPT_FILENAME', ''); - $segs = explode('/', trim($file, '/')); - $segs = array_reverse($segs); - $index = 0; - $last = \count($segs); - $baseUrl = ''; - do { - $seg = $segs[$index]; - $baseUrl = '/' . $seg . $baseUrl; - ++$index; - } while ($last > $index && (false !== $pos = strpos($path, $baseUrl)) && 0 != $pos); - } - - // Does the baseUrl have anything in common with the request_uri? - $requestUri = $this->getRequestUri(); - if ('' !== $requestUri && '/' !== $requestUri[0]) { - $requestUri = '/' . $requestUri; - } - - if ($baseUrl && null !== $prefix = $this->getUrlencodedPrefix($requestUri, $baseUrl)) { - // full $baseUrl matches - return $prefix; - } - - if ($baseUrl && null !== $prefix = $this->getUrlencodedPrefix($requestUri, rtrim(\dirname($baseUrl), '/' . \DIRECTORY_SEPARATOR) . '/')) { - // directory portion of $baseUrl matches - return rtrim($prefix, '/' . \DIRECTORY_SEPARATOR); - } - - $truncatedRequestUri = $requestUri; - if (false !== $pos = strpos($requestUri, '?')) { - $truncatedRequestUri = substr($requestUri, 0, $pos); - } - - $basename = basename($baseUrl ?? ''); - if (!$basename || !strpos(rawurldecode($truncatedRequestUri), $basename)) { - // no match whatsoever; set it blank - return ''; - } - - // If using mod_rewrite or ISAPI_Rewrite strip the script filename - // out of baseUrl. $pos !== 0 makes sure it is not matching a value - // from PATH_INFO or QUERY_STRING - if (\strlen($requestUri) >= \strlen($baseUrl) && (false !== $pos = strpos($requestUri, $baseUrl)) && 0 !== $pos) { - $baseUrl = substr($requestUri, 0, $pos + \strlen($baseUrl)); - } - - return rtrim($baseUrl, '/' . \DIRECTORY_SEPARATOR); + return $this->requestUri ??= $this->getPath(); } /** @@ -1321,37 +1148,6 @@ public function getPathInfo(): string return $this->pathInfo ??= $this->getPath(); } - /** - * The following methods are derived from code of the PHP Symfony Framework - * Prepares the path info. - */ - protected function preparePathInfo(): string - { - if (null === ($requestUri = $this->getRequestUri())) { - return '/'; - } - - // Remove the query string from REQUEST_URI - if (false !== $pos = strpos($requestUri, '?')) { - $requestUri = substr($requestUri, 0, $pos); - } - if ('' !== $requestUri && '/' !== $requestUri[0]) { - $requestUri = '/' . $requestUri; - } - - if (null === ($baseUrl = $this->getBaseUrlReal())) { - return $requestUri; - } - - $pathInfo = substr($requestUri, \strlen($baseUrl)); - if ('' === $pathInfo) { - // If substr() returns false then PATH_INFO is set to an empty string - return '/'; - } - - return $pathInfo; - } - /** * The following methods are derived from code of the PHP Symfony Framework * Returns the root URL from which this request is executed. @@ -1534,33 +1330,7 @@ public static function getTrustedProxies(): array */ private function getBaseUrlReal(): string { - return $this->baseUrl ??= $this->prepareBaseUrl(); - } - - /** - * The following methods are derived from code of the PHP Symfony Framework - * Returns the prefix as encoded in the string when the string starts with - * the given prefix, null otherwise. - */ - private function getUrlencodedPrefix(string $string, string $prefix): ?string - { - if ($this->isIisRewrite()) { - // ISS with UrlRewriteModule might report SCRIPT_NAME/PHP_SELF with wrong case - // see https://github.com/php/php-src/issues/11981 - if (0 !== stripos(rawurldecode($string), $prefix)) { - return null; - } - } elseif (!str_starts_with(rawurldecode($string), $prefix)) { - return null; - } - - $len = \strlen($prefix); - - if (preg_match(\sprintf('#^(%%[[:xdigit:]]{2}|.){%d}#', $len), $string, $match)) { - return $match[0]; - } - - return null; + return $this->baseUrl ??= base_url(); } /** @@ -1595,7 +1365,11 @@ public function getRouteParams(): array */ public function getRouteParam(string $param, mixed $default = null): mixed { - return $this->attributes[$param] ?? $default; + if (array_key_exists($param, $this->routeParams)) { + return $this->routeParams[$param]; + } + + return $this->attributes->get($param, $default); } /** @@ -1644,21 +1418,7 @@ public function hasFile(string $name): bool return false; } - $file = $this->files[$name]; - - // Handle single file upload - if (!is_array($file['name'])) { - return $file['error'] !== UPLOAD_ERR_NO_FILE && $file['error'] === UPLOAD_ERR_OK; - } - - // Handle multiple file upload - foreach ($file['error'] as $error) { - if ($error !== UPLOAD_ERR_NO_FILE && $error === UPLOAD_ERR_OK) { - return true; - } - } - - return false; + return in_array(UPLOAD_ERR_OK, (array) $this->files[$name]['error'], true); } /** @@ -1668,21 +1428,9 @@ public function hasFile(string $name): bool */ public function hasFiles(): bool { - if (empty($this->files)) { - return false; - } - - foreach ($this->files as $file) { - if (!is_array($file['name'])) { - if ($file['error'] !== UPLOAD_ERR_NO_FILE && $file['error'] === UPLOAD_ERR_OK) { - return true; - } - } else { - foreach ($file['error'] as $error) { - if ($error !== UPLOAD_ERR_NO_FILE && $error === UPLOAD_ERR_OK) { - return true; - } - } + foreach (array_keys($this->files) as $name) { + if ($this->hasFile((string) $name)) { + return true; } } @@ -1708,11 +1456,25 @@ public static function createFromGlobals(): static { $request = new static($_GET, $_POST, [], $_COOKIE, $_FILES, $_SERVER); - $request->request = new InputBag($request->parseRequestBody()); + try { + $request->request = new InputBag($request->parseRequestBody()); + } catch (\RuntimeException $e) { + $request->bodyParseError = $e; + } return $request; } + /** + * Get the error raised while parsing the request body, if any. + * + * @return \RuntimeException|null + */ + public function getBodyParseError(): ?\RuntimeException + { + return $this->bodyParseError; + } + /** * Captures the current request as a Request instance. * @@ -1822,16 +1584,12 @@ public function hasHeader(string $header): bool * * @return string|null */ - public function bearerToken() + public function bearerToken(): ?string { - $bearerToken = $this->header('Authorization', ''); - - if (is_null($bearerToken)) { - return null; - } + $authorization = $this->header('Authorization'); - if (Str::startsWith($bearerToken, 'Bearer ')) { - return Str::substr($bearerToken, 7); + if ($authorization !== null && Str::startsWith($authorization, 'Bearer ')) { + return Str::substr($authorization, 7); } return null; @@ -1853,7 +1611,7 @@ public function getAcceptableContentTypes(): array * @param string $key * @return bool */ - public function hasCookie($key) + public function hasCookie(string $key): bool { return ! is_null($this->cookies->get($key)); } diff --git a/src/Phaseolies/Http/Response.php b/src/Phaseolies/Http/Response.php index f7814d90..44501189 100644 --- a/src/Phaseolies/Http/Response.php +++ b/src/Phaseolies/Http/Response.php @@ -8,6 +8,9 @@ use Phaseolies\Http\Response\HttpStatus; use Phaseolies\Http\Exceptions\HttpException; use Phaseolies\Http\Controllers\Controller; +use Phaseolies\Database\Entity\Builder; +use Phaseolies\Database\Entity\Model; +use Phaseolies\Support\Collection; class Response implements HttpStatus { @@ -16,7 +19,7 @@ class Response implements HttpStatus * * @var string|null */ - public ?string $body; + public ?string $body = null; /** * The exception that triggered the error response (if applicable). @@ -65,7 +68,7 @@ class Response implements HttpStatus /** * Tracks headers already sent in informational responses. */ - private array $sentHeaders; + private array $sentHeaders = []; /** * Constructor for the Response class. @@ -179,10 +182,10 @@ public function getCharset(): ?string /** * Set the response exception. * - * @param $exception - * @return self + * @param \Throwable|null $exception + * @return static */ - public function setException($exception): static + public function setException(?Throwable $exception): static { $this->exception = $exception; @@ -192,20 +195,14 @@ public function setException($exception): static /** * Set the HTTP status code for the response. * - * @param int $statusCode The HTTP status code. + * @param int $statusCode + * @param string|null $text * @return static */ public function setStatusCode(int $statusCode, ?string $text = null): static { $this->statusCode = $statusCode; - - if (null === $text) { - $this->statusText = $this->getStatusCodeText($statusCode) ?? 'unknown status'; - - return $this; - } - - $this->statusText = $text; + $this->statusText = $text ?? $this->getStatusCodeText($statusCode); return $this; } @@ -238,10 +235,10 @@ public function setHeader(string $name, string $value, $replace = true): Respons /** * Set multiple HTTP headers for the response. * - * @param array $headers An associative array of headers. - * @return Response Returns the current Response instance. + * @param array|HeaderBag $headers + * @return static */ - public function withHeaders(array $headers): Response + public function withHeaders(array|HeaderBag $headers): static { if ($headers instanceof HeaderBag) { $headers = $headers->all(); @@ -250,20 +247,19 @@ public function withHeaders(array $headers): Response foreach ($headers as $name => $value) { $this->headers->set($name, $value); } + return $this; } /** - * Set the exception to attach to the response. + * Set the exception to attach to the response (alias of setException()). * * @param \Throwable $e - * @return $this + * @return static */ - public function withException(Throwable $e) + public function withException(Throwable $e): static { - $this->exception = $e; - - return $this; + return $this->setException($e); } /** @@ -353,7 +349,7 @@ public function prepare(Request $request): static $headers->remove('Content-Length'); } - if ($request->isHead('HEAD')) { + if ($request->isHead()) { // cf. RFC2616 14.13 $length = $headers->get('Content-Length'); $this->setBody(null); @@ -371,7 +367,7 @@ public function prepare(Request $request): static // Check if we need to send extra expire info headers if ('1.0' == $this->getProtocolVersion() && str_contains($headers->get('Cache-Control', ''), 'no-cache')) { $headers->set('pragma', 'no-cache'); - $headers->set('expires', -1); + $headers->set('expires', '-1'); } $this->ensureIEOverSSLCompatibility($request); @@ -439,7 +435,7 @@ public function sendHeaders(?int $statusCode = null): static } if ($informationalResponse) { - $this->headers_send($statusCode); + \headers_send($statusCode); return $this; } @@ -470,26 +466,11 @@ protected function ensureIEOverSSLCompatibility(Request $request): void } /** - * The following methods are derived from code of the PHP Symfony Framework - * @param int $statusCode + * Get the standard reason phrase for an HTTP status code. * - * @return void + * @param int $statusCode + * @return string */ - function headers_send(int $statusCode): void - { - if (headers_sent()) { - return; - } - - // Use the response's protocol version, not hardcoded 1.1 - header(sprintf( - 'HTTP/%s %d %s', - $this->getProtocolVersion(), - $statusCode, - $this->getStatusCodeText($statusCode) - ), true, $statusCode); - } - public function getStatusCodeText(int $statusCode): string { $statusTexts = [ @@ -664,42 +645,49 @@ public static function dispatchHttpException(HttpException $exception): void */ protected static function renderErrorPage(int $status, string $message): void { - $customPath = base_path("templates/views/errors/{$status}.odo.php"); - $errorPage = base_path("vendor/doppar/framework/src/Phaseolies/Support/View/errors/{$status}.odo.php"); + $errorView = static::resolveErrorView($status); - if (file_exists($customPath)) { - include $customPath; - } elseif (file_exists($errorPage)) { - include $errorPage; - } else { + if ($errorView === null) { abort($status, $message); } + + include $errorView; } /** - * Renders a view with the given data and returns the rendered content as a string. + * Resolve the error view for a status code. * - * @return string + * @param int $status + * @return string|null */ - public function render(): string + public static function resolveErrorView(int $status): ?string { - if (empty($this->body)) { - throw new \RuntimeException('No view content to render.'); - } + $candidates = [ + base_path("templates/views/errors/{$status}.odo.php"), + dirname(__DIR__) . str_replace('/', DIRECTORY_SEPARATOR, "/Support/View/errors/{$status}.odo.php"), + ]; - if (is_string($this->body)) { - return $this->body; + foreach ($candidates as $path) { + if (file_exists($path)) { + return $path; + } } - if (is_callable($this->body)) { - return call_user_func($this->body); - } + return null; + } - if (is_object($this->body) && method_exists($this->body, '__toString')) { - return $this->body->__toString(); + /** + * Renders a view with the given data and returns the rendered content as a string. + * + * @return string + */ + public function render(): string + { + if ($this->body === null || $this->body === '') { + throw new \RuntimeException('No view content to render.'); } - return (string) $this->body; + return $this->body; } /** @@ -707,18 +695,12 @@ public function render(): string * * @param string $view * @param array $data - * @return Response + * @param array $headers + * @return static */ - public function view(string $view, array $data = [], array $headers = []): Response + public function view(string $view, array $data = [], array $headers = []): static { - $content = $this->renderView($view, $data); - - $this->body = $content; - foreach ($headers as $name => $value) { - $this->headers->set($name, $value); - } - - return $this; + return $this->setBody($this->renderView($view, $data))->withHeaders($headers); } /** @@ -756,12 +738,9 @@ public function json(mixed $data, int $status = 200, array $headers = []): JsonR */ public function text(string $content, int $status = 200, array $headers = []): Response { - $this->body = $content; - $this->statusCode = $status; - - foreach ($headers as $name => $value) { - $this->headers->set($name, $value); - } + $this->setBody($content) + ->setStatusCode($status) + ->withHeaders($headers); $this->headers->set('Content-Type', 'text/plain'); @@ -1562,10 +1541,7 @@ public function prepareBody(mixed $content) { $this->setOriginal($content); - if ( - is_array($content) || - is_object($content) - ) { + if (static::shouldBeJson($content)) { $content = $this->encodeJsonContent($content); } @@ -1575,34 +1551,67 @@ public function prepareBody(mixed $content) } /** - * Encode a value for JSON responses without assuming every object has toArray(). + * Determine if the given content should be serialized as JSON. * * @param mixed $content - * @param int $options - * @return string + * @return bool */ - protected function encodeJsonContent(mixed $content, int $options = 0): string + public static function shouldBeJson(mixed $content): bool + { + if (is_array($content)) { + return true; + } + + if (!is_object($content) || $content instanceof self) { + return false; + } + + return $content instanceof \JsonSerializable + || $content instanceof Builder + || !$content instanceof \Stringable; + } + + /** + * Convert content into a structure that json_encode() can serialize faithfully. + * + * @param mixed $content + * @return mixed + */ + public static function toSerializable(mixed $content): mixed { - if ($content instanceof \Phaseolies\Support\Collection) { - return json_encode($content->toArray(), $options); + if ($content instanceof Builder) { + return $content->get()->toArray(); + } + + if ($content instanceof Model || $content instanceof Collection) { + return $content->toArray(); + } + + if ($content instanceof \ArrayObject) { + return $content->getArrayCopy(); } - if ( - $content instanceof \Phaseolies\Database\Entity\Model || - $content instanceof \Phaseolies\Database\Entity\Builder || - $content instanceof \JsonSerializable || - $content instanceof \stdClass || - $content instanceof \ArrayObject || - is_array($content) - ) { - return json_encode($content, $options); + if ($content instanceof \JsonSerializable || $content instanceof \stdClass) { + return $content; } if (is_object($content) && method_exists($content, 'toArray')) { - return json_encode($content->toArray(), $options); + return $content->toArray(); } - return json_encode($content, $options); + return $content; + } + + /** + * Encode a value for a JSON response body. + * + * @param mixed $content + * @param int $options + * @return string + */ + protected function encodeJsonContent(mixed $content, int $options = 0): string + { + return json_encode(static::toSerializable($content), $options); } /** @@ -1615,12 +1624,12 @@ protected function encodeJsonContent(mixed $content, int $options = 0): string public function setExceptionError(Throwable $exception, ?int $statusCode = null): static { $status = $statusCode ?? ($exception->getCode() ?: 500); - $error = $exception->getMessage() ?? 'An error occurred'; + $error = $exception->getMessage() ?: 'An error occurred'; $this->setBody(json_encode($error)); $this->setOriginal($exception); $this->setStatusCode($status); - $this->setException($status); + $this->setException($exception); // Fallback if (!headers_sent()) { diff --git a/src/Phaseolies/Http/Response/BaseJsonResponse.php b/src/Phaseolies/Http/Response/BaseJsonResponse.php deleted file mode 100644 index 9c0f892f..00000000 --- a/src/Phaseolies/Http/Response/BaseJsonResponse.php +++ /dev/null @@ -1,231 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Phaseolies\Http\Response; - -use Phaseolies\Http\Response; - -/** - * Response represents an HTTP response in JSON format. - * - * Note that this class does not force the returned JSON content to be an - * object. It is however recommended that you do return an object as it - * protects yourself against XSSI and JSON-JavaScript Hijacking. - * - * @see https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/AJAX_Security_Cheat_Sheet.md#always-return-json-with-an-object-on-the-outside - * - * @author Igor Wiedler - */ -class BaseJsonResponse extends Response -{ - protected mixed $data; - protected ?string $callback = null; - - // Encode <, >, ', &, and " characters in the JSON, making it also safe to be embedded into HTML. - // 15 === JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT - public const DEFAULT_ENCODING_OPTIONS = 15; - - protected int $encodingOptions = self::DEFAULT_ENCODING_OPTIONS; - - /** - * @param bool $json If the data is already a JSON string - */ - public function __construct(mixed $data = null, int $status = 200, array $headers = [], bool $json = false) - { - parent::__construct('', $status, $headers); - - if ($json && !\is_string($data) && !is_numeric($data) && !\is_callable([$data, '__toString'])) { - throw new \TypeError(sprintf('"%s": If $json is set to true, argument $data must be a string or object implementing __toString(), "%s" given.', __METHOD__, get_debug_type($data))); - } - - $data ??= new \ArrayObject(); - - $json ? $this->setJson($data) : $this->setData($data); - } - - /** - * Factory method for chainability. - * - * Example: - * - * return JsonResponse::fromJsonString('{"key": "value"}') - * ->setSharedMaxAge(300); - * - * @param string $data The JSON response string - * @param int $status The response status code (200 "OK" by default) - * @param array $headers An array of response headers - */ - public static function fromJsonString(string $data, int $status = 200, array $headers = []): static - { - return new static($data, $status, $headers, true); - } - - /** - * Sets the JSONP callback. - * - * @param string|null $callback The JSONP callback or null to use none - * - * @return $this - * - * @throws \InvalidArgumentException When the callback name is not valid - */ - public function setCallback(?string $callback): static - { - if (null !== $callback) { - // partially taken from https://geekality.net/2011/08/03/valid-javascript-identifier/ - // partially taken from https://github.com/willdurand/JsonpCallbackValidator - // JsonpCallbackValidator is released under the MIT License. See https://github.com/willdurand/JsonpCallbackValidator/blob/v1.1.0/LICENSE for details. - // (c) William Durand - $pattern = '/^[$_\p{L}][$_\p{L}\p{Mn}\p{Mc}\p{Nd}\p{Pc}\x{200C}\x{200D}]*(?:\[(?:"(?:\\\.|[^"\\\])*"|\'(?:\\\.|[^\'\\\])*\'|\d+)\])*?$/u'; - $reserved = [ - 'break', - 'do', - 'instanceof', - 'typeof', - 'case', - 'else', - 'new', - 'var', - 'catch', - 'finally', - 'return', - 'void', - 'continue', - 'for', - 'switch', - 'while', - 'debugger', - 'function', - 'this', - 'with', - 'default', - 'if', - 'throw', - 'delete', - 'in', - 'try', - 'class', - 'enum', - 'extends', - 'super', - 'const', - 'export', - 'import', - 'implements', - 'let', - 'private', - 'public', - 'yield', - 'interface', - 'package', - 'protected', - 'static', - 'null', - 'true', - 'false', - ]; - $parts = explode('.', $callback); - foreach ($parts as $part) { - if (!preg_match($pattern, $part) || \in_array($part, $reserved, true)) { - throw new \InvalidArgumentException('The callback name is not valid.'); - } - } - } - - $this->callback = $callback; - - return $this->update(); - } - - /** - * Sets a raw string containing a JSON document to be sent. - * - * @return $this - */ - public function setJson(string $json): static - { - $this->data = $json; - - return $this->update(); - } - - /** - * Sets the data to be sent as JSON. - * - * @return $this - * - * @throws \InvalidArgumentException - */ - public function setData(mixed $data = []): static - { - try { - $data = json_encode($data, $this->encodingOptions); - } catch (\Exception $e) { - if ('Exception' === $e::class && str_starts_with($e->getMessage(), 'Failed calling ')) { - throw $e->getPrevious() ?: $e; - } - throw $e; - } - - if (\JSON_THROW_ON_ERROR & $this->encodingOptions) { - return $this->setJson($data); - } - - if (\JSON_ERROR_NONE !== json_last_error()) { - throw new \InvalidArgumentException(json_last_error_msg()); - } - - return $this->setJson($data); - } - - /** - * Returns options used while encoding data to JSON. - */ - public function getEncodingOptions(): int - { - return $this->encodingOptions; - } - - /** - * Sets options used while encoding data to JSON. - * - * @return $this - */ - public function setEncodingOptions(int $encodingOptions): static - { - $this->encodingOptions = $encodingOptions; - - return $this->setData(json_decode($this->data)); - } - - /** - * Updates the content and headers according to the JSON data and callback. - * - * @return $this - */ - protected function update(): static - { - if (null !== $this->callback) { - // Not using application/javascript for compatibility reasons with older browsers. - $this->headers->set('Content-Type', 'text/javascript'); - - return $this->setBody(sprintf('/**/%s(%s);', $this->callback, $this->data)); - } - - // Only set the header when there is none or when it equals 'text/javascript' (from a previous update with callback) - // in order to not overwrite a custom definition. - if (!$this->headers->has('Content-Type') || 'text/javascript' === $this->headers->get('Content-Type')) { - $this->headers->set('Content-Type', 'application/json'); - } - - return $this->setBody($this->data); - } -} diff --git a/src/Phaseolies/Http/Response/RedirectResponse.php b/src/Phaseolies/Http/Response/RedirectResponse.php index 57a552cf..3391770b 100644 --- a/src/Phaseolies/Http/Response/RedirectResponse.php +++ b/src/Phaseolies/Http/Response/RedirectResponse.php @@ -94,10 +94,7 @@ public function intended($default = '/', $status = 302, array $headers = [], ?bo */ public function to(string $url, int $statusCode = 302, array $headers = [], ?bool $secure = null): RedirectResponse { - $this->setStatusCode($statusCode); - foreach ($headers as $name => $value) { - $this->headers->set($name, $value); - } + $this->setStatusCode($statusCode)->withHeaders($headers); if ($secure !== null) { $url = $this->ensureScheme($url, $secure); @@ -110,19 +107,18 @@ public function to(string $url, int $statusCode = 302, array $headers = [], ?boo /** * Redirect back to the previous page. + * * @param int $status * @param array $headers - * @param bool $fallback + * @param string|bool|null $fallback * @return RedirectResponse */ - public function back($status = 302, $headers = [], ?bool $fallback = false): RedirectResponse + public function back($status = 302, $headers = [], string|bool|null $fallback = false): RedirectResponse { - $url = request()->headers->get('referer') ?? '/'; - $this->setStatusCode($status); - foreach ($headers as $name => $value) { - $this->headers->set($name, $value); - } + $fallbackUrl = is_string($fallback) && $fallback !== '' ? $fallback : '/'; + $url = request()->headers->get('referer') ?? $fallbackUrl; + $this->setStatusCode($status)->withHeaders($headers); $this->setTargetUrl($url); return $this; @@ -137,12 +133,13 @@ public function back($status = 302, $headers = [], ?bool $fallback = false): Red */ protected function ensureScheme(string $url, bool $secure): string { + $scheme = $secure ? 'https' : 'http'; + // If the URL is already absolute (contains ://), parse it if (strpos($url, '://') !== false) { $parsedUrl = parse_url($url); // Rebuild the URL with the new scheme - $scheme = $secure ? 'https' : 'http'; $url = $scheme . '://'; // Add the host if it exists @@ -171,8 +168,7 @@ protected function ensureScheme(string $url, bool $secure): string } } else { // For relative URLs, prepend the current host and scheme - $scheme = $secure ? 'https' : 'http'; - $host = request()->headers->get('host') ?? $_SERVER['HTTP_HOST']; + $host = request()->headers->get('host') ?? ($_SERVER['HTTP_HOST'] ?? ''); $url = $scheme . '://' . $host . $url; } diff --git a/src/Phaseolies/Http/Response/Stream/BinaryFileResponse.php b/src/Phaseolies/Http/Response/Stream/BinaryFileResponse.php index 889c0113..321d5254 100644 --- a/src/Phaseolies/Http/Response/Stream/BinaryFileResponse.php +++ b/src/Phaseolies/Http/Response/Stream/BinaryFileResponse.php @@ -286,7 +286,7 @@ public function prepare(Request $request): static } } - if ($request->isHead('HEAD')) { + if ($request->isHead()) { $this->maxlen = 0; } @@ -362,22 +362,17 @@ public function sendContent(): static } /** - * @throws \LogicException when the content is not null + * @throws \LogicException when the body is not null */ - public function setContent(?string $content): static + public function setBody(?string $body): static { - if (null !== $content) { + if (null !== $body) { throw new \LogicException('The content cannot be set on a BinaryFileResponse instance.'); } return $this; } - public function getContent(): string|false - { - return false; - } - /** * Trust X-Sendfile-Type header. */ diff --git a/src/Phaseolies/Http/Response/Stream/StreamedJsonResponse.php b/src/Phaseolies/Http/Response/Stream/StreamedJsonResponse.php index dd4bafe6..f4e7897c 100644 --- a/src/Phaseolies/Http/Response/Stream/StreamedJsonResponse.php +++ b/src/Phaseolies/Http/Response/Stream/StreamedJsonResponse.php @@ -11,6 +11,7 @@ namespace Phaseolies\Http\Response\Stream; +use Phaseolies\Http\Response\JsonResponse; use Phaseolies\Http\Response\Stream\StreamedResponse; /** @@ -46,6 +47,8 @@ */ class StreamedJsonResponse extends StreamedResponse { + public const DEFAULT_ENCODING_OPTIONS = JsonResponse::DEFAULT_ENCODING_OPTIONS; + private const PLACEHOLDER = '__symfony_json__'; /** diff --git a/src/Phaseolies/Http/Response/Stream/StreamedResponse.php b/src/Phaseolies/Http/Response/Stream/StreamedResponse.php index 324292fd..d6b12e58 100644 --- a/src/Phaseolies/Http/Response/Stream/StreamedResponse.php +++ b/src/Phaseolies/Http/Response/Stream/StreamedResponse.php @@ -33,10 +33,6 @@ class StreamedResponse extends Response private bool $headersSent = false; - // Encode <, >, ', &, and " characters in the JSON, making it also safe to be embedded into HTML. - // 15 === JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT - public const DEFAULT_ENCODING_OPTIONS = 15; - /** * @param int $status The HTTP status code (200 "OK" by default) */ @@ -117,11 +113,11 @@ public function sendContent(): static /** * @return $this * - * @throws \LogicException when the content is not null + * @throws \LogicException when the body is not null */ - public function setContent(?string $content): static + public function setBody(?string $body): static { - if (null !== $content) { + if (null !== $body) { throw new \LogicException('The content cannot be set on a StreamedResponse instance.'); } @@ -129,9 +125,4 @@ public function setContent(?string $content): static return $this; } - - public function getContent(): string|false - { - return false; - } } diff --git a/src/Phaseolies/Http/ResponseFactory.php b/src/Phaseolies/Http/ResponseFactory.php index 839da3be..24d90e7f 100644 --- a/src/Phaseolies/Http/ResponseFactory.php +++ b/src/Phaseolies/Http/ResponseFactory.php @@ -6,18 +6,11 @@ use Phaseolies\Http\Response\Stream\StreamedJsonResponse; use Phaseolies\Http\Response\Stream\BinaryFileResponse; use Phaseolies\Http\Response\JsonResponse; +use Phaseolies\Http\Response\RedirectResponse; use Phaseolies\Http\Exceptions\StreamedResponseException; -use Phaseolies\Database\Entity\Model; -use Phaseolies\Database\Entity\Builder; -use Phaseolies\Support\Collection; class ResponseFactory extends Response { - public function __construct() - { - parent::__construct(); - } - /** * Create a new response instance. * @@ -28,7 +21,11 @@ public function __construct() */ public function make($content = '', $status = 200, array $headers = []): Response { - if ($this->shouldBeJson($content)) { + if ($content instanceof Response) { + return $content; + } + + if (static::shouldBeJson($content)) { return $this->json($content, $status, $headers); } @@ -70,7 +67,7 @@ public function json(mixed $data, int $status = 200, array $headers = []): JsonR */ public function text(string $content, int $status = 200, array $headers = []): Response { - return app(Response::class)->text($content, $status, $headers); + return (new Response())->text($content, $status, $headers); } /** @@ -175,13 +172,11 @@ public function file($file, array $headers = []) * @param string $url * @param int $status * @param array $headers - * @return Response + * @return RedirectResponse */ - public function redirect(string $url, int $status = 302, array $headers = []): Response + public function redirect(string $url, int $status = 302, array $headers = []): RedirectResponse { - $headers['Location'] = $url; - - return new Response('', $status, $headers); + return (new RedirectResponse())->to($url, $status, $headers); } /** @@ -200,30 +195,4 @@ protected function fallbackName($name) return $name; } - - /** - * Determine if the given data should be returned as JSON. - * - * @param mixed $data - * @return bool - */ - protected function shouldBeJson($data): bool - { - if ($data instanceof JsonResponse) { - return false; - } - - if ($data instanceof \Stringable) { - return false; - } - - return is_array($data) || - is_object($data) || - $data instanceof \JsonSerializable || - $data instanceof Model || - $data instanceof Collection || - $data instanceof Builder || - $data instanceof \stdClass || - $data instanceof \ArrayObject; - } } diff --git a/src/Phaseolies/Http/Support/InteractsWithContentTypes.php b/src/Phaseolies/Http/Support/InteractsWithContentTypes.php index e54dda6c..6f58e3ad 100644 --- a/src/Phaseolies/Http/Support/InteractsWithContentTypes.php +++ b/src/Phaseolies/Http/Support/InteractsWithContentTypes.php @@ -115,6 +115,8 @@ public function prefers($contentTypes) } } } + + return null; } /** diff --git a/src/Phaseolies/Http/Support/RequestAbortion.php b/src/Phaseolies/Http/Support/RequestAbortion.php index b5c7dd27..3b1bb7d9 100644 --- a/src/Phaseolies/Http/Support/RequestAbortion.php +++ b/src/Phaseolies/Http/Support/RequestAbortion.php @@ -25,34 +25,9 @@ public function abort(int $code, string $message = '', array $headers = []): voi $shouldJsonResponse = $request->isAjax() || $request->isApiRequest(); $httpException = HttpException::fromStatusCode($code, $message, null, $headers); - $customPath = - base_path( - 'templates' - . DIRECTORY_SEPARATOR . 'views' - . DIRECTORY_SEPARATOR . 'errors' - . DIRECTORY_SEPARATOR . "{$code}.odo.php" - ); - - $packagePath = - base_path( - 'vendor' - . DIRECTORY_SEPARATOR . 'doppar' - . DIRECTORY_SEPARATOR . 'framework' - . DIRECTORY_SEPARATOR . 'src' - . DIRECTORY_SEPARATOR . 'Phaseolies' - . DIRECTORY_SEPARATOR . 'Support' - . DIRECTORY_SEPARATOR . 'View' - . DIRECTORY_SEPARATOR . 'errors' - . DIRECTORY_SEPARATOR . "{$code}.odo.php" - ); - - if (!$shouldJsonResponse) { - $viewPath = file_exists($customPath) ? $customPath : (file_exists($packagePath) ? $packagePath : null); - - if ($viewPath) { - throw (new HttpResponseException($message, $code, $httpException)) - ->setResponse($this->buildErrorViewResponse($viewPath, $code, $message, $headers, $httpException)); - } + if (!$shouldJsonResponse && ($viewPath = Response::resolveErrorView($code)) !== null) { + throw (new HttpResponseException($message, $code, $httpException)) + ->setResponse($this->buildErrorViewResponse($viewPath, $code, $message, $headers, $httpException)); } if ($shouldJsonResponse) { diff --git a/src/Phaseolies/Http/Support/RequestHelper.php b/src/Phaseolies/Http/Support/RequestHelper.php index 75c52746..9b83a3f6 100644 --- a/src/Phaseolies/Http/Support/RequestHelper.php +++ b/src/Phaseolies/Http/Support/RequestHelper.php @@ -25,13 +25,6 @@ trait RequestHelper */ public array $errors = []; - /** - * Stores the input data. - * - * @var array - */ - public ?array $input = []; - /** * Retrieves all input data except for the specified keys. * @@ -97,13 +90,13 @@ public function validateDto(object $dto, bool $strict = true): object } /** - * Retrieves the validation errors, excluding the `csrf_token` field. + * Retrieves all validation errors. * * @return array */ - public function failed(array $excludeKeys = ['csrf_token']): array + public function failed(): array { - return $this->errors ?? []; + return $this->errors; } /** @@ -151,11 +144,13 @@ public function isEmpty(): bool */ public function input(?string $key = null, $default = null): mixed { - if (empty($this->input)) { - $this->input = $this->all(); + $input = $this->all(); + + if ($key === null) { + return $input; } - return $this->input[$key] ?? $default; + return $input[$key] ?? $default; } /** @@ -166,21 +161,19 @@ public function input(?string $key = null, $default = null): mixed */ public function has(string $param): bool { - if (empty($this->input)) { - $this->input = $this->all(); - } + $input = $this->all(); - return array_key_exists($param, $this->input) && $this->input[$param] !== ''; + return array_key_exists($param, $input) && $input[$param] !== ''; } /** - * Get the authenticated user. + * Get the authenticated user (alias of user()). * * @return Model|null */ public function auth(): ?Model { - return app('auth')->user() ?? null; + return $this->user(); } /** @@ -190,7 +183,7 @@ public function auth(): ?Model */ public function user(): ?Model { - return app('auth')->user() ?? null; + return app('auth')->user(); } /** @@ -264,7 +257,6 @@ public function nullifyBlanks(bool $includeStrings = true, bool $includeArrays = }); $this->request->replace($data); - $this->input = []; return $this; } @@ -307,14 +299,8 @@ public function ifFilled(string $key, callable $callback): static */ public function filled(string $key): bool { - $value = $this->input($key); - - // Check if the value exists and is not empty - // - empty strings - // - null values - // - empty arrays - // are considered "not filled" - return !empty($value); + // Any PHP "empty" value (null, '', '0', 0, false, []) is considered "not filled". + return !empty($this->input($key)); } /** diff --git a/src/Phaseolies/Http/Support/RequestParser.php b/src/Phaseolies/Http/Support/RequestParser.php index 5e887013..6c672215 100644 --- a/src/Phaseolies/Http/Support/RequestParser.php +++ b/src/Phaseolies/Http/Support/RequestParser.php @@ -2,7 +2,6 @@ namespace Phaseolies\Http\Support; -use Phaseolies\Utilities\IpUtils; use Phaseolies\Utilities\PathMatcher; trait RequestParser @@ -10,82 +9,24 @@ trait RequestParser /** * Retrieves the client's IP address. * + * Forwarded headers are only honoured when the request comes from a trusted proxy. + * * @return string|null */ public function ip(): ?string { - if ($this->trustedHeaderSet & self::HEADER_FORWARDED) { - $forwarded = $this->headers->get('FORWARDED'); - if ($forwarded) { - $parts = explode(';', $forwarded); - foreach ($parts as $part) { - $keyValue = explode('=', trim($part), 2); - if (count($keyValue) === 2 && $keyValue[0] === 'for') { - return $keyValue[1]; - } - } - } - } - - if ($this->trustedHeaderSet & self::HEADER_X_FORWARDED_FOR) { - $xForwardedFor = $this->headers->get('X_FORWARDED_FOR'); - if ($xForwardedFor) { - $ips = explode(',', $xForwardedFor); - return trim($ips[0]); - } - } - - return $this->server->get('REMOTE_ADDR'); + return $this->getClientIp(); } /** - * Get all client IP addresses, including those forwarded by proxies. - * The original client IP will be at the end of the array. + * Get all client IP addresses, with trusted proxies stripped. + * The original client IP is the first element of the array. * - * @return array + * @return array */ public function ips(): array { - $ips = []; - - $remoteAddr = $this->server->get('REMOTE_ADDR'); - - if ($this->isFromTrustedProxy()) { - if ($this->trustedHeaderSet & self::HEADER_X_FORWARDED_FOR) { - $xForwardedFor = $this->headers->get('X_FORWARDED_FOR'); - if ($xForwardedFor) { - $forwardedIps = array_map('trim', explode(',', $xForwardedFor)); - $ips = array_merge($ips, $forwardedIps); - } - } - - if ($this->trustedHeaderSet & self::HEADER_FORWARDED) { - $forwarded = $this->headers->get('FORWARDED'); - if ($forwarded) { - $parts = explode(';', $forwarded); - foreach ($parts as $part) { - $keyValue = explode('=', trim($part), 2); - if (count($keyValue) === 2 && $keyValue[0] === 'for') { - $ips[] = trim($keyValue[1], '"\''); - } - } - } - } - } - - if ($remoteAddr) { - $ips[] = $remoteAddr; - } - - $ips = array_filter($ips, function ($ip) { - return filter_var($ip, FILTER_VALIDATE_IP); - }); - - $ips = array_filter($ips, function ($ip) { - return !IpUtils::checkIp($ip, self::$trustedProxies); - }); - - return array_values($ips); + return array_values(array_filter($this->getClientIps(), fn($ip) => $ip !== null && $ip !== '')); } /** @@ -136,7 +77,7 @@ public function header(string $name): ?string */ public function scheme(): string { - return $this->server->get('HTTPS') === 'on' ? 'https' : 'http'; + return $this->getScheme(); } /** @@ -271,7 +212,7 @@ public function isAjax(): bool * * @return bool */ - public function isPjax() + public function isPjax(): bool { return $this->headers->get('X-PJAX') == true; } @@ -316,11 +257,7 @@ public static function is(string $pattern): bool */ public function isApiRequest(): bool { - if ($this->is('/api/*')) { - return true; - } - - return false; + return $this->is('/api/*'); } /** @@ -336,6 +273,6 @@ public static function route(?string $name = null): string|bool|null return $routeName; } - return $routeName === $name ? true : false; + return $routeName === $name; } } diff --git a/src/Phaseolies/Http/Validation/Rule.php b/src/Phaseolies/Http/Validation/Rule.php index 6e344b5d..db48683b 100644 --- a/src/Phaseolies/Http/Validation/Rule.php +++ b/src/Phaseolies/Http/Validation/Rule.php @@ -61,25 +61,23 @@ public function sanitize(array $rules): array|Response $input = $this->all(); MessageBag::flashInput(); - if (is_array($input)) { - foreach ($rules as $fieldName => $value) { - // Custom rule class bound via Bind::to(new Rule())->context([...]) - if ($value instanceof Bind) { - $errorMessage = $value->evaluate($fieldName, $input); - if ($errorMessage) { - $errors[$fieldName][] = $errorMessage; - } - continue; + foreach ($rules as $fieldName => $value) { + // Custom rule class bound via Bind::to(new Rule())->context([...]) + if ($value instanceof Bind) { + $errorMessage = $value->evaluate($fieldName, $input); + if ($errorMessage) { + $errors[$fieldName][] = $errorMessage; } + continue; + } - $fieldRules = $this->splitRules($value); - foreach ($fieldRules as $rule) { - $ruleValue = $this->_getRuleSuffix($rule); - $rule = $this->_removeRuleSuffix($rule); - $errorMessage = $this->sanitizeUserRequest($input, $fieldName, $rule, $ruleValue); - if ($errorMessage && ! in_array($errorMessage, $errors[$fieldName] ?? [], true)) { - $errors[$fieldName][] = $errorMessage; - } + $fieldRules = $this->splitRules($value); + foreach ($fieldRules as $rule) { + $ruleValue = $this->_getRuleSuffix($rule); + $rule = $this->_removeRuleSuffix($rule); + $errorMessage = $this->sanitizeUserRequest($input, $fieldName, $rule, $ruleValue); + if ($errorMessage && ! in_array($errorMessage, $errors[$fieldName] ?? [], true)) { + $errors[$fieldName][] = $errorMessage; } } } diff --git a/src/Phaseolies/Support/Facades/Response.php b/src/Phaseolies/Support/Facades/Response.php index 3183ab96..9762059f 100644 --- a/src/Phaseolies/Support/Facades/Response.php +++ b/src/Phaseolies/Support/Facades/Response.php @@ -3,74 +3,79 @@ namespace Phaseolies\Support\Facades; /** - * @method static setBody(?string $body): static - * @method static getBody(): ?string - * @method static setOriginal($original): static - * @method static getOriginal() - * @method static setCharset(string $charset): static - * @method static getCharset(): ?string - * @method static setException($exception): static - * @method static setStatusCode(int $statusCode, ?string $text = null): static - * @method static getStatusCode(): int - * @method static setHeader(string $name, string $value, $replace = true): Response - * @method static withHeaders(array $headers): Response - * @method static withException(Throwable $e) - * @method static isInformational(): bool - * @method static isEmpty(): bool - * @method static setProtocolVersion(string $version): static - * @method static getProtocolVersion(): string - * @method static prepare(Request $request): static - * @method static sendHeaders(?int $statusCode = null): static - * @method static getStatusCodeText(int $statusCode): string - * @method static sendContent(): static - * @method static send(bool $flush = true): static - * @method static closeOutputBuffers(int $targetLevel, bool $flush): void - * @method static dispatchHttpException(HttpException $exception): void - * @method static render(): string - * @method static view(string $view, array $data = [], array $headers = []): Response - * @method static renderView(string $view, array $data = []): string - * @method static json(mixed $data, int $statusCode = 200, array $headers = []): JsonResponse - * @method static text(string $content, int $statusCode = 200, array $headers = []): Response - * @method static isCacheable(): bool - * @method static isFresh(): bool - * @method static isValidateable(): bool - * @method static setPrivate(): static - * @method static setPublic(): static - * @method static setImmutable(bool $immutable = true): static - * @method static isImmutable(): bool - * @method static mustRevalidate(): bool - * @method static getDate(): ?\DateTimeImmutable - * @method static setDate(\DateTimeInterface $date): static - * @method static getAge(): int - * @method static expire(): static - * @method static getExpires(): ?\DateTimeImmutable - * @method static setExpires(?\DateTimeInterface $date): static - * @method static getMaxAge(): ?int - * @method static setMaxAge(int $value): static - * @method static setStaleIfError(int $value): static - * @method static setStaleWhileRevalidate(int $value): static - * @method static setSharedMaxAge(int $value): static - * @method static getTtl(): ?int - * @method static setTtl(int $seconds): static - * @method static setClientTtl(int $seconds): static - * @method static getLastModified(): ?\DateTimeImmutable - * @method static getEtag(): ?string - * @method static setEtag(?string $etag, bool $weak = false): static - * @method static setCache(array $options): static - * @method static setNotModified(): static - * @method static hasVary(): bool - * @method static getVary(): array - * @method static setVary(string|array $headers, bool $replace = true): static - * @method static isNotModified(Request $request): bool - * @method static isInvalid(): bool - * @method static isSuccessful(): bool - * @method static isRedirection(): bool - * @method static isServerError(): bool - * @method static isOk(): bool - * @method static isForbidden(): bool - * @method static isNotFound(): bool - * @method static isRedirect(?string $location = null): bool - * @method statis setContentSafe(bool $safe = true): void + * @method static \Phaseolies\Http\Response setBody(?string $body) + * @method static string|null getBody() + * @method static \Phaseolies\Http\Response setOriginal(mixed $original) + * @method static mixed getOriginal() + * @method static \Phaseolies\Http\Response setCharset(string $charset) + * @method static string|null getCharset() + * @method static \Phaseolies\Http\Response setException(?\Throwable $exception) + * @method static \Phaseolies\Http\Response setStatusCode(int $statusCode, ?string $text = null) + * @method static int getStatusCode() + * @method static \Phaseolies\Http\Response setHeader(string $name, string $value, bool $replace = true) + * @method static \Phaseolies\Http\Response withHeaders(array|\Phaseolies\Http\HeaderBag $headers) + * @method static \Phaseolies\Http\Response withException(\Throwable $e) + * @method static bool isInformational() + * @method static bool isEmpty() + * @method static \Phaseolies\Http\Response setProtocolVersion(string $version) + * @method static string getProtocolVersion() + * @method static \Phaseolies\Http\Response prepare(\Phaseolies\Http\Request $request) + * @method static \Phaseolies\Http\Response sendHeaders(?int $statusCode = null) + * @method static string getStatusCodeText(int $statusCode) + * @method static \Phaseolies\Http\Response sendContent() + * @method static \Phaseolies\Http\Response send(bool $flush = true) + * @method static void closeOutputBuffers(int $targetLevel, bool $flush) + * @method static void dispatchHttpException(\Phaseolies\Http\Exceptions\HttpException $exception) + * @method static string|null resolveErrorView(int $status) + * @method static string render() + * @method static \Phaseolies\Http\Response view(string $view, array $data = [], array $headers = []) + * @method static string renderView(string $view, array $data = []) + * @method static \Phaseolies\Http\Response\JsonResponse json(mixed $data, int $status = 200, array $headers = []) + * @method static \Phaseolies\Http\Response text(string $content, int $status = 200, array $headers = []) + * @method static bool shouldBeJson(mixed $content) + * @method static mixed toSerializable(mixed $content) + * @method static bool isCacheable() + * @method static bool isFresh() + * @method static bool isValidateable() + * @method static \Phaseolies\Http\Response setPrivate() + * @method static \Phaseolies\Http\Response setPublic() + * @method static \Phaseolies\Http\Response setImmutable(bool $immutable = true) + * @method static bool isImmutable() + * @method static bool mustRevalidate() + * @method static \DateTimeImmutable|null getDate() + * @method static \Phaseolies\Http\Response setDate(\DateTimeInterface $date) + * @method static int getAge() + * @method static \Phaseolies\Http\Response expire() + * @method static \DateTimeImmutable|null getExpires() + * @method static \Phaseolies\Http\Response setExpires(?\DateTimeInterface $date) + * @method static int|null getMaxAge() + * @method static \Phaseolies\Http\Response setMaxAge(int $value) + * @method static \Phaseolies\Http\Response setStaleIfError(int $value) + * @method static \Phaseolies\Http\Response setStaleWhileRevalidate(int $value) + * @method static \Phaseolies\Http\Response setSharedMaxAge(int $value) + * @method static int|null getTtl() + * @method static \Phaseolies\Http\Response setTtl(int $seconds) + * @method static \Phaseolies\Http\Response setClientTtl(int $seconds) + * @method static \DateTimeImmutable|null getLastModified() + * @method static \Phaseolies\Http\Response setLastModified(?\DateTimeInterface $date) + * @method static string|null getEtag() + * @method static \Phaseolies\Http\Response setEtag(?string $etag, bool $weak = false) + * @method static \Phaseolies\Http\Response setCache(array $options) + * @method static \Phaseolies\Http\Response setNotModified() + * @method static bool hasVary() + * @method static array getVary() + * @method static \Phaseolies\Http\Response setVary(string|array $headers, bool $replace = true) + * @method static bool isNotModified(\Phaseolies\Http\Request $request) + * @method static bool isInvalid() + * @method static bool isSuccessful() + * @method static bool isRedirection() + * @method static bool isClientError() + * @method static bool isServerError() + * @method static bool isOk() + * @method static bool isForbidden() + * @method static bool isNotFound() + * @method static bool isRedirect(?string $location = null) + * @method static void setContentSafe(bool $safe = true) * * @see \Phaseolies\Http\Response */ diff --git a/src/Phaseolies/Support/Router.php b/src/Phaseolies/Support/Router.php index 9600ca93..138718fd 100644 --- a/src/Phaseolies/Support/Router.php +++ b/src/Phaseolies/Support/Router.php @@ -2,7 +2,6 @@ namespace Phaseolies\Support; -use Ramsey\Collection\Collection; use Phaseolies\Middleware\Attributes\Middleware; use Phaseolies\Support\Router\Plan\ActionPlanStore; use Phaseolies\Support\Router\InteractsWithCurrentRouter; @@ -14,7 +13,6 @@ use Phaseolies\Http\Response; use Phaseolies\Http\Request; use Phaseolies\Database\Entity\Model; -use Phaseolies\Database\Entity\Builder; use Phaseolies\Application; use Phaseolies\Http\Contracts\GatewayInterface; @@ -36,6 +34,13 @@ class Router */ private ?ActionPlanStore $actionPlans = null; + /** + * Route table resolved per HEAD request (HEAD or GET fallback) + * + * @var \WeakMap|null + */ + private ?\WeakMap $resolvedRouteMethods = null; + /** * Holds the registered routes. * @@ -1165,9 +1170,47 @@ protected function getCurrentRequestMethod(): string * @return mixed The route callback or false if not found. */ public function getCallback($request): mixed + { + return $this->matchRoute($this->resolveRouteMethod($request), $request); + } + + /** + * Resolve which route table serves the request. + * + * HEAD requests are served by an explicit HEAD route when one matches, + * otherwise by the GET route for the same path (RFC 9110 §9.3.2). Every + * route lookup (callback, middleware, route name) goes through this so a + * HEAD request always runs the same route, with the same middleware, as GET. + * + * @param Request $request + * @return string + */ + protected function resolveRouteMethod($request): string { $method = $request->getMethod(); - $url = $request->getPath(); + + if ($method !== Request::METHOD_HEAD) { + return $method; + } + + $this->resolvedRouteMethods ??= new \WeakMap(); + + return $this->resolvedRouteMethods[$request] ??= + $this->matchRoute(Request::METHOD_HEAD, $request) !== false + ? Request::METHOD_HEAD + : Request::METHOD_GET; + } + + /** + * Find the route callback registered for a method that matches the request path. + * + * @param string $method + * @param Request $request + * @return mixed The route callback or false if not found. + */ + protected function matchRoute(string $method, $request): mixed + { + $url = $request->getPath(); $url = ($url !== '/') ? rtrim($url, '/') : $url; @@ -1327,7 +1370,7 @@ protected function isModifyingRequest(Request $request): bool public function getCurrentRouteMiddleware($request): ?array { $url = $request->getPath(); - $method = $request->getMethod(); + $method = $this->resolveRouteMethod($request); $routes = self::$routes[$method] ?? []; foreach ($routes as $route => $callback) { @@ -1814,8 +1857,8 @@ private function resolveModelParameter(array $parameter, array $routeParams): mi /** * Resolve form request class * - * @param Application $app The application instance - * @param string $typeName The class name to resolve + * @param Application $app + * @param string $typeName * @return mixed */ private function resolveFormRequestValidationClass($app, $typeName): mixed @@ -1849,11 +1892,10 @@ private function getResolutionResponse($request, $result): Response $response = response()->make(); $response->setOriginal($result); - if ($this->shouldBeJson($result)) { + if (Response::shouldBeJson($result)) { $request->setRequestFormat('json'); $response->headers->set('Content-Type', 'application/json'); - $result = $this->convertToSerializable($result); - $response->setBody(json_encode($result, JSON_THROW_ON_ERROR)); + $response->setBody(json_encode(Response::toSerializable($result), JSON_THROW_ON_ERROR)); } else { $response->setBody($result ?? ''); } @@ -1861,51 +1903,6 @@ private function getResolutionResponse($request, $result): Response return $response; } - /** - * Determine if the given data should be returned as JSON. - * - * @param mixed $data - * @return bool - */ - protected function shouldBeJson($data): bool - { - return is_array($data) || - is_object($data) || - $data instanceof \JsonSerializable || - $data instanceof Model || - $data instanceof Collection || - $data instanceof Builder || - $data instanceof \stdClass || - $data instanceof \ArrayObject; - } - - /** - * Convert various data types into a format suitable for serialization - * - * @param mixed $data - * @return mixed - */ - protected function convertToSerializable($data) - { - if ($data instanceof Model || $data instanceof Collection) { - return $data->toArray(); - } - - if ($data instanceof Builder) { - return $data->get()->toArray(); - } - - if ($data instanceof \JsonSerializable) { - return $data->jsonSerialize(); - } - - if ($data instanceof \stdClass || $data instanceof \ArrayObject) { - return (array) $data; - } - - return $data; - } - /** * Execute a controller action within a database transaction * diff --git a/src/Phaseolies/Support/Router/InteractsWithCurrentRouter.php b/src/Phaseolies/Support/Router/InteractsWithCurrentRouter.php index 83979c02..447e72cf 100644 --- a/src/Phaseolies/Support/Router/InteractsWithCurrentRouter.php +++ b/src/Phaseolies/Support/Router/InteractsWithCurrentRouter.php @@ -22,7 +22,7 @@ public function getRouteNames(): ?array public function getCurrentMiddlewareNames(): ?array { $url = request()->getPath(); - $method = request()->getMethod(); + $method = $this->resolveRouteMethod(request()); if (isset(self::$routeMiddlewares[$method][$url])) { return self::$routeMiddlewares[$method][$url]; @@ -82,7 +82,7 @@ public function is(string $name): bool public function currentRouteName(): ?string { $currentPath = request()->getPath(); - $currentMethod = request()->getMethod(); + $currentMethod = $this->resolveRouteMethod(request()); foreach (self::$namedRoutes as $name => $path) { if ($currentPath === $path) { diff --git a/tests/Application/ApplicationTest.php b/tests/Application/ApplicationTest.php index 3384768e..672af535 100644 --- a/tests/Application/ApplicationTest.php +++ b/tests/Application/ApplicationTest.php @@ -398,6 +398,29 @@ public function testHandleReturnsResolvedResponse(): void $this->assertSame($response, $handled); } + public function testHandleRejectsUnparseableBodyWithBadRequest(): void + { + $request = new class () extends Request { + public function __construct() + { + parent::__construct(); + $this->bodyParseError = new \RuntimeException('Invalid JSON data in request body.'); + } + }; + + $router = $this->createMock(Router::class); + $router->expects($this->never())->method('resolve'); + $this->app->router = $router; + + try { + $this->app->handle($request); + $this->fail('Expected a 400 Bad Request exception.'); + } catch (\Phaseolies\Http\Exceptions\BadRequestHttpException $e) { + $this->assertSame(400, $e->getStatusCode()); + $this->assertSame('Invalid JSON data in request body.', $e->getMessage()); + } + } + public function testDispatchReturnsTerminableResult(): void { $request = new Request(); diff --git a/tests/Requests/AllRequestInputTest.php b/tests/Requests/AllRequestInputTest.php index 069fa376..ad66cc3c 100644 --- a/tests/Requests/AllRequestInputTest.php +++ b/tests/Requests/AllRequestInputTest.php @@ -1500,4 +1500,179 @@ public function testItDetectsRequestsFromTrustedProxies() $this->assertTrue($request->isFromTrustedProxy()); } + + public function testIpUsesForwardedForOnlyFromTrustedProxies() + { + Request::setTrustedProxies(['192.168.1.1'], Request::HEADER_X_FORWARDED_FOR); + $_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.1'; + + $_SERVER['REMOTE_ADDR'] = '192.168.1.1'; + $trusted = Request::createFromGlobals(); + + $_SERVER['REMOTE_ADDR'] = '198.51.100.7'; + $untrusted = Request::createFromGlobals(); + + $this->assertSame('203.0.113.1', $trusted->ip()); + $this->assertSame(['203.0.113.1'], $trusted->ips()); + $this->assertSame('198.51.100.7', $untrusted->ip()); + $this->assertSame(['198.51.100.7'], $untrusted->ips()); + } + + public function testSchemeMatchesGetSchemeBehindTrustedProxy() + { + Request::setTrustedProxies(['192.168.1.1'], Request::HEADER_X_FORWARDED_PROTO); + + $_SERVER['REMOTE_ADDR'] = '192.168.1.1'; + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + $_SERVER['HTTPS'] = 'off'; + + $request = Request::createFromGlobals(); + + $this->assertSame('https', $request->scheme()); + $this->assertSame($request->getScheme(), $request->scheme()); + } + + public function testGetRouteParamReadsRouteParamsThenAttributes() + { + $request = new Request(attributes: ['locale' => 'en'], server: $this->defaultServerData); + $request->setRouteParams(['id' => '42']); + + $this->assertSame('42', $request->getRouteParam('id')); + $this->assertSame('en', $request->getRouteParam('locale')); + $this->assertSame('fallback', $request->getRouteParam('missing', 'fallback')); + } + + public function testInputWithoutKeyReturnsAllInput() + { + $_POST = ['name' => 'Doppar', 'role' => 'framework']; + + $request = Request::createFromGlobals(); + + $this->assertSame($request->all(), $request->input()); + $this->assertSame('Doppar', $request->input()['name']); + } + + public function testInputReflectsChangesMadeDirectlyToTheRequestBag() + { + $_POST = ['status' => 'draft']; + $request = Request::createFromGlobals(); + + $this->assertSame('draft', $request->input('status')); + + $request->request->set('status', 'published'); + + $this->assertSame('published', $request->input('status')); + $this->assertTrue($request->has('status')); + } + + public function testEmptyJsonBodyDoesNotThrow() + { + $request = $this->bodyParsingRequest('POST', 'application/json', ''); + + $this->assertSame([], $request->parse()); + } + + public function testScalarJsonBodyIsIgnored() + { + $request = $this->bodyParsingRequest('POST', 'application/json', '"just a string"'); + + $this->assertSame([], $request->parse()); + } + + public function testCreateFromGlobalsKeepsAUsableRequestWhenTheBodyIsInvalid() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_POST = ['kept' => 'yes']; + + $class = get_class(new class extends Request { + public function getContent(bool $asResource = false) + { + return '{invalid'; + } + }); + + $request = $class::createFromGlobals(); + + $this->assertInstanceOf(\RuntimeException::class, $request->getBodyParseError()); + $this->assertSame('Invalid JSON data in request body.', $request->getBodyParseError()->getMessage()); + $this->assertSame(['kept' => 'yes'], $request->all()); + } + + public function testCreateFromGlobalsHasNoBodyParseErrorForValidRequests() + { + $this->assertNull(Request::createFromGlobals()->getBodyParseError()); + } + + public function testInvalidJsonBodyStillThrows() + { + $request = $this->bodyParsingRequest('POST', 'application/json', '{invalid'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid JSON data in request body.'); + + $request->parse(); + } + + public function testUrlEncodedBodyKeepsQueryParametersLikeJsonBodies() + { + $urlEncoded = $this->bodyParsingRequest('PUT', 'application/x-www-form-urlencoded', 'name=doppar', ['page' => '2']); + $json = $this->bodyParsingRequest('PUT', 'application/json', '{"name":"doppar"}', ['page' => '2']); + + $this->assertSame(['page' => '2', 'name' => 'doppar'], $urlEncoded->parse()); + $this->assertSame($json->parse(), $urlEncoded->parse()); + } + + public function testHasFilesDetectsSingleAndMultipleUploads() + { + $_FILES = [ + 'avatar' => ['name' => 'a.png', 'type' => 'image/png', 'tmp_name' => '/tmp/a', 'error' => UPLOAD_ERR_NO_FILE, 'size' => 0], + 'docs' => [ + 'name' => ['a.pdf', 'b.pdf'], + 'type' => ['application/pdf', 'application/pdf'], + 'tmp_name' => ['/tmp/x', '/tmp/y'], + 'error' => [UPLOAD_ERR_NO_FILE, UPLOAD_ERR_OK], + 'size' => [0, 10], + ], + ]; + + $request = Request::createFromGlobals(); + + $this->assertFalse($request->hasFile('avatar')); + $this->assertTrue($request->hasFile('docs')); + $this->assertFalse($request->hasFile('missing')); + $this->assertTrue($request->hasFiles()); + } + + public function testBearerTokenIsExtractedFromAuthorizationHeader() + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer secret-token'; + $withToken = Request::createFromGlobals(); + + unset($_SERVER['HTTP_AUTHORIZATION']); + $withoutToken = Request::createFromGlobals(); + + $this->assertSame('secret-token', $withToken->bearerToken()); + $this->assertNull($withoutToken->bearerToken()); + } + + /** + * Build a request whose protected body parser can be invoked directly. + * + * @param array $query + */ + private function bodyParsingRequest(string $method, string $contentType, string $content, array $query = []): Request + { + $server = array_merge($this->defaultServerData, [ + 'REQUEST_METHOD' => $method, + 'CONTENT_TYPE' => $contentType, + ]); + + return new class ($query, [], [], [], [], $server, $content) extends Request { + public function parse(): array + { + return $this->parseRequestBody(); + } + }; + } } \ No newline at end of file diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 66d36d8e..259c5b03 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -413,4 +413,121 @@ public function testClone() $this->assertEquals('Value', $clone->headers->get('X-Test')); $this->assertNotSame($this->response->headers, $clone->headers); } + + public function testTextUpdatesStatusTextWithStatusCode() + { + $response = $this->response->text('Missing', 404); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertStringContainsString('HTTP/1.0 404 Not Found', (string) $response); + $this->assertSame('text/plain', $response->headers->get('Content-Type')); + } + + public function testSetExceptionErrorStoresTheExceptionInstance() + { + $exception = new \RuntimeException('Boom', 500); + + // In the CLI, http_response_code() warns once a status line was already sent. + set_error_handler(static fn() => true); + + try { + $this->response->setExceptionError($exception, 500); + } finally { + restore_error_handler(); + } + + $this->assertSame($exception, $this->response->exception); + $this->assertSame(500, $this->response->getStatusCode()); + } + + public function testStreamedResponseDoesNotStreamForHeadRequests() + { + $response = new \Phaseolies\Http\Response\Stream\StreamedResponse(function () { + echo 'streamed body'; + }); + $request = new Request(); + $request->server->set('SERVER_PROTOCOL', 'HTTP/1.1'); + $request->server->set('REQUEST_METHOD', 'HEAD'); + + $response->prepare($request); + + ob_start(); + $response->sendContent(); + $output = ob_get_clean(); + + $this->assertSame('', $output); + } + + public function testStreamedResponseRejectsAStringBody() + { + $response = new \Phaseolies\Http\Response\Stream\StreamedResponse(fn() => null); + + $this->expectException(\LogicException::class); + + $response->setBody('not allowed'); + } + + public function testShouldBeJsonDistinguishesSerializableFromStringableContent() + { + $stringable = new class implements \Stringable { + public function __toString(): string + { + return 'plain'; + } + }; + + $serializableStringable = new class implements \Stringable, \JsonSerializable { + public function __toString(): string + { + return 'plain'; + } + + public function jsonSerialize(): array + { + return ['ok' => true]; + } + }; + + $this->assertTrue(Response::shouldBeJson(['a' => 1])); + $this->assertTrue(Response::shouldBeJson(new \stdClass())); + $this->assertTrue(Response::shouldBeJson($serializableStringable)); + $this->assertFalse(Response::shouldBeJson($stringable)); + $this->assertFalse(Response::shouldBeJson('text')); + $this->assertFalse(Response::shouldBeJson(new Response())); + } + + public function testJsonResponseEncodesArrayObjectContents() + { + $response = new JsonResponse(new \ArrayObject(['name' => 'Doppar'])); + + $this->assertSame('{"name":"Doppar"}', $response->getBody()); + } + + public function testResponseFactoryRedirectReturnsRedirectResponse() + { + $response = (new \Phaseolies\Http\ResponseFactory())->redirect('/dashboard', 301); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame(301, $response->getStatusCode()); + $this->assertSame('/dashboard', $response->headers->get('Location')); + } + + public function testResponseFactoryMakeReturnsExistingResponseUnchanged() + { + $existing = new Response('body', 201); + + $this->assertSame($existing, (new \Phaseolies\Http\ResponseFactory())->make($existing)); + } + + public function testResponseFactoryTextDoesNotShareState() + { + $factory = new \Phaseolies\Http\ResponseFactory(); + + $first = $factory->text('first', 200); + $second = $factory->text('second', 404); + + $this->assertNotSame($first, $second); + $this->assertSame('first', $first->getBody()); + $this->assertSame(200, $first->getStatusCode()); + } } diff --git a/tests/Router/RouterTest.php b/tests/Router/RouterTest.php index f2d73f57..7743640f 100644 --- a/tests/Router/RouterTest.php +++ b/tests/Router/RouterTest.php @@ -450,6 +450,61 @@ public function testGetCallbackReturnsFalseForNotFound(): void $this->assertFalse($result); } + // ========================================================================= + // HEAD Requests + // ========================================================================= + + public function testHeadRequestFallsBackToGetRoute(): void + { + $callback = fn() => 'user profile'; + $this->router->get('/users/{id}', $callback); + + $request = new TestRequestStub('HEAD', '/users/7', 'localhost'); + + $this->assertSame($callback, $this->router->getCallback($request)); + $this->assertEquals(['id' => '7'], $request->getRouteParams()); + } + + public function testExplicitHeadRouteTakesPrecedenceOverGetRoute(): void + { + $getCallback = fn() => 'get'; + $headCallback = fn() => 'head'; + $this->router->get('/status', $getCallback); + $this->router->head('/status', $headCallback); + + $request = new TestRequestStub('HEAD', '/status', 'localhost'); + + $this->assertSame($headCallback, $this->router->getCallback($request)); + } + + public function testHeadRequestDoesNotFallBackToNonGetRoutes(): void + { + $this->router->post('/submit', fn() => 'post'); + + $request = new TestRequestStub('HEAD', '/submit', 'localhost'); + + $this->assertFalse($this->router->getCallback($request)); + } + + public function testHeadRequestUsesTheGetRouteMiddleware(): void + { + $this->router->get('/admin', fn() => 'admin')->middleware('auth', 'admin'); + + $request = new TestRequestStub('HEAD', '/admin', 'localhost'); + + $this->assertEquals(['auth', 'admin'], $this->router->getCurrentRouteMiddleware($request)); + } + + public function testHeadRequestUsesTheExplicitHeadRouteMiddleware(): void + { + $this->router->get('/report', fn() => 'get')->middleware('auth'); + $this->router->head('/report', fn() => 'head')->middleware('throttle:10,1'); + + $request = new TestRequestStub('HEAD', '/report', 'localhost'); + + $this->assertEquals(['throttle:10,1'], $this->router->getCurrentRouteMiddleware($request)); + } + public function testResolveUsesFreshResponseForScalarRouteResults(): void { $freshRouter = new TestableRouter(new Gateway()); diff --git a/tests/Session/DispatchSessionTest.php b/tests/Session/DispatchSessionTest.php index ce7049a7..76c9e9d1 100644 --- a/tests/Session/DispatchSessionTest.php +++ b/tests/Session/DispatchSessionTest.php @@ -360,7 +360,7 @@ public function write(string $id, array $data): string public function testASessionChangedBeforeAnHttpErrorIsStillSavedWhenTheErrorPageCannotRender(): void { - // Rendering the page needs a bootstrapped application, so here it fails. + // 418 has no error view, so rendering falls back to abort() and fails. // The session is saved around it, and no output buffer is left behind. $id = $this->seed(); $request = $this->requestOn($id); @@ -368,7 +368,7 @@ public function testASessionChangedBeforeAnHttpErrorIsStillSavedWhenTheErrorPage $this->routeTo(function () use ($request) { $request->session()->put('flash', 'kept'); - throw new HttpException(403, 'Forbidden'); + throw new HttpException(418, "I'm a teapot"); }); $level = ob_get_level(); From 8d4283c9dda775c41b3a7b8831fe8d737cb4b9ec Mon Sep 17 00:00:00 2001 From: techmahedy Date: Wed, 4 Nov 2026 18:06:19 +0600 Subject: [PATCH 2/5] fix(http): address PR review feedback on routing, request parsing and JSON output --- src/Phaseolies/Application.php | 6 +- src/Phaseolies/Error/WebErrorRenderer.php | 25 +--- .../ConflictingHeadersException.php | 12 ++ src/Phaseolies/Http/Request.php | 89 +++++++------- src/Phaseolies/Http/Response.php | 12 +- src/Phaseolies/Http/Support/RequestParser.php | 16 ++- src/Phaseolies/Support/Collection.php | 5 +- src/Phaseolies/Support/Router.php | 99 +++++++++------- .../Router/InteractsWithCurrentRouter.php | 38 ++---- .../Support/View/errors/400.odo.php | 23 ++++ tests/Application/ApplicationTest.php | 19 ++- tests/Error/WebErrorRendererTest.php | 42 ++++++- tests/Requests/AllRequestInputTest.php | 111 +++++++++++++++--- tests/ResponseTest.php | 48 ++++++++ tests/Router/RouterTest.php | 50 ++++++++ 15 files changed, 416 insertions(+), 179 deletions(-) create mode 100644 src/Phaseolies/Http/Exceptions/ConflictingHeadersException.php create mode 100644 src/Phaseolies/Support/View/errors/400.odo.php diff --git a/src/Phaseolies/Application.php b/src/Phaseolies/Application.php index d758fb29..9dc59aa9 100644 --- a/src/Phaseolies/Application.php +++ b/src/Phaseolies/Application.php @@ -1524,8 +1524,10 @@ protected function typeAllowsNull(\ReflectionType $type): bool */ public function handle(Request $request): Response { - if ($bodyParseError = $request->getBodyParseError()) { - throw new BadRequestHttpException($bodyParseError->getMessage(), $bodyParseError); + if ($error = $request->getMalformedRequestError()) { + throw $error instanceof BadRequestHttpException + ? $error + : new BadRequestHttpException($error->getMessage(), $error); } return $this->router->resolve($this, $request); diff --git a/src/Phaseolies/Error/WebErrorRenderer.php b/src/Phaseolies/Error/WebErrorRenderer.php index 55e1af93..f8a78cf0 100644 --- a/src/Phaseolies/Error/WebErrorRenderer.php +++ b/src/Phaseolies/Error/WebErrorRenderer.php @@ -225,29 +225,6 @@ protected function resolveStatusCode(Throwable $exception): int */ protected function resolveErrorViewPath(int $statusCode): ?string { - $customPath = base_path( - 'templates' - . DIRECTORY_SEPARATOR . 'views' - . DIRECTORY_SEPARATOR . 'errors' - . DIRECTORY_SEPARATOR . "{$statusCode}.odo.php" - ); - - if (file_exists($customPath)) { - return $customPath; - } - - $packagePath = base_path( - 'vendor' - . DIRECTORY_SEPARATOR . 'doppar' - . DIRECTORY_SEPARATOR . 'framework' - . DIRECTORY_SEPARATOR . 'src' - . DIRECTORY_SEPARATOR . 'Phaseolies' - . DIRECTORY_SEPARATOR . 'Support' - . DIRECTORY_SEPARATOR . 'View' - . DIRECTORY_SEPARATOR . 'errors' - . DIRECTORY_SEPARATOR . "{$statusCode}.odo.php" - ); - - return file_exists($packagePath) ? $packagePath : null; + return Response::resolveErrorView($statusCode); } } diff --git a/src/Phaseolies/Http/Exceptions/ConflictingHeadersException.php b/src/Phaseolies/Http/Exceptions/ConflictingHeadersException.php new file mode 100644 index 00000000..1ddc8274 --- /dev/null +++ b/src/Phaseolies/Http/Exceptions/ConflictingHeadersException.php @@ -0,0 +1,12 @@ +content = $content; $this->requestUri = $this->getPath(); $this->baseUrl = base_url(); - $this->getMethod(); + $this->validateMethodOverride(); + } + + /** + * Resolve the request method once, recording an invalid method instead of throwing. + * + * @return void + */ + private function validateMethodOverride(): void + { + try { + $this->getMethod(); + } catch (BadRequestHttpException $e) { + $this->malformedRequestError ??= $e; + $this->ignoreMethodOverride = true; + $this->method = $this->getRealMethod(); + } } /** @@ -240,16 +259,16 @@ protected function parseRequestBody(): array throw new \RuntimeException("Invalid JSON data in request body."); } - return is_array($jsonData) ? array_merge($request, $jsonData) : $request; + return is_array($jsonData) ? array_replace($request, $jsonData) : $request; } if ($isUrlEncoded) { parse_str($rawContent, $data); - return array_merge($request, $data); + return array_replace($request, $data); } - return array_merge($request, $this->parseMultipartFormData($rawContent)); + return array_replace($request, $this->parseMultipartFormData($rawContent)); } /** @@ -422,34 +441,6 @@ public function isValidMethod(): bool return $method !== self::METHOD_ANY && in_array($method, self::VALID_HTTP_METHODS, true); } - /** - * Validates that a request coming from a trusted proxy carries every trusted header. - * - * @return bool - */ - public function isValidRequest(): bool - { - if (!$this->isFromTrustedProxy()) { - return true; - } - - if (self::$trustedHeaderSet <= 0) { - // No trusted headers set, all requests are valid. - return true; - } - - foreach (self::TRUSTED_HEADERS as $headerBit => $headerName) { - if ((self::$trustedHeaderSet & $headerBit) === $headerBit) { - if (!$this->server->has("HTTP_" . $headerName)) { - // Required trusted header is missing. - return false; - } - } - } - - return true; - } - public function __toString(): string { $content = $this->getContent(); @@ -591,7 +582,7 @@ public function all(): array */ public function merge(array $input): self { - $this->request->replace(array_merge($this->request->all(), $input)); + $this->request->replace(array_replace($this->request->all(), $input)); return $this; } @@ -619,11 +610,15 @@ public function getPath(): string * * The method is always an uppercased string. * @return string - * @@throws \InvalidArgumentException + * @throws BadRequestHttpException */ public function getMethod(): string { - $this->method = strtoupper($this->server->get("REQUEST_METHOD", "GET")); + $this->method = $this->getRealMethod(); + + if ($this->ignoreMethodOverride) { + return $this->method; + } if ($this->headers->has("X-HTTP-METHOD-OVERRIDE")) { $method = strtoupper($this->headers->get("X-HTTP-METHOD-OVERRIDE")); @@ -642,7 +637,11 @@ public function getMethod(): string return $this->method; } - throw new \InvalidArgumentException("Invalid HTTP method override: $method."); + throw new BadRequestHttpException( + $method === $this->method + ? "Unsupported HTTP method: $method." + : "Invalid HTTP method override: $method." + ); } /** @@ -1226,12 +1225,14 @@ private function getTrustedValues(int $type, ?string $ip = null): array return $this->trustedValuesCache[$cacheKey] = $clientValues; } + // After the conflict has been reported once, fall back to the connecting + // address so error handling (logging, rendering) can still read the IP. if (!$this->isForwardedValid) { - return $this->trustedValuesCache[$cacheKey] = null !== $ip ? ['0.0.0.0', $ip] : []; + return $this->trustedValuesCache[$cacheKey] = null !== $ip ? [$ip] : []; } $this->isForwardedValid = false; - throw new \Exception(\sprintf('The request has both a trusted "%s" header and a trusted "%s" header, conflicting with each other. You should either configure your proxy to remove one of them, or configure your project to distrust the offending one.', self::TRUSTED_HEADERS[self::HEADER_FORWARDED], self::TRUSTED_HEADERS[$type])); + throw new ConflictingHeadersException(\sprintf('The request has both a trusted "%s" header and a trusted "%s" header, conflicting with each other. You should either configure your proxy to remove one of them, or configure your project to distrust the offending one.', self::TRUSTED_HEADERS[self::HEADER_FORWARDED], self::TRUSTED_HEADERS[$type])); } /** @@ -1459,20 +1460,22 @@ public static function createFromGlobals(): static try { $request->request = new InputBag($request->parseRequestBody()); } catch (\RuntimeException $e) { - $request->bodyParseError = $e; + $request->malformedRequestError ??= $e; } + $request->validateMethodOverride(); + return $request; } /** - * Get the error raised while parsing the request body, if any. + * Get the error that makes this request malformed, if any. * * @return \RuntimeException|null */ - public function getBodyParseError(): ?\RuntimeException + public function getMalformedRequestError(): ?\RuntimeException { - return $this->bodyParseError; + return $this->malformedRequestError; } /** diff --git a/src/Phaseolies/Http/Response.php b/src/Phaseolies/Http/Response.php index 44501189..24403a53 100644 --- a/src/Phaseolies/Http/Response.php +++ b/src/Phaseolies/Http/Response.php @@ -9,8 +9,6 @@ use Phaseolies\Http\Exceptions\HttpException; use Phaseolies\Http\Controllers\Controller; use Phaseolies\Database\Entity\Builder; -use Phaseolies\Database\Entity\Model; -use Phaseolies\Support\Collection; class Response implements HttpStatus { @@ -1580,21 +1578,17 @@ public static function shouldBeJson(mixed $content): bool public static function toSerializable(mixed $content): mixed { if ($content instanceof Builder) { - return $content->get()->toArray(); + return $content->get(); } - if ($content instanceof Model || $content instanceof Collection) { - return $content->toArray(); + if ($content instanceof \JsonSerializable || $content instanceof \stdClass) { + return $content; } if ($content instanceof \ArrayObject) { return $content->getArrayCopy(); } - if ($content instanceof \JsonSerializable || $content instanceof \stdClass) { - return $content; - } - if (is_object($content) && method_exists($content, 'toArray')) { return $content->toArray(); } diff --git a/src/Phaseolies/Http/Support/RequestParser.php b/src/Phaseolies/Http/Support/RequestParser.php index 6c672215..0e3e33b3 100644 --- a/src/Phaseolies/Http/Support/RequestParser.php +++ b/src/Phaseolies/Http/Support/RequestParser.php @@ -7,11 +7,14 @@ trait RequestParser { /** - * Retrieves the client's IP address. + * Retrieves the client's IP address (alias of getClientIp()). * - * Forwarded headers are only honoured when the request comes from a trusted proxy. + * Forwarded headers are only honoured when the request comes from a trusted + * proxy. The result is the first address in ips(): the closest hop that is + * not a trusted proxy. * * @return string|null + * @throws \Phaseolies\Http\Exceptions\ConflictingHeadersException */ public function ip(): ?string { @@ -19,10 +22,15 @@ public function ip(): ?string } /** - * Get all client IP addresses, with trusted proxies stripped. - * The original client IP is the first element of the array. + * Get the IP addresses of the request's forwarding chain, with trusted proxies stripped. + * + * Ordered from the most trusted to the least trusted address: the first + * element is the closest hop that is not a trusted proxy (what ip() returns), + * and the last is the originating client as reported by the forwarding + * headers, which an untrusted hop could have forged. * * @return array + * @throws \Phaseolies\Http\Exceptions\ConflictingHeadersException */ public function ips(): array { diff --git a/src/Phaseolies/Support/Collection.php b/src/Phaseolies/Support/Collection.php index 1d9d854d..558e6441 100644 --- a/src/Phaseolies/Support/Collection.php +++ b/src/Phaseolies/Support/Collection.php @@ -147,7 +147,10 @@ public function getIterator(): Traversable */ public function jsonSerialize(): array { - return $this->toArray(); + return array_map( + fn($item) => $item instanceof JsonSerializable ? $item->jsonSerialize() : $item, + $this->data + ); } /** diff --git a/src/Phaseolies/Support/Router.php b/src/Phaseolies/Support/Router.php index 138718fd..8028b0cc 100644 --- a/src/Phaseolies/Support/Router.php +++ b/src/Phaseolies/Support/Router.php @@ -35,11 +35,11 @@ class Router private ?ActionPlanStore $actionPlans = null; /** - * Route table resolved per HEAD request (HEAD or GET fallback) + * The route dispatched for each request by getCallback() * - * @var \WeakMap|null + * @var \WeakMap|null */ - private ?\WeakMap $resolvedRouteMethods = null; + private ?\WeakMap $dispatchedRoutes = null; /** * Holds the registered routes. @@ -1171,44 +1171,66 @@ protected function getCurrentRequestMethod(): string */ public function getCallback($request): mixed { - return $this->matchRoute($this->resolveRouteMethod($request), $request); + $match = $this->findRoute($request); + + $this->dispatchedRoutes ??= new \WeakMap(); + + if ($match === null) { + unset($this->dispatchedRoutes[$request]); + + return false; + } + + $this->dispatchedRoutes[$request] = $match; + + return $match['callback']; } /** - * Resolve which route table serves the request. + * Find the route that serves the request in the current route tables. * * HEAD requests are served by an explicit HEAD route when one matches, - * otherwise by the GET route for the same path (RFC 9110 §9.3.2). Every - * route lookup (callback, middleware, route name) goes through this so a - * HEAD request always runs the same route, with the same middleware, as GET. + * otherwise by the GET route for the same path (RFC 9110 §9.3.2). * * @param Request $request - * @return string + * @return array{method: string, route: string, callback: mixed}|null */ - protected function resolveRouteMethod($request): string + protected function findRoute($request): ?array { $method = $request->getMethod(); - if ($method !== Request::METHOD_HEAD) { - return $method; + $candidates = $method === Request::METHOD_HEAD + ? [Request::METHOD_HEAD, Request::METHOD_GET] + : [$method]; + + foreach ($candidates as $candidate) { + if (($match = $this->matchRoute($candidate, $request)) !== null) { + return $match; + } } - $this->resolvedRouteMethods ??= new \WeakMap(); + return null; + } - return $this->resolvedRouteMethods[$request] ??= - $this->matchRoute(Request::METHOD_HEAD, $request) !== false - ? Request::METHOD_HEAD - : Request::METHOD_GET; + /** + * Get the route serving the request: the one getCallback() dispatched + * + * @param Request $request + * @return array{method: string, route: string, callback: mixed}|null + */ + protected function routeFor($request): ?array + { + return $this->dispatchedRoutes[$request] ?? $this->findRoute($request); } /** - * Find the route callback registered for a method that matches the request path. + * Find the route registered for a method that matches the request path. * * @param string $method * @param Request $request - * @return mixed The route callback or false if not found. + * @return array{method: string, route: string, callback: mixed}|null */ - protected function matchRoute(string $method, $request): mixed + protected function matchRoute(string $method, $request): ?array { $url = $request->getPath(); @@ -1224,14 +1246,11 @@ protected function matchRoute(string $method, $request): mixed continue; } - // Exact match - if ($route === $url) { - return $callback; - } + $match = ['method' => $method, 'route' => (string) $route, 'callback' => $callback]; - // Catch-all wildcard - if ($route === '(.*)') { - return $callback; + // Exact match, or the catch-all wildcard + if ($route === $url || $route === '(.*)') { + return $match; } // Pattern match with named parameters @@ -1240,15 +1259,14 @@ protected function matchRoute(string $method, $request): mixed if (preg_match($routeRegex, $url, $matches)) { $params = $this->extractRouteParameters($route, $matches); if ($params !== false) { - $existing = $request->getRouteParams(); - $merged = array_merge($existing, $params); - $request->setRouteParams($merged); - return $callback; + $request->setRouteParams(array_merge($request->getRouteParams(), $params)); + + return $match; } } } - return false; + return null; } /** @@ -1369,22 +1387,13 @@ protected function isModifyingRequest(Request $request): bool */ public function getCurrentRouteMiddleware($request): ?array { - $url = $request->getPath(); - $method = $this->resolveRouteMethod($request); - $routes = self::$routes[$method] ?? []; + $match = $this->routeFor($request); - foreach ($routes as $route => $callback) { - $routeRegex = "@^" . preg_replace_callback( - self::PARAM_PATTERN, - fn(array $m) => '(' . (isset($m[2]) ? $this->constraintRegex($m[2]) : '[^/]+') . ')', - $route - ) . "$@"; - if (preg_match($routeRegex, $url)) { - return self::$routeMiddlewares[$method][$route] ?? null; - } + if ($match === null) { + return null; } - return null; + return self::$routeMiddlewares[$match['method']][$match['route']] ?? null; } /** diff --git a/src/Phaseolies/Support/Router/InteractsWithCurrentRouter.php b/src/Phaseolies/Support/Router/InteractsWithCurrentRouter.php index 447e72cf..6880e05a 100644 --- a/src/Phaseolies/Support/Router/InteractsWithCurrentRouter.php +++ b/src/Phaseolies/Support/Router/InteractsWithCurrentRouter.php @@ -21,21 +21,7 @@ public function getRouteNames(): ?array */ public function getCurrentMiddlewareNames(): ?array { - $url = request()->getPath(); - $method = $this->resolveRouteMethod(request()); - - if (isset(self::$routeMiddlewares[$method][$url])) { - return self::$routeMiddlewares[$method][$url]; - } - - foreach (self::$routeMiddlewares[$method] as $route => $middlewares) { - $routeRegex = $this->convertRouteToRegex($route); - if (preg_match($routeRegex, $url)) { - return $middlewares; - } - } - - return null; + return $this->getCurrentRouteMiddleware(request()); } /** @@ -81,23 +67,15 @@ public function is(string $name): bool */ public function currentRouteName(): ?string { - $currentPath = request()->getPath(); - $currentMethod = $this->resolveRouteMethod(request()); - - foreach (self::$namedRoutes as $name => $path) { - if ($currentPath === $path) { - if (isset(self::$routes[$currentMethod][$path])) { - return $name; - } - } - - $routeRegex = $this->convertRouteToRegex($path); - if (preg_match($routeRegex, $currentPath) && isset(self::$routes[$currentMethod][$path])) { - return $name; - } + $match = $this->routeFor(request()); + + if ($match === null) { + return null; } - return null; + $name = array_search($match['route'], self::$namedRoutes, true); + + return $name === false ? null : $name; } /** diff --git a/src/Phaseolies/Support/View/errors/400.odo.php b/src/Phaseolies/Support/View/errors/400.odo.php new file mode 100644 index 00000000..2a574d52 --- /dev/null +++ b/src/Phaseolies/Support/View/errors/400.odo.php @@ -0,0 +1,23 @@ + + + + + + 400 Bad Request + + +
+
+
+ 400 +
+
+ +
+
+
+ + diff --git a/tests/Application/ApplicationTest.php b/tests/Application/ApplicationTest.php index 672af535..8343505d 100644 --- a/tests/Application/ApplicationTest.php +++ b/tests/Application/ApplicationTest.php @@ -404,7 +404,7 @@ public function testHandleRejectsUnparseableBodyWithBadRequest(): void public function __construct() { parent::__construct(); - $this->bodyParseError = new \RuntimeException('Invalid JSON data in request body.'); + $this->malformedRequestError = new \RuntimeException('Invalid JSON data in request body.'); } }; @@ -421,6 +421,23 @@ public function __construct() } } + public function testHandleRethrowsAnInvalidMethodOverrideAsIs(): void + { + $request = new Request(server: ['REQUEST_METHOD' => 'POST', 'HTTP_X_HTTP_METHOD_OVERRIDE' => 'BOGUS']); + + $router = $this->createMock(Router::class); + $router->expects($this->never())->method('resolve'); + $this->app->router = $router; + + try { + $this->app->handle($request); + $this->fail('Expected a 400 Bad Request exception.'); + } catch (\Phaseolies\Http\Exceptions\BadRequestHttpException $e) { + $this->assertSame($request->getMalformedRequestError(), $e); + $this->assertSame('Invalid HTTP method override: BOGUS.', $e->getMessage()); + } + } + public function testDispatchReturnsTerminableResult(): void { $request = new Request(); diff --git a/tests/Error/WebErrorRendererTest.php b/tests/Error/WebErrorRendererTest.php index 15826cce..30751243 100644 --- a/tests/Error/WebErrorRendererTest.php +++ b/tests/Error/WebErrorRendererTest.php @@ -27,11 +27,14 @@ public function testRenderProductionReturnsResponseForGenericException(): void $renderer = new WebErrorRenderer(); $exception = new RuntimeException('Boom'); - $response = $renderer->renderProduction($exception); + $response = $this->renderWithPageBuffer($renderer, $exception); + // Rendered with the framework's bundled 500 view $this->assertInstanceOf(Response::class, $response); $this->assertSame(500, $response->getStatusCode()); - $this->assertSame('Something went wrong', $response->getBody()); + $this->assertStringContainsString('', $response->getBody()); + $this->assertStringContainsString('Something went wrong', $response->getBody()); + $this->assertStringNotContainsString('Boom', $response->getBody()); $this->assertSame($exception, $response->getOriginal()); } @@ -40,13 +43,44 @@ public function testRenderProductionPreservesHttpExceptionStatus(): void $renderer = new WebErrorRenderer(); $exception = HttpException::fromStatusCode(404, 'Not Found'); - $response = $renderer->renderProduction($exception); + $response = $this->renderWithPageBuffer($renderer, $exception); + // Rendered with the framework's bundled 404 view $this->assertSame(404, $response->getStatusCode()); - $this->assertSame('Not Found', $response->getBody()); + $this->assertStringContainsString('', $response->getBody()); + $this->assertStringContainsString('Not Found', $response->getBody()); $this->assertSame($exception, $response->getOriginal()); } + public function testRenderProductionFallsBackToPlainMessageWithoutAView(): void + { + $renderer = new WebErrorRenderer(); + $exception = HttpException::fromStatusCode(418, "I'm a teapot"); + + $response = $renderer->renderProduction($exception); + + $this->assertSame(418, $response->getStatusCode()); + $this->assertSame("I'm a teapot", $response->getBody()); + } + + /** + * Render inside an extra output buffer, as a real request has one open: + * rendering an error view discards the page's partial output first. + */ + private function renderWithPageBuffer(WebErrorRenderer $renderer, \Throwable $exception): Response + { + $level = ob_get_level(); + ob_start(); + + try { + return $renderer->renderProduction($exception); + } finally { + while (ob_get_level() > $level) { + ob_end_clean(); + } + } + } + public function testRenderProductionKeepsTheStatusOfATokenMismatch(): void { $renderer = new WebErrorRenderer(); diff --git a/tests/Requests/AllRequestInputTest.php b/tests/Requests/AllRequestInputTest.php index ad66cc3c..d38dfec2 100644 --- a/tests/Requests/AllRequestInputTest.php +++ b/tests/Requests/AllRequestInputTest.php @@ -1418,18 +1418,6 @@ public function testItFiltersTrustedProxyIpsFromClientIps() $this->assertContains('203.0.113.1', $clientIps); } - public function testItValidatesRequestsFromTrustedProxies() - { - Request::setTrustedProxies(['192.168.1.1'], Request::HEADER_X_FORWARDED_FOR); - - $_SERVER['REMOTE_ADDR'] = '192.168.1.1'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.1'; - - $request = Request::createFromGlobals(); - - $this->assertTrue($request->isValidRequest()); - } - public function testItHandlesForwardedHeaderRfc7239() { Request::setTrustedProxies(['192.168.1.1'], Request::HEADER_FORWARDED); @@ -1594,14 +1582,14 @@ public function getContent(bool $asResource = false) $request = $class::createFromGlobals(); - $this->assertInstanceOf(\RuntimeException::class, $request->getBodyParseError()); - $this->assertSame('Invalid JSON data in request body.', $request->getBodyParseError()->getMessage()); + $this->assertInstanceOf(\RuntimeException::class, $request->getMalformedRequestError()); + $this->assertSame('Invalid JSON data in request body.', $request->getMalformedRequestError()->getMessage()); $this->assertSame(['kept' => 'yes'], $request->all()); } - public function testCreateFromGlobalsHasNoBodyParseErrorForValidRequests() + public function testCreateFromGlobalsHasNoMalformedRequestErrorForValidRequests() { - $this->assertNull(Request::createFromGlobals()->getBodyParseError()); + $this->assertNull(Request::createFromGlobals()->getMalformedRequestError()); } public function testInvalidJsonBodyStillThrows() @@ -1656,6 +1644,97 @@ public function testBearerTokenIsExtractedFromAuthorizationHeader() $this->assertNull($withoutToken->bearerToken()); } + public function testUrlEncodedBodyKeepsNumericKeys() + { + // A form POST arrives both in $_POST and in the raw body + $server = array_merge($this->defaultServerData, [ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded', + ]); + $request = new class ([], ['10' => 'a', '20' => 'b'], [], [], [], $server, '10=a&20=b') extends Request { + public function parse(): array + { + return $this->parseRequestBody(); + } + }; + + $this->assertSame([10 => 'a', 20 => 'b'], $request->parse()); + } + + public function testJsonBodyKeepsNumericKeys() + { + $request = $this->bodyParsingRequest('POST', 'application/json', '{"10":"a","20":"b"}', ['5' => 'q']); + + $this->assertSame([5 => 'q', 10 => 'a', 20 => 'b'], $request->parse()); + } + + public function testMergeKeepsNumericKeys() + { + $_POST = ['10' => 'a']; + $request = Request::createFromGlobals(); + + $request->merge(['20' => 'b']); + + $this->assertSame([10 => 'a', 20 => 'b'], $request->all()); + } + + public function testConflictingTrustedForwardingHeadersAreABadRequest() + { + Request::setTrustedProxies(['192.168.1.1'], Request::HEADER_FORWARDED | Request::HEADER_X_FORWARDED_FOR); + + $_SERVER['REMOTE_ADDR'] = '192.168.1.1'; + $_SERVER['HTTP_FORWARDED'] = 'for=203.0.113.1'; + $_SERVER['HTTP_X_FORWARDED_FOR'] = '198.51.100.7'; + + $request = Request::createFromGlobals(); + + try { + $request->ip(); + $this->fail('Expected conflicting forwarding headers to be rejected.'); + } catch (\Phaseolies\Http\Exceptions\ConflictingHeadersException $e) { + $this->assertSame(400, $e->getStatusCode()); + } + + // Error handling can still read an address: the connecting one, not 0.0.0.0 + $this->assertSame('192.168.1.1', $request->ip()); + } + + public function testInvalidMethodOverrideIsRecordedInsteadOfThrown() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] = 'BOGUS'; + + $request = Request::createFromGlobals(); + + $error = $request->getMalformedRequestError(); + $this->assertInstanceOf(\Phaseolies\Http\Exceptions\BadRequestHttpException::class, $error); + $this->assertSame(400, $error->getStatusCode()); + $this->assertSame('Invalid HTTP method override: BOGUS.', $error->getMessage()); + $this->assertSame('POST', $request->getMethod()); + } + + public function testInvalidMethodOverrideInTheBodyIsRecordedInsteadOfThrown() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST = ['_method' => 'BOGUS']; + + $request = Request::createFromGlobals(); + + $this->assertInstanceOf(\Phaseolies\Http\Exceptions\BadRequestHttpException::class, $request->getMalformedRequestError()); + $this->assertSame('POST', $request->getMethod()); + } + + public function testValidMethodOverrideStillApplies() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST = ['_method' => 'PUT']; + + $request = Request::createFromGlobals(); + + $this->assertNull($request->getMalformedRequestError()); + $this->assertSame('PUT', $request->getMethod()); + } + /** * Build a request whose protected body parser can be invoked directly. * diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 259c5b03..dd8e959f 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -530,4 +530,52 @@ public function testResponseFactoryTextDoesNotShareState() $this->assertSame('first', $first->getBody()); $this->assertSame(200, $first->getStatusCode()); } + + public function testJsonHonoursCustomJsonSerializeOverToArray() + { + $model = $this->modelWithHiddenSecret(); + + $direct = new JsonResponse($model); + $collection = new JsonResponse(new \Phaseolies\Support\Collection(\Phaseolies\Database\Entity\Model::class, [$model])); + + $this->assertSame('{"name":"Doppar"}', $direct->getBody()); + $this->assertSame('[{"name":"Doppar"}]', $collection->getBody()); + // The router path (json_encode of toSerializable()) behaves the same way + $this->assertSame('{"name":"Doppar"}', json_encode(Response::toSerializable($model))); + } + + public function testBundledBadRequestViewEscapesTheMessage() + { + $message = ''; + + ob_start(); + include dirname(__DIR__) . '/src/Phaseolies/Support/View/errors/400.odo.php'; + $html = ob_get_clean(); + + $this->assertStringContainsString('400', $html); + $this->assertStringNotContainsString('