diff --git a/composer.json b/composer.json index eb2e9a9..cf4f01e 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "scripts": { "lint": "vendor/bin/pint --test", "format": "vendor/bin/pint", - "check": "vendor/bin/phpstan analyse -c phpstan.neon", + "check": "vendor/bin/phpstan analyse -c phpstan.neon --memory-limit 512M", "test": "vendor/bin/phpunit --configuration phpunit.xml", "bench": "vendor/bin/phpbench run --report=benchmark" }, diff --git a/composer.lock b/composer.lock index 96f9415..1282e5d 100644 --- a/composer.lock +++ b/composer.lock @@ -8,25 +8,25 @@ "packages": [ { "name": "brick/math", - "version": "0.13.1", + "version": "0.14.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", - "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", + "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.1", - "vimeo/psalm": "6.8.8" + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.13.1" + "source": "https://github.com/brick/math/tree/0.14.0" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2025-03-29T13:50:30+00:00" + "time": "2025-08-29T12:40:03+00:00" }, { "name": "composer/semver", @@ -336,16 +336,16 @@ }, { "name": "open-telemetry/api", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7" + "reference": "7692075f486c14d8cfd37fba98a08a5667f089e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7", - "reference": "b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7692075f486c14d8cfd37fba98a08a5667f089e5", + "reference": "7692075f486c14d8cfd37fba98a08a5667f089e5", "shasum": "" }, "require": { @@ -402,7 +402,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-06-19T23:36:51+00:00" + "time": "2025-08-07T23:07:38+00:00" }, { "name": "open-telemetry/context", @@ -592,22 +592,22 @@ }, { "name": "open-telemetry/sdk", - "version": "1.7.0", + "version": "1.7.1", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "86287cf30fd6549444d7b8f7d8758d92e24086ac" + "reference": "52690d4b37ae4f091af773eef3c238ed2bc0aa06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/86287cf30fd6549444d7b8f7d8758d92e24086ac", - "reference": "86287cf30fd6549444d7b8f7d8758d92e24086ac", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/52690d4b37ae4f091af773eef3c238ed2bc0aa06", + "reference": "52690d4b37ae4f091af773eef3c238ed2bc0aa06", "shasum": "" }, "require": { "ext-json": "*", "nyholm/psr7-server": "^1.1", - "open-telemetry/api": "~1.4.0", + "open-telemetry/api": "^1.4", "open-telemetry/context": "^1.0", "open-telemetry/sem-conv": "^1.0", "php": "^8.1", @@ -685,20 +685,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-08-06T03:07:06+00:00" + "time": "2025-09-05T07:17:06+00:00" }, { "name": "open-telemetry/sem-conv", - "version": "1.36.0", + "version": "1.37.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sem-conv.git", - "reference": "60dd18fd21d45e6f4234ecab89c14021b6e3de9a" + "reference": "8da7ec497c881e39afa6657d72586e27efbd29a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/60dd18fd21d45e6f4234ecab89c14021b6e3de9a", - "reference": "60dd18fd21d45e6f4234ecab89c14021b6e3de9a", + "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/8da7ec497c881e39afa6657d72586e27efbd29a1", + "reference": "8da7ec497c881e39afa6657d72586e27efbd29a1", "shasum": "" }, "require": { @@ -742,7 +742,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-08-04T03:22:08+00:00" + "time": "2025-09-03T12:08:10+00:00" }, { "name": "php-http/discovery", @@ -1164,20 +1164,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.0", + "version": "4.9.1", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -1236,9 +1236,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.0" + "source": "https://github.com/ramsey/uuid/tree/4.9.1" }, - "time": "2025-06-25T14:20:11+00:00" + "time": "2025-09-04T20:59:21+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1309,16 +1309,16 @@ }, { "name": "symfony/http-client", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1c064a0c67749923483216b081066642751cc2c7" + "reference": "333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1c064a0c67749923483216b081066642751cc2c7", - "reference": "1c064a0c67749923483216b081066642751cc2c7", + "url": "https://api.github.com/repos/symfony/http-client/zipball/333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019", + "reference": "333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019", "shasum": "" }, "require": { @@ -1326,6 +1326,7 @@ "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -1384,7 +1385,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.2" + "source": "https://github.com/symfony/http-client/tree/v7.3.3" }, "funding": [ { @@ -1404,7 +1405,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-08-27T07:45:05+00:00" }, { "name": "symfony/http-client-contracts", @@ -1649,6 +1650,86 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-08T02:45:35+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.6.0", @@ -4161,16 +4242,16 @@ }, { "name": "symfony/console", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1" + "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/5f360ebc65c55265a74d23d7fe27f957870158a1", - "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1", + "url": "https://api.github.com/repos/symfony/console/zipball/cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", + "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", "shasum": "" }, "require": { @@ -4235,7 +4316,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.2" + "source": "https://github.com/symfony/console/tree/v7.3.3" }, "funding": [ { @@ -4255,7 +4336,7 @@ "type": "tidelift" } ], - "time": "2025-07-30T17:13:41+00:00" + "time": "2025-08-25T06:35:40+00:00" }, { "name": "symfony/filesystem", @@ -4397,16 +4478,16 @@ }, { "name": "symfony/options-resolver", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "119bcf13e67dbd188e5dbc74228b1686f66acd37" + "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/119bcf13e67dbd188e5dbc74228b1686f66acd37", - "reference": "119bcf13e67dbd188e5dbc74228b1686f66acd37", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d", "shasum": "" }, "require": { @@ -4444,7 +4525,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.3.2" + "source": "https://github.com/symfony/options-resolver/tree/v7.3.3" }, "funding": [ { @@ -4464,7 +4545,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-08-05T10:16:07+00:00" }, { "name": "symfony/polyfill-ctype", @@ -4718,16 +4799,16 @@ }, { "name": "symfony/process", - "version": "v7.3.0", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" + "reference": "32241012d521e2e8a9d713adb0812bb773b907f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", - "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "url": "https://api.github.com/repos/symfony/process/zipball/32241012d521e2e8a9d713adb0812bb773b907f1", + "reference": "32241012d521e2e8a9d713adb0812bb773b907f1", "shasum": "" }, "require": { @@ -4759,7 +4840,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.0" + "source": "https://github.com/symfony/process/tree/v7.3.3" }, "funding": [ { @@ -4770,25 +4851,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-17T09:11:12+00:00" + "time": "2025-08-18T09:42:54+00:00" }, { "name": "symfony/string", - "version": "v7.3.2", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" + "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", - "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", + "url": "https://api.github.com/repos/symfony/string/zipball/17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", + "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", "shasum": "" }, "require": { @@ -4846,7 +4931,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.2" + "source": "https://github.com/symfony/string/tree/v7.3.3" }, "funding": [ { @@ -4866,7 +4951,7 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-08-25T06:35:40+00:00" }, { "name": "theseer/tokenizer", @@ -4970,12 +5055,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=8.1" }, - "platform-dev": {}, - "plugin-api-version": "2.6.0" + "platform-dev": [], + "plugin-api-version": "2.3.0" } diff --git a/src/Response.php b/src/Response.php index bd94667..adcfc02 100755 --- a/src/Response.php +++ b/src/Response.php @@ -47,6 +47,10 @@ class Response public const STATUS_CODE_SWITCHING_PROTOCOLS = 101; + public const STATUS_CODE_PROCESSING = 102; + + public const STATUS_CODE_EARLY_HINTS = 103; + public const STATUS_CODE_OK = 200; public const STATUS_CODE_CREATED = 201; @@ -61,6 +65,12 @@ class Response public const STATUS_CODE_PARTIALCONTENT = 206; + public const STATUS_CODE_MULTI_STATUS = 207; + + public const STATUS_CODE_ALREADY_REPORTED = 208; + + public const STATUS_CODE_IM_USED = 226; + public const STATUS_CODE_MULTIPLE_CHOICES = 300; public const STATUS_CODE_MOVED_PERMANENTLY = 301; @@ -77,6 +87,8 @@ class Response public const STATUS_CODE_TEMPORARY_REDIRECT = 307; + public const STATUS_CODE_PERMANENT_REDIRECT = 308; + public const STATUS_CODE_BAD_REQUEST = 400; public const STATUS_CODE_UNAUTHORIZED = 401; @@ -113,12 +125,26 @@ class Response public const STATUS_CODE_EXPECTATION_FAILED = 417; + public const STATUS_CODE_IM_A_TEAPOT = 418; + + public const STATUS_CODE_MISDIRECTED_REQUEST = 421; + public const STATUS_CODE_UNPROCESSABLE_ENTITY = 422; + public const STATUS_CODE_LOCKED = 423; + + public const STATUS_CODE_FAILED_DEPENDENCY = 424; + public const STATUS_CODE_TOO_EARLY = 425; + public const STATUS_CODE_UPGRADE_REQUIRED = 426; + + public const STATUS_CODE_PRECONDITION_REQUIRED = 428; + public const STATUS_CODE_TOO_MANY_REQUESTS = 429; + public const STATUS_CODE_REQUEST_HEADER_FIELDS_TOO_LARGE = 431; + public const STATUS_CODE_UNAVAILABLE_FOR_LEGAL_REASONS = 451; public const STATUS_CODE_INTERNAL_SERVER_ERROR = 500; @@ -133,12 +159,24 @@ class Response public const STATUS_CODE_HTTP_VERSION_NOT_SUPPORTED = 505; + public const STATUS_CODE_VARIANT_ALSO_NEGOTIATES = 506; + + public const STATUS_CODE_INSUFFICIENT_STORAGE = 507; + + public const STATUS_CODE_LOOP_DETECTED = 508; + + public const STATUS_CODE_NOT_EXTENDED = 510; + + public const STATUS_CODE_NETWORK_AUTHENTICATION_REQUIRED = 511; + /** * @var array */ protected $statusCodes = [ self::STATUS_CODE_CONTINUE => 'Continue', self::STATUS_CODE_SWITCHING_PROTOCOLS => 'Switching Protocols', + self::STATUS_CODE_PROCESSING => 'Processing', + self::STATUS_CODE_EARLY_HINTS => 'Early Hints', self::STATUS_CODE_OK => 'OK', self::STATUS_CODE_CREATED => 'Created', self::STATUS_CODE_ACCEPTED => 'Accepted', @@ -146,6 +184,9 @@ class Response self::STATUS_CODE_NOCONTENT => 'No Content', self::STATUS_CODE_RESETCONTENT => 'Reset Content', self::STATUS_CODE_PARTIALCONTENT => 'Partial Content', + self::STATUS_CODE_MULTI_STATUS => 'Multi-Status', + self::STATUS_CODE_ALREADY_REPORTED => 'Already Reported', + self::STATUS_CODE_IM_USED => 'IM Used', self::STATUS_CODE_MULTIPLE_CHOICES => 'Multiple Choices', self::STATUS_CODE_MOVED_PERMANENTLY => 'Moved Permanently', self::STATUS_CODE_FOUND => 'Found', @@ -154,6 +195,7 @@ class Response self::STATUS_CODE_USE_PROXY => 'Use Proxy', self::STATUS_CODE_UNUSED => '(Unused)', self::STATUS_CODE_TEMPORARY_REDIRECT => 'Temporary Redirect', + self::STATUS_CODE_PERMANENT_REDIRECT => 'Permanent Redirect', self::STATUS_CODE_BAD_REQUEST => 'Bad Request', self::STATUS_CODE_UNAUTHORIZED => 'Unauthorized', self::STATUS_CODE_PAYMENT_REQUIRED => 'Payment Required', @@ -172,8 +214,16 @@ class Response self::STATUS_CODE_UNSUPPORTED_MEDIA_TYPE => 'Unsupported Media Type', self::STATUS_CODE_REQUESTED_RANGE_NOT_SATISFIABLE => 'Requested Range Not Satisfiable', self::STATUS_CODE_EXPECTATION_FAILED => 'Expectation Failed', + self::STATUS_CODE_IM_A_TEAPOT => 'I\'m a teapot', + self::STATUS_CODE_MISDIRECTED_REQUEST => 'Misdirected Request', + self::STATUS_CODE_UNPROCESSABLE_ENTITY => 'Unprocessable Entity', + self::STATUS_CODE_LOCKED => 'Locked', + self::STATUS_CODE_FAILED_DEPENDENCY => 'Failed Dependency', self::STATUS_CODE_TOO_EARLY => 'Too Early', + self::STATUS_CODE_UPGRADE_REQUIRED => 'Upgrade Required', + self::STATUS_CODE_PRECONDITION_REQUIRED => 'Precondition Required', self::STATUS_CODE_TOO_MANY_REQUESTS => 'Too Many Requests', + self::STATUS_CODE_REQUEST_HEADER_FIELDS_TOO_LARGE => 'Request Header Fields Too Large', self::STATUS_CODE_UNAVAILABLE_FOR_LEGAL_REASONS => 'Unavailable For Legal Reasons', self::STATUS_CODE_INTERNAL_SERVER_ERROR => 'Internal Server Error', self::STATUS_CODE_NOT_IMPLEMENTED => 'Not Implemented', @@ -181,10 +231,15 @@ class Response self::STATUS_CODE_SERVICE_UNAVAILABLE => 'Service Unavailable', self::STATUS_CODE_GATEWAY_TIMEOUT => 'Gateway Timeout', self::STATUS_CODE_HTTP_VERSION_NOT_SUPPORTED => 'HTTP Version Not Supported', + self::STATUS_CODE_VARIANT_ALSO_NEGOTIATES => 'Variant Also Negotiates', + self::STATUS_CODE_INSUFFICIENT_STORAGE => 'Insufficient Storage', + self::STATUS_CODE_LOOP_DETECTED => 'Loop Detected', + self::STATUS_CODE_NOT_EXTENDED => 'Not Extended', + self::STATUS_CODE_NETWORK_AUTHENTICATION_REQUIRED => 'Network Authentication Required', ]; /** - * Mime Types with compressible content + * Mime Types with compression support * * @var array */ @@ -284,7 +339,7 @@ class Response protected bool $sent = false; /** - * @var array + * @var array> */ protected array $headers = []; @@ -488,7 +543,15 @@ public function enablePayload(): static */ public function addHeader(string $key, string $value): static { - $this->headers[$key] = $value; + 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; + } return $this; } @@ -514,7 +577,7 @@ public function removeHeader(string $key): static * * Return array of all response headers * - * @return array + * @return array> */ public function getHeaders(): array { @@ -527,13 +590,13 @@ public function getHeaders(): array * Add an HTTP cookie to response header * * @param string $name - * @param string $value - * @param int $expire - * @param string $path - * @param string $domain - * @param bool $secure - * @param bool $httponly - * @param string $sameSite + * @param string|null $value + * @param int|null $expire + * @param string|null $path + * @param string|null $domain + * @param bool|null $secure + * @param bool|null $httponly + * @param string|null $sameSite */ public function addCookie(string $name, ?string $value = null, ?int $expire = null, ?string $path = null, ?string $domain = null, ?bool $secure = null, ?bool $httponly = null, ?string $sameSite = null): static { @@ -627,9 +690,21 @@ public function send(string $body = ''): void return; } - $headerSize = strlen(implode("\n", $this->headers)); + $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); + } + } + $headersSize += (\count($this->headers) - 1) * 2; // linebreaks + $bodyLength = strlen($body); - $this->size += $headerSize + $bodyLength; + $this->size += $headersSize + $bodyLength; if ($bodyLength <= self::CHUNK_SIZE) { $this->end($body); @@ -752,12 +827,18 @@ protected function sendStatus(int $statusCode): void * Output Header * * @param string $key - * @param string $value + * @param string|array $value * @return void */ - protected function sendHeader(string $key, string $value): void + protected function sendHeader(string $key, mixed $value): void { - \header($key.': '.$value); + if (\is_array($value)) { + foreach ($value as $v) { + \header($key.': '.$v, false); + } + } else { + \header($key.': '.$value); + } } /** diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index cedd4cf..03bc1e2 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -61,6 +61,8 @@ public function call(string $method, string $path = '', array $headers = [], arr $responseType = ''; $responseBody = ''; + $cookies = []; + $query = match ($headers['content-type'] ?? '') { 'application/json' => \json_encode($params), 'text/plain' => $params, @@ -85,7 +87,7 @@ public function call(string $method, string $path = '', array $headers = [], arr curl_setopt($ch, CURLOPT_HTTPHEADER, $formattedHeaders); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0); curl_setopt($ch, CURLOPT_TIMEOUT, 15); - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) { + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders, &$cookies) { $len = strlen($header); $header = explode(':', $header, 2); @@ -93,6 +95,12 @@ public function call(string $method, string $path = '', array $headers = [], arr return $len; } + if (strtolower(trim($header[0])) == 'set-cookie') { + $parsed = $this->parseCookie((string)trim($header[1])); + $name = array_key_first($parsed); + $cookies[$name] = $parsed[$name]; + } + $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]); return $len; @@ -120,6 +128,22 @@ public function call(string $method, string $path = '', array $headers = [], arr return [ 'headers' => $responseHeaders, 'body' => $responseBody, + 'cookies' => $cookies, ]; } + + /** + * Parse Cookie String + * + * @param string $cookie + * @return array + */ + public function parseCookie(string $cookie): array + { + $cookies = []; + + parse_str(strtr($cookie, ['&' => '%26', '+' => '%2B', ';' => '&']), $cookies); + + return $cookies; + } } diff --git a/tests/e2e/ResponseTest.php b/tests/e2e/ResponseTest.php index 0a8d70a..ccb734b 100644 --- a/tests/e2e/ResponseTest.php +++ b/tests/e2e/ResponseTest.php @@ -122,4 +122,45 @@ public function testDoubleSlash() $this->assertEquals(200, $response['headers']['status-code']); $this->assertEmpty($response['body']); } + + public function testCookie() + { + // One cookie + $cookie = 'cookie1=value1'; + $response = $this->client->call(Client::METHOD_GET, '/cookies', [ 'Cookie' => $cookie ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($cookie, $response['body']); + + // Two cookiees + $cookie = 'cookie1=value1; cookie2=value2'; + $response = $this->client->call(Client::METHOD_GET, '/cookies', [ 'Cookie' => $cookie ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($cookie, $response['body']); + + // Two cookies without optional space + $cookie = 'cookie1=value1;cookie2=value2'; + $response = $this->client->call(Client::METHOD_GET, '/cookies', [ 'Cookie' => $cookie ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($cookie, $response['body']); + + // Cookie with "=" in value + $cookie = 'cookie1=value1=value2'; + $response = $this->client->call(Client::METHOD_GET, '/cookies', [ 'Cookie' => $cookie ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($cookie, $response['body']); + + // Case sensitivity for cookie names + $cookie = 'cookie1=v1; Cookie1=v2'; + $response = $this->client->call(Client::METHOD_GET, '/cookies', [ 'Cookie' => $cookie ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($cookie, $response['body']); + } + + public function testSetCookie() + { + $response = $this->client->call(Client::METHOD_GET, '/set-cookie'); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('value1', $response['cookies']['key1']); + $this->assertEquals('value2', $response['cookies']['key2']); + } } diff --git a/tests/e2e/server.php b/tests/e2e/server.php index 179767c..0a6641a 100644 --- a/tests/e2e/server.php +++ b/tests/e2e/server.php @@ -26,6 +26,22 @@ $response->send($value); }); +App::get('/cookies') + ->inject('request') + ->inject('response') + ->action(function (Request $request, Response $response) { + $response->send($request->getHeaders()['cookie'] ?? ''); + }); + +App::get('/set-cookie') + ->inject('request') + ->inject('response') + ->action(function (Request $request, Response $response) { + $response->addHeader('Set-Cookie', 'key1=value1'); + $response->addHeader('Set-Cookie', 'key2=value2'); + $response->send('OK'); + }); + App::get('/chunked') ->inject('response') ->action(function (Response $response) {