From 1de729321d4eeb78e8ef88a8c0faa0e93fa9b426 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:30:58 +0100 Subject: [PATCH] Feat: PSR-compliant headers and cookies on Request/Response Align the Request and Response header/cookie APIs with PSR-7 semantics while keeping utopia's mutable style. Headers: - Names are case-insensitive and stored lowercased internally. - getHeader() now returns a list of values (string[]); getHeaderLine() returns the comma-joined string; add hasHeader() and setHeader(). - getHeaders() returns array. - Header logic is centralized in the base Request; adapters only supply the raw source via generateHeaders()/generateCookies(). Cookies: - Add getCookieParams()/setCookieParams() on Request, backed by a lazy generateCookies() per adapter. getCookie() reads through it. Adapters: - FPM sendHeader() title-cases header names on the wire to match Swoole and preserve prior output; emits one line per value (Set-Cookie). - Swoole multi-value headers are passed as a single array (array_set in swoole-src overwrites, so per-value calls would drop all but the last). - Harden cookie sending: coalesce null SameSite/value to typed defaults so they are not passed where a string is expected. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Http/Adapter/FPM/Request.php | 91 ++------------ src/Http/Adapter/FPM/Response.php | 38 +++--- src/Http/Adapter/Swoole/Request.php | 79 +++++------- src/Http/Adapter/Swoole/Response.php | 8 +- src/Http/Http.php | 2 +- src/Http/Request.php | 176 ++++++++++++++++++++++----- src/Http/Response.php | 117 ++++++++++++------ tests/RequestTest.php | 72 +++++++++-- tests/ResponseTest.php | 16 +++ tests/e2e/init.php | 4 +- 10 files changed, 375 insertions(+), 228 deletions(-) diff --git a/src/Http/Adapter/FPM/Request.php b/src/Http/Adapter/FPM/Request.php index eda1dcd..2f3db6b 100644 --- a/src/Http/Adapter/FPM/Request.php +++ b/src/Http/Adapter/FPM/Request.php @@ -59,7 +59,7 @@ public function getIP(): string $remoteAddr = $this->getServer('REMOTE_ADDR') ?? '0.0.0.0'; foreach ($this->trustedIpHeaders as $header) { - $headerValue = $this->getHeader($header); + $headerValue = $this->getHeaderLine($header); if (empty($headerValue)) { continue; @@ -209,57 +209,19 @@ public function getAccept(string $default = ''): string } /** - * Get cookie + * Generate cookies * - * Method for querying HTTP cookie parameters. If $key is not found $default value will be returned. - */ - public function getCookie(string $key, string $default = ''): string - { - return $_COOKIE[$key] ?? $default; - } - - /** - * Get header - * - * Method for querying HTTP header parameters. If $key is not found $default value will be returned. - */ - public function getHeader(string $key, string $default = ''): string - { - $headers = $this->generateHeaders(); - - if (!isset($headers[$key])) { - return $default; - } - - $value = $headers[$key]; - - return \is_array($value) ? implode(', ', $value) : $value; - } - - /** - * Set header - * - * Method for adding HTTP header parameters. - */ - public function addHeader(string $key, string $value): static - { - $this->headers[$key] = $value; - - return $this; - } - - /** - * Remvoe header + * Parse request cookies into an associative array of cookie name to value. * - * Method for removing HTTP header parameters. + * @return array */ - public function removeHeader(string $key): static + protected function generateCookies(): array { - if (isset($this->headers[$key])) { - unset($this->headers[$key]); + if (null === $this->cookies) { + $this->cookies = $_COOKIE; } - return $this; + return $this->cookies; } /** @@ -275,7 +237,7 @@ protected function generateInput(): array $this->queryString = $_GET; } if (null === $this->payload) { - $contentType = $this->getHeader('content-type'); + $contentType = $this->getHeaderLine('content-type'); // Get content-type without the charset $length = strpos($contentType, ';'); @@ -302,39 +264,4 @@ protected function generateInput(): array default => $this->queryString, }; } - - /** - * Generate headers - * - * Parse request headers as an array for easy querying using the getHeader method - * - * @return array> - */ - #[\Override] - protected function generateHeaders(): array - { - if (null === $this->headers) { - /** - * Fallback for older PHP versions - * that do not support generateHeaders - */ - if (!\function_exists('getallheaders')) { - $headers = []; - - foreach ($_SERVER as $name => $value) { - if (str_starts_with($name, 'HTTP_')) { - $headers[str_replace(' ', '-', strtolower(str_replace('_', ' ', substr($name, 5))))] = $value; - } - } - - $this->headers = $headers; - - return $this->headers; - } - - $this->headers = array_change_key_case(getallheaders()); - } - - return $this->headers; - } } diff --git a/src/Http/Adapter/FPM/Response.php b/src/Http/Adapter/FPM/Response.php index 6a41a74..64765c0 100644 --- a/src/Http/Adapter/FPM/Response.php +++ b/src/Http/Adapter/FPM/Response.php @@ -45,18 +45,22 @@ protected function sendStatus(int $statusCode): void /** * Send Header * - * Output Header + * Output Header. Header names are stored lowercased internally; they are + * formatted to the conventional Title-Case form on the wire to match the + * Swoole adapter (e.g. "content-type" => "Content-Type"). * - * @param string|array $value + * @param array $value */ - public function sendHeader(string $key, mixed $value): void + public function sendHeader(string $key, array $value): void { - if (\is_array($value)) { - foreach ($value as $v) { - header($key . ': ' . $v, false); - } - } else { - header($key . ': ' . $value); + $key = ucwords(strtolower($key), '-'); + + // First value replaces any header of the same name; the rest are + // appended so multi-value headers (e.g. Set-Cookie) emit one line each. + $replace = true; + foreach ($value as $v) { + header($key . ': ' . $v, $replace); + $replace = false; } } @@ -69,11 +73,15 @@ public function sendHeader(string $key, mixed $value): void */ protected function sendCookie(string $name, string $value, array $options): void { - // Use proper PHP keyword name - $options['expires'] = $options['expire']; - unset($options['expire']); - - // Set the cookie - setcookie($name, $value, $options); + // Coalesce nulls to the types setcookie() expects for each option, and + // map our 'expire' key to PHP's 'expires' keyword. + setcookie($name, $value, [ + 'expires' => $options['expire'] ?? 0, + 'path' => $options['path'] ?? '', + 'domain' => $options['domain'] ?? '', + 'secure' => $options['secure'] ?? false, + 'httponly' => $options['httponly'] ?? false, + 'samesite' => $options['samesite'] ?? '', + ]); } } diff --git a/src/Http/Adapter/Swoole/Request.php b/src/Http/Adapter/Swoole/Request.php index 5bf23bd..3c4e19e 100644 --- a/src/Http/Adapter/Swoole/Request.php +++ b/src/Http/Adapter/Swoole/Request.php @@ -64,7 +64,7 @@ public function getIP(): string $remoteAddr = $this->getServer('remote_addr') ?? '0.0.0.0'; foreach ($this->trustedIpHeaders as $header) { - $headerValue = $this->getHeader($header); + $headerValue = $this->getHeaderLine($header); if (empty($headerValue)) { continue; @@ -92,7 +92,7 @@ public function getIP(): string */ public function getProtocol(): string { - $protocol = $this->getHeader('x-forwarded-proto', $this->getServer('server_protocol') ?? 'https'); + $protocol = $this->getHeaderLine('x-forwarded-proto', $this->getServer('server_protocol') ?? 'https'); if ($protocol === 'HTTP/1.1') { return 'http'; @@ -111,7 +111,7 @@ public function getProtocol(): string */ public function getPort(): string { - return $this->getHeader('x-forwarded-port', (string) parse_url($this->getProtocol() . '://' . $this->getHeader('x-forwarded-host', $this->getHeader('host')), PHP_URL_PORT)); + return $this->getHeaderLine('x-forwarded-port', (string) parse_url($this->getProtocol() . '://' . $this->getHeaderLine('x-forwarded-host', $this->getHeaderLine('host')), PHP_URL_PORT)); } /** @@ -121,7 +121,7 @@ public function getPort(): string */ public function getHostname(): string { - $hostname = parse_url($this->getProtocol() . '://' . $this->getHeader('x-forwarded-host', $this->getHeader('host')), PHP_URL_HOST); + $hostname = parse_url($this->getProtocol() . '://' . $this->getHeaderLine('x-forwarded-host', $this->getHeaderLine('host')), PHP_URL_HOST); return strtolower(\strval($hostname)); } @@ -177,7 +177,7 @@ public function setURI(string $uri): static */ public function getReferer(string $default = ''): string { - return $this->getHeader('referer', ''); + return $this->getHeaderLine('referer', $default); } /** @@ -187,7 +187,7 @@ public function getReferer(string $default = ''): string */ public function getOrigin(string $default = ''): string { - return $this->getHeader('origin', $default); + return $this->getHeaderLine('origin', $default); } /** @@ -197,7 +197,7 @@ public function getOrigin(string $default = ''): string */ public function getUserAgent(string $default = ''): string { - return $this->getHeader('user-agent', $default); + return $this->getHeaderLine('user-agent', $default); } /** @@ -207,7 +207,7 @@ public function getUserAgent(string $default = ''): string */ public function getAccept(string $default = ''): string { - return $this->getHeader('accept', $default); + return $this->getHeaderLine('accept', $default); } /** @@ -226,47 +226,19 @@ public function getFiles($key): array } /** - * Get cookie + * Generate cookies * - * Method for querying HTTP cookie parameters. If $key is not found $default value will be returned. - */ - public function getCookie(string $key, string $default = ''): string - { - $key = strtolower($key); - - return $this->swoole->cookie[$key] ?? $default; - } - - /** - * Get header + * Parse request cookies into an associative array of cookie name to value. * - * Method for querying HTTP header parameters. If $key is not found $default value will be returned. - */ - public function getHeader(string $key, string $default = ''): string - { - return $this->swoole->header[$key] ?? $default; - } - - /** - * Method for adding HTTP header parameters. - */ - public function addHeader(string $key, string $value): static - { - $this->swoole->header[$key] = $value; - - return $this; - } - - /** - * Method for removing HTTP header parameters. + * @return array */ - public function removeHeader(string $key): static + protected function generateCookies(): array { - if (isset($this->swoole->header[$key])) { - unset($this->swoole->header[$key]); + if (null === $this->cookies) { + $this->cookies = $this->swoole->cookie ?? []; } - return $this; + return $this->cookies; } public function getSwooleRequest(): SwooleRequest @@ -287,7 +259,7 @@ protected function generateInput(): array $this->queryString = $this->swoole->get ?? []; } if (null === $this->payload) { - $contentType = $this->getHeader('content-type'); + $contentType = $this->getHeaderLine('content-type'); // Get content-type without the charset $length = strpos($contentType, ';'); @@ -316,13 +288,26 @@ protected function generateInput(): array /** * Generate headers * - * Parse request headers as an array for easy querying using the getHeader method + * Parse Swoole request headers into a PSR-7 style map of lowercased header name + * to a list of string values for easy querying using the getHeader method. * - * @return array + * @return array> */ #[\Override] protected function generateHeaders(): array { - return $this->swoole->header; + if (null === $this->headers) { + $headers = []; + + foreach ($this->swoole->header ?? [] as $name => $value) { + $headers[strtolower($name)] = \is_array($value) + ? array_values(array_map(strval(...), $value)) + : [(string) $value]; + } + + $this->headers = $headers; + } + + return $this->headers; } } diff --git a/src/Http/Adapter/Swoole/Response.php b/src/Http/Adapter/Swoole/Response.php index 8676144..e453a7d 100644 --- a/src/Http/Adapter/Swoole/Response.php +++ b/src/Http/Adapter/Swoole/Response.php @@ -57,9 +57,9 @@ protected function sendStatus(int $statusCode): void /** * Send Header * - * @param string|array $value + * @param array $value */ - public function sendHeader(string $key, mixed $value): void + public function sendHeader(string $key, array $value): void { $this->swoole->header($key, $value); } @@ -73,6 +73,8 @@ public function sendHeader(string $key, mixed $value): void */ protected function sendCookie(string $name, string $value, array $options): void { + // Coalesce nulls to the types Swoole's cookie() expects: the SameSite + // argument is parsed as a string (Z_PARAM_STR), so it must not be a bool. $this->swoole->cookie( $name, $value, @@ -81,7 +83,7 @@ protected function sendCookie(string $name, string $value, array $options): void $options['domain'] ?? '', $options['secure'] ?? false, $options['httponly'] ?? false, - $options['samesite'] ?? false, + $options['samesite'] ?? '', ); } } diff --git a/src/Http/Http.php b/src/Http/Http.php index b4ae9bc..d13e049 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -791,7 +791,7 @@ public function run(Request $request, Response $response): static private function runInternal(Request $request, Response $response): static { if ($this->compression) { - $response->setAcceptEncoding($request->getHeader('accept-encoding', '')); + $response->setAcceptEncoding($request->getHeaderLine('accept-encoding', '')); $response->setCompressionMinSize($this->compressionMinSize); $response->setCompressionSupported($this->compressionSupported); } diff --git a/src/Http/Request.php b/src/Http/Request.php index 7042de6..d286213 100755 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -42,9 +42,19 @@ abstract class Request /** * Container for parsed headers * - * @var array>|null + * Each header is stored under its lowercased name and maps to a list of + * one or more string values, following the PSR-7 header representation. + * + * @var array>|null + */ + protected ?array $headers = null; + + /** + * Container for parsed cookies + * + * @var array|null */ - protected $headers; + protected ?array $cookies = null; /** * @var array @@ -240,23 +250,93 @@ abstract public function getAccept(string $default = ''): string; /** * Get cookie * - * Method for querying HTTP cookie parameters. If $key is not found $default value will be returned. + * Method for querying a single HTTP cookie. If $key is not found $default value will be returned. + */ + public function getCookie(string $key, string $default = ''): string + { + $cookies = $this->generateCookies(); + + return $cookies[$key] ?? $default; + } + + /** + * Get cookie params + * + * Method for getting all HTTP cookies as an associative array, following PSR-7. + * + * @return array + */ + public function getCookieParams(): array + { + return $this->generateCookies(); + } + + /** + * Set cookie params + * + * Replace the request cookies with the given associative array, following PSR-7. + * + * @param array $cookies + */ + public function setCookieParams(array $cookies): static + { + $this->cookies = $cookies; + + return $this; + } + + /** + * Has header + * + * Checks if a header exists by the given case-insensitive name, following PSR-7. */ - abstract public function getCookie(string $key, string $default = ''): string; + public function hasHeader(string $key): bool + { + $headers = $this->generateHeaders(); + + return isset($headers[strtolower($key)]); + } /** * Get header * - * Method for querying HTTP header parameters. If $key is not found $default value will be returned. + * Method for querying all values of a single HTTP header by its case-insensitive + * name, following PSR-7. Returns a list of strings, or $default when not found. + * + * @param array $default + * @return array + */ + public function getHeader(string $key, array $default = []): array + { + $headers = $this->generateHeaders(); + + return $headers[strtolower($key)] ?? $default; + } + + /** + * Get header line + * + * Returns all values for the given case-insensitive header name concatenated + * using a comma, following PSR-7. Returns $default when the header is not found. */ - abstract public function getHeader(string $key, string $default = ''): string; + public function getHeaderLine(string $key, string $default = ''): string + { + $values = $this->getHeader($key); + + if ($values === []) { + return $default; + } + + return implode(', ', $values); + } /** * Get headers * - * Method for getting all HTTP header parameters. + * Method for getting all HTTP headers as an associative array of header name + * to a list of string values, following PSR-7. * - * @return array + * @return array> */ public function getHeaders(): array { @@ -266,16 +346,43 @@ public function getHeaders(): array /** * Set header * - * Method for adding HTTP header parameters. + * Replace any existing values for the given header with a single value, + * mirroring PSR-7's withHeader. + */ + public function setHeader(string $key, string $value): static + { + $this->generateHeaders(); + $this->headers[strtolower($key)] = [$value]; + + return $this; + } + + /** + * Add header + * + * Append a value to an existing header, or create it if it does not exist, + * mirroring PSR-7's withAddedHeader. */ - abstract public function addHeader(string $key, string $value): static; + public function addHeader(string $key, string $value): static + { + $this->generateHeaders(); + $this->headers[strtolower($key)][] = $value; + + return $this; + } /** - * Remvoe header + * Remove header * - * Method for removing HTTP header parameters. + * Method for removing an HTTP header by its case-insensitive name. */ - abstract public function removeHeader(string $key): static; + public function removeHeader(string $key): static + { + $this->generateHeaders(); + unset($this->headers[strtolower($key)]); + + return $this; + } /** * Get Request Size @@ -286,8 +393,8 @@ public function getSize(): int { $headers = $this->generateHeaders(); $headerStrings = []; - foreach ($headers as $key => $value) { - $headerStrings[] = \is_array($value) ? $key . ': ' . implode(', ', $value) : $key . ': ' . $value; + foreach ($headers as $key => $values) { + $headerStrings[] = $key . ': ' . implode(', ', $values); } return mb_strlen(implode("\n", $headerStrings), '8bit') + mb_strlen(file_get_contents('php://input') ?: '', '8bit'); } @@ -420,37 +527,48 @@ public function setPayload(array $params): static /** * Generate headers * - * Parse request headers as an array for easy querying using the getHeader method + * Parse request headers into a PSR-7 style map of lowercased header name to a + * list of string values for easy querying using the getHeader method. * - * @return array> + * @return array> */ protected function generateHeaders(): array { if (null === $this->headers) { + $headers = []; + /** - * Fallback for older PHP versions - * that do not support generateHeaders + * Fallback for environments + * that do not support getallheaders */ if (!\function_exists('getallheaders')) { - $headers = []; - foreach ($_SERVER as $name => $value) { if (str_starts_with($name, 'HTTP_')) { - $headers[str_replace(' ', '-', strtolower(str_replace('_', ' ', substr($name, 5))))] = $value; + $key = str_replace(' ', '-', strtolower(str_replace('_', ' ', substr($name, 5)))); + $headers[$key] = [(string) $value]; } } - - $this->headers = $headers; - - return $this->headers; + } else { + foreach (getallheaders() as $name => $value) { + $headers[strtolower($name)] = [(string) $value]; + } } - $this->headers = array_change_key_case(getallheaders()); + $this->headers = $headers; } return $this->headers; } + /** + * Generate cookies + * + * Parse request cookies into an associative array of cookie name to value. + * + * @return array + */ + abstract protected function generateCookies(): array; + /** * Generate input * @@ -469,7 +587,7 @@ abstract protected function generateInput(): array; */ protected function parseContentRange(): ?array { - $contentRange = $this->getHeader('content-range', ''); + $contentRange = $this->getHeaderLine('content-range', ''); $data = []; if (!empty($contentRange)) { $contentRange = explode(' ', $contentRange); @@ -523,7 +641,7 @@ protected function parseContentRange(): ?array */ protected function parseRange(): ?array { - $rangeHeader = $this->getHeader('range', ''); + $rangeHeader = $this->getHeaderLine('range', ''); if (empty($rangeHeader)) { return null; } diff --git a/src/Http/Response.php b/src/Http/Response.php index fde5d6b..6660e49 100755 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -272,7 +272,10 @@ abstract class Response protected bool $headersSent = false; /** - * @var array> + * Response headers, stored under their lowercased name and mapping to a list of + * one or more string values, following the PSR-7 header representation. + * + * @var array> */ protected array $headers = []; @@ -436,22 +439,28 @@ public function enablePayload(): static return $this; } + /** + * Set header + * + * Replace any existing values for the given header with a single value, + * mirroring PSR-7's withHeader. + */ + public function setHeader(string $key, string $value): static + { + $this->headers[strtolower($key)] = [$value]; + + return $this; + } + /** * Add header * - * Add an HTTP response header + * Append a value to an existing response header, or create it if it does not + * exist, mirroring PSR-7's withAddedHeader. */ public function addHeader(string $key, string $value): static { - if (\array_key_exists($key, $this->headers)) { - if (\is_array($this->headers[$key])) { - $this->headers[$key][] = $value; - } else { - $this->headers[$key] = [$this->headers[$key], $value]; - } - } else { - $this->headers[$key] = $value; - } + $this->headers[strtolower($key)][] = $value; return $this; } @@ -459,23 +468,63 @@ public function addHeader(string $key, string $value): static /** * Remove header * - * Remove HTTP response header + * Remove an HTTP response header by its case-insensitive name. */ public function removeHeader(string $key): static { - if (isset($this->headers[$key])) { - unset($this->headers[$key]); - } + unset($this->headers[strtolower($key)]); return $this; } + /** + * Has header + * + * Checks if a response header exists by the given case-insensitive name, following PSR-7. + */ + public function hasHeader(string $key): bool + { + return isset($this->headers[strtolower($key)]); + } + + /** + * Get header + * + * Return all values of a single response header by its case-insensitive name, + * following PSR-7. Returns a list of strings, or $default when not found. + * + * @param array $default + * @return array + */ + public function getHeader(string $key, array $default = []): array + { + return $this->headers[strtolower($key)] ?? $default; + } + + /** + * Get header line + * + * Return all values for the given case-insensitive header name concatenated + * using a comma, following PSR-7. Returns $default when the header is not found. + */ + public function getHeaderLine(string $key, string $default = ''): string + { + $values = $this->getHeader($key); + + if ($values === []) { + return $default; + } + + return implode(', ', $values); + } + /** * Get Headers * - * Return array of all response headers + * Return array of all response headers, each mapping to a list of string values, + * following PSR-7. * - * @return array> + * @return array> */ public function getHeaders(): array { @@ -542,17 +591,9 @@ public function send(string $body = ''): void $this->appendCookies(); - $hasContentEncoding = false; - foreach ($this->headers as $name => $values) { - if (strtolower($name) === 'content-encoding') { - $hasContentEncoding = true; - break; - } - } - // Compress body only if all conditions are met: if ( - !$hasContentEncoding + !$this->hasHeader('Content-Encoding') && !empty($this->acceptEncoding) && $this->isCompressible($this->contentType) && \strlen($body) > $this->compressionMinSize @@ -588,14 +629,10 @@ public function send(string $body = ''): void $headersSize = 0; foreach ($this->headers as $name => $values) { - if (\is_array($values)) { - foreach ($values as $value) { - $headersSize += \strlen($name . ': ' . $value); - } - $headersSize += (\count($values) - 1) * 2; // linebreaks - } else { - $headersSize += \strlen($name . ': ' . $values); + foreach ($values as $value) { + $headersSize += \strlen($name . ': ' . $value); } + $headersSize += (\count($values) - 1) * 2; // linebreaks } $headersSize += (\count($this->headers) - 1) * 2; // linebreaks @@ -684,12 +721,12 @@ protected function appendHeaders(): static // Send content type header if (!empty($this->contentType)) { - $this->addHeader('Content-Type', $this->contentType); + $this->setHeader('Content-Type', $this->contentType); } // Set application headers - foreach ($this->headers as $key => $value) { - $this->sendHeader($key, $value); + foreach ($this->headers as $key => $values) { + $this->sendHeader($key, $values); } return $this; @@ -705,9 +742,9 @@ abstract protected function sendStatus(int $statusCode): void; * * Output Header * - * @param string|array $value + * @param array $value */ - abstract public function sendHeader(string $key, mixed $value): void; + abstract public function sendHeader(string $key, array $value): void; /** * Send Cookie @@ -726,7 +763,9 @@ abstract protected function sendCookie(string $name, string $value, array $optio protected function appendCookies(): static { foreach ($this->cookies as $cookie) { - $this->sendCookie($cookie['name'], $cookie['value'], [ + // A cookie may be registered without a value; send an empty string + // rather than passing null to the non-nullable $value parameter. + $this->sendCookie($cookie['name'], $cookie['value'] ?? '', [ 'expire' => $cookie['expire'], 'path' => $cookie['path'], 'domain' => $cookie['domain'], diff --git a/tests/RequestTest.php b/tests/RequestTest.php index d404d2b..3142163 100755 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -13,6 +13,17 @@ final class RequestTest extends TestCase public function setUp(): void { + // Reset request-related superglobals so each test starts from a clean state. + foreach (array_keys($_SERVER) as $key) { + if (str_starts_with($key, 'HTTP_')) { + unset($_SERVER[$key]); + } + } + unset($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER['REQUEST_SCHEME'], $_SERVER['key']); + $_GET = []; + $_POST = []; + $_COOKIE = []; + $this->request = new Request(); } @@ -26,13 +37,25 @@ public function testCanGetHeaders(): void $_SERVER['HTTP_CUSTOM'] = 'value1'; $_SERVER['HTTP_CUSTOM_NEW'] = 'value2'; - $this->assertSame('value1', $this->request->getHeader('custom')); - $this->assertSame('value2', $this->request->getHeader('custom-new')); + // Header lookups are case-insensitive and return a list of values (PSR-7). + $this->assertSame(['value1'], $this->request->getHeader('custom')); + $this->assertSame(['value2'], $this->request->getHeader('Custom-New')); + + // getHeaderLine concatenates the values into a single string. + $this->assertSame('value1', $this->request->getHeaderLine('custom')); + + // hasHeader reports presence case-insensitively. + $this->assertTrue($this->request->hasHeader('CUSTOM')); + $this->assertFalse($this->request->hasHeader('missing')); + + // Missing headers fall back to the provided default. + $this->assertSame([], $this->request->getHeader('missing')); + $this->assertSame('fallback', $this->request->getHeaderLine('missing', 'fallback')); $headers = $this->request->getHeaders(); $this->assertCount(2, $headers); - $this->assertSame('value1', $headers['custom']); - $this->assertSame('value2', $headers['custom-new']); + $this->assertSame(['value1'], $headers['custom']); + $this->assertSame(['value2'], $headers['custom-new']); } public function testCanAddHeaders(): void @@ -40,8 +63,17 @@ public function testCanAddHeaders(): void $this->request->addHeader('custom', 'value1'); $this->request->addHeader('custom-new', 'value2'); - $this->assertSame('value1', $this->request->getHeader('custom')); - $this->assertSame('value2', $this->request->getHeader('custom-new')); + $this->assertSame(['value1'], $this->request->getHeader('custom')); + $this->assertSame(['value2'], $this->request->getHeader('custom-new')); + + // addHeader appends additional values, mirroring PSR-7's withAddedHeader. + $this->request->addHeader('Custom', 'value3'); + $this->assertSame(['value1', 'value3'], $this->request->getHeader('custom')); + $this->assertSame('value1, value3', $this->request->getHeaderLine('custom')); + + // setHeader replaces all existing values, mirroring PSR-7's withHeader. + $this->request->setHeader('custom', 'only'); + $this->assertSame(['only'], $this->request->getHeader('custom')); } public function testCanRemoveHeaders(): void @@ -49,13 +81,14 @@ public function testCanRemoveHeaders(): void $this->request->addHeader('custom', 'value1'); $this->request->addHeader('custom-new', 'value2'); - $this->assertSame('value1', $this->request->getHeader('custom')); - $this->assertSame('value2', $this->request->getHeader('custom-new')); + $this->assertSame(['value1'], $this->request->getHeader('custom')); + $this->assertSame(['value2'], $this->request->getHeader('custom-new')); - $this->request->removeHeader('custom'); + $this->request->removeHeader('Custom'); - $this->assertSame('', $this->request->getHeader('custom')); - $this->assertSame('value2', $this->request->getHeader('custom-new')); + $this->assertFalse($this->request->hasHeader('custom')); + $this->assertSame([], $this->request->getHeader('custom')); + $this->assertSame(['value2'], $this->request->getHeader('custom-new')); } public function testCanGetQueryParameter(): void @@ -116,6 +149,23 @@ public function testCanGetCookie(): void $this->assertSame('test', $this->request->getCookie('unknown', 'test')); } + public function testCanGetCookieParams(): void + { + $_COOKIE['key'] = 'value'; + $_COOKIE['other'] = 'second'; + + $this->assertSame(['key' => 'value', 'other' => 'second'], $this->request->getCookieParams()); + } + + public function testCanSetCookieParams(): void + { + $this->request->setCookieParams(['key' => 'value']); + + $this->assertSame(['key' => 'value'], $this->request->getCookieParams()); + $this->assertSame('value', $this->request->getCookie('key')); + $this->assertSame('test', $this->request->getCookie('unknown', 'test')); + } + public function testCanGetProtocol(): void { $_SERVER['HTTP_X_FORWARDED_PROTO'] = null; diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 1ba1ecc..160c0d3 100755 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -88,6 +88,22 @@ public function testCanSend(): void $this->assertSame('body', $html); } + public function testCanSendMinimalCookie(): void + { + ob_start(); //Start of build + + // A cookie with only a name (null value, no SameSite/secure/etc.) must + // not trigger a TypeError when proxied to setcookie()/Swoole's cookie(). + @$this->response + ->addCookie('name') + ->send('body'); + + $html = ob_get_contents(); + ob_end_clean(); //End of build + + $this->assertSame('body', $html); + } + public function testCanSendRedirect(): void { ob_start(); //Start of build diff --git a/tests/e2e/init.php b/tests/e2e/init.php index ccd7786..d2ce8cc 100644 --- a/tests/e2e/init.php +++ b/tests/e2e/init.php @@ -1,5 +1,7 @@ inject('request') ->inject('response') ->action(function (Request $request, Response $response) { - $response->send($request->getHeaders()['cookie'] ?? ''); + $response->send($request->getHeaderLine('cookie')); }); Http::get('/cookie/:key')