diff --git a/.github/dependabot-schedule.yml b/.github/dependabot-schedule.yml
new file mode 100644
index 0000000..1d6b64c
--- /dev/null
+++ b/.github/dependabot-schedule.yml
@@ -0,0 +1,12 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+updates:
+ - package-ecosystem: "composer"
+ # Look for `composer.json` and `composer.lock` files in the `root` directory
+ directory: "/"
+ schedule:
+ interval: "weekly"
\ No newline at end of file
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
new file mode 100644
index 0000000..816db37
--- /dev/null
+++ b/.github/workflows/continuous-integration.yml
@@ -0,0 +1,55 @@
+name: CI (Continuous Integration)
+
+# Runs unit tests.
+
+on: [push]
+
+# Cancels all previous workflow runs for the same branch that have not yet completed.
+concurrency:
+ # The concurrency group contains the workflow name and the branch name.
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ unit-tests:
+ name: "Unit Tests (PHPUnit)"
+# needs: ["code-coverage"]
+ runs-on: "${{ matrix.operating-system }}"
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version:
+ - "8.4" # Minimum supported PHP version
+ - "8.5"
+ operating-system:
+ - "ubuntu-latest"
+ dependency-versions:
+ - "locked"
+ - "highest"
+
+ steps:
+ - name: "Configure Git (for Windows)"
+ if: ${{ matrix.operating-system == 'windows-latest' }}
+ run: |
+ git config --system core.autocrlf false
+ git config --system core.eol lf
+
+ - name: "Checkout repository"
+ uses: "actions/checkout@v5"
+
+ - name: "Install PHP"
+ uses: "shivammathur/setup-php@v2"
+ with:
+ php-version: "${{ matrix.php-version }}"
+ extensions: "sodium"
+ coverage: "none"
+ ini-values: "memory_limit=-1"
+
+ - name: "Install dependencies (Composer)"
+ uses: "ramsey/composer-install@v3"
+ with:
+ dependency-versions: "${{ matrix.dependency-versions }}"
+
+ - name: "Run unit tests (PHPUnit)"
+ run: "composer test"
diff --git a/README.md b/README.md
index 8202e4d..c4a9931 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# rodas-diactoros
+HTTP Message implementations
+
> Diactoros (pronunciation: `/dɪʌktɒrɒs/`): an epithet for Hermes, meaning literally, "the messenger."
This project is based (a copy) of the code of the project [laminas/diactoros](https://github.com/laminas/laminas-diactoros).
@@ -7,7 +9,7 @@ This project is based (a copy) of the code of the project [laminas/diactoros](ht
The main difference is that it relies on and implements the `rodas/http` interfaces, instead of `psr/http-factory` and `psr/http-message`.
> `laminas-diactoros` package supercedes and replaces [phly/http](https://github.com/phly/http).
->
+>
> It is a PHP package containing implementations of the
> [PSR-7 HTTP message interfaces](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md)
> and [PSR-17 HTTP message factory interfaces](https://www.php-fig.org/psr/psr-17).
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..6c6aa7c
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.1.0
\ No newline at end of file
diff --git a/build/.gitignore b/build/.gitignore
new file mode 100644
index 0000000..1375c16
--- /dev/null
+++ b/build/.gitignore
@@ -0,0 +1,6 @@
+*
+!.gitignore
+!cache
+!cache/.gitkeep
+!logs
+!logs/.gitkeep
diff --git a/build/cache/.gitkeep b/build/cache/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/build/logs/.gitkeep b/build/logs/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..d5dc474
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,74 @@
+{
+ "name": "rodas/diactoros",
+ "description": "HTTP Message implementations",
+ "type": "library",
+ "keywords": [
+ "rodas",
+ "http",
+ "psr"
+ ],
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Marcos Porto Mariño",
+ "email": "php@marcospor.to"
+ }
+ ],
+ "support": {
+ "source": "https://github.com/Marqitos/php-diactoros",
+ "issues": "https://github.com/Marqitos/php-diactoros/issues"
+ },
+ "homepage": "https://marcospor.to/repositories",
+ "minimum-stability": "stable",
+ "config": {
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ },
+ "sort-packages": true,
+ "platform": {
+ "php": "8.4.4"
+ }
+ },
+ "extra": {
+ "laminas": {
+ "config-provider": "Rodas\\Diactoros\\ConfigProvider",
+ "module": "Rodas\\Diactoros"
+ }
+ },
+ "require": {
+ "php": "~8.4.0 || ~8.5.0",
+ "rodas/psr-scaffold": "^2.0"
+ },
+ "require-dev": {
+ "ext-curl": "*",
+ "ext-dom": "*",
+ "ext-gd": "*",
+ "ext-libxml": "*",
+ "phpunit/phpunit": "^12.5.4",
+ "psalm/plugin-phpunit": "^0.19.5",
+ "vimeo/psalm": "^6.13"
+ },
+ "provide": {
+ "rodas/psr-http-message-implementation": "^1.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Rodas\\Diactoros\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Rodas\\Test\\Diactoros\\": "test/"
+ }
+ },
+ "scripts": {
+ "check": [
+ "@cs-check",
+ "@test"
+ ],
+ "cs-check": "phpcs",
+ "cs-fix": "phpcbf",
+ "test": "phpunit --colors=always",
+ "test-coverage": "phpunit --colors=always --coverage-clover clover.xml"
+ }
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..ed1f8e7
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,5034 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "c01d7707dfc3a5d67b00fb858ccc9aa0",
+ "packages": [
+ {
+ "name": "rodas/psr-scaffold",
+ "version": "v2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Marqitos/php-psr.git",
+ "reference": "bd39ae798590d2025a622e592f00df4c7323ffe9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Marqitos/php-psr/zipball/bd39ae798590d2025a622e592f00df4c7323ffe9",
+ "reference": "bd39ae798590d2025a622e592f00df4c7323ffe9",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "provide": {
+ "psr/clock": "1.0.0",
+ "rodas/psr-http-client": "1.0.0",
+ "rodas/psr-http-message": "1.0.0",
+ "rodas/psr-log": "1.0.0"
+ },
+ "require-dev": {
+ "fig/http-message-util": "^1.1",
+ "php-parallel-lint/php-console-highlighter": "^1.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4",
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^12.4",
+ "psr/clock": "^1.0",
+ "psr/http-client": "^1.0",
+ "psr/http-factory": "^1.1",
+ "psr/http-message": "^2.0",
+ "psr/log": "^3.0",
+ "slevomat/coding-standard": "^8.24",
+ "squizlabs/php_codesniffer": "^4.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Psr\\": "src/Psr",
+ "Rodas\\Psr\\": "src/Rodas/Psr"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marcos Porto Mariño",
+ "email": "php@marcospor.to"
+ }
+ ],
+ "description": "PSR and FIG packages, with PHP 8.4 syntax, and scaffolding autoload",
+ "keywords": [
+ "clock",
+ "factory",
+ "http",
+ "http-client",
+ "http-message",
+ "log",
+ "message",
+ "now",
+ "psr",
+ "psr-17",
+ "psr-18",
+ "psr-20",
+ "psr-3",
+ "psr-7",
+ "request",
+ "response",
+ "rodas-psr",
+ "time"
+ ],
+ "support": {
+ "issues": "https://github.com/Marqitos/php-psr/issues",
+ "source": "https://github.com/Marqitos/php-psr"
+ },
+ "time": "2025-12-24T16:10:48+00:00"
+ }
+ ],
+ "packages-dev": [
+ {
+ "name": "amphp/amp",
+ "version": "v3.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/amp.git",
+ "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f",
+ "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.23.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Future/functions.php",
+ "src/Internal/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ }
+ ],
+ "description": "A non-blocking concurrency framework for PHP applications.",
+ "homepage": "https://amphp.org/amp",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "awaitable",
+ "concurrency",
+ "event",
+ "event-loop",
+ "future",
+ "non-blocking",
+ "promise"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/amp/issues",
+ "source": "https://github.com/amphp/amp/tree/v3.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-27T21:42:00+00:00"
+ },
+ {
+ "name": "amphp/byte-stream",
+ "version": "v2.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/byte-stream.git",
+ "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46",
+ "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/parser": "^1.1",
+ "amphp/pipeline": "^1",
+ "amphp/serialization": "^1",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2.3"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.22.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\ByteStream\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "A stream abstraction to make working with non-blocking I/O simple.",
+ "homepage": "https://amphp.org/byte-stream",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "io",
+ "non-blocking",
+ "stream"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/byte-stream/issues",
+ "source": "https://github.com/amphp/byte-stream/tree/v2.1.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-03-16T17:10:27+00:00"
+ },
+ {
+ "name": "amphp/cache",
+ "version": "v2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/cache.git",
+ "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c",
+ "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/serialization": "^1",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Cache\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ }
+ ],
+ "description": "A fiber-aware cache API based on Amp and Revolt.",
+ "homepage": "https://amphp.org/cache",
+ "support": {
+ "issues": "https://github.com/amphp/cache/issues",
+ "source": "https://github.com/amphp/cache/tree/v2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-04-19T03:38:06+00:00"
+ },
+ {
+ "name": "amphp/dns",
+ "version": "v2.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/dns.git",
+ "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71",
+ "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/cache": "^2",
+ "amphp/parser": "^1",
+ "amphp/process": "^2",
+ "daverandom/libdns": "^2.0.2",
+ "ext-filter": "*",
+ "ext-json": "*",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.20"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Dns\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Chris Wright",
+ "email": "addr@daverandom.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ },
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "Async DNS resolution for Amp.",
+ "homepage": "https://github.com/amphp/dns",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "client",
+ "dns",
+ "resolve"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/dns/issues",
+ "source": "https://github.com/amphp/dns/tree/v2.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-01-19T15:43:40+00:00"
+ },
+ {
+ "name": "amphp/parallel",
+ "version": "v2.3.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/parallel.git",
+ "reference": "296b521137a54d3a02425b464e5aee4c93db2c60"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/parallel/zipball/296b521137a54d3a02425b464e5aee4c93db2c60",
+ "reference": "296b521137a54d3a02425b464e5aee4c93db2c60",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/cache": "^2",
+ "amphp/parser": "^1",
+ "amphp/pipeline": "^1",
+ "amphp/process": "^2",
+ "amphp/serialization": "^1",
+ "amphp/socket": "^2",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.18"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/Context/functions.php",
+ "src/Context/Internal/functions.php",
+ "src/Ipc/functions.php",
+ "src/Worker/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Parallel\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Stephen Coakley",
+ "email": "me@stephencoakley.com"
+ }
+ ],
+ "description": "Parallel processing component for Amp.",
+ "homepage": "https://github.com/amphp/parallel",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "concurrent",
+ "multi-processing",
+ "multi-threading"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/parallel/issues",
+ "source": "https://github.com/amphp/parallel/tree/v2.3.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-11-15T06:23:42+00:00"
+ },
+ {
+ "name": "amphp/parser",
+ "version": "v1.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/parser.git",
+ "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7",
+ "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Parser\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "A generator parser to make streaming parsers simple.",
+ "homepage": "https://github.com/amphp/parser",
+ "keywords": [
+ "async",
+ "non-blocking",
+ "parser",
+ "stream"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/parser/issues",
+ "source": "https://github.com/amphp/parser/tree/v1.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-21T19:16:53+00:00"
+ },
+ {
+ "name": "amphp/pipeline",
+ "version": "v1.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/pipeline.git",
+ "reference": "7b52598c2e9105ebcddf247fc523161581930367"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367",
+ "reference": "7b52598c2e9105ebcddf247fc523161581930367",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.18"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Pipeline\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Asynchronous iterators and operators.",
+ "homepage": "https://amphp.org/pipeline",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "io",
+ "iterator",
+ "non-blocking"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/pipeline/issues",
+ "source": "https://github.com/amphp/pipeline/tree/v1.2.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-03-16T16:33:53+00:00"
+ },
+ {
+ "name": "amphp/process",
+ "version": "v2.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/process.git",
+ "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d",
+ "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Process\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "A fiber-aware process manager based on Amp and Revolt.",
+ "homepage": "https://amphp.org/process",
+ "support": {
+ "issues": "https://github.com/amphp/process/issues",
+ "source": "https://github.com/amphp/process/tree/v2.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-04-19T03:13:44+00:00"
+ },
+ {
+ "name": "amphp/serialization",
+ "version": "v1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/serialization.git",
+ "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1",
+ "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "dev-master",
+ "phpunit/phpunit": "^9 || ^8 || ^7"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Serialization\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Serialization tools for IPC and data storage in PHP.",
+ "homepage": "https://github.com/amphp/serialization",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "serialization",
+ "serialize"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/serialization/issues",
+ "source": "https://github.com/amphp/serialization/tree/master"
+ },
+ "time": "2020-03-25T21:39:07+00:00"
+ },
+ {
+ "name": "amphp/socket",
+ "version": "v2.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/socket.git",
+ "reference": "58e0422221825b79681b72c50c47a930be7bf1e1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1",
+ "reference": "58e0422221825b79681b72c50c47a930be7bf1e1",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/dns": "^2",
+ "ext-openssl": "*",
+ "kelunik/certificate": "^1.1",
+ "league/uri": "^6.5 | ^7",
+ "league/uri-interfaces": "^2.3 | ^7",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "amphp/process": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.20"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php",
+ "src/SocketAddress/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Socket\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@gmail.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.",
+ "homepage": "https://github.com/amphp/socket",
+ "keywords": [
+ "amp",
+ "async",
+ "encryption",
+ "non-blocking",
+ "sockets",
+ "tcp",
+ "tls"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/socket/issues",
+ "source": "https://github.com/amphp/socket/tree/v2.3.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-04-21T14:33:03+00:00"
+ },
+ {
+ "name": "amphp/sync",
+ "version": "v2.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/sync.git",
+ "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1",
+ "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/pipeline": "^1",
+ "amphp/serialization": "^1",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.23"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Sync\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Stephen Coakley",
+ "email": "me@stephencoakley.com"
+ }
+ ],
+ "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.",
+ "homepage": "https://github.com/amphp/sync",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "mutex",
+ "semaphore",
+ "synchronization"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/sync/issues",
+ "source": "https://github.com/amphp/sync/tree/v2.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-03T19:31:26+00:00"
+ },
+ {
+ "name": "composer/pcre",
+ "version": "3.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/pcre.git",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan": "<1.11.10"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.12 || ^2",
+ "phpstan/phpstan-strict-rules": "^1 || ^2",
+ "phpunit/phpunit": "^8 || ^9"
+ },
+ "type": "library",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Pcre\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+ "keywords": [
+ "PCRE",
+ "preg",
+ "regex",
+ "regular expression"
+ ],
+ "support": {
+ "issues": "https://github.com/composer/pcre/issues",
+ "source": "https://github.com/composer/pcre/tree/3.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-12T16:29:46+00:00"
+ },
+ {
+ "name": "composer/semver",
+ "version": "3.4.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/semver.git",
+ "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+ "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.3.2 || ^7.0 || ^8.0"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.11",
+ "symfony/phpunit-bridge": "^3 || ^7"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Semver\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nils Adermann",
+ "email": "naderman@naderman.de",
+ "homepage": "http://www.naderman.de"
+ },
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ },
+ {
+ "name": "Rob Bast",
+ "email": "rob.bast@gmail.com",
+ "homepage": "http://robbast.nl"
+ }
+ ],
+ "description": "Semver library that offers utilities, version constraint parsing and validation.",
+ "keywords": [
+ "semantic",
+ "semver",
+ "validation",
+ "versioning"
+ ],
+ "support": {
+ "irc": "ircs://irc.libera.chat:6697/composer",
+ "issues": "https://github.com/composer/semver/issues",
+ "source": "https://github.com/composer/semver/tree/3.4.4"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-20T19:15:30+00:00"
+ },
+ {
+ "name": "composer/xdebug-handler",
+ "version": "3.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/xdebug-handler.git",
+ "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef",
+ "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef",
+ "shasum": ""
+ },
+ "require": {
+ "composer/pcre": "^1 || ^2 || ^3",
+ "php": "^7.2.5 || ^8.0",
+ "psr/log": "^1 || ^2 || ^3"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.0",
+ "phpstan/phpstan-strict-rules": "^1.1",
+ "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Composer\\XdebugHandler\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "John Stevenson",
+ "email": "john-stevenson@blueyonder.co.uk"
+ }
+ ],
+ "description": "Restarts a process without Xdebug.",
+ "keywords": [
+ "Xdebug",
+ "performance"
+ ],
+ "support": {
+ "irc": "ircs://irc.libera.chat:6697/composer",
+ "issues": "https://github.com/composer/xdebug-handler/issues",
+ "source": "https://github.com/composer/xdebug-handler/tree/3.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-05-06T16:37:16+00:00"
+ },
+ {
+ "name": "danog/advanced-json-rpc",
+ "version": "v3.2.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/danog/php-advanced-json-rpc.git",
+ "reference": "aadb1c4068a88c3d0530cfe324b067920661efcb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/danog/php-advanced-json-rpc/zipball/aadb1c4068a88c3d0530cfe324b067920661efcb",
+ "reference": "aadb1c4068a88c3d0530cfe324b067920661efcb",
+ "shasum": ""
+ },
+ "require": {
+ "netresearch/jsonmapper": "^5",
+ "php": ">=8.1",
+ "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0.0"
+ },
+ "replace": {
+ "felixfbecker/php-advanced-json-rpc": "^3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "AdvancedJsonRpc\\": "lib/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "ISC"
+ ],
+ "authors": [
+ {
+ "name": "Felix Becker",
+ "email": "felix.b@outlook.com"
+ },
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ }
+ ],
+ "description": "A more advanced JSONRPC implementation",
+ "support": {
+ "issues": "https://github.com/danog/php-advanced-json-rpc/issues",
+ "source": "https://github.com/danog/php-advanced-json-rpc/tree/v3.2.2"
+ },
+ "time": "2025-02-14T10:55:15+00:00"
+ },
+ {
+ "name": "daverandom/libdns",
+ "version": "v2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/DaveRandom/LibDNS.git",
+ "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
+ "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "php": ">=7.1"
+ },
+ "suggest": {
+ "ext-intl": "Required for IDN support"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "LibDNS\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "DNS protocol implementation written in pure PHP",
+ "keywords": [
+ "dns"
+ ],
+ "support": {
+ "issues": "https://github.com/DaveRandom/LibDNS/issues",
+ "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0"
+ },
+ "time": "2024-04-12T12:12:48+00:00"
+ },
+ {
+ "name": "dnoegel/php-xdg-base-dir",
+ "version": "v0.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dnoegel/php-xdg-base-dir.git",
+ "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dnoegel/php-xdg-base-dir/zipball/8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd",
+ "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~7.0|~6.0|~5.0|~4.8.35"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "XdgBaseDir\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "implementation of xdg base directory specification for php",
+ "support": {
+ "issues": "https://github.com/dnoegel/php-xdg-base-dir/issues",
+ "source": "https://github.com/dnoegel/php-xdg-base-dir/tree/v0.1.1"
+ },
+ "time": "2019-12-04T15:06:13+00:00"
+ },
+ {
+ "name": "doctrine/deprecations",
+ "version": "1.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/deprecations.git",
+ "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
+ "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<=7.5 || >=13"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^9 || ^12 || ^13",
+ "phpstan/phpstan": "1.4.10 || 2.1.11",
+ "phpstan/phpstan-phpunit": "^1.0 || ^2",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12",
+ "psr/log": "^1 || ^2 || ^3"
+ },
+ "suggest": {
+ "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Deprecations\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
+ "homepage": "https://www.doctrine-project.org/",
+ "support": {
+ "issues": "https://github.com/doctrine/deprecations/issues",
+ "source": "https://github.com/doctrine/deprecations/tree/1.1.5"
+ },
+ "time": "2025-04-07T20:06:18+00:00"
+ },
+ {
+ "name": "felixfbecker/language-server-protocol",
+ "version": "v1.5.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/felixfbecker/php-language-server-protocol.git",
+ "reference": "a9e113dbc7d849e35b8776da39edaf4313b7b6c9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/felixfbecker/php-language-server-protocol/zipball/a9e113dbc7d849e35b8776da39edaf4313b7b6c9",
+ "reference": "a9e113dbc7d849e35b8776da39edaf4313b7b6c9",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "*",
+ "squizlabs/php_codesniffer": "^3.1",
+ "vimeo/psalm": "^4.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "LanguageServerProtocol\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "ISC"
+ ],
+ "authors": [
+ {
+ "name": "Felix Becker",
+ "email": "felix.b@outlook.com"
+ }
+ ],
+ "description": "PHP classes for the Language Server Protocol",
+ "keywords": [
+ "language",
+ "microsoft",
+ "php",
+ "server"
+ ],
+ "support": {
+ "issues": "https://github.com/felixfbecker/php-language-server-protocol/issues",
+ "source": "https://github.com/felixfbecker/php-language-server-protocol/tree/v1.5.3"
+ },
+ "time": "2024-04-30T00:40:11+00:00"
+ },
+ {
+ "name": "fidry/cpu-core-counter",
+ "version": "1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theofidry/cpu-core-counter.git",
+ "reference": "db9508f7b1474469d9d3c53b86f817e344732678"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
+ "reference": "db9508f7b1474469d9d3c53b86f817e344732678",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "fidry/makefile": "^0.2.0",
+ "fidry/php-cs-fixer-config": "^1.1.2",
+ "phpstan/extension-installer": "^1.2.0",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-deprecation-rules": "^2.0.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^8.5.31 || ^9.5.26",
+ "webmozarts/strict-phpunit": "^7.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Fidry\\CpuCoreCounter\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Théo FIDRY",
+ "email": "theo.fidry@gmail.com"
+ }
+ ],
+ "description": "Tiny utility to get the number of CPU cores.",
+ "keywords": [
+ "CPU",
+ "core"
+ ],
+ "support": {
+ "issues": "https://github.com/theofidry/cpu-core-counter/issues",
+ "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theofidry",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-14T07:29:31+00:00"
+ },
+ {
+ "name": "kelunik/certificate",
+ "version": "v1.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/kelunik/certificate.git",
+ "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
+ "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-openssl": "*",
+ "php": ">=7.0"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^6 | 7 | ^8 | ^9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Kelunik\\Certificate\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Access certificate details and transform between different formats.",
+ "keywords": [
+ "DER",
+ "certificate",
+ "certificates",
+ "openssl",
+ "pem",
+ "x509"
+ ],
+ "support": {
+ "issues": "https://github.com/kelunik/certificate/issues",
+ "source": "https://github.com/kelunik/certificate/tree/v1.1.3"
+ },
+ "time": "2023-02-03T21:26:53+00:00"
+ },
+ {
+ "name": "league/uri",
+ "version": "7.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/uri.git",
+ "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807",
+ "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807",
+ "shasum": ""
+ },
+ "require": {
+ "league/uri-interfaces": "^7.7",
+ "php": "^8.1",
+ "psr/http-factory": "^1"
+ },
+ "conflict": {
+ "league/uri-schemes": "^1.0"
+ },
+ "suggest": {
+ "ext-bcmath": "to improve IPV4 host parsing",
+ "ext-dom": "to convert the URI into an HTML anchor tag",
+ "ext-fileinfo": "to create Data URI from file contennts",
+ "ext-gmp": "to improve IPV4 host parsing",
+ "ext-intl": "to handle IDN host with the best performance",
+ "ext-uri": "to use the PHP native URI class",
+ "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain",
+ "league/uri-components": "Needed to easily manipulate URI objects components",
+ "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP",
+ "php-64bit": "to improve IPV4 host parsing",
+ "rowbot/url": "to handle WHATWG URL",
+ "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "7.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\Uri\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ignace Nyamagana Butera",
+ "email": "nyamsprod@gmail.com",
+ "homepage": "https://nyamsprod.com"
+ }
+ ],
+ "description": "URI manipulation library",
+ "homepage": "https://uri.thephpleague.com",
+ "keywords": [
+ "URN",
+ "data-uri",
+ "file-uri",
+ "ftp",
+ "hostname",
+ "http",
+ "https",
+ "middleware",
+ "parse_str",
+ "parse_url",
+ "psr-7",
+ "query-string",
+ "querystring",
+ "rfc2141",
+ "rfc3986",
+ "rfc3987",
+ "rfc6570",
+ "rfc8141",
+ "uri",
+ "uri-template",
+ "url",
+ "ws"
+ ],
+ "support": {
+ "docs": "https://uri.thephpleague.com",
+ "forum": "https://thephpleague.slack.com",
+ "issues": "https://github.com/thephpleague/uri-src/issues",
+ "source": "https://github.com/thephpleague/uri/tree/7.7.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/nyamsprod",
+ "type": "github"
+ }
+ ],
+ "time": "2025-12-07T16:02:06+00:00"
+ },
+ {
+ "name": "league/uri-interfaces",
+ "version": "7.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/uri-interfaces.git",
+ "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c",
+ "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c",
+ "shasum": ""
+ },
+ "require": {
+ "ext-filter": "*",
+ "php": "^8.1",
+ "psr/http-message": "^1.1 || ^2.0"
+ },
+ "suggest": {
+ "ext-bcmath": "to improve IPV4 host parsing",
+ "ext-gmp": "to improve IPV4 host parsing",
+ "ext-intl": "to handle IDN host with the best performance",
+ "php-64bit": "to improve IPV4 host parsing",
+ "rowbot/url": "to handle WHATWG URL",
+ "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "7.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\Uri\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ignace Nyamagana Butera",
+ "email": "nyamsprod@gmail.com",
+ "homepage": "https://nyamsprod.com"
+ }
+ ],
+ "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI",
+ "homepage": "https://uri.thephpleague.com",
+ "keywords": [
+ "data-uri",
+ "file-uri",
+ "ftp",
+ "hostname",
+ "http",
+ "https",
+ "parse_str",
+ "parse_url",
+ "psr-7",
+ "query-string",
+ "querystring",
+ "rfc3986",
+ "rfc3987",
+ "rfc6570",
+ "uri",
+ "url",
+ "ws"
+ ],
+ "support": {
+ "docs": "https://uri.thephpleague.com",
+ "forum": "https://thephpleague.slack.com",
+ "issues": "https://github.com/thephpleague/uri-src/issues",
+ "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/nyamsprod",
+ "type": "github"
+ }
+ ],
+ "time": "2025-12-07T16:03:21+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.13.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-01T08:46:24+00:00"
+ },
+ {
+ "name": "netresearch/jsonmapper",
+ "version": "v5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cweiske/jsonmapper.git",
+ "reference": "8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c",
+ "reference": "8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-pcre": "*",
+ "ext-reflection": "*",
+ "ext-spl": "*",
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~7.5 || ~8.0 || ~9.0 || ~10.0",
+ "squizlabs/php_codesniffer": "~3.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "JsonMapper": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "OSL-3.0"
+ ],
+ "authors": [
+ {
+ "name": "Christian Weiske",
+ "email": "cweiske@cweiske.de",
+ "homepage": "http://github.com/cweiske/jsonmapper/",
+ "role": "Developer"
+ }
+ ],
+ "description": "Map nested JSON structures onto PHP classes",
+ "support": {
+ "email": "cweiske@cweiske.de",
+ "issues": "https://github.com/cweiske/jsonmapper/issues",
+ "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.0"
+ },
+ "time": "2024-09-08T10:20:00+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+ },
+ "time": "2025-12-06T11:56:16+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "phpdocumentor/reflection-common",
+ "version": "2.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
+ "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+ "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-2.x": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
+ "homepage": "http://www.phpdoc.org",
+ "keywords": [
+ "FQSEN",
+ "phpDocumentor",
+ "phpdoc",
+ "reflection",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
+ },
+ "time": "2020-06-27T09:03:43+00:00"
+ },
+ {
+ "name": "phpdocumentor/reflection-docblock",
+ "version": "5.6.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
+ "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8",
+ "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.1",
+ "ext-filter": "*",
+ "php": "^7.4 || ^8.0",
+ "phpdocumentor/reflection-common": "^2.2",
+ "phpdocumentor/type-resolver": "^1.7",
+ "phpstan/phpdoc-parser": "^1.7|^2.0",
+ "webmozart/assert": "^1.9.1 || ^2"
+ },
+ "require-dev": {
+ "mockery/mockery": "~1.3.5 || ~1.6.0",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-mockery": "^1.1",
+ "phpstan/phpstan-webmozart-assert": "^1.2",
+ "phpunit/phpunit": "^9.5",
+ "psalm/phar": "^5.26"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ },
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6"
+ },
+ "time": "2025-12-22T21:13:58+00:00"
+ },
+ {
+ "name": "phpdocumentor/type-resolver",
+ "version": "1.12.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/TypeResolver.git",
+ "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195",
+ "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.0",
+ "php": "^7.3 || ^8.0",
+ "phpdocumentor/reflection-common": "^2.0",
+ "phpstan/phpdoc-parser": "^1.18|^2.0"
+ },
+ "require-dev": {
+ "ext-tokenizer": "*",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-phpunit": "^1.1",
+ "phpunit/phpunit": "^9.5",
+ "rector/rector": "^0.13.9",
+ "vimeo/psalm": "^4.25"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-1.x": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ }
+ ],
+ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
+ "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0"
+ },
+ "time": "2025-11-21T15:09:14+00:00"
+ },
+ {
+ "name": "phpstan/phpdoc-parser",
+ "version": "2.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpdoc-parser.git",
+ "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495",
+ "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/annotations": "^2.0",
+ "nikic/php-parser": "^5.3.0",
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^9.6",
+ "symfony/process": "^5.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\PhpDocParser\\": [
+ "src/"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPDoc parser with support for nullable, intersection and generic types",
+ "support": {
+ "issues": "https://github.com/phpstan/phpdoc-parser/issues",
+ "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0"
+ },
+ "time": "2025-08-30T15:50:23+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "12.5.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b",
+ "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^5.7.0",
+ "php": ">=8.3",
+ "phpunit/php-file-iterator": "^6.0",
+ "phpunit/php-text-template": "^5.0",
+ "sebastian/complexity": "^5.0",
+ "sebastian/environment": "^8.0.3",
+ "sebastian/lines-of-code": "^4.0",
+ "sebastian/version": "^6.0",
+ "theseer/tokenizer": "^2.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.1"
+ },
+ "suggest": {
+ "ext-pcov": "PHP extension that provides line coverage",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "12.5.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-12-24T07:03:04+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "961bc913d42fe24a257bfff826a5068079ac7782"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782",
+ "reference": "961bc913d42fe24a257bfff826a5068079ac7782",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:58:37+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406",
+ "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^12.0"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:58:58+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53",
+ "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:59:16+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "8.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
+ "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "security": "https://github.com/sebastianbergmann/php-timer/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:59:38+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "12.5.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4ba0e923f9d3fc655de22f9547c01d15a41fc93a",
+ "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xml": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.13.4",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=8.3",
+ "phpunit/php-code-coverage": "^12.5.1",
+ "phpunit/php-file-iterator": "^6.0.0",
+ "phpunit/php-invoker": "^6.0.0",
+ "phpunit/php-text-template": "^5.0.0",
+ "phpunit/php-timer": "^8.0.0",
+ "sebastian/cli-parser": "^4.2.0",
+ "sebastian/comparator": "^7.1.3",
+ "sebastian/diff": "^7.0.0",
+ "sebastian/environment": "^8.0.3",
+ "sebastian/exporter": "^7.0.2",
+ "sebastian/global-state": "^8.0.2",
+ "sebastian/object-enumerator": "^7.0.0",
+ "sebastian/type": "^6.0.3",
+ "sebastian/version": "^6.0.0",
+ "staabm/side-effects-detector": "^1.0.5"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "12.5-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.4"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsors.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-12-15T06:05:34+00:00"
+ },
+ {
+ "name": "psalm/plugin-phpunit",
+ "version": "0.19.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/psalm/psalm-plugin-phpunit.git",
+ "reference": "143f9d5e049fffcdbc0da3fbb99f6149f9d3e2dc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/psalm/psalm-plugin-phpunit/zipball/143f9d5e049fffcdbc0da3fbb99f6149f9d3e2dc",
+ "reference": "143f9d5e049fffcdbc0da3fbb99f6149f9d3e2dc",
+ "shasum": ""
+ },
+ "require": {
+ "ext-simplexml": "*",
+ "php": ">=8.1",
+ "vimeo/psalm": "dev-master || ^6.10.0"
+ },
+ "conflict": {
+ "phpspec/prophecy": "<1.20.0",
+ "phpspec/prophecy-phpunit": "<2.3.0",
+ "phpunit/phpunit": "<8.5.1"
+ },
+ "require-dev": {
+ "php": "^7.3 || ^8.0",
+ "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
+ "squizlabs/php_codesniffer": "^3.3.1",
+ "weirdan/prophecy-shim": "^1.0 || ^2.0"
+ },
+ "type": "psalm-plugin",
+ "extra": {
+ "psalm": {
+ "pluginClass": "Psalm\\PhpUnitPlugin\\Plugin"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psalm\\PhpUnitPlugin\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Matt Brown",
+ "email": "github@muglug.com"
+ }
+ ],
+ "description": "Psalm plugin for PHPUnit",
+ "support": {
+ "issues": "https://github.com/psalm/psalm-plugin-phpunit/issues",
+ "source": "https://github.com/psalm/psalm-plugin-phpunit/tree/0.19.5"
+ },
+ "time": "2025-03-31T18:49:55+00:00"
+ },
+ {
+ "name": "psr/container",
+ "version": "2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/container.git",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Container\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common Container Interface (PHP FIG PSR-11)",
+ "homepage": "https://github.com/php-fig/container",
+ "keywords": [
+ "PSR-11",
+ "container",
+ "container-interface",
+ "container-interop",
+ "psr"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/2.0.2"
+ },
+ "time": "2021-11-05T16:47:00+00:00"
+ },
+ {
+ "name": "psr/http-factory",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory"
+ },
+ "time": "2024-04-15T12:06:14+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/2.0"
+ },
+ "time": "2023-04-04T09:54:51+00:00"
+ },
+ {
+ "name": "psr/log",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/3.0.2"
+ },
+ "time": "2024-09-11T13:17:53+00:00"
+ },
+ {
+ "name": "revolt/event-loop",
+ "version": "v1.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/revoltphp/event-loop.git",
+ "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c",
+ "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "ext-json": "*",
+ "jetbrains/phpstorm-stubs": "^2019.3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.15"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Revolt\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Cees-Jan Kiewiet",
+ "email": "ceesjank@gmail.com"
+ },
+ {
+ "name": "Christian Lück",
+ "email": "christian@clue.engineering"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Rock-solid event loop for concurrent PHP applications.",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "concurrency",
+ "event",
+ "event-loop",
+ "non-blocking",
+ "scheduler"
+ ],
+ "support": {
+ "issues": "https://github.com/revoltphp/event-loop/issues",
+ "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8"
+ },
+ "time": "2025-08-27T21:33:23+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "4.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04",
+ "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-09-14T09:36:45+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "7.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/dc904b4bb3ab070865fa4068cd84f3da8b945148",
+ "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.3",
+ "sebastian/diff": "^7.0",
+ "sebastian/exporter": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.2"
+ },
+ "suggest": {
+ "ext-bcmath": "For comparing BcMath\\Number objects"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-20T11:27:00+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb",
+ "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:55:25+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "7ab1ea946c012266ca32390913653d844ecd085f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f",
+ "reference": "7ab1ea946c012266ca32390913653d844ecd085f",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0",
+ "symfony/process": "^7.2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "security": "https://github.com/sebastianbergmann/diff/security/policy",
+ "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:55:46+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "8.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68",
+ "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "https://github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "security": "https://github.com/sebastianbergmann/environment/security/policy",
+ "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-12T14:11:56+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "7.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "016951ae10980765e4e7aee491eb288c64e505b7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7",
+ "reference": "016951ae10980765e4e7aee491eb288c64e505b7",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=8.3",
+ "sebastian/recursion-context": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-09-24T06:16:11+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "8.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "ef1377171613d09edd25b7816f05be8313f9115d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d",
+ "reference": "ef1377171613d09edd25b7816f05be8313f9115d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "https://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-29T11:29:25+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "4.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f",
+ "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:57:28+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:57:48+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "4bfa827c969c98be1e527abd576533293c634f6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a",
+ "reference": "4bfa827c969c98be1e527abd576533293c634f6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:58:17+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "7.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-13T04:44:59+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "6.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d",
+ "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "security": "https://github.com/sebastianbergmann/type/security/policy",
+ "source": "https://github.com/sebastianbergmann/type/tree/6.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-09T06:57:12+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c",
+ "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "security": "https://github.com/sebastianbergmann/version/security/policy",
+ "source": "https://github.com/sebastianbergmann/version/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T05:00:38+00:00"
+ },
+ {
+ "name": "spatie/array-to-xml",
+ "version": "3.4.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/array-to-xml.git",
+ "reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/88b2f3852a922dd73177a68938f8eb2ec70c7224",
+ "reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.2",
+ "pestphp/pest": "^1.21",
+ "spatie/pest-plugin-snapshots": "^1.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Spatie\\ArrayToXml\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Freek Van der Herten",
+ "email": "freek@spatie.be",
+ "homepage": "https://freek.dev",
+ "role": "Developer"
+ }
+ ],
+ "description": "Convert an array to xml",
+ "homepage": "https://github.com/spatie/array-to-xml",
+ "keywords": [
+ "array",
+ "convert",
+ "xml"
+ ],
+ "support": {
+ "source": "https://github.com/spatie/array-to-xml/tree/3.4.4"
+ },
+ "funding": [
+ {
+ "url": "https://spatie.be/open-source/support-us",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/spatie",
+ "type": "github"
+ }
+ ],
+ "time": "2025-12-15T09:00:41+00:00"
+ },
+ {
+ "name": "staabm/side-effects-detector",
+ "version": "1.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/staabm/side-effects-detector.git",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^1.12.6",
+ "phpunit/phpunit": "^9.6.21",
+ "symfony/var-dumper": "^5.4.43",
+ "tomasvotruba/type-coverage": "1.0.0",
+ "tomasvotruba/unused-public": "1.0.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "lib/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A static analysis tool to detect side effects in PHP code",
+ "keywords": [
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/staabm/side-effects-detector/issues",
+ "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-20T05:08:20+00:00"
+ },
+ {
+ "name": "symfony/console",
+ "version": "v8.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/console.git",
+ "reference": "6145b304a5c1ea0bdbd0b04d297a5864f9a7d587"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/console/zipball/6145b304a5c1ea0bdbd0b04d297a5864f9a7d587",
+ "reference": "6145b304a5c1ea0bdbd0b04d297a5864f9a7d587",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "symfony/polyfill-mbstring": "^1.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/string": "^7.4|^8.0"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0|2.0|3.0"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^7.4|^8.0",
+ "symfony/dependency-injection": "^7.4|^8.0",
+ "symfony/event-dispatcher": "^7.4|^8.0",
+ "symfony/http-foundation": "^7.4|^8.0",
+ "symfony/http-kernel": "^7.4|^8.0",
+ "symfony/lock": "^7.4|^8.0",
+ "symfony/messenger": "^7.4|^8.0",
+ "symfony/process": "^7.4|^8.0",
+ "symfony/stopwatch": "^7.4|^8.0",
+ "symfony/var-dumper": "^7.4|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Console\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Eases the creation of beautiful and testable command line interfaces",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "cli",
+ "command-line",
+ "console",
+ "terminal"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v8.0.3"
+ },
+ "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-12-23T14:52:06+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "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": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-25T14:21:43+00:00"
+ },
+ {
+ "name": "symfony/filesystem",
+ "version": "v8.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/filesystem.git",
+ "reference": "d937d400b980523dc9ee946bb69972b5e619058d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d",
+ "reference": "d937d400b980523dc9ee946bb69972b5e619058d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-mbstring": "~1.8"
+ },
+ "require-dev": {
+ "symfony/process": "^7.4|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Filesystem\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides basic utilities for the filesystem",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/filesystem/tree/v8.0.1"
+ },
+ "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-12-01T09:13:36+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-ctype": "*"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/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": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ }
+ },
+ "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 for intl's grapheme_* functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "grapheme",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/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-06-27T09:58:17+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-normalizer",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+ },
+ "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 for intl's Normalizer class and related functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "intl",
+ "normalizer",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/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": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ }
+ },
+ "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 for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/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": "2024-12-23T08:48:59+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php84",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php84.git",
+ "reference": "d8ced4d875142b6a7426000426b8abc631d6b191"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191",
+ "reference": "d8ced4d875142b6a7426000426b8abc631d6b191",
+ "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\\Php84\\": ""
+ },
+ "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.4+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php84/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-06-24T13:30:11+00:00"
+ },
+ {
+ "name": "symfony/service-contracts",
+ "version": "v3.6.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/service-contracts.git",
+ "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/container": "^1.1|^2.0",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "ext-psr": "<1.1|>=2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Service\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "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": "Generic abstractions related to writing services",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ },
+ "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-15T11:30:57+00:00"
+ },
+ {
+ "name": "symfony/string",
+ "version": "v8.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/string.git",
+ "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc",
+ "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "symfony/polyfill-ctype": "^1.8",
+ "symfony/polyfill-intl-grapheme": "^1.33",
+ "symfony/polyfill-intl-normalizer": "^1.0",
+ "symfony/polyfill-mbstring": "^1.0"
+ },
+ "conflict": {
+ "symfony/translation-contracts": "<2.5"
+ },
+ "require-dev": {
+ "symfony/emoji": "^7.4|^8.0",
+ "symfony/http-client": "^7.4|^8.0",
+ "symfony/intl": "^7.4|^8.0",
+ "symfony/translation-contracts": "^2.5|^3.0",
+ "symfony/var-exporter": "^7.4|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\String\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "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": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "grapheme",
+ "i18n",
+ "string",
+ "unicode",
+ "utf-8",
+ "utf8"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v8.0.1"
+ },
+ "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-12-01T09:13:36+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^8.1"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-12-08T11:19:18+00:00"
+ },
+ {
+ "name": "vimeo/psalm",
+ "version": "6.14.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/vimeo/psalm.git",
+ "reference": "d0b040a91f280f071c1abcb1b77ce3822058725a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/vimeo/psalm/zipball/d0b040a91f280f071c1abcb1b77ce3822058725a",
+ "reference": "d0b040a91f280f071c1abcb1b77ce3822058725a",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/parallel": "^2.3",
+ "composer-runtime-api": "^2",
+ "composer/semver": "^1.4 || ^2.0 || ^3.0",
+ "composer/xdebug-handler": "^2.0 || ^3.0",
+ "danog/advanced-json-rpc": "^3.1",
+ "dnoegel/php-xdg-base-dir": "^0.1.1",
+ "ext-ctype": "*",
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-simplexml": "*",
+ "ext-tokenizer": "*",
+ "felixfbecker/language-server-protocol": "^1.5.3",
+ "fidry/cpu-core-counter": "^0.4.1 || ^0.5.1 || ^1.0.0",
+ "netresearch/jsonmapper": "^5.0",
+ "nikic/php-parser": "^5.0.0",
+ "php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3 || ~8.5.0",
+ "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0",
+ "spatie/array-to-xml": "^2.17.0 || ^3.0",
+ "symfony/console": "^6.0 || ^7.0 || ^8.0",
+ "symfony/filesystem": "~6.3.12 || ~6.4.3 || ^7.0.3 || ^8.0",
+ "symfony/polyfill-php84": "^1.31.0"
+ },
+ "provide": {
+ "psalm/psalm": "self.version"
+ },
+ "require-dev": {
+ "amphp/phpunit-util": "^3",
+ "bamarni/composer-bin-plugin": "^1.4",
+ "brianium/paratest": "^6.9",
+ "danog/class-finder": "^0.4.8",
+ "dg/bypass-finals": "^1.5",
+ "ext-curl": "*",
+ "mockery/mockery": "^1.5",
+ "nunomaduro/mock-final-classes": "^1.1",
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/phpdoc-parser": "^1.6",
+ "phpunit/phpunit": "^9.6",
+ "psalm/plugin-mockery": "^1.1",
+ "psalm/plugin-phpunit": "^0.19",
+ "slevomat/coding-standard": "^8.4",
+ "squizlabs/php_codesniffer": "^3.6",
+ "symfony/process": "^6.0 || ^7.0 || ^8.0"
+ },
+ "suggest": {
+ "ext-curl": "In order to send data to shepherd",
+ "ext-igbinary": "^2.0.5 is required, used to serialize caching data"
+ },
+ "bin": [
+ "psalm",
+ "psalm-language-server",
+ "psalm-plugin",
+ "psalm-refactor",
+ "psalm-review",
+ "psalter"
+ ],
+ "type": "project",
+ "extra": {
+ "branch-alias": {
+ "dev-1.x": "1.x-dev",
+ "dev-2.x": "2.x-dev",
+ "dev-3.x": "3.x-dev",
+ "dev-4.x": "4.x-dev",
+ "dev-5.x": "5.x-dev",
+ "dev-6.x": "6.x-dev",
+ "dev-master": "7.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psalm\\": "src/Psalm/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Matthew Brown"
+ },
+ {
+ "name": "Daniil Gentili",
+ "email": "daniil@daniil.it"
+ }
+ ],
+ "description": "A static analysis tool for finding errors in PHP applications",
+ "keywords": [
+ "code",
+ "inspection",
+ "php",
+ "static analysis"
+ ],
+ "support": {
+ "docs": "https://psalm.dev/docs",
+ "issues": "https://github.com/vimeo/psalm/issues",
+ "source": "https://github.com/vimeo/psalm"
+ },
+ "time": "2025-12-23T15:36:48+00:00"
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "2.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webmozarts/assert.git",
+ "reference": "bdbabc199a7ba9965484e4725d66170e5711323b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/bdbabc199a7ba9965484e4725d66170e5711323b",
+ "reference": "bdbabc199a7ba9965484e4725d66170e5711323b",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-date": "*",
+ "ext-filter": "*",
+ "php": "^8.2"
+ },
+ "suggest": {
+ "ext-intl": "",
+ "ext-simplexml": "",
+ "ext-spl": ""
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-feature/2-0": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Webmozart\\Assert\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ },
+ {
+ "name": "Woody Gilk",
+ "email": "woody.gilk@gmail.com"
+ }
+ ],
+ "description": "Assertions to validate method input/output with nice error messages.",
+ "keywords": [
+ "assert",
+ "check",
+ "validate"
+ ],
+ "support": {
+ "issues": "https://github.com/webmozarts/assert/issues",
+ "source": "https://github.com/webmozarts/assert/tree/2.1.1"
+ },
+ "time": "2026-01-08T11:28:40+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": "~8.4.0 || ~8.5.0"
+ },
+ "platform-dev": {
+ "ext-curl": "*",
+ "ext-dom": "*",
+ "ext-gd": "*",
+ "ext-libxml": "*"
+ },
+ "platform-overrides": {
+ "php": "8.4.4"
+ },
+ "plugin-api-version": "2.6.0"
+}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..e5ee732
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+ ./tests
+
+
+
+
+
+
+
+ ./src
+
+
+
+
diff --git a/src/AbstractSerializer.php b/src/AbstractSerializer.php
new file mode 100644
index 0000000..cc1c73a
--- /dev/null
+++ b/src/AbstractSerializer.php
@@ -0,0 +1,161 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use Rodas\Diactoros\Exception\DeserializationException;
+use Rodas\Psr\Http\Message\StreamInterface;
+
+use function array_pop;
+use function assert;
+use function implode;
+use function is_string;
+use function preg_match;
+use function sprintf;
+use function str_replace;
+use function trim;
+use function ucwords;
+
+/**
+ * Provides base functionality for request and response de/serialization
+ * strategies, including functionality for retrieving a line at a time from
+ * the message, splitting headers from the body, and serializing headers.
+ */
+abstract class AbstractSerializer {
+ public const CR = "\r";
+ public const EOL = "\r\n";
+ public const LF = "\n";
+
+ /**
+ * Retrieve a single line from the stream.
+ *
+ * Retrieves a line from the stream; a line is defined as a sequence of
+ * characters ending in a CRLF sequence.
+ *
+ * @throws DeserializationException If the sequence contains a CR
+ * or LF in isolation, or ends in a CR.
+ */
+ protected static function getLine(StreamInterface $stream): string {
+ $line = '';
+ $crFound = false;
+ while (! $stream->eof()) {
+ $char = $stream->read(1);
+
+ if ($crFound && $char === self::LF) {
+ $crFound = false;
+ break;
+ }
+
+ // CR NOT followed by LF
+ if ($crFound && $char !== self::LF) {
+ throw DeserializationException::forUnexpectedCarriageReturn();
+ }
+
+ // LF in isolation
+ if (! $crFound && $char === self::LF) {
+ throw DeserializationException::forUnexpectedLineFeed();
+ }
+
+ // CR found; do not append
+ if ($char === self::CR) {
+ $crFound = true;
+ continue;
+ }
+
+ // Any other character: append
+ $line .= $char;
+ }
+
+ // CR found at end of stream
+ if ($crFound) {
+ throw DeserializationException::forUnexpectedEndOfHeaders();
+ }
+
+ return $line;
+ }
+
+ /**
+ * Split the stream into headers and body content.
+ *
+ * Returns an array containing two elements
+ *
+ * - The first is an array of headers
+ * - The second is a StreamInterface containing the body content
+ *
+ * @throws DeserializationException For invalid headers.
+ */
+ protected static function splitStream(StreamInterface $stream): array {
+ $headers = [];
+ $currentHeader = false;
+
+ while ($line = self::getLine($stream)) {
+ if (preg_match(';^(?P[!#$%&\'*+.^_`\|~0-9a-zA-Z-]+):(?P.*)$;', $line, $matches)) {
+ $currentHeader = $matches['name'];
+ if (! isset($headers[$currentHeader])) {
+ $headers[$currentHeader] = [];
+ }
+ $headers[$currentHeader][] = trim($matches['value'], "\t ");
+ continue;
+ }
+
+ if ($currentHeader === false) {
+ throw DeserializationException::forInvalidHeader();
+ }
+
+ if (! preg_match('#^[ \t]#', $line)) {
+ throw DeserializationException::forInvalidHeaderContinuation();
+ }
+
+ // Append continuation to last header value found
+ $value = array_pop($headers[$currentHeader]);
+ assert(is_string($value));
+ $headers[$currentHeader][] = $value . ' ' . trim($line, "\t ");
+ }
+
+ // use RelativeStream to avoid copying initial stream into memory
+ return [$headers, new RelativeStream($stream, $stream->tell())];
+ }
+
+ /**
+ * Serialize headers to string values.
+ *
+ * @psalm-param array $headers
+ */
+ protected static function serializeHeaders(array $headers): string {
+ $lines = [];
+ foreach ($headers as $header => $values) {
+ $normalized = self::filterHeader($header);
+ foreach ($values as $value) {
+ $lines[] = sprintf('%s: %s', $normalized, $value);
+ }
+ }
+
+ return implode("\r\n", $lines);
+ }
+
+ /**
+ * Filter a header name to wordcase
+ *
+ * @param string $header
+ */
+ protected static function filterHeader($header): string {
+ $filtered = str_replace('-', ' ', $header);
+ $filtered = ucwords($filtered);
+ return str_replace(' ', '-', $filtered);
+ }
+}
diff --git a/src/CallbackStream.php b/src/CallbackStream.php
new file mode 100644
index 0000000..fe7e376
--- /dev/null
+++ b/src/CallbackStream.php
@@ -0,0 +1,185 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use InvalidArgumentException;
+use Override;
+use Rodas\Psr\Http\Message\StreamInterface;
+use Stringable;
+
+use function array_key_exists;
+
+use const SEEK_SET;
+
+/**
+ * Implementation of PSR HTTP streams
+ */
+class CallbackStream implements StreamInterface, Stringable {
+ /** @var callable|null */
+ protected $callback;
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function __construct(callable $callback) {
+ $this->attach($callback);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function __toString(): string {
+ return $this->getContents();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function close(): void {
+ $this->callback = null;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @return null|callable
+ */
+ #[Override]
+ public function detach(): ?callable {
+ $callback = $this->callback;
+ $this->callback = null;
+ return $callback;
+ }
+
+ /**
+ * Attach a new callback to the instance.
+ */
+ public function attach(callable $callback): void {
+ $this->callback = $callback;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public ?int $size {
+ get => null;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function tell(): int {
+ throw Exception\UntellableStreamException::forCallbackStream();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function eof(): bool {
+ return $this->callback === null;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public bool $isSeekable {
+ get => false;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function seek(int $offset, int $whence = SEEK_SET): void {
+ throw Exception\UnseekableStreamException::forCallbackStream();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function rewind(): void {
+ throw Exception\UnrewindableStreamException::forCallbackStream();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public bool $isWritable {
+ get => false;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function write(string $string): int {
+ throw Exception\UnwritableStreamException::forCallbackStream();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public bool $isReadable {
+ get => false;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function read(int $length): string {
+ throw Exception\UnreadableStreamException::forCallbackStream();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function getContents(): string {
+ $callback = $this->detach();
+ return $callback !== null ? (string) $callback() : '';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function getMetadata(?string $key = null) {
+ $metadata = [
+ 'eof' => $this->eof(),
+ 'stream_type' => 'callback',
+ 'seekable' => false,
+ ];
+
+ if (null === $key) {
+ return $metadata;
+ }
+
+ if (! array_key_exists($key, $metadata)) {
+ return null;
+ }
+
+ return $metadata[$key];
+ }
+}
diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php
new file mode 100644
index 0000000..4d49c79
--- /dev/null
+++ b/src/ConfigProvider.php
@@ -0,0 +1,71 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use Rodas\Psr\Http\Message\RequestFactoryInterface;
+use Rodas\Psr\Http\Message\ResponseFactoryInterface;
+use Rodas\Psr\Http\Message\ServerRequestFactoryInterface;
+use Rodas\Psr\Http\Message\StreamFactoryInterface;
+use Rodas\Psr\Http\Message\UploadedFileFactoryInterface;
+use Rodas\Psr\Http\Message\UriFactoryInterface;
+
+class ConfigProvider {
+ public const CONFIG_KEY = 'rodas-diactoros';
+ public const X_FORWARDED = 'x-forwarded-request-filter';
+ public const X_FORWARDED_TRUSTED_PROXIES = 'trusted-proxies';
+ public const X_FORWARDED_TRUSTED_HEADERS = 'trusted-headers';
+
+ /**
+ * Retrieve configuration for rodas-diactoros.
+ */
+ public function __invoke(): array {
+ return [
+ 'dependencies' => $this->getDependencies(),
+ self::CONFIG_KEY => $this->getComponentConfig(),
+ ];
+ }
+
+ /**
+ * Returns the container dependencies.
+ * Maps factory interfaces to factories.
+ */
+ public function getDependencies(): array {
+ // @codingStandardsIgnoreStart
+ return [
+ 'invokables' => [
+ RequestFactoryInterface::class => RequestFactory::class,
+ ResponseFactoryInterface::class => ResponseFactory::class,
+ StreamFactoryInterface::class => StreamFactory::class,
+ ServerRequestFactoryInterface::class => ServerRequestFactory::class,
+ UploadedFileFactoryInterface::class => UploadedFileFactory::class,
+ UriFactoryInterface::class => UriFactory::class
+ ],
+ ];
+ // @codingStandardsIgnoreEnd
+ }
+
+ public function getComponentConfig(): array {
+ return [
+ self::X_FORWARDED => [
+ self::X_FORWARDED_TRUSTED_PROXIES => '',
+ self::X_FORWARDED_TRUSTED_HEADERS => [],
+ ],
+ ];
+ }
+}
diff --git a/src/Exception/DeserializationException.php b/src/Exception/DeserializationException.php
new file mode 100644
index 0000000..cddcf63
--- /dev/null
+++ b/src/Exception/DeserializationException.php
@@ -0,0 +1,52 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use Throwable;
+use UnexpectedValueException;
+
+class DeserializationException extends UnexpectedValueException {
+ public static function forInvalidHeader(): self {
+ throw new self('Invalid header detected');
+ }
+
+ public static function forInvalidHeaderContinuation(): self {
+ throw new self('Invalid header continuation');
+ }
+
+ public static function forRequestFromArray(Throwable $previous): self {
+ return new self('Cannot deserialize request', (int) $previous->getCode(), $previous);
+ }
+
+ public static function forResponseFromArray(Throwable $previous): self {
+ return new self('Cannot deserialize response', (int) $previous->getCode(), $previous);
+ }
+
+ public static function forUnexpectedCarriageReturn(): self {
+ throw new self('Unexpected carriage return detected');
+ }
+
+ public static function forUnexpectedEndOfHeaders(): self {
+ throw new self('Unexpected end of headers');
+ }
+
+ public static function forUnexpectedLineFeed(): self {
+ throw new self('Unexpected line feed detected');
+ }
+}
diff --git a/src/Exception/InvalidForwardedHeaderNameException.php b/src/Exception/InvalidForwardedHeaderNameException.php
new file mode 100644
index 0000000..7cc9d42
--- /dev/null
+++ b/src/Exception/InvalidForwardedHeaderNameException.php
@@ -0,0 +1,41 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use Rodas\Diactoros\ServerRequestFilter\FilterUsingXForwardedHeaders;
+
+use RuntimeException;
+
+use function get_debug_type;
+use function is_string;
+use function sprintf;
+
+class InvalidForwardedHeaderNameException extends RuntimeException {
+ public static function forHeader(mixed $name): self {
+ if (! is_string($name)) {
+ $name = sprintf('(value of type %s)', get_debug_type($name));
+ }
+
+ return new self(sprintf(
+ 'Invalid X-Forwarded-* header name "%s" provided to %s',
+ $name,
+ FilterUsingXForwardedHeaders::class
+ ));
+ }
+}
diff --git a/src/Exception/InvalidProxyAddressException.php b/src/Exception/InvalidProxyAddressException.php
new file mode 100644
index 0000000..7285c03
--- /dev/null
+++ b/src/Exception/InvalidProxyAddressException.php
@@ -0,0 +1,44 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use RuntimeException;
+
+use function get_debug_type;
+use function sprintf;
+
+class InvalidProxyAddressException extends RuntimeException {
+ public static function forInvalidProxyArgument(mixed $proxy): self {
+ $type = get_debug_type($proxy);
+ return new self(sprintf(
+ 'Invalid proxy of type "%s" provided;'
+ . ' must be a valid IPv4 or IPv6 address, optionally with a subnet mask provided'
+ . ' or an array of such values',
+ $type,
+ ));
+ }
+
+ public static function forAddress(string $address): self {
+ return new self(sprintf(
+ 'Invalid proxy address "%s" provided;'
+ . ' must be a valid IPv4 or IPv6 address, optionally with a subnet mask provided',
+ $address,
+ ));
+ }
+}
diff --git a/src/Exception/InvalidStreamPointerPositionException.php b/src/Exception/InvalidStreamPointerPositionException.php
new file mode 100644
index 0000000..27f325c
--- /dev/null
+++ b/src/Exception/InvalidStreamPointerPositionException.php
@@ -0,0 +1,33 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use RuntimeException;
+use Throwable;
+
+class InvalidStreamPointerPositionException extends RuntimeException {
+ /** {@inheritDoc} */
+ public function __construct(
+ string $message = 'Invalid pointer position',
+ int $code = 0,
+ ?Throwable $previous = null
+ ) {
+ parent::__construct($message, $code, $previous);
+ }
+}
diff --git a/src/Exception/SerializationException.php b/src/Exception/SerializationException.php
new file mode 100644
index 0000000..c74eb77
--- /dev/null
+++ b/src/Exception/SerializationException.php
@@ -0,0 +1,31 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use UnexpectedValueException;
+
+class SerializationException extends UnexpectedValueException {
+ public static function forInvalidRequestLine(): self {
+ return new self('Invalid request line detected');
+ }
+
+ public static function forInvalidStatusLine(): self {
+ return new self('No status line detected');
+ }
+}
diff --git a/src/Exception/UnreadableStreamException.php b/src/Exception/UnreadableStreamException.php
new file mode 100644
index 0000000..2991f40
--- /dev/null
+++ b/src/Exception/UnreadableStreamException.php
@@ -0,0 +1,39 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use RuntimeException;
+
+class UnreadableStreamException extends RuntimeException {
+ public static function dueToConfiguration(): self {
+ return new self('Stream is not readable');
+ }
+
+ public static function dueToMissingResource(): self {
+ return new self('No resource available; cannot read');
+ }
+
+ public static function dueToPhpError(): self {
+ return new self('Error reading stream');
+ }
+
+ public static function forCallbackStream(): self {
+ return new self('Callback streams cannot read');
+ }
+}
diff --git a/src/Exception/UnrecognizedProtocolVersionException.php b/src/Exception/UnrecognizedProtocolVersionException.php
new file mode 100644
index 0000000..439aeda
--- /dev/null
+++ b/src/Exception/UnrecognizedProtocolVersionException.php
@@ -0,0 +1,29 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use UnexpectedValueException;
+
+use function sprintf;
+
+class UnrecognizedProtocolVersionException extends UnexpectedValueException {
+ public static function forVersion(string $version): self {
+ return new self(sprintf('Unrecognized protocol version (%s)', $version));
+ }
+}
diff --git a/src/Exception/UnrewindableStreamException.php b/src/Exception/UnrewindableStreamException.php
new file mode 100644
index 0000000..5d6a36a
--- /dev/null
+++ b/src/Exception/UnrewindableStreamException.php
@@ -0,0 +1,27 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use RuntimeException;
+
+class UnrewindableStreamException extends RuntimeException {
+ public static function forCallbackStream(): self {
+ return new self('Callback streams cannot rewind position');
+ }
+}
diff --git a/src/Exception/UnseekableStreamException.php b/src/Exception/UnseekableStreamException.php
new file mode 100644
index 0000000..2993676
--- /dev/null
+++ b/src/Exception/UnseekableStreamException.php
@@ -0,0 +1,39 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use RuntimeException;
+
+class UnseekableStreamException extends RuntimeException {
+ public static function dueToConfiguration(): self {
+ return new self('Stream is not seekable');
+ }
+
+ public static function dueToMissingResource(): self {
+ return new self('No resource available; cannot seek position');
+ }
+
+ public static function dueToPhpError(): self {
+ return new self('Error seeking within stream');
+ }
+
+ public static function forCallbackStream(): self {
+ return new self('Callback streams cannot seek position');
+ }
+}
diff --git a/src/Exception/UntellableStreamException.php b/src/Exception/UntellableStreamException.php
new file mode 100644
index 0000000..509dc48
--- /dev/null
+++ b/src/Exception/UntellableStreamException.php
@@ -0,0 +1,35 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use RuntimeException;
+
+class UntellableStreamException extends RuntimeException {
+ public static function dueToMissingResource(): self {
+ return new self('No resource available; cannot tell position');
+ }
+
+ public static function dueToPhpError(): self {
+ return new self('Error occurred during tell operation');
+ }
+
+ public static function forCallbackStream(): self {
+ return new self('Callback streams cannot tell position');
+ }
+}
diff --git a/src/Exception/UnwritableStreamException.php b/src/Exception/UnwritableStreamException.php
new file mode 100644
index 0000000..3469e2e
--- /dev/null
+++ b/src/Exception/UnwritableStreamException.php
@@ -0,0 +1,39 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use RuntimeException;
+
+class UnwritableStreamException extends RuntimeException {
+ public static function dueToConfiguration(): self {
+ return new self('Stream is not writable');
+ }
+
+ public static function dueToMissingResource(): self {
+ return new self('No resource available; cannot write');
+ }
+
+ public static function dueToPhpError(): self {
+ return new self('Error writing to stream');
+ }
+
+ public static function forCallbackStream(): self {
+ return new self('Callback streams cannot write');
+ }
+}
diff --git a/src/Exception/UploadedFileAlreadyMovedException.php b/src/Exception/UploadedFileAlreadyMovedException.php
new file mode 100644
index 0000000..8a01ed3
--- /dev/null
+++ b/src/Exception/UploadedFileAlreadyMovedException.php
@@ -0,0 +1,33 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use RuntimeException;
+use Throwable;
+
+class UploadedFileAlreadyMovedException extends RuntimeException {
+ /** {@inheritDoc} */
+ public function __construct(
+ string $message = 'Cannot retrieve stream after it has already moved',
+ int $code = 0,
+ ?Throwable $previous = null
+ ) {
+ parent::__construct($message, $code, $previous);
+ }
+}
diff --git a/src/Exception/UploadedFileErrorException.php b/src/Exception/UploadedFileErrorException.php
new file mode 100644
index 0000000..ba3af84
--- /dev/null
+++ b/src/Exception/UploadedFileErrorException.php
@@ -0,0 +1,47 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Exception;
+
+use RuntimeException;
+
+use function sprintf;
+
+class UploadedFileErrorException extends RuntimeException {
+ public static function forUnmovableFile(): self {
+ return new self('Error occurred while moving uploaded file');
+ }
+
+ public static function dueToStreamUploadError(string $error): self {
+ return new self(sprintf(
+ 'Cannot retrieve stream due to upload error: %s',
+ $error
+ ));
+ }
+
+ public static function dueToUnwritablePath(): self {
+ return new self('Unable to write to designated path');
+ }
+
+ public static function dueToUnwritableTarget(string $targetDirectory): self {
+ return new self(sprintf(
+ 'The target directory `%s` does not exist or is not writable',
+ $targetDirectory
+ ));
+ }
+}
diff --git a/src/HeaderSecurity.php b/src/HeaderSecurity.php
new file mode 100644
index 0000000..bc82cc7
--- /dev/null
+++ b/src/HeaderSecurity.php
@@ -0,0 +1,171 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use InvalidArgumentException;
+
+use function get_debug_type;
+use function in_array;
+use function is_numeric;
+use function is_string;
+use function ord;
+use function preg_match;
+use function sprintf;
+use function strlen;
+
+/**
+ * Provide security tools around HTTP headers to prevent common injection vectors.
+ */
+final class HeaderSecurity {
+ /**
+ * Private constructor; non-instantiable.
+ *
+ * @codeCoverageIgnore
+ */
+ private function __construct() { }
+
+ /**
+ * Filter a header value
+ *
+ * Ensures CRLF header injection vectors are filtered.
+ *
+ * Per RFC 7230, only VISIBLE ASCII characters, spaces, and horizontal
+ * tabs are allowed in values; header continuations MUST consist of
+ * a single CRLF sequence followed by a space or horizontal tab.
+ *
+ * This method filters any values not allowed from the string, and is
+ * lossy.
+ *
+ * @see http://en.wikipedia.org/wiki/HTTP_response_splitting
+ */
+ public static function filter(string $value): string {
+ $length = strlen($value);
+ $string = '';
+ for ($i = 0; $i < $length; $i += 1) {
+ $ascii = ord($value[$i]);
+
+ // Detect continuation sequences
+ if ($ascii === 13) {
+ $lf = ord($value[$i + 1]);
+ $ws = ord($value[$i + 2]);
+ if ($lf === 10 && in_array($ws, [9, 32], true)) {
+ $string .= $value[$i] . $value[$i + 1];
+ $i += 1;
+ }
+
+ continue;
+ }
+
+ // Non-visible, non-whitespace characters
+ // 9 === horizontal tab
+ // 32-126, 128-254 === visible
+ // 127 === DEL
+ // 255 === null byte
+ if (($ascii < 32 &&
+ $ascii !== 9) ||
+ $ascii === 127 ||
+ $ascii > 254) {
+ continue;
+ }
+
+ $string .= $value[$i];
+ }
+
+ return $string;
+ }
+
+ /**
+ * Validate a header value.
+ *
+ * Per RFC 7230, only VISIBLE ASCII characters, spaces, and horizontal
+ * tabs are allowed in values; header continuations MUST consist of
+ * a single CRLF sequence followed by a space or horizontal tab.
+ *
+ * @see http://en.wikipedia.org/wiki/HTTP_response_splitting
+ *
+ * @param string|int|float $value
+ */
+ public static function isValid($value): bool {
+ $value = (string) $value;
+
+ // Look for:
+ // \n not preceded by \r, OR
+ // \r not followed by \n, OR
+ // \r\n not followed by space or horizontal tab; these are all CRLF attacks
+ if (preg_match("#(?:(?:(?
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use InvalidArgumentException;
+use Rodas\Psr\Http\Message\MessageInterface;
+use Rodas\Psr\Http\Message\StreamInterface;
+
+use function array_map;
+use function array_merge;
+use function array_values;
+use function implode;
+use function is_array;
+use function is_resource;
+use function is_string;
+use function preg_match;
+use function sprintf;
+use function str_replace;
+use function strtolower;
+use function trim;
+
+/**
+ * Trait implementing the various methods defined in MessageInterface.
+ *
+ * @see https://github.com/php-fig/http-message/tree/master/src/MessageInterface.php
+ */
+trait MessageTrait {
+
+ /**
+ * Map of normalized header name to original name used to register header.
+ *
+ * @var array
+ */
+ protected $headerNames = [];
+
+ /**
+ * Gets the HTTP protocol version as a string.
+ *
+ * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
+ *
+ * @var string HTTP protocol version.
+ */
+ public private(set) string $protocolVersion = '1.1' {
+ get => $this->protocolVersion;
+ set => $this->protocolVersion = $value;
+ }
+
+ /**
+ * Gets the body of the message.
+ *
+ * @var StreamInterface
+ */
+ public private(set) StreamInterface $body {
+ get => $this->body;
+ set => $this->body = $value;
+ }
+
+
+ /**
+ * Return an instance with the specified HTTP protocol version.
+ *
+ * The version string MUST contain only the HTTP version number (e.g.,
+ * "1.1", "1.0").
+ *
+ * This method MUST be implemented in such a way as to retain the
+ * immutability of the message, and MUST return an instance that has the
+ * new protocol version.
+ *
+ * @param string $version HTTP protocol version
+ * @return static
+ */
+ public function withProtocolVersion(string $version): MessageInterface
+ {
+ $this->validateProtocolVersion($version);
+ $new = clone $this;
+ $new->protocolVersion = $version;
+ return $new;
+ }
+
+ /**
+ * Checks if a header exists by the given case-insensitive name.
+ *
+ * @param string $name Case-insensitive header name.
+ * @return bool Returns true if any header names match the given header
+ * name using a case-insensitive string comparison. Returns false if
+ * no matching header name is found in the message.
+ */
+ public function hasHeader(string $name): bool {
+ return isset($this->headerNames[strtolower($name)]);
+ }
+
+ /**
+ * Retrieves a comma-separated string of the values for a single header.
+ *
+ * This method returns all of the header values of the given
+ * case-insensitive header name as a string concatenated together using
+ * a comma.
+ *
+ * NOTE: Not all header values may be appropriately represented using
+ * comma concatenation. For such headers, use getHeader() instead
+ * and supply your own delimiter when concatenating.
+ *
+ * If the header does not appear in the message, this method MUST return
+ * an empty string.
+ *
+ * @param string $name Case-insensitive header field name.
+ * @return string A string of values as provided for the given header
+ * concatenated together using a comma. If the header does not appear in
+ * the message, this method MUST return an empty string.
+ */
+ public function getHeaderLine(string $name): string {
+ $value = $this->getHeader($name);
+ if (empty($value)) {
+ return '';
+ }
+
+ return implode(',', $value);
+ }
+
+ /**
+ * Return an instance with the provided header, replacing any existing
+ * values of any headers with the same case-insensitive name.
+ *
+ * While header names are case-insensitive, the casing of the header will
+ * be preserved by this function, and returned from $headers.
+ *
+ * This method MUST be implemented in such a way as to retain the
+ * immutability of the message, and MUST return an instance that has the
+ * new and/or updated header and value.
+ *
+ * @param string $name Case-insensitive header field name.
+ * @param string|string[] $value Header value(s).
+ * @return static
+ * @throws InvalidArgumentException For invalid header names or values.
+ */
+ public function withHeader(string $name, $value): MessageInterface {
+ $this->assertHeader($name);
+
+ $normalized = strtolower($name);
+
+ $new = clone $this;
+ if ($new->hasHeader($name)) {
+ unset($new->headers[$new->headerNames[$normalized]]);
+ }
+
+ $value = $this->filterHeaderValue($value);
+
+ $new->headerNames[$normalized] = $name;
+ $new->headers[$name] = $value;
+
+ return $new;
+ }
+
+ /**
+ * Return an instance with the specified header appended with the
+ * given value.
+ *
+ * Existing values for the specified header will be maintained. The new
+ * value(s) will be appended to the existing list. If the header did not
+ * exist previously, it will be added.
+ *
+ * This method MUST be implemented in such a way as to retain the
+ * immutability of the message, and MUST return an instance that has the
+ * new header and/or value.
+ *
+ * @param string $name Case-insensitive header field name to add.
+ * @param string|string[] $value Header value(s).
+ * @return static
+ * @throws InvalidArgumentException For invalid header names or values.
+ */
+ public function withAddedHeader(string $name, $value): MessageInterface {
+ $this->assertHeader($name);
+
+ if (! $this->hasHeader($name)) {
+ return $this->withHeader($name, $value);
+ }
+
+ $header = $this->headerNames[strtolower($name)];
+
+ $new = clone $this;
+ $value = $this->filterHeaderValue($value);
+ $new->headers[$header] = array_merge($this->headers[$header], $value);
+ return $new;
+ }
+
+ /**
+ * Return an instance without the specified header.
+ *
+ * Header resolution MUST be done without case-sensitivity.
+ *
+ * This method MUST be implemented in such a way as to retain the
+ * immutability of the message, and MUST return an instance that removes
+ * the named header.
+ *
+ * @param string $name Case-insensitive header field name to remove.
+ * @return static
+ */
+ public function withoutHeader(string $name): MessageInterface {
+ if ($name === '' || ! $this->hasHeader($name)) {
+ return clone $this;
+ }
+
+ $normalized = strtolower($name);
+ $original = $this->headerNames[$normalized];
+
+ $new = clone $this;
+ unset($new->headers[$original], $new->headerNames[$normalized]);
+ return $new;
+ }
+
+ /**
+ * Return an instance with the specified message body.
+ *
+ * The body MUST be a StreamInterface object.
+ *
+ * This method MUST be implemented in such a way as to retain the
+ * immutability of the message, and MUST return a new instance that has the
+ * new body stream.
+ *
+ * @param StreamInterface $body Body.
+ * @return static
+ * @throws InvalidArgumentException When the body is not valid.
+ */
+ public function withBody(StreamInterface $body): MessageInterface {
+ $new = clone $this;
+ $new->body = $body;
+ return $new;
+ }
+
+ /** @param StreamInterface|string|resource $stream */
+ private function getStream($stream, string $modeIfNotInstance): StreamInterface {
+ if ($stream instanceof StreamInterface) {
+ return $stream;
+ }
+
+ /** @psalm-suppress DocblockTypeContradiction */
+ if (! is_string($stream) && ! is_resource($stream)) {
+ throw new InvalidArgumentException(
+ 'Stream must be a string stream resource identifier, '
+ . 'an actual stream resource, '
+ . 'or a Rodas\Psr\Http\Message\StreamInterface implementation'
+ );
+ }
+
+ return new Stream($stream, $modeIfNotInstance);
+ }
+
+ /**
+ * Filter a set of headers to ensure they are in the correct internal format.
+ *
+ * Used by message constructors to allow setting all initial headers at once.
+ *
+ * @param array $originalHeaders Headers to filter.
+ */
+ private function setHeaders(array $originalHeaders): void {
+ $headerNames = $headers = [];
+
+ foreach ($originalHeaders as $header => $value) {
+ $value = $this->filterHeaderValue($value);
+
+ $this->assertHeader($header);
+
+ $headerNames[strtolower($header)] = $header;
+ $headers[$header] = $value;
+ }
+
+ $this->headerNames = $headerNames;
+ $this->headers = $headers;
+ }
+
+ /**
+ * Validate the HTTP protocol version
+ *
+ * @throws InvalidArgumentException On invalid HTTP protocol version.
+ */
+ private function validateProtocolVersion(string $version): void {
+ if (empty($version)) {
+ throw new InvalidArgumentException(
+ 'HTTP protocol version can not be empty'
+ );
+ }
+
+ // HTTP/1 uses a "." numbering scheme to indicate
+ // versions of the protocol, while HTTP/2 does not.
+ if (! preg_match('#^(1\.[01]|2(\.0)?)$#', $version)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Unsupported HTTP protocol version "%s" provided',
+ $version
+ ));
+ }
+ }
+
+ /** @return list */
+ private function filterHeaderValue(mixed $values): array {
+ if (! is_array($values)) {
+ $values = [$values];
+ }
+
+ if ([] === $values) {
+ throw new InvalidArgumentException(
+ 'Invalid header value: must be a string or array of strings; '
+ . 'cannot be an empty array'
+ );
+ }
+
+ return array_map(static function ($value): string {
+ HeaderSecurity::assertValid($value);
+
+ $value = (string) $value;
+
+ // Normalize line folding to a single space (RFC 7230#3.2.4).
+ $value = str_replace(["\r\n\t", "\r\n "], ' ', $value);
+
+ // Remove optional whitespace (OWS, RFC 7230#3.2.3) around the header value.
+ return trim($value, "\t ");
+ }, array_values($values));
+ }
+
+ /**
+ * Ensure header name and values are valid.
+ *
+ * @psalm-assert non-empty-string $name
+ * @throws InvalidArgumentException
+ */
+ private function assertHeader(mixed $name): void {
+ HeaderSecurity::assertValidName($name);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function getHeader(string $name): array {
+ if (empty($name) ||
+ ! $this->hasHeader($name)) {
+
+ if (strtolower($name) === 'host' &&
+ $this->uri->host) {
+ return [$this->getHostFromUri()];
+ }
+
+ return [];
+ }
+
+ $header = $this->headerNames[strtolower($name)];
+
+ return $this->headers[$header];
+ }
+}
diff --git a/src/Module.php b/src/Module.php
new file mode 100644
index 0000000..31efee2
--- /dev/null
+++ b/src/Module.php
@@ -0,0 +1,27 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+class Module {
+ public function getConfig(): array {
+ return [
+ 'service_manager' => (new ConfigProvider())->getDependencies(),
+ ];
+ }
+}
diff --git a/src/RelativeStream.php b/src/RelativeStream.php
new file mode 100644
index 0000000..4950875
--- /dev/null
+++ b/src/RelativeStream.php
@@ -0,0 +1,176 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use Override;
+use Rodas\Psr\Http\Message\StreamInterface;
+use Stringable;
+
+use const SEEK_SET;
+
+/**
+ * Wrapper for default Stream class, representing subpart (starting from given offset) of initial stream.
+ * It can be used to avoid copying full stream, conserving memory.
+ *
+ * @see AbstractSerializer::splitStream()
+ */
+final class RelativeStream implements StreamInterface, Stringable {
+ private readonly int $offset;
+
+ public function __construct(private readonly StreamInterface $decoratedStream, ?int $offset) {
+ $this->offset = (int) $offset;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function __toString(): string {
+ if ($this->isSeekable()) {
+ $this->seek(0);
+ }
+ return $this->getContents();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function close(): void {
+ $this->decoratedStream->close();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function detach() {
+ return $this->decoratedStream->detach();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public ?int $size {
+ get {
+ $size = $this->decoratedStream->getSize();
+ if ($size === null) {
+ return null;
+ }
+ return $size - $this->offset;
+ }
+ }
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function tell(): int {
+ return $this->decoratedStream->tell() - $this->offset;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function eof(): bool {
+ return $this->decoratedStream->eof();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public bool $isSeekable {
+ get => $this->decoratedStream->isSeekable;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function seek(int $offset, int $whence = SEEK_SET): void {
+ if ($whence === SEEK_SET) {
+ $this->decoratedStream->seek($offset + $this->offset, $whence);
+ return;
+ }
+ $this->decoratedStream->seek($offset, $whence);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function rewind(): void {
+ $this->seek(0);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public bool $isWritable {
+ get => $this->decoratedStream->isWritable;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function write(string $string): int {
+ if ($this->tell() < 0) {
+ throw new Exception\InvalidStreamPointerPositionException();
+ }
+ return $this->decoratedStream->write($string);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public bool $isReadable {
+ get => $this->decoratedStream->isReadable;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function read(int $length): string {
+ if ($this->tell() < 0) {
+ throw new Exception\InvalidStreamPointerPositionException();
+ }
+ return $this->decoratedStream->read($length);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function getContents(): string {
+ if ($this->tell() < 0) {
+ throw new Exception\InvalidStreamPointerPositionException();
+ }
+ return $this->decoratedStream->getContents();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function getMetadata(?string $key = null) {
+ return $this->decoratedStream->getMetadata($key);
+ }
+}
diff --git a/src/Request.php b/src/Request.php
new file mode 100644
index 0000000..4d318ec
--- /dev/null
+++ b/src/Request.php
@@ -0,0 +1,68 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use InvalidArgumentException;
+use Override;
+use Rodas\Psr\Http\Message\RequestInterface;
+use Rodas\Psr\Http\Message\StatusCode;
+use Rodas\Psr\Http\Message\StreamInterface;
+use Rodas\Psr\Http\Message\UriInterface;
+
+use function strtolower;
+
+/**
+ * HTTP Request encapsulation
+ *
+ * Requests are considered immutable; all methods that might change state are
+ * implemented such that they retain the internal state of the current
+ * message and return a new instance that contains the changed state.
+ */
+class Request implements RequestInterface {
+ use RequestTrait;
+
+ /**
+ * @param null|string|UriInterface $uri URI for the request, if any.
+ * @param null|string $method HTTP method for the request, if any.
+ * @param string|resource|StreamInterface $body Message body, if any.
+ * @param array $headers Headers for the message, if any.
+ * @throws InvalidArgumentException For any invalid value.
+ */
+ public function __construct($uri = null, ?string $method = null, $body = 'php://temp', array $headers = []) {
+ $this->initialize($uri, $method, $body, $headers);
+ }
+
+ /**
+ * List of all registered headers, as key => array of values.
+ *
+ * @var array>
+ */
+ public protected(set) array $headers = [] {
+ get {
+ $headers = $this->headers;
+ if (! $this->hasHeader('host') &&
+ $this->uri->host) {
+ $headers['Host'] = [$this->getHostFromUri()];
+ }
+
+ return $headers;
+ }
+ set => $this->headers = $value;
+ }
+}
diff --git a/src/Request/ArraySerializer.php b/src/Request/ArraySerializer.php
new file mode 100644
index 0000000..edaf7ea
--- /dev/null
+++ b/src/Request/ArraySerializer.php
@@ -0,0 +1,95 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Request;
+
+use Rodas\Diactoros\Exception;
+use Rodas\Diactoros\Request;
+use Rodas\Diactoros\Stream;
+use Rodas\Psr\Http\Message\RequestInterface;
+use Throwable;
+
+use function sprintf;
+
+/**
+ * Serialize or deserialize request messages to/from arrays.
+ *
+ * This class provides functionality for serializing a RequestInterface instance
+ * to an array, as well as the reverse operation of creating a Request instance
+ * from an array representing a message.
+ */
+final class ArraySerializer {
+ /**
+ * Serialize a request message to an array.
+ *
+ * @return array{
+ * method: string,
+ * request_target: string,
+ * uri: string,
+ * protocol_version: string,
+ * headers: array>,
+ * body: string
+ * }
+ */
+ public static function toArray(RequestInterface $request): array {
+ return [
+ 'method' => $request->getMethod(),
+ 'request_target' => $request->getRequestTarget(),
+ 'uri' => (string) $request->getUri(),
+ 'protocol_version' => $request->getProtocolVersion(),
+ 'headers' => $request->getHeaders(),
+ 'body' => (string) $request->getBody(),
+ ];
+ }
+
+ /**
+ * Deserialize a request array to a request instance.
+ *
+ * @throws Exception\DeserializationException When the response cannot be deserialized.
+ */
+ public static function fromArray(array $serializedRequest): Request {
+ try {
+ $uri = self::getValueFromKey($serializedRequest, 'uri');
+ $method = self::getValueFromKey($serializedRequest, 'method');
+ $body = new Stream('php://memory', 'wb+');
+ $body->write(self::getValueFromKey($serializedRequest, 'body'));
+ $headers = self::getValueFromKey($serializedRequest, 'headers');
+ $requestTarget = self::getValueFromKey($serializedRequest, 'request_target');
+ $protocolVersion = self::getValueFromKey($serializedRequest, 'protocol_version');
+
+ return (new Request($uri, $method, $body, $headers))
+ ->withRequestTarget($requestTarget)
+ ->withProtocolVersion($protocolVersion);
+ } catch (Throwable $exception) {
+ throw Exception\DeserializationException::forRequestFromArray($exception);
+ }
+ }
+
+ /**
+ * @throws Exception\DeserializationException
+ */
+ private static function getValueFromKey(array $data, string $key, ?string $message = null): mixed {
+ if (isset($data[$key])) {
+ return $data[$key];
+ }
+ if ($message === null) {
+ $message = sprintf('Missing "%s" key in serialized request', $key);
+ }
+ throw new Exception\DeserializationException($message);
+ }
+}
diff --git a/src/Request/Serializer.php b/src/Request/Serializer.php
new file mode 100644
index 0000000..be972ce
--- /dev/null
+++ b/src/Request/Serializer.php
@@ -0,0 +1,144 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Request;
+
+use InvalidArgumentException;
+use Rodas\Diactoros\AbstractSerializer;
+use Rodas\Diactoros\Exception;
+use Rodas\Diactoros\Request;
+use Rodas\Diactoros\Stream;
+use Rodas\Diactoros\Uri;
+use Rodas\Psr\Http\Message\RequestInterface;
+use Rodas\Psr\Http\Message\StreamInterface;
+
+use function preg_match;
+use function sprintf;
+
+/**
+ * Serialize (cast to string) or deserialize (cast string to Request) messages.
+ *
+ * This class provides functionality for serializing a RequestInterface instance
+ * to a string, as well as the reverse operation of creating a Request instance
+ * from a string/stream representing a message.
+ */
+final class Serializer extends AbstractSerializer {
+ /**
+ * Deserialize a request string to a request instance.
+ *
+ * Internally, casts the message to a stream and invokes fromStream().
+ *
+ * @throws Exception\SerializationException When errors occur parsing the message.
+ */
+ public static function fromString(string $message): Request {
+ $stream = new Stream('php://temp', 'wb+');
+ $stream->write($message);
+ return self::fromStream($stream);
+ }
+
+ /**
+ * Deserialize a request stream to a request instance.
+ *
+ * @throws InvalidArgumentException If the message stream is not readable or seekable.
+ * @throws Exception\SerializationException If an invalid request line is detected.
+ */
+ public static function fromStream(StreamInterface $stream): Request {
+ if (! $stream->isReadable ||
+ ! $stream->isSeekable) {
+ throw new InvalidArgumentException('Message stream must be both readable and seekable');
+ }
+
+ $stream->rewind();
+
+ [$method, $requestTarget, $version] = self::getRequestLine($stream);
+ $uri = self::createUriFromRequestTarget($requestTarget);
+
+ [$headers, $body] = self::splitStream($stream);
+
+ return (new Request($uri, $method, $body, $headers))
+ ->withProtocolVersion($version)
+ ->withRequestTarget($requestTarget);
+ }
+
+ /**
+ * Serialize a request message to a string.
+ */
+ public static function toString(RequestInterface $request): string {
+ $httpMethod = $request->getMethod();
+ $headers = self::serializeHeaders($request->getHeaders());
+ $body = (string) $request->getBody();
+ $format = '%s %s HTTP/%s%s%s';
+
+ if (! empty($headers)) {
+ $headers = "\r\n" . $headers;
+ }
+ if (! empty($body)) {
+ $headers .= "\r\n\r\n";
+ }
+
+ return sprintf(
+ $format,
+ $httpMethod,
+ $request->getRequestTarget(),
+ $request->getProtocolVersion(),
+ $headers,
+ $body
+ );
+ }
+
+ /**
+ * Retrieve the components of the request line.
+ *
+ * Retrieves the first line of the stream and parses it, raising an
+ * exception if it does not follow specifications; if valid, returns a list
+ * with the method, target, and version, in that order.
+ *
+ * @throws Exception\SerializationException
+ */
+ private static function getRequestLine(StreamInterface $stream): array {
+ $requestLine = self::getLine($stream);
+
+ if (! preg_match(
+ '#^(?P[!\#$%&\'*+.^_`|~a-zA-Z0-9-]+) (?P[^\s]+) HTTP/(?P[1-9]\d*\.\d+)$#',
+ $requestLine,
+ $matches)) {
+ throw Exception\SerializationException::forInvalidRequestLine();
+ }
+
+ return [$matches['method'], $matches['target'], $matches['version']];
+ }
+
+ /**
+ * Create and return a Uri instance based on the provided request target.
+ *
+ * If the request target is of authority or asterisk form, an empty Uri
+ * instance is returned; otherwise, the value is used to create and return
+ * a new Uri instance.
+ */
+ private static function createUriFromRequestTarget(string $requestTarget): Uri {
+ if (preg_match('#^https?://#', $requestTarget)) {
+ return new Uri($requestTarget);
+ }
+
+ if (preg_match('#^(\*|[^/])#', $requestTarget)) {
+ return new Uri();
+ }
+
+ return new Uri($requestTarget);
+ }
+}
diff --git a/src/RequestFactory.php b/src/RequestFactory.php
new file mode 100644
index 0000000..482174f
--- /dev/null
+++ b/src/RequestFactory.php
@@ -0,0 +1,33 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use Override;
+use Rodas\Psr\Http\Message\RequestFactoryInterface;
+use Rodas\Psr\Http\Message\RequestInterface;
+
+class RequestFactory implements RequestFactoryInterface {
+ /**
+ * {@inheritDoc}
+ */
+ #[Override]
+ public function createRequest(string $method, $uri): RequestInterface {
+ return new Request($uri, $method);
+ }
+}
diff --git a/src/RequestTrait.php b/src/RequestTrait.php
new file mode 100644
index 0000000..36883f9
--- /dev/null
+++ b/src/RequestTrait.php
@@ -0,0 +1,320 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use InvalidArgumentException;
+use Rodas\Psr\Http\Message\RequestInterface;
+use Rodas\Psr\Http\Message\RequestMethod;
+use Rodas\Psr\Http\Message\StreamInterface;
+use Rodas\Psr\Http\Message\UriInterface;
+
+use function array_keys;
+use function is_string;
+use function preg_match;
+use function sprintf;
+use function strtolower;
+
+/**
+ * Trait with common request behaviors.
+ *
+ * Server and client-side requests differ slightly in how the Host header is
+ * handled; on client-side, it should be calculated on-the-fly from the
+ * composed URI (if present), while on server-side, it will be calculated from
+ * the environment. As such, this trait exists to provide the common code
+ * between both client-side and server-side requests, and each can then
+ * use the headers functionality required by their implementations.
+ */
+trait RequestTrait {
+ use MessageTrait;
+
+ /**
+ * Gets the HTTP method of the request.
+ *
+ * @var string
+ */
+ public private(set) string $method = 'GET' {
+ get => $this->method;
+ set => $this->method = $value;
+ }
+
+ /**
+ * Gets the HTTP method of the request.
+ *
+ * @var RequestMethod|null Returns the request method.
+ */
+ public private(set) ?RequestMethod $requestMethod = RequestMethod::GET {
+ get => $this->requestMethod;
+ set => $this->requestMethod = $value;
+ }
+
+ /**
+ * Gets the message's request target.
+ *
+ * Retrieves the message's request-target either as it will appear (for
+ * clients), as it appeared at request (for servers), or as it was
+ * specified for the instance (see withRequestTarget()).
+ *
+ * In most cases, this will be the origin-form of the composed URI,
+ * unless a value was provided to the concrete implementation (see
+ * withRequestTarget() below).
+ *
+ * If no URI is available, and no request-target has been specifically
+ * provided, this method MUST return the string "/".
+ *
+ * @var string
+ */
+ public private(set) string $requestTarget {
+ get {
+ if (isset($this->requestTarget) &&
+ null !== $this->requestTarget) {
+ return $this->requestTarget;
+ }
+
+ $target = $this->uri->path;
+ if ($this->uri->query) {
+ $target .= '?' . $this->uri->query;
+ }
+
+ if (empty($target)) {
+ $target = '/';
+ }
+
+ return $target;
+ }
+ set => $this->requestTarget = $value;
+ }
+
+ public private(set) StreamInterface $stream {
+ get => $this->stream;
+ set => $this->stream = $value;
+ }
+
+ /**
+ * Gets the URI instance.
+ *
+ * This method MUST return a UriInterface instance.
+ *
+ * @link https://tools.ietf.org/html/rfc3986#section-4.3
+ * @var UriInterface Returns a UriInterface instance
+ * representing the URI of the request.
+ */
+ public private(set) UriInterface $uri {
+ get => $this->uri;
+ set => $this->uri = $value;
+ }
+
+ /**
+ * Initialize request state.
+ *
+ * Used by constructors.
+ *
+ * @param null|string|UriInterface $uri URI for the request, if any.
+ * @param null|string $method HTTP method for the request, if any.
+ * @param string|resource|StreamInterface $body Message body, if any.
+ * @param array $headers Headers for the message, if any.
+ * @throws InvalidArgumentException For any invalid value.
+ */
+ private function initialize(
+ $uri = null,
+ RequestMethod|string|null $method = null,
+ $body = 'php://memory',
+ array $headers = []
+ ): void {
+ if ($method !== null) {
+ $this->setMethod($method);
+ }
+
+ $this->uri = $this->createUri($uri);
+ $this->stream = $this->getStream($body, 'wb+');
+
+ $this->setHeaders($headers);
+
+ // per PSR-7: attempt to set the Host header from a provided URI if no
+ // Host header is provided
+ if (! $this->hasHeader('Host') && $this->uri->host) {
+ $this->headerNames['host'] = 'Host';
+ $this->headers['Host'] = [$this->getHostFromUri()];
+ }
+ }
+
+ /**
+ * Create and return a URI instance.
+ *
+ * If `$uri` is a already a `UriInterface` instance, returns it.
+ *
+ * If `$uri` is a string, passes it to the `Uri` constructor to return an
+ * instance.
+ *
+ * If `$uri is null, creates and returns an empty `Uri` instance.
+ *
+ * Otherwise, it raises an exception.
+ *
+ * @throws InvalidArgumentException
+ */
+ private function createUri(null|string|UriInterface $uri): UriInterface {
+ if ($uri instanceof UriInterface) {
+ return $uri;
+ }
+
+ if (is_string($uri)) {
+ return new Uri($uri);
+ }
+
+ return new Uri();
+ }
+
+
+ /**
+ * Create a new instance with a specific request-target.
+ *
+ * If the request needs a non-origin-form request-target — e.g., for
+ * specifying an absolute-form, authority-form, or asterisk-form —
+ * this method may be used to create an instance with the specified
+ * request-target, verbatim.
+ *
+ * This method MUST be implemented in such a way as to retain the
+ * immutability of the message, and MUST return a new instance that has the
+ * changed request target.
+ *
+ * @link http://tools.ietf.org/html/rfc7230#section-2.7 (for the various
+ * request-target forms allowed in request messages)
+ *
+ * @throws InvalidArgumentException If the request target is invalid.
+ * @return static
+ */
+ public function withRequestTarget(string $requestTarget): RequestInterface {
+ if (preg_match('#\s#', $requestTarget)) {
+ throw new InvalidArgumentException(
+ 'Invalid request target provided; cannot contain whitespace'
+ );
+ }
+
+ $new = clone $this;
+ $new->requestTarget = $requestTarget;
+ return $new;
+ }
+
+ /**
+ * Return an instance with the provided HTTP method.
+ *
+ * While HTTP method names are typically all uppercase characters, HTTP
+ * method names are case-sensitive and thus implementations SHOULD NOT
+ * modify the given string.
+ *
+ * This method MUST be implemented in such a way as to retain the
+ * immutability of the message, and MUST return an instance that has the
+ * changed request method.
+ *
+ * @param string $method Case-insensitive method.
+ * @throws InvalidArgumentException For invalid HTTP methods.
+ * @return static
+ */
+ public function withMethod(RequestMethod|string $method): RequestInterface {
+ $new = clone $this;
+ $new->setMethod($method);
+ return $new;
+ }
+
+ /**
+ * Returns an instance with the provided URI.
+ *
+ * This method will update the Host header of the returned request by
+ * default if the URI contains a host component. If the URI does not
+ * contain a host component, any pre-existing Host header will be carried
+ * over to the returned request.
+ *
+ * You can opt-in to preserving the original state of the Host header by
+ * setting `$preserveHost` to `true`. When `$preserveHost` is set to
+ * `true`, the returned request will not update the Host header of the
+ * returned message -- even if the message contains no Host header. This
+ * means that a call to `getHeader('Host')` on the original request MUST
+ * equal the return value of a call to `getHeader('Host')` on the returned
+ * request.
+ *
+ * This method MUST be implemented in such a way as to retain the
+ * immutability of the message, and MUST return an instance that has the
+ * new UriInterface instance.
+ *
+ * @link http://tools.ietf.org/html/rfc3986#section-4.3
+ *
+ * @param UriInterface $uri New request URI to use.
+ * @param bool $preserveHost Preserve the original state of the Host header.
+ * @return static
+ */
+ public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface {
+ $new = clone $this;
+ $new->uri = $uri;
+
+ if ($preserveHost && $this->hasHeader('Host')) {
+ return $new;
+ }
+
+ if (! $uri->host) {
+ return $new;
+ }
+
+ $host = $uri->host;
+ if ($uri->port !== null) {
+ $host .= ':' . $uri->port;
+ }
+
+ $new->headerNames['host'] = 'Host';
+
+ // Remove an existing host header if present, regardless of current
+ // de-normalization of the header name.
+ // @see https://github.com/zendframework/zend-diactoros/issues/91
+ foreach (array_keys($new->headers) as $header) {
+ if (strtolower($header) === 'host') {
+ unset($new->headers[$header]);
+ }
+ }
+
+ $new->headers['Host'] = [$host];
+
+ return $new;
+ }
+
+ /**
+ * Set and validate the HTTP method
+ *
+ * @throws InvalidArgumentException On invalid HTTP method.
+ */
+ private function setMethod(RequestMethod|string $method): void {
+ if ($method instanceof RequestMethod) {
+ $this->requestMethod = $method;
+ $method = $method->value;
+ } elseif (! preg_match('/^[!#$%&\'*+.^_`\|~0-9a-z-]+$/i', $method)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Unsupported HTTP method "%s" provided',
+ $method
+ ));
+ }
+ $this->method = $method;
+ $this->requestMethod = RequestMethod::tryParse($method);
+ }
+
+ /**
+ * Retrieve the host from the URI instance
+ */
+ private function getHostFromUri(): string {
+ $host = $this->uri->host;
+ $host .= $this->uri->port !== null ? ':' . $this->uri->port : '';
+ return $host;
+ }
+}
diff --git a/src/Response.php b/src/Response.php
new file mode 100644
index 0000000..a237ff5
--- /dev/null
+++ b/src/Response.php
@@ -0,0 +1,241 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use InvalidArgumentException;
+use Override;
+use Rodas\Psr\Http\Message\ResponseInterface;
+use Rodas\Psr\Http\Message\StatusCode;
+use Rodas\Psr\Http\Message\StreamInterface;
+
+use function sprintf;
+
+/**
+ * HTTP response encapsulation.
+ *
+ * Responses are considered immutable; all methods that might change state are
+ * implemented such that they retain the internal state of the current
+ * message and return a new instance that contains the changed state.
+ */
+class Response implements ResponseInterface {
+ use MessageTrait;
+
+ public const MIN_STATUS_CODE_VALUE = 100;
+ public const MAX_STATUS_CODE_VALUE = 599;
+
+ // TODO: Use Enum from Rodas/Psr
+ /**
+ * Map of standard HTTP status code/reason phrases
+ *
+ * @psalm-var array
+ */
+ private array $phrases = [
+ // INFORMATIONAL CODES
+ 100 => 'Continue',
+ 101 => 'Switching Protocols',
+ 102 => 'Processing',
+ 103 => 'Early Hints',
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ 104 => 'Upload Resumption Supported (TEMPORARY - registered 2024-11-13, extension registered 2025-09-15, expires 2026-11-13)',
+ // SUCCESS CODES
+ 200 => 'OK',
+ 201 => 'Created',
+ 202 => 'Accepted',
+ 203 => 'Non-Authoritative Information',
+ 204 => 'No Content',
+ 205 => 'Reset Content',
+ 206 => 'Partial Content',
+ 207 => 'Multi-Status',
+ 208 => 'Already Reported',
+ 226 => 'IM Used',
+ // REDIRECTION CODES
+ 300 => 'Multiple Choices',
+ 301 => 'Moved Permanently',
+ 302 => 'Found',
+ 303 => 'See Other',
+ 304 => 'Not Modified',
+ 305 => 'Use Proxy',
+ 306 => 'Switch Proxy', // Deprecated to 306 => '(Unused)'
+ 307 => 'Temporary Redirect',
+ 308 => 'Permanent Redirect',
+ // CLIENT ERROR
+ 400 => 'Bad Request',
+ 401 => 'Unauthorized',
+ 402 => 'Payment Required',
+ 403 => 'Forbidden',
+ 404 => 'Not Found',
+ 405 => 'Method Not Allowed',
+ 406 => 'Not Acceptable',
+ 407 => 'Proxy Authentication Required',
+ 408 => 'Request Timeout',
+ 409 => 'Conflict',
+ 410 => 'Gone',
+ 411 => 'Length Required',
+ 412 => 'Precondition Failed',
+ 413 => 'Content Too Large',
+ 414 => 'URI Too Long',
+ 415 => 'Unsupported Media Type',
+ 416 => 'Range Not Satisfiable',
+ 417 => 'Expectation Failed',
+ 418 => 'I\'m a teapot',
+ 421 => 'Misdirected Request',
+ 422 => 'Unprocessable Content',
+ 423 => 'Locked',
+ 424 => 'Failed Dependency',
+ 425 => 'Too Early',
+ 426 => 'Upgrade Required',
+ 428 => 'Precondition Required',
+ 429 => 'Too Many Requests',
+ 431 => 'Request Header Fields Too Large',
+ 444 => 'Connection Closed Without Response',
+ 451 => 'Unavailable For Legal Reasons',
+ // SERVER ERROR
+ 499 => 'Client Closed Request',
+ 500 => 'Internal Server Error',
+ 501 => 'Not Implemented',
+ 502 => 'Bad Gateway',
+ 503 => 'Service Unavailable',
+ 504 => 'Gateway Timeout',
+ 505 => 'HTTP Version Not Supported',
+ 506 => 'Variant Also Negotiates',
+ 507 => 'Insufficient Storage',
+ 508 => 'Loop Detected',
+ 510 => 'Not Extended (OBSOLETED)',
+ 511 => 'Network Authentication Required',
+ 599 => 'Network Connect Timeout Error',
+ ];
+
+ /**
+ * Retrieves all message headers.
+ *
+ * The keys represent the header name as it will be sent over the wire, and
+ * each value is an array of strings associated with the header.
+ *
+ * // Represent the headers as a string
+ * foreach ($message->headers as $name => $values) {
+ * echo $name . ": " . implode(", ", $values);
+ * }
+ *
+ * // Emit headers iteratively:
+ * foreach ($message->headers as $name => $values) {
+ * foreach ($values as $value) {
+ * header(sprintf('%s: %s', $name, $value), false);
+ * }
+ * }
+ *
+ * @return array Returns an associative array of the message's headers. Each
+ * key MUST be a header name, and each value MUST be an array of strings.
+ * @psalm-return array>
+ */
+ public array $headers {
+ get => $this->headers;
+ set => $this->headers = $value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public private(set) string $reasonPhrase {
+ get => $this->reasonPhrase;
+ set => $this->reasonPhrase = $value;
+ }
+
+ public private(set) int $status {
+ get => $this->status;
+ set(int $value) {
+ if ($value < static::MIN_STATUS_CODE_VALUE ||
+ $value > static::MAX_STATUS_CODE_VALUE) {
+ throw new InvalidArgumentException(sprintf(
+ 'Invalid status code "%s"; must be an integer between %d and %d, inclusive',
+ $value,
+ self::MIN_STATUS_CODE_VALUE,
+ self::MAX_STATUS_CODE_VALUE
+ ));
+ } else {
+ $statusCode = StatusCode::tryFrom($value);
+ if ($this->statusCode !== $statusCode) {
+ $this->statusCode = $statusCode;
+ }
+ }
+ $this->status = $value;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public private(set) ?StatusCode $statusCode {
+ get => $this->statusCode;
+ set(?StatusCode $value) {
+ if ($value === null) {
+ throw new InvalidArgumentException('Status code cannot be null');
+ }
+ $this->statusCode = $value;
+ $code = $statusCode->value;
+ if ($this->status !== $code) {
+ $this->status = $code;
+ }
+ }
+ }
+
+ /**
+ * @param string|resource|StreamInterface $body Stream identifier and/or actual stream resource
+ * @param int $status Status code for the response, if any.
+ * @param array $headers Headers for the response, if any.
+ * @throws InvalidArgumentException On any invalid element.
+ */
+ public function __construct($body = 'php://memory', int $status = 200, array $headers = []) {
+ $this->setStatusCode($status);
+ $this->stream = $this->getStream($body, 'wb+');
+ $this->setHeaders($headers);
+ }
+
+
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withStatus(StatusCode|int $code, string $reasonPhrase = ''): Response {
+ $new = clone $this;
+ $new->setStatusCode($code, $reasonPhrase);
+ return $new;
+ }
+
+ /**
+ * Set a valid status code.
+ *
+ * @throws InvalidArgumentException On an invalid status code.
+ */
+ private function setStatusCode(StatusCode|int $code, string $reasonPhrase = ''): void {
+ if (is_int($code)) {
+ $this->status = $code;
+ } else {
+ $this->statusCode = $code;
+ }
+
+ if ($reasonPhrase === '' &&
+ isset($this->phrases[$code])) {
+
+ $reasonPhrase = $this->phrases[$code];
+ }
+
+ $this->reasonPhrase = $reasonPhrase;
+ }
+}
diff --git a/src/Response/ArraySerializer.php b/src/Response/ArraySerializer.php
new file mode 100644
index 0000000..9b65a93
--- /dev/null
+++ b/src/Response/ArraySerializer.php
@@ -0,0 +1,93 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Response;
+
+use Rodas\Diactoros\Exception;
+use Rodas\Diactoros\Response;
+use Rodas\Diactoros\Stream;
+use Rodas\Psr\Http\Message\ResponseInterface;
+use Throwable;
+
+use function sprintf;
+
+/**
+ * Serialize or deserialize response messages to/from arrays.
+ *
+ * This class provides functionality for serializing a ResponseInterface instance
+ * to an array, as well as the reverse operation of creating a Response instance
+ * from an array representing a message.
+ */
+final class ArraySerializer {
+ /**
+ * Serialize a response message to an array.
+ *
+ * @return array{
+ * status_code: int,
+ * reason_phrase: string,
+ * protocol_version: string,
+ * headers: array>,
+ * body: string
+ * }
+ */
+ public static function toArray(ResponseInterface $response): array {
+ return [
+ 'status_code' => $response->getStatusCode(),
+ 'reason_phrase' => $response->getReasonPhrase(),
+ 'protocol_version' => $response->getProtocolVersion(),
+ 'headers' => $response->getHeaders(),
+ 'body' => (string) $response->getBody(),
+ ];
+ }
+
+ /**
+ * Deserialize a response array to a response instance.
+ *
+ * @throws Exception\DeserializationException When cannot deserialize response.
+ */
+ public static function fromArray(array $serializedResponse): Response {
+ try {
+ $body = new Stream('php://memory', 'wb+');
+ $body->write(self::getValueFromKey($serializedResponse, 'body'));
+
+ $statusCode = self::getValueFromKey($serializedResponse, 'status_code');
+ $headers = self::getValueFromKey($serializedResponse, 'headers');
+ $protocolVersion = self::getValueFromKey($serializedResponse, 'protocol_version');
+ $reasonPhrase = self::getValueFromKey($serializedResponse, 'reason_phrase');
+
+ return (new Response($body, $statusCode, $headers))
+ ->withProtocolVersion($protocolVersion)
+ ->withStatus($statusCode, $reasonPhrase);
+ } catch (Throwable $exception) {
+ throw Exception\DeserializationException::forResponseFromArray($exception);
+ }
+ }
+
+ /**
+ * @throws Exception\DeserializationException
+ */
+ private static function getValueFromKey(array $data, string $key, ?string $message = null): mixed {
+ if (isset($data[$key])) {
+ return $data[$key];
+ }
+ if ($message === null) {
+ $message = sprintf('Missing "%s" key in serialized response', $key);
+ }
+ throw new Exception\DeserializationException($message);
+ }
+}
diff --git a/src/Response/EmptyResponse.php b/src/Response/EmptyResponse.php
new file mode 100644
index 0000000..45b28b3
--- /dev/null
+++ b/src/Response/EmptyResponse.php
@@ -0,0 +1,47 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Response;
+
+use Rodas\Diactoros\Response;
+use Rodas\Diactoros\Stream;
+
+/**
+ * A class representing empty HTTP responses.
+ */
+class EmptyResponse extends Response {
+ /**
+ * Create an empty response with the given status code.
+ *
+ * @param int $status Status code for the response, if any.
+ * @param array $headers Headers for the response, if any.
+ */
+ public function __construct(int $status = 204, array $headers = []) {
+ $body = new Stream('php://temp', 'r');
+ parent::__construct($body, $status, $headers);
+ }
+
+ /**
+ * Create an empty response with the given headers.
+ *
+ * @param array $headers Headers for the response.
+ */
+ public static function withHeaders(array $headers): EmptyResponse {
+ return new static(204, $headers);
+ }
+}
diff --git a/src/Response/HtmlResponse.php b/src/Response/HtmlResponse.php
new file mode 100644
index 0000000..dc59962
--- /dev/null
+++ b/src/Response/HtmlResponse.php
@@ -0,0 +1,85 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Response;
+
+use InvalidArgumentException;
+use Rodas\Diactoros\Exception;
+use Rodas\Diactoros\Response;
+use Rodas\Diactoros\Stream;
+use Rodas\Psr\Http\Message\StreamInterface;
+
+use function get_debug_type;
+use function is_string;
+use function sprintf;
+
+/**
+ * HTML response.
+ *
+ * Allows creating a response by passing an HTML string to the constructor;
+ * by default, sets a status code of 200 and sets the Content-Type header to
+ * text/html.
+ */
+class HtmlResponse extends Response {
+ use InjectContentTypeTrait;
+
+ /**
+ * Create an HTML response.
+ *
+ * Produces an HTML response with a Content-Type of text/html and a default
+ * status of 200.
+ *
+ * @param string|StreamInterface $html HTML or stream for the message body.
+ * @param int $status Integer status code for the response; 200 by default.
+ * @param array $headers Array of headers to use at initialization.
+ * @throws InvalidArgumentException If $html is neither a string or stream.
+ */
+ public function __construct($html, int $status = 200, array $headers = []) {
+ parent::__construct(
+ $this->createBody($html),
+ $status,
+ $this->injectContentType('text/html; charset=utf-8', $headers)
+ );
+ }
+
+ /**
+ * Create the message body.
+ *
+ * @param string|StreamInterface $html
+ * @throws InvalidArgumentException If $html is neither a string or stream.
+ */
+ private function createBody($html): StreamInterface {
+ if ($html instanceof StreamInterface) {
+ return $html;
+ }
+
+ /** @psalm-suppress DocblockTypeContradiction */
+ if (! is_string($html)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Invalid content (%s) provided to %s',
+ get_debug_type($html),
+ self::class
+ ));
+ }
+
+ $body = new Stream('php://temp', 'wb+');
+ $body->write($html);
+ $body->rewind();
+ return $body;
+ }
+}
diff --git a/src/Response/InjectContentTypeTrait.php b/src/Response/InjectContentTypeTrait.php
new file mode 100644
index 0000000..7e8284f
--- /dev/null
+++ b/src/Response/InjectContentTypeTrait.php
@@ -0,0 +1,45 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Response;
+
+use function array_keys;
+use function array_reduce;
+use function strtolower;
+
+trait InjectContentTypeTrait {
+ /**
+ * Inject the provided Content-Type, if none is already present.
+ *
+ * @param array $headers
+ * @return array Headers with injected Content-Type
+ */
+ private function injectContentType(string $contentType, array $headers): array {
+ $hasContentType = array_reduce(
+ array_keys($headers),
+ static fn(bool $carry, string $item): bool => $carry ?: strtolower($item) === 'content-type',
+ false
+ );
+
+ if (! $hasContentType) {
+ $headers['content-type'] = [$contentType];
+ }
+
+ return $headers;
+ }
+}
diff --git a/src/Response/JsonResponse.php b/src/Response/JsonResponse.php
new file mode 100644
index 0000000..f224d96
--- /dev/null
+++ b/src/Response/JsonResponse.php
@@ -0,0 +1,167 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Response;
+
+use JsonException;
+use InvalidArgumentException;
+use Rodas\Diactoros\Exception;
+use Rodas\Diactoros\Response;
+use Rodas\Diactoros\Stream;
+
+use function is_object;
+use function is_resource;
+use function json_encode;
+use function sprintf;
+
+use const JSON_HEX_AMP;
+use const JSON_HEX_APOS;
+use const JSON_HEX_QUOT;
+use const JSON_HEX_TAG;
+use const JSON_THROW_ON_ERROR;
+use const JSON_UNESCAPED_SLASHES;
+
+/**
+ * JSON response.
+ *
+ * Allows creating a response by passing data to the constructor; by default,
+ * serializes the data to JSON, sets a status code of 200 and sets the
+ * Content-Type header to application/json.
+ */
+class JsonResponse extends Response {
+ use InjectContentTypeTrait;
+
+ /**
+ * Default flags for json_encode
+ *
+ * @const int
+ */
+ public const DEFAULT_JSON_FLAGS = JSON_HEX_TAG
+ | JSON_HEX_APOS
+ | JSON_HEX_AMP
+ | JSON_HEX_QUOT
+ | JSON_UNESCAPED_SLASHES;
+
+ private mixed $payload;
+
+ /**
+ * Create a JSON response with the given data.
+ *
+ * Default JSON encoding is performed with the following options, which
+ * produces RFC4627-compliant JSON, capable of embedding into HTML.
+ *
+ * - JSON_HEX_TAG
+ * - JSON_HEX_APOS
+ * - JSON_HEX_AMP
+ * - JSON_HEX_QUOT
+ * - JSON_UNESCAPED_SLASHES
+ *
+ * @param mixed $data Data to convert to JSON.
+ * @param int $status Integer status code for the response; 200 by default.
+ * @param array $headers Array of headers to use at initialization.
+ * @param int $encodingOptions JSON encoding options to use.
+ * @throws InvalidArgumentException If unable to encode the $data to JSON.
+ */
+ public function __construct(
+ $data,
+ int $status = 200,
+ array $headers = [],
+ private int $encodingOptions = self::DEFAULT_JSON_FLAGS
+ ) {
+ $this->setPayload($data);
+
+ $json = $this->jsonEncode($data, $this->encodingOptions);
+ $body = $this->createBodyFromJson($json);
+
+ $headers = $this->injectContentType('application/json', $headers);
+
+ parent::__construct($body, $status, $headers);
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getPayload() {
+ return $this->payload;
+ }
+
+ public function withPayload(mixed $data): JsonResponse {
+ $new = clone $this;
+ $new->setPayload($data);
+ return $this->updateBodyFor($new);
+ }
+
+ public function getEncodingOptions(): int {
+ return $this->encodingOptions;
+ }
+
+ public function withEncodingOptions(int $encodingOptions): JsonResponse {
+ $new = clone $this;
+ $new->encodingOptions = $encodingOptions;
+ return $this->updateBodyFor($new);
+ }
+
+ private function createBodyFromJson(string $json): Stream {
+ $body = new Stream('php://temp', 'wb+');
+ $body->write($json);
+ $body->rewind();
+
+ return $body;
+ }
+
+ /**
+ * Encode the provided data to JSON.
+ *
+ * @throws InvalidArgumentException If unable to encode the $data to JSON.
+ */
+ private function jsonEncode(mixed $data, int $encodingOptions): string {
+ if (is_resource($data)) {
+ throw new InvalidArgumentException('Cannot JSON encode resources');
+ }
+
+ try {
+ return json_encode($data, $encodingOptions | JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ throw new InvalidArgumentException(sprintf(
+ 'Unable to encode data to JSON in %s: %s',
+ self::class,
+ $e->getMessage()
+ ), 0, $e);
+ }
+ }
+
+ private function setPayload(mixed $data): void {
+ if (is_object($data)) {
+ $data = clone $data;
+ }
+
+ $this->payload = $data;
+ }
+
+ /**
+ * Update the response body for the given instance.
+ *
+ * @param self $toUpdate Instance to update.
+ * @return JsonResponse Returns a new instance with an updated body.
+ */
+ private function updateBodyFor(JsonResponse $toUpdate): JsonResponse {
+ $json = $this->jsonEncode($toUpdate->payload, $toUpdate->encodingOptions);
+ $body = $this->createBodyFromJson($json);
+ return $toUpdate->withBody($body);
+ }
+}
diff --git a/src/Response/RedirectResponse.php b/src/Response/RedirectResponse.php
new file mode 100644
index 0000000..13ac247
--- /dev/null
+++ b/src/Response/RedirectResponse.php
@@ -0,0 +1,58 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Response;
+
+use InvalidArgumentException;
+use Rodas\Diactoros\Exception;
+use Rodas\Diactoros\Response;
+use Rodas\Psr\Http\Message\UriInterface;
+
+use function get_debug_type;
+use function is_string;
+use function sprintf;
+
+/**
+ * Produce a redirect response.
+ */
+class RedirectResponse extends Response {
+ /**
+ * Create a redirect response.
+ *
+ * Produces a redirect response with a Location header and the given status
+ * (302 by default).
+ *
+ * Note: this method overwrites the `location` $headers value.
+ *
+ * @param string|UriInterface $uri URI for the Location header.
+ * @param int $status Integer status code for the redirect; 302 by default.
+ * @param array $headers Array of headers to use at initialization.
+ */
+ public function __construct($uri, int $status = 302, array $headers = []) {
+ if (! is_string($uri) && ! $uri instanceof UriInterface) {
+ throw new InvalidArgumentException(sprintf(
+ 'Uri provided to %s MUST be a string or Rodas\Psr\Http\Message\UriInterface instance; received "%s"',
+ self::class,
+ get_debug_type($uri)
+ ));
+ }
+
+ $headers['location'] = [(string) $uri];
+ parent::__construct('php://temp', $status, $headers);
+ }
+}
diff --git a/src/Response/Serializer.php b/src/Response/Serializer.php
new file mode 100644
index 0000000..63854f7
--- /dev/null
+++ b/src/Response/Serializer.php
@@ -0,0 +1,109 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Response;
+
+use InvalidArgumentException;
+use Rodas\Diactoros\AbstractSerializer;
+use Rodas\Diactoros\Exception;
+use Rodas\Diactoros\Response;
+use Rodas\Diactoros\Stream;
+use Rodas\Psr\Http\Message\ResponseInterface;
+use Rodas\Psr\Http\Message\StreamInterface;
+
+use function preg_match;
+use function sprintf;
+
+final class Serializer extends AbstractSerializer {
+ /**
+ * Deserialize a response string to a response instance.
+ *
+ * @throws Exception\SerializationException When errors occur parsing the message.
+ */
+ public static function fromString(string $message): Response {
+ $stream = new Stream('php://temp', 'wb+');
+ $stream->write($message);
+ return static::fromStream($stream);
+ }
+
+ /**
+ * Parse a response from a stream.
+ *
+ * @throws InvalidArgumentException When the stream is not readable.
+ * @throws Exception\SerializationException When errors occur parsing the message.
+ */
+ public static function fromStream(StreamInterface $stream): Response {
+ if (! $stream->isReadable ||
+ ! $stream->isSeekable) {
+ throw new InvalidArgumentException('Message stream must be both readable and seekable');
+ }
+
+ $stream->rewind();
+
+ [$version, $status, $reasonPhrase] = self::getStatusLine($stream);
+ [$headers, $body] = self::splitStream($stream);
+
+ return (new Response($body, $status, $headers))
+ ->withProtocolVersion($version)
+ ->withStatus((int) $status, $reasonPhrase);
+ }
+
+ /**
+ * Create a string representation of a response.
+ */
+ public static function toString(ResponseInterface $response): string {
+ $reasonPhrase = $response->getReasonPhrase();
+ $headers = self::serializeHeaders($response->getHeaders());
+ $body = (string) $response->getBody();
+ $format = 'HTTP/%s %d%s%s%s';
+
+ if (! empty($headers)) {
+ $headers = "\r\n" . $headers;
+ }
+
+ $headers .= "\r\n\r\n";
+
+ return sprintf(
+ $format,
+ $response->getProtocolVersion(),
+ $response->getStatusCode(),
+ $reasonPhrase ? ' ' . $reasonPhrase : '',
+ $headers,
+ $body
+ );
+ }
+
+ /**
+ * Retrieve the status line for the message.
+ *
+ * @return array Array with three elements: 0 => version, 1 => status, 2 => reason
+ * @throws Exception\SerializationException If line is malformed.
+ */
+ private static function getStatusLine(StreamInterface $stream): array {
+ $line = self::getLine($stream);
+
+ if (! preg_match(
+ '#^HTTP/(?P[1-9]\d*\.\d) (?P[1-5]\d{2})(\s+(?P.+))?$#',
+ $line,
+ $matches)) {
+ throw Exception\SerializationException::forInvalidStatusLine();
+ }
+
+ return [$matches['version'], (int) $matches['status'], $matches['reason'] ?? ''];
+ }
+}
diff --git a/src/Response/TextResponse.php b/src/Response/TextResponse.php
new file mode 100644
index 0000000..5e479be
--- /dev/null
+++ b/src/Response/TextResponse.php
@@ -0,0 +1,85 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Response;
+
+use InvalidArgumentException;
+use Rodas\Diactoros\Exception;
+use Rodas\Diactoros\Response;
+use Rodas\Diactoros\Stream;
+use Rodas\Psr\Http\Message\StreamInterface;
+
+use function get_debug_type;
+use function is_string;
+use function sprintf;
+
+/**
+ * Plain text response.
+ *
+ * Allows creating a response by passing a string to the constructor;
+ * by default, sets a status code of 200 and sets the Content-Type header to
+ * text/plain.
+ */
+class TextResponse extends Response {
+ use InjectContentTypeTrait;
+
+ /**
+ * Create a plain text response.
+ *
+ * Produces a text response with a Content-Type of text/plain and a default
+ * status of 200.
+ *
+ * @param string|StreamInterface $text String or stream for the message body.
+ * @param int $status Integer status code for the response; 200 by default.
+ * @param array $headers Array of headers to use at initialization.
+ * @throws InvalidArgumentException If $text is neither a string or stream.
+ */
+ public function __construct($text, int $status = 200, array $headers = []) {
+ parent::__construct(
+ $this->createBody($text),
+ $status,
+ $this->injectContentType('text/plain; charset=utf-8', $headers)
+ );
+ }
+
+ /**
+ * Create the message body.
+ *
+ * @param string|StreamInterface $text
+ * @throws InvalidArgumentException If $text is neither a string or stream.
+ */
+ private function createBody($text): StreamInterface {
+ if ($text instanceof StreamInterface) {
+ return $text;
+ }
+
+ /** @psalm-suppress DocblockTypeContradiction */
+ if (! is_string($text)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Invalid content (%s) provided to %s',
+ get_debug_type($text),
+ self::class
+ ));
+ }
+
+ $body = new Stream('php://temp', 'wb+');
+ $body->write($text);
+ $body->rewind();
+ return $body;
+ }
+}
diff --git a/src/Response/XmlResponse.php b/src/Response/XmlResponse.php
new file mode 100644
index 0000000..8fdc922
--- /dev/null
+++ b/src/Response/XmlResponse.php
@@ -0,0 +1,88 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\Response;
+
+use InvalidArgumentException;
+use Rodas\Diactoros\Exception;
+use Rodas\Diactoros\Response;
+use Rodas\Diactoros\Stream;
+use Rodas\Psr\Http\Message\StreamInterface;
+
+use function get_debug_type;
+use function is_string;
+use function sprintf;
+
+/**
+ * XML response.
+ *
+ * Allows creating a response by passing an XML string to the constructor; by default,
+ * sets a status code of 200 and sets the Content-Type header to application/xml.
+ */
+class XmlResponse extends Response {
+ use InjectContentTypeTrait;
+
+ /**
+ * Create an XML response.
+ *
+ * Produces an XML response with a Content-Type of application/xml and a default
+ * status of 200.
+ *
+ * @param string|StreamInterface $xml String or stream for the message body.
+ * @param int $status Integer status code for the response; 200 by default.
+ * @param array $headers Array of headers to use at initialization.
+ * @throws InvalidArgumentException If $text is neither a string or stream.
+ */
+ public function __construct(
+ $xml,
+ int $status = 200,
+ array $headers = []
+ ) {
+ parent::__construct(
+ $this->createBody($xml),
+ $status,
+ $this->injectContentType('application/xml; charset=utf-8', $headers)
+ );
+ }
+
+ /**
+ * Create the message body.
+ *
+ * @param string|StreamInterface $xml
+ * @throws InvalidArgumentException If $xml is neither a string or stream.
+ */
+ private function createBody($xml): StreamInterface {
+ if ($xml instanceof StreamInterface) {
+ return $xml;
+ }
+
+ /** @psalm-suppress DocblockTypeContradiction */
+ if (! is_string($xml)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Invalid content (%s) provided to %s',
+ get_debug_type($xml),
+ self::class
+ ));
+ }
+
+ $body = new Stream('php://temp', 'wb+');
+ $body->write($xml);
+ $body->rewind();
+ return $body;
+ }
+}
diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php
new file mode 100644
index 0000000..3b4af49
--- /dev/null
+++ b/src/ResponseFactory.php
@@ -0,0 +1,34 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use Override;
+use Rodas\Psr\Http\Message\ResponseFactoryInterface;
+use Rodas\Psr\Http\Message\ResponseInterface;
+
+class ResponseFactory implements ResponseFactoryInterface {
+ /**
+ * {@inheritDoc}
+ */
+ #[Override]
+ public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface {
+ return (new Response())
+ ->withStatus($code, $reasonPhrase);
+ }
+}
diff --git a/src/ServerRequest.php b/src/ServerRequest.php
new file mode 100644
index 0000000..0b76b9b
--- /dev/null
+++ b/src/ServerRequest.php
@@ -0,0 +1,255 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use InvalidArgumentException;
+use Override;
+use Rodas\Psr\Http\Message\ServerRequestInterface;
+use Rodas\Psr\Http\Message\StreamInterface;
+use Rodas\Psr\Http\Message\UploadedFileInterface;
+use Rodas\Psr\Http\Message\UriInterface;
+
+use function array_key_exists;
+use function gettype;
+use function is_array;
+use function is_object;
+use function sprintf;
+
+/**
+ * Server-side HTTP request
+ *
+ * Extends the Request definition to add methods for accessing incoming data,
+ * specifically server parameters, cookies, matched path parameters, query
+ * string arguments, body parameters, and upload file information.
+ *
+ * "Attributes" are discovered via decomposing the request (and usually
+ * specifically the URI path), and typically will be injected by the application.
+ *
+ * Requests are considered immutable; all methods that might change state are
+ * implemented such that they retain the internal state of the current
+ * message and return a new instance that contains the changed state.
+ */
+class ServerRequest implements ServerRequestInterface {
+ use RequestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ public private(set) array $attributes = [] {
+ get => $this->attributes;
+ set => $this->attributes = $value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public private(set) array $cookieParams = [] {
+ get => $this->cookieParams;
+ set => $this->cookieParams = $value;
+ }
+
+ /**
+ * List of all registered headers, as key => array of values.
+ *
+ * @var array>
+ */
+ public protected(set) array $headers = [] {
+ get {
+ $headers = $this->headers;
+ if (! $this->hasHeader('host') &&
+ $this->uri->host) {
+ $headers['Host'] = [$this->getHostFromUri()];
+ }
+
+ return $headers;
+ }
+ set => $this->headers = $value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public private(set) array $queryParams = [] {
+ get => $this->queryParams;
+ set => $this->queryParams = $value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public private(set) array $serverParams = [] {
+ get => $this->serverParams;
+ set => $this->serverParams = $value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public private(set) array $uploadedFiles;
+
+ /**
+ * @param array $serverParams Server parameters, typically from $_SERVER
+ * @param array $uploadedFiles Upload file information, a tree of UploadedFiles
+ * @param null|string|UriInterface $uri URI for the request, if any.
+ * @param null|string $method HTTP method for the request, if any.
+ * @param string|resource|StreamInterface $body Message body, if any.
+ * @param array $headers Headers for the message, if any.
+ * @param array $cookieParams Cookies for the message, if any.
+ * @param array $queryParams Query params for the message, if any.
+ * @param null|array|object $parsedBody The deserialized body parameters, if any.
+ * @param string $protocol HTTP protocol version.
+ * @throws InvalidArgumentException For any invalid value.
+ */
+ public function __construct(
+ array $serverParams = [],
+ array $uploadedFiles = [],
+ null|string|UriInterface $uri = null,
+ RequestMethod|string|null $method = null,
+ $body = 'php://input',
+ array $headers = [],
+ array $cookieParams = [],
+ array $queryParams = [],
+ private $parsedBody = null,
+ string $protocol = '1.1'
+ ) {
+ $this->validateUploadedFiles($uploadedFiles);
+
+ if ($body === 'php://input') {
+ $body = new Stream($body, 'r');
+ }
+
+ $this->initialize($uri, $method, $body, $headers);
+ $this->cookieParams = $cookieParams;
+ $this->uploadedFiles = $uploadedFiles;
+ $this->serverParams = $serverParams;
+ $this->protocolVersion = $protocol;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withUploadedFiles(array $uploadedFiles): ServerRequest {
+ $this->validateUploadedFiles($uploadedFiles);
+ $new = clone $this;
+ $new->uploadedFiles = $uploadedFiles;
+ return $new;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withCookieParams(array $cookies): ServerRequest {
+ $new = clone $this;
+ $new->cookieParams = $cookies;
+ return $new;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withQueryParams(array $query): ServerRequest {
+ $new = clone $this;
+ $new->queryParams = $query;
+ return $new;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function getParsedBody() {
+ return $this->parsedBody;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withParsedBody($data): ServerRequest {
+ /** @psalm-suppress DocblockTypeContradiction */
+ if (! is_array($data) && ! is_object($data) && null !== $data) {
+ throw new InvalidArgumentException(sprintf(
+ '%s expects a null, array, or object argument; received %s',
+ __METHOD__,
+ gettype($data)
+ ));
+ }
+
+ $new = clone $this;
+ $new->parsedBody = $data;
+ return $new;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function getAttribute(string $name, $default = null) {
+ if (! array_key_exists($name, $this->attributes)) {
+ return $default;
+ }
+
+ return $this->attributes[$name];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withAttribute(string $name, $value): ServerRequest {
+ $new = clone $this;
+ $attributes = $new->attributes;
+ $attributes[$name] = $value;
+ $new->attributes = $attributes;
+ return $new;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withoutAttribute(string $name): ServerRequest {
+ $new = clone $this;
+ $attributes = $new->attributes;
+ unset($attributes[$name]);
+ $new->attributes = $attributes;
+ return $new;
+ }
+
+ /**
+ * Recursively validate the structure in an uploaded files array.
+ *
+ * @throws InvalidArgumentException If any leaf is not an UploadedFileInterface instance.
+ */
+ private function validateUploadedFiles(array $uploadedFiles): void {
+ foreach ($uploadedFiles as $file) {
+ if (is_array($file)) {
+ $this->validateUploadedFiles($file);
+ continue;
+ }
+
+ if (! $file instanceof UploadedFileInterface) {
+ throw new InvalidArgumentException('Invalid leaf in uploaded files structure');
+ }
+ }
+ }
+}
diff --git a/src/ServerRequestFactory.php b/src/ServerRequestFactory.php
new file mode 100644
index 0000000..4810e18
--- /dev/null
+++ b/src/ServerRequestFactory.php
@@ -0,0 +1,427 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use InvalidArgumentException;
+use Override;
+use Rodas\Diactoros\ServerRequestFilter\FilterServerRequestInterface;
+use Rodas\Diactoros\ServerRequestFilter\FilterUsingXForwardedHeaders;
+use Rodas\Psr\Http\Message\RequestMethod;
+use Rodas\Psr\Http\Message\ServerRequestFactoryInterface;
+use Rodas\Psr\Http\Message\ServerRequestInterface;
+
+use function array_filter;
+use function array_key_exists;
+use function is_array;
+use function is_callable;
+use function is_string;
+use function preg_match;
+use function preg_match_all;
+use function sprintf;
+use function rawurldecode;
+use function str_starts_with;
+use function strtolower;
+use function strtr;
+use function substr;
+
+use const ARRAY_FILTER_USE_KEY;
+use const PREG_SET_ORDER;
+
+/**
+ * Class for marshaling a request object from the current PHP environment.
+ */
+class ServerRequestFactory implements ServerRequestFactoryInterface {
+ /**
+ * Function to use to get apache request headers; present only to simplify mocking.
+ *
+ * @var callable|string
+ */
+ private static $apacheRequestHeaders = 'apache_request_headers';
+
+ /**
+ * Create an uploaded file instance from an array of values.
+ *
+ * @param array $spec A single $_FILES entry.
+ * @throws InvalidArgumentException If one or more of the tmp_name,
+ * size, or error keys are missing from $spec.
+ */
+ static function createUploadedFile(array $spec): UploadedFile {
+ if (! isset($spec['tmp_name']) ||
+ ! isset($spec['size']) ||
+ ! isset($spec['error'])) {
+ throw new InvalidArgumentException(sprintf(
+ '$spec provided to %s MUST contain each of the keys "tmp_name",'
+ . ' "size", and "error"; one or more were missing',
+ __FUNCTION__
+ ));
+ }
+
+ return new UploadedFile(
+ $spec['tmp_name'],
+ (int) $spec['size'],
+ $spec['error'],
+ $spec['name'] ?? null,
+ $spec['type'] ?? null
+ );
+ }
+
+ /**
+ * Create a request from the supplied superglobal values.
+ *
+ * If any argument is not supplied, the corresponding superglobal value will
+ * be used.
+ *
+ * The ServerRequest created is then passed to the fromServer() method in
+ * order to marshal the request URI and headers.
+ *
+ * @see fromServer()
+ *
+ * @param null|array $server $_SERVER superglobal
+ * @param null|array $query $_GET superglobal
+ * @param null|array $body $_POST superglobal
+ * @param null|array $cookies $_COOKIE superglobal
+ * @param null|array $files $_FILES superglobal
+ * @param null|FilterServerRequestInterface $requestFilter If present, the
+ * generated request will be passed to this instance and the result
+ * returned by this method. When not present, a default instance of
+ * FilterUsingXForwardedHeaders is created, using the `trustReservedSubnets()`
+ * constructor.
+ */
+ public static function fromGlobals(
+ ?array $server = null,
+ ?array $query = null,
+ ?array $body = null,
+ ?array $cookies = null,
+ ?array $files = null,
+ ?FilterServerRequestInterface $requestFilter = null
+ ): ServerRequestInterface {
+ $requestFilter ??= FilterUsingXForwardedHeaders::trustReservedSubnets();
+
+ $server = static::normalizeServer(
+ $server ?? $_SERVER,
+ is_callable(self::$apacheRequestHeaders) ? self::$apacheRequestHeaders : null
+ );
+ $files = static::normalizeUploadedFiles($files ?? $_FILES);
+ $headers = static::marshalHeadersFromSapi($server);
+
+ if (null === $cookies && array_key_exists('cookie', $headers)) {
+ $cookies = static::parseCookieHeader($headers['cookie']);
+ }
+
+ return $requestFilter(new ServerRequest(
+ $server,
+ $files,
+ UriFactory::createFromSapi($server, $headers),
+ static::marshalMethodFromSapi($server),
+ 'php://input',
+ $headers,
+ $cookies ?? $_COOKIE,
+ $query ?? $_GET,
+ $body ?? $_POST,
+ static::marshalProtocolVersionFromSapi($server)
+ ));
+ }
+
+ /**
+ * @param array $server Values obtained from the SAPI (generally `$_SERVER`).
+ * @return array Header/value pairs
+ */
+ static function marshalHeadersFromSapi(array $server): array {
+ $contentHeaderLookup = isset($server['LAMINAS_DIACTOROS_STRICT_CONTENT_HEADER_LOOKUP'])
+ ? static function (string $key): bool {
+ static $contentHeaders = [
+ 'CONTENT_TYPE' => true,
+ 'CONTENT_LENGTH' => true,
+ 'CONTENT_MD5' => true,
+ ];
+ return isset($contentHeaders[$key]);
+ }
+ : static fn(string $key): bool => str_starts_with($key, 'CONTENT_');
+
+ $headers = [];
+ foreach ($server as $key => $value) {
+ if (! is_string($key) ||
+ $key === '') {
+
+ continue;
+ }
+
+ if ($value === '') {
+ continue;
+ }
+
+ // Apache prefixes environment variables with REDIRECT_
+ // if they are added by rewrite rules
+ if (str_starts_with($key, 'REDIRECT_')) {
+ $key = substr($key, 9);
+
+ // We will not overwrite existing variables with the
+ // prefixed versions, though
+ if (array_key_exists($key, $server)) {
+ continue;
+ }
+ }
+
+ if (str_starts_with($key, 'HTTP_')) {
+ $name = strtr(strtolower(substr($key, 5)), '_', '-');
+ $headers[$name] = $value;
+ continue;
+ }
+
+ if ($contentHeaderLookup($key)) {
+ $name = strtr(strtolower($key), '_', '-');
+ $headers[$name] = $value;
+ }
+ }
+
+ // Filter out integer keys.
+ // These can occur if the translated header name is a string integer.
+ // PHP will cast those to integers when assigned to an array.
+ // This filters them out.
+ return array_filter($headers, fn(string|int $key): bool => is_string($key), ARRAY_FILTER_USE_KEY);
+ }
+
+ /**
+ * Retrieve the request method from the SAPI parameters.
+ */
+ static function marshalMethodFromSapi(array $server): string {
+ return $server['REQUEST_METHOD'] ?? 'GET';
+ }
+
+ /**
+ * Return HTTP protocol version (X.Y) as discovered within a `$_SERVER` array.
+ *
+ * @throws Exception\UnrecognizedProtocolVersionException If the
+ * $server['SERVER_PROTOCOL'] value is malformed.
+ */
+ static function marshalProtocolVersionFromSapi(array $server): string {
+ if (! isset($server['SERVER_PROTOCOL'])) {
+ return '1.1';
+ }
+
+ if (! preg_match('#^(HTTP/)?(?P[1-9]\d*(?:\.\d)?)$#', $server['SERVER_PROTOCOL'], $matches)) {
+ throw Exception\UnrecognizedProtocolVersionException::forVersion(
+ (string) $server['SERVER_PROTOCOL']
+ );
+ }
+
+ return $matches['version'];
+ }
+
+ /**
+ * Marshal the $_SERVER array
+ *
+ * Pre-processes and returns the $_SERVER superglobal. In particularly, it
+ * attempts to detect the Authorization header, which is often not aggregated
+ * correctly under various SAPI/httpd combinations.
+ *
+ * @param null|callable $apacheRequestHeaderCallback Callback that can be used to
+ * retrieve Apache request headers. This defaults to
+ * `apache_request_headers` under the Apache mod_php.
+ * @return array Either $server verbatim, or with an added HTTP_AUTHORIZATION header.
+ */
+ static function normalizeServer(array $server, ?callable $apacheRequestHeaderCallback = null): array {
+ if (null === $apacheRequestHeaderCallback &&
+ is_callable('apache_request_headers')) {
+
+ $apacheRequestHeaderCallback = 'apache_request_headers';
+ }
+
+ // If the HTTP_AUTHORIZATION value is already set, or the callback is not
+ // callable, we return verbatim
+ if (isset($server['HTTP_AUTHORIZATION']) ||
+ ! is_callable($apacheRequestHeaderCallback)) {
+
+ return $server;
+ }
+
+ $apacheRequestHeaders = $apacheRequestHeaderCallback();
+ if (isset($apacheRequestHeaders['Authorization'])) {
+ $server['HTTP_AUTHORIZATION'] = $apacheRequestHeaders['Authorization'];
+ return $server;
+ }
+
+ if (isset($apacheRequestHeaders['authorization'])) {
+ $server['HTTP_AUTHORIZATION'] = $apacheRequestHeaders['authorization'];
+ return $server;
+ }
+
+ return $server;
+ }
+
+ /**
+ * Normalize uploaded files
+ *
+ * Transforms each value into an UploadedFile instance, and ensures that nested
+ * arrays are normalized.
+ *
+ * @return UploadedFileInterface[]
+ * @throws InvalidArgumentException For unrecognized values.
+ */
+ static function normalizeUploadedFiles(array $files): array {
+ /**
+ * Traverse a nested tree of uploaded file specifications.
+ *
+ * @param string[]|array[] $tmpNameTree
+ * @param int[]|array[] $sizeTree
+ * @param int[]|array[] $errorTree
+ * @param string[]|array[]|null $nameTree
+ * @param string[]|array[]|null $typeTree
+ * @return UploadedFile[]|array[]
+ */
+ $recursiveNormalize = static function (
+ array $tmpNameTree,
+ array $sizeTree,
+ array $errorTree,
+ ?array $nameTree = null,
+ ?array $typeTree = null
+ ) use (&$recursiveNormalize): array {
+ $normalized = [];
+ foreach ($tmpNameTree as $key => $value) {
+ if (is_array($value)) {
+ // Traverse
+ $normalized[$key] = $recursiveNormalize(
+ $tmpNameTree[$key],
+ $sizeTree[$key],
+ $errorTree[$key],
+ $nameTree[$key] ?? null,
+ $typeTree[$key] ?? null
+ );
+ continue;
+ }
+ $normalized[$key] = static::createUploadedFile([
+ 'tmp_name' => $tmpNameTree[$key],
+ 'size' => $sizeTree[$key],
+ 'error' => $errorTree[$key],
+ 'name' => $nameTree[$key] ?? null,
+ 'type' => $typeTree[$key] ?? null,
+ ]);
+ }
+ return $normalized;
+ };
+
+ /**
+ * Normalize an array of file specifications.
+ *
+ * Loops through all nested files (as determined by receiving an array to the
+ * `tmp_name` key of a `$_FILES` specification) and returns a normalized array
+ * of UploadedFile instances.
+ *
+ * This function normalizes a `$_FILES` array representing a nested set of
+ * uploaded files as produced by the php-fpm SAPI, CGI SAPI, or mod_php
+ * SAPI.
+ *
+ * @param array $files
+ * @return UploadedFile[]
+ */
+ $normalizeUploadedFileSpecification = static function (array $files = []) use (&$recursiveNormalize): array {
+ if (
+ ! isset($files['tmp_name']) || ! is_array($files['tmp_name'])
+ || ! isset($files['size']) || ! is_array($files['size'])
+ || ! isset($files['error']) || ! is_array($files['error'])
+ ) {
+ throw new InvalidArgumentException(sprintf(
+ '$files provided to %s MUST contain each of the keys "tmp_name",'
+ . ' "size", and "error", with each represented as an array;'
+ . ' one or more were missing or non-array values',
+ __FUNCTION__
+ ));
+ }
+
+ return $recursiveNormalize(
+ $files['tmp_name'],
+ $files['size'],
+ $files['error'],
+ $files['name'] ?? null,
+ $files['type'] ?? null
+ );
+ };
+
+ $normalized = [];
+ foreach ($files as $key => $value) {
+ if ($value instanceof UploadedFileInterface) {
+ $normalized[$key] = $value;
+ continue;
+ }
+
+ if (is_array($value) && isset($value['tmp_name']) && is_array($value['tmp_name'])) {
+ $normalized[$key] = $normalizeUploadedFileSpecification($value);
+ continue;
+ }
+
+ if (is_array($value) && isset($value['tmp_name'])) {
+ $normalized[$key] = static::createUploadedFile($value);
+ continue;
+ }
+
+ if (is_array($value)) {
+ $normalized[$key] = static::normalizeUploadedFiles($value);
+ continue;
+ }
+
+ throw new InvalidArgumentException('Invalid value in files specification');
+ }
+ return $normalized;
+ }
+
+ /**
+ * Parse a cookie header according to RFC 6265.
+ *
+ * PHP will replace special characters in cookie names, which results in other cookies not being available due to
+ * overwriting. Thus, the server request should take the cookies from the request header instead.
+ *
+ * @param string $cookieHeader A string cookie header value.
+ * @return array key/value cookie pairs.
+ */
+ static function parseCookieHeader($cookieHeader): array {
+ preg_match_all('(
+ (?:^\\n?[ \t]*|;[ ])
+ (?P[!#$%&\'*+-.0-9A-Z^_`a-z|~]+)
+ =
+ (?P"?)
+ (?P[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*)
+ (?P=DQUOTE)
+ (?=\\n?[ \t]*$|;[ ])
+ )x', $cookieHeader, $matches, PREG_SET_ORDER);
+
+ $cookies = [];
+
+ foreach ($matches as $match) {
+ $cookies[$match['name']] = rawurldecode($match['value']);
+ }
+
+ return $cookies;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ #[Override]
+ public function createServerRequest(RequestMethod|string $method, $uri, array $serverParams = []): ServerRequestInterface {
+ $uploadedFiles = [];
+
+ return new ServerRequest(
+ $serverParams,
+ $uploadedFiles,
+ $uri,
+ $method,
+ 'php://temp'
+ );
+ }
+}
diff --git a/src/ServerRequestFilter/DoNotFilter.php b/src/ServerRequestFilter/DoNotFilter.php
new file mode 100644
index 0000000..ebbc149
--- /dev/null
+++ b/src/ServerRequestFilter/DoNotFilter.php
@@ -0,0 +1,29 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\ServerRequestFilter;
+
+use Override;
+use Rodas\Psr\Http\Message\ServerRequestInterface;
+
+final class DoNotFilter implements FilterServerRequestInterface {
+ #[Override]
+ public function __invoke(ServerRequestInterface $request): ServerRequestInterface {
+ return $request;
+ }
+}
diff --git a/src/ServerRequestFilter/FilterServerRequestInterface.php b/src/ServerRequestFilter/FilterServerRequestInterface.php
new file mode 100644
index 0000000..26ba8a4
--- /dev/null
+++ b/src/ServerRequestFilter/FilterServerRequestInterface.php
@@ -0,0 +1,42 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\ServerRequestFilter;
+
+use Rodas\Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Filter/initialize a server request.
+ *
+ * Implementations of this interface will take an incoming request, and
+ * decide if additional modifications are necessary. As examples:
+ *
+ * - Injecting a unique request identifier header.
+ * - Using the X-Forwarded-* headers to rewrite the URI to reflect the original request.
+ * - Using the Forwarded header to rewrite the URI to reflect the original request.
+ *
+ * This functionality is consumed by the ServerRequestFactory using the request
+ * instance it generates, just prior to returning a request.
+ */
+interface FilterServerRequestInterface {
+ /**
+ * Determine if a request needs further modification, and if so, return a
+ * new instance reflecting those modifications.
+ */
+ public function __invoke(ServerRequestInterface $request): ServerRequestInterface;
+}
diff --git a/src/ServerRequestFilter/FilterUsingXForwardedHeaders.php b/src/ServerRequestFilter/FilterUsingXForwardedHeaders.php
new file mode 100644
index 0000000..a233511
--- /dev/null
+++ b/src/ServerRequestFilter/FilterUsingXForwardedHeaders.php
@@ -0,0 +1,263 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros\ServerRequestFilter;
+
+use Rodas\Diactoros\Exception\InvalidForwardedHeaderNameException;
+use Rodas\Diactoros\Exception\InvalidProxyAddressException;
+use Rodas\Diactoros\UriFactory;
+use Override;
+use Rodas\Psr\Http\Message\ServerRequestInterface;
+
+use function array_values;
+use function assert;
+use function count;
+use function explode;
+use function filter_var;
+use function in_array;
+use function is_string;
+use function str_contains;
+use function strtolower;
+
+use const FILTER_FLAG_IPV4;
+use const FILTER_FLAG_IPV6;
+use const FILTER_VALIDATE_IP;
+
+/**
+ * Modify the URI to reflect the X-Forwarded-* headers.
+ *
+ * If the request comes from a trusted proxy, this filter will analyze the
+ * various X-Forwarded-* headers, if any, and if they are marked as trusted,
+ * in order to return a new request that composes a URI instance that reflects
+ * those headers.
+ */
+final class FilterUsingXForwardedHeaders implements FilterServerRequestInterface {
+ public const HEADER_HOST = 'X-FORWARDED-HOST';
+ public const HEADER_PORT = 'X-FORWARDED-PORT';
+ public const HEADER_PROTO = 'X-FORWARDED-PROTO';
+
+ private const X_FORWARDED_HEADERS = [
+ self::HEADER_HOST,
+ self::HEADER_PORT,
+ self::HEADER_PROTO,
+ ];
+
+ /**
+ * Only allow construction via named constructors
+ *
+ * @param list $trustedProxies
+ * @param list $trustedHeaders
+ */
+ private function __construct(
+ private readonly array $trustedProxies = [],
+ private readonly array $trustedHeaders = []
+ ) { }
+
+ #[Override]
+ public function __invoke(ServerRequestInterface $request): ServerRequestInterface {
+ $remoteAddress = $request->getServerParams()['REMOTE_ADDR'] ?? '';
+
+ if ('' === $remoteAddress || ! is_string($remoteAddress)) {
+ // Should we trigger a warning here?
+ return $request;
+ }
+
+ if (! $this->isFromTrustedProxy($remoteAddress)) {
+ // Do nothing
+ return $request;
+ }
+
+ // Update the URI based on the trusted headers
+ $uri = $originalUri = $request->getUri();
+ foreach ($this->trustedHeaders as $headerName) {
+ $header = $request->getHeaderLine($headerName);
+ if ('' === $header || str_contains($header, ',')) {
+ // Reject empty headers and/or headers with multiple values
+ continue;
+ }
+
+ switch ($headerName) {
+ case self::HEADER_HOST:
+ [$host, $port] = UriFactory::marshalHostAndPortFromHeader($header);
+ $uri = $uri
+ ->withHost($host);
+ if ($port !== null) {
+ $uri = $uri->withPort($port);
+ }
+ break;
+ case self::HEADER_PORT:
+ $uri = $uri->withPort((int) $header);
+ break;
+ case self::HEADER_PROTO:
+ $scheme = strtolower($header) === 'https' ? 'https' : 'http';
+ $uri = $uri->withScheme($scheme);
+ break;
+ }
+ }
+
+ if ($uri !== $originalUri) {
+ return $request->withUri($uri);
+ }
+
+ return $request;
+ }
+
+ /**
+ * Indicate which proxies and which X-Forwarded headers to trust.
+ *
+ * @param list $proxyCIDRList Each element may
+ * be an IP address or a subnet specified using CIDR notation; both IPv4
+ * and IPv6 are supported. The special string "*" will be translated to
+ * two entries, "0.0.0.0/0" and "::/0". An empty list indicates no
+ * proxies are trusted.
+ * @param list $trustedHeaders If
+ * the list is empty, all X-Forwarded headers are trusted.
+ * @throws InvalidProxyAddressException
+ * @throws InvalidForwardedHeaderNameException
+ */
+ public static function trustProxies(
+ array $proxyCIDRList,
+ array $trustedHeaders = self::X_FORWARDED_HEADERS
+ ): self {
+ $proxyCIDRList = self::normalizeProxiesList($proxyCIDRList);
+ self::validateTrustedHeaders($trustedHeaders);
+
+ return new self($proxyCIDRList, $trustedHeaders);
+ }
+
+ /**
+ * Trust any X-FORWARDED-* headers from any address.
+ *
+ * This is functionally equivalent to calling `trustProxies(['*'])`.
+ *
+ * WARNING: Only do this if you know for certain that your application
+ * sits behind a trusted proxy that cannot be spoofed. This should only
+ * be the case if your server is not publicly addressable, and all requests
+ * are routed via a reverse proxy (e.g., a load balancer, a server such as
+ * Caddy, when using Traefik, etc.).
+ */
+ public static function trustAny(): self {
+ return self::trustProxies(['*']);
+ }
+
+ /**
+ * Trust X-Forwarded headers from reserved subnetworks.
+ *
+ * This is functionally equivalent to calling `trustProxies()` where the
+ * `$proxcyCIDRList` argument is a list with the following:
+ *
+ * - 10.0.0.0/8
+ * - 127.0.0.0/8
+ * - 172.16.0.0/12
+ * - 192.168.0.0/16
+ * - ::1/128 (IPv6 localhost)
+ * - fc00::/7 (IPv6 private networks)
+ * - fe80::/10 (IPv6 local-link addresses)
+ *
+ * @param list $trustedHeaders If
+ * the list is empty, all X-Forwarded headers are trusted.
+ * @throws InvalidForwardedHeaderNameException
+ */
+ public static function trustReservedSubnets(array $trustedHeaders = self::X_FORWARDED_HEADERS): self {
+ return self::trustProxies([
+ '10.0.0.0/8',
+ '127.0.0.0/8',
+ '172.16.0.0/12',
+ '192.168.0.0/16',
+ '::1/128', // ipv6 localhost
+ 'fc00::/7', // ipv6 private networks
+ 'fe80::/10', // ipv6 local-link addresses
+ ], $trustedHeaders);
+ }
+
+ private function isFromTrustedProxy(string $remoteAddress): bool {
+ foreach ($this->trustedProxies as $proxy) {
+ if (IPRange::matches($remoteAddress, $proxy)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /** @throws InvalidForwardedHeaderNameException */
+ private static function validateTrustedHeaders(array $headers): void {
+ foreach ($headers as $header) {
+ if (! in_array($header, self::X_FORWARDED_HEADERS, true)) {
+ throw InvalidForwardedHeaderNameException::forHeader($header);
+ }
+ }
+ }
+
+ /**
+ * @param list $proxyCIDRList
+ * @return list
+ * @throws InvalidProxyAddressException
+ */
+ private static function normalizeProxiesList(array $proxyCIDRList): array {
+ $foundWildcard = false;
+
+ foreach ($proxyCIDRList as $index => $cidr) {
+ if ($cidr === '*') {
+ unset($proxyCIDRList[$index]);
+ $foundWildcard = true;
+ continue;
+ }
+
+ if (! self::validateProxyCIDR($cidr)) {
+ throw InvalidProxyAddressException::forAddress($cidr);
+ }
+ }
+
+ if ($foundWildcard) {
+ $proxyCIDRList[] = '0.0.0.0/0';
+ $proxyCIDRList[] = '::/0';
+ }
+
+ return array_values($proxyCIDRList);
+ }
+
+ private static function validateProxyCIDR(mixed $cidr): bool {
+ if (! is_string($cidr) || '' === $cidr) {
+ return false;
+ }
+
+ $address = $cidr;
+ $mask = null;
+ if (str_contains($cidr, '/')) {
+ $parts = explode('/', $cidr, 2);
+ assert(count($parts) >= 2);
+ [$address, $mask] = $parts;
+ $mask = (int) $mask;
+ }
+
+ if (str_contains($address, ':')) {
+ // is IPV6
+ return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) &&
+ ($mask === null ||
+ ($mask <= 128 &&
+ $mask >= 0));
+ }
+
+ // is IPV4
+ return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) &&
+ ($mask === null ||
+ ($mask <= 32 &&
+ $mask >= 0));
+ }
+}
diff --git a/src/ServerRequestFilter/IPRange.php b/src/ServerRequestFilter/IPRange.php
new file mode 100644
index 0000000..8dd8b56
--- /dev/null
+++ b/src/ServerRequestFilter/IPRange.php
@@ -0,0 +1,118 @@
+= 2);
+ [$subnet, $mask] = $parts;
+ $mask = (int) $mask;
+ }
+
+ if ($mask < 0 || $mask > 32) {
+ return false;
+ }
+
+ $ip = ip2long($ip);
+ $subnet = ip2long($subnet);
+ if (false === $ip || false === $subnet) {
+ // Invalid data
+ return false;
+ }
+
+ return 0 === substr_compare(
+ sprintf("%032b", $ip),
+ sprintf("%032b", $subnet),
+ 0,
+ $mask
+ );
+ }
+
+ /** @psalm-pure */
+ public static function matchesIPv6(string $ip, string $cidr): bool {
+ $mask = 128;
+ $subnet = $cidr;
+
+ if (str_contains($cidr, '/')) {
+ $parts = explode('/', $cidr, 2);
+ assert(count($parts) >= 2);
+ [$subnet, $mask] = $parts;
+ $mask = (int) $mask;
+ }
+
+ if ($mask < 0 || $mask > 128) {
+ return false;
+ }
+
+ $ip = inet_pton($ip);
+ $subnet = inet_pton($subnet);
+
+ if (false === $ip || false === $subnet) {
+ // Invalid data
+ return false;
+ }
+
+ // mask 0: if it's a valid IP, it's valid
+ if ($mask === 0) {
+ return (bool) unpack('n*', $ip);
+ }
+
+ // @see http://stackoverflow.com/questions/7951061/matching-ipv6-address-to-a-cidr-subnet, MW answer
+ $binMask = str_repeat("f", intval($mask / 4));
+ switch ($mask % 4) {
+ case 0:
+ break;
+ case 1:
+ $binMask .= "8";
+ break;
+ case 2:
+ $binMask .= "c";
+ break;
+ case 3:
+ $binMask .= "e";
+ break;
+ }
+
+ $binMask = str_pad($binMask, 32, '0');
+ $binMask = pack("H*", $binMask);
+
+ return ($ip & $binMask) === $subnet;
+ }
+}
diff --git a/src/Stream.php b/src/Stream.php
new file mode 100644
index 0000000..e7d5fd4
--- /dev/null
+++ b/src/Stream.php
@@ -0,0 +1,391 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use InvalidArgumentException;
+use Override;
+use Rodas\Psr\Http\Message\StreamInterface;
+use RuntimeException;
+use Stringable;
+use Throwable;
+
+use function array_key_exists;
+use function assert;
+use function fclose;
+use function feof;
+use function fopen;
+use function fread;
+use function fseek;
+use function fstat;
+use function ftell;
+use function fwrite;
+use function get_resource_type;
+use function in_array;
+use function is_int;
+use function is_resource;
+use function is_string;
+use function sprintf;
+use function str_contains;
+use function stream_get_contents;
+use function stream_get_meta_data;
+
+use const SEEK_SET;
+
+/**
+ * Implementation of PSR HTTP streams
+ */
+class Stream implements StreamInterface, Stringable {
+ /**
+ * A list of allowed stream resource types that are allowed to instantiate a Stream
+ */
+ private const ALLOWED_STREAM_RESOURCE_TYPES = ['stream'];
+
+ /** @var resource|null */
+ protected $resource;
+
+ /** @var string|object|resource|null */
+ protected $stream;
+
+ /**
+ * {@inheritdoc}
+ */
+ public bool $isSeekable {
+ get {
+ if (! $this->resource) {
+ return false;
+ }
+
+ $meta = stream_get_meta_data($this->resource);
+ return $meta['seekable'];
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public bool $isReadable {
+ get {
+ if (! $this->resource) {
+ return false;
+ }
+
+ $meta = stream_get_meta_data($this->resource);
+ $mode = $meta['mode'];
+
+ return str_contains($mode, 'r') ||
+ str_contains($mode, '+');
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public bool $isWritable {
+ get {
+ if (! $this->resource) {
+ return false;
+ }
+
+ $meta = stream_get_meta_data($this->resource);
+ $mode = $meta['mode'];
+
+ return str_contains($mode, 'x')
+ || str_contains($mode, 'w')
+ || str_contains($mode, 'c')
+ || str_contains($mode, 'a')
+ || str_contains($mode, '+');
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public ?int $size {
+ get {
+ if (null === $this->resource) {
+ return null;
+ }
+
+ $stats = fstat($this->resource);
+ if ($stats !== false) {
+ return $stats['size'];
+ }
+
+ return null;
+ }
+ }
+
+ /**
+ * @param string|object|resource $stream
+ * @param string $mode Mode with which to open stream
+ * @throws InvalidArgumentException
+ */
+ public function __construct($stream, string $mode = 'r') {
+ $this->setStream($stream, $mode);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function __toString(): string {
+ if (! $this->isReadable) {
+ return '';
+ }
+
+ try {
+ if ($this->isSeekable) {
+ $this->rewind();
+ }
+
+ return $this->getContents();
+ } catch (RuntimeException) {
+ return '';
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function close(): void {
+ if (! $this->resource) {
+ return;
+ }
+
+ $resource = $this->detach();
+ assert(is_resource($resource), 'Always true condition for psalm type safety');
+ fclose($resource);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function detach() {
+ $resource = $this->resource;
+ $this->resource = null;
+ return $resource;
+ }
+
+ /**
+ * Attach a new stream/resource to the instance.
+ *
+ * @param string|object|resource $resource
+ * @throws InvalidArgumentException For stream identifier that cannot be cast to a resource.
+ * @throws InvalidArgumentException For non-resource stream.
+ */
+ public function attach($resource, string $mode = 'r'): void {
+ $this->setStream($resource, $mode);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function tell(): int {
+ if (! $this->resource) {
+ throw Exception\UntellableStreamException::dueToMissingResource();
+ }
+
+ $result = ftell($this->resource);
+ if (! is_int($result)) {
+ throw Exception\UntellableStreamException::dueToPhpError();
+ }
+
+ return $result;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function eof(): bool {
+ if (! $this->resource) {
+ return true;
+ }
+
+ return feof($this->resource);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function seek(int $offset, int $whence = SEEK_SET): void {
+ if (! $this->resource) {
+ throw Exception\UnseekableStreamException::dueToMissingResource();
+ }
+
+ if (! $this->isSeekable) {
+ throw Exception\UnseekableStreamException::dueToConfiguration();
+ }
+
+ $result = fseek($this->resource, $offset, $whence);
+
+ if (0 !== $result) {
+ throw Exception\UnseekableStreamException::dueToPhpError();
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function rewind(): void {
+ $this->seek(0);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function write($string): int {
+ if (! $this->resource) {
+ throw Exception\UnwritableStreamException::dueToMissingResource();
+ }
+
+ if (! $this->isWritable) {
+ throw Exception\UnwritableStreamException::dueToConfiguration();
+ }
+
+ $result = fwrite($this->resource, $string);
+
+ if (false === $result) {
+ throw Exception\UnwritableStreamException::dueToPhpError();
+ }
+
+ return $result;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function read(int $length): string {
+ if (! $this->resource) {
+ throw Exception\UnreadableStreamException::dueToMissingResource();
+ }
+
+ if (! $this->isReadable) {
+ throw Exception\UnreadableStreamException::dueToConfiguration();
+ }
+
+ $result = fread($this->resource, $length);
+
+ if (false === $result) {
+ throw Exception\UnreadableStreamException::dueToPhpError();
+ }
+
+ return $result;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function getContents(): string {
+ if (! $this->isReadable) {
+ throw Exception\UnreadableStreamException::dueToConfiguration();
+ }
+
+ assert($this->resource !== null, 'Always true condition for psalm type safety');
+ $result = stream_get_contents($this->resource);
+ if (false === $result) {
+ throw Exception\UnreadableStreamException::dueToPhpError();
+ }
+ return $result;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function getMetadata(?string $key = null) {
+ $metadata = [];
+ if (null !== $this->resource) {
+ $metadata = stream_get_meta_data($this->resource);
+ }
+
+ if (null === $key) {
+ return $metadata;
+ }
+
+ if (! array_key_exists($key, $metadata)) {
+ return null;
+ }
+
+ return $metadata[$key];
+ }
+
+ /**
+ * Set the internal stream resource.
+ *
+ * @param string|object|resource $stream String stream target or stream resource.
+ * @param string $mode Resource mode for stream target.
+ * @throws InvalidArgumentException For invalid streams or resources.
+ */
+ private function setStream($stream, string $mode = 'r'): void {
+ $error = null;
+ $resource = $stream;
+
+ if (is_string($stream)) {
+ try {
+ $resource = fopen($stream, $mode);
+ } catch (Throwable $error) {
+ }
+
+ if (! is_resource($resource)) {
+ throw new RuntimeException(
+ sprintf(
+ 'Empty or non-existent stream identifier or file path provided: "%s"',
+ $stream,
+ ),
+ 0,
+ $error
+ );
+ }
+ }
+
+ if (! $this->isValidStreamResourceType($resource)) {
+ throw new InvalidArgumentException(
+ 'Invalid stream provided; must be a string stream identifier or stream resource'
+ );
+ }
+
+ if ($stream !== $resource) {
+ $this->stream = $stream;
+ }
+
+ $this->resource = $resource;
+ }
+
+ /**
+ * Determine if a resource is one of the resource types allowed to instantiate a Stream
+ *
+ * @param mixed $resource Stream resource.
+ * @psalm-assert-if-true resource $resource
+ */
+ private function isValidStreamResourceType(mixed $resource): bool {
+ if (is_resource($resource)) {
+ return in_array(get_resource_type($resource), self::ALLOWED_STREAM_RESOURCE_TYPES, true);
+ }
+
+ return false;
+ }
+}
diff --git a/src/StreamFactory.php b/src/StreamFactory.php
new file mode 100644
index 0000000..6dcadfc
--- /dev/null
+++ b/src/StreamFactory.php
@@ -0,0 +1,60 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use Override;
+use Rodas\Psr\Http\Message\StreamFactoryInterface;
+use Rodas\Psr\Http\Message\StreamInterface;
+
+use function assert;
+use function fopen;
+use function fwrite;
+use function is_resource;
+use function rewind;
+
+class StreamFactory implements StreamFactoryInterface {
+ /**
+ * {@inheritDoc}
+ */
+ #[Override]
+ public function createStream(string $content = ''): StreamInterface {
+ $resource = fopen('php://temp', 'r+');
+ assert(is_resource($resource), 'Something is really wrong if PHP failed to open stream in memory');
+ fwrite($resource, $content);
+ rewind($resource);
+
+ return $this->createStreamFromResource($resource);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ #[Override]
+ public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface {
+ return new Stream($filename, $mode);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ #[Override]
+ public function createStreamFromResource($resource): StreamInterface {
+ return new Stream($resource);
+ }
+}
diff --git a/src/UploadedFile.php b/src/UploadedFile.php
new file mode 100644
index 0000000..64001a8
--- /dev/null
+++ b/src/UploadedFile.php
@@ -0,0 +1,252 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use InvalidArgumentException;
+use Override;
+use Rodas\Psr\Http\Message\StreamInterface;
+use Rodas\Psr\Http\Message\UploadedFileInterface;
+
+use function assert;
+use function dirname;
+use function fclose;
+use function file_exists;
+use function fopen;
+use function fwrite;
+use function is_dir;
+use function is_resource;
+use function is_string;
+use function is_writable;
+use function move_uploaded_file;
+use function str_starts_with;
+use function unlink;
+
+use const PHP_SAPI;
+use const UPLOAD_ERR_CANT_WRITE;
+use const UPLOAD_ERR_EXTENSION;
+use const UPLOAD_ERR_FORM_SIZE;
+use const UPLOAD_ERR_INI_SIZE;
+use const UPLOAD_ERR_NO_FILE;
+use const UPLOAD_ERR_NO_TMP_DIR;
+use const UPLOAD_ERR_OK;
+use const UPLOAD_ERR_PARTIAL;
+
+class UploadedFile implements UploadedFileInterface {
+ // TODO: Use Resources
+ public const ERROR_MESSAGES = [
+ UPLOAD_ERR_OK => 'There is no error, the file uploaded with success',
+ UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive in php.ini',
+ UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was '
+ . 'specified in the HTML form',
+ UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded',
+ UPLOAD_ERR_NO_FILE => 'No file was uploaded',
+ UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder',
+ UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
+ UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload.',
+ ];
+
+ /**
+ * {@inheritdoc}
+ *
+ * @see http://php.net/manual/en/features.file-upload.errors.php
+ *
+ * @var int One of PHP's UPLOAD_ERR_XXX constants.
+ */
+ public private(set) int $error {
+ get => $this->error;
+ set => $this->error = $value;
+ }
+
+ private ?string $file = null;
+
+ private bool $moved = false;
+
+ public private(set) StreamInterface $stream {
+ get {
+ if ($this->error !== UPLOAD_ERR_OK) {
+ throw Exception\UploadedFileErrorException::dueToStreamUploadError(
+ self::ERROR_MESSAGES[$this->error]
+ );
+ }
+
+ if ($this->moved) {
+ throw new Exception\UploadedFileAlreadyMovedException();
+ }
+
+ if (isset($this->stream)) {
+ return $this->stream;
+ }
+
+ assert($this->file !== null, 'Always true condition for psalm type safety');
+ $this->stream = new Stream($this->file);
+ return $this->stream;
+ }
+ set => $this->stream = $value;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @var int|null The file size in bytes or null if unknown.
+ */
+ public private(set) ?int $size {
+ get => $this->size;
+ set => $this->size = $value;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @var string|null The filename sent by the client or null if none was provided.
+ */
+ public private(set) ?string $clientFilename = null {
+ get => $this->clientFilename;
+ set => $this->clientFilename = $value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public private(set) ?string $clientMediaType = null {
+ get => $this->clientMediaType;
+ set => $this->clientMediaType = $value;
+ }
+
+ /**
+ * @param string|resource|StreamInterface $streamOrFile
+ * @throws InvalidArgumentException
+ */
+ public function __construct(
+ $streamOrFile,
+ ?int $size,
+ int $errorStatus,
+ ?string $clientFilename = null,
+ ?string $clientMediaType = null
+ ) {
+ $this->size = $size;
+ $this->clientFilename = $clientFilename;
+ $this->clientMediaType = $clientMediaType;
+ $this->error = $errorStatus;
+ if ($errorStatus === UPLOAD_ERR_OK) {
+ if (is_string($streamOrFile)) {
+ $this->file = $streamOrFile;
+ }
+ if (is_resource($streamOrFile)) {
+ $this->stream = new Stream($streamOrFile);
+ }
+
+ if ($this->file === null &&
+ ! isset($this->stream)) {
+
+ if (! $streamOrFile instanceof StreamInterface) {
+ throw new InvalidArgumentException('Invalid stream or file provided for UploadedFile');
+ }
+ $this->stream = $streamOrFile;
+ }
+ }
+
+ if (0 > $errorStatus ||
+ 8 < $errorStatus) {
+ throw new InvalidArgumentException(
+ 'Invalid error status for UploadedFile; must be an UPLOAD_ERR_* constant'
+ );
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @see http://php.net/is_uploaded_file
+ * @see http://php.net/move_uploaded_file
+ *
+ * @param string $targetPath Path to which to move the uploaded file.
+ * @throws Exception\UploadedFileErrorException If the upload was not successful.
+ * @throws InvalidArgumentException If the $path specified is invalid.
+ * @throws Exception\UploadedFileErrorException On any error during the
+ * move operation, or on the second or subsequent call to the method.
+ */
+ #[Override]
+ public function moveTo(string $targetPath): void
+ {
+ if ($this->moved) {
+ throw new Exception\UploadedFileAlreadyMovedException('Cannot move file; already moved!');
+ }
+
+ if ($this->error !== UPLOAD_ERR_OK) {
+ throw Exception\UploadedFileErrorException::dueToStreamUploadError(
+ self::ERROR_MESSAGES[$this->error]
+ );
+ }
+
+ if (empty($targetPath)) {
+ throw new InvalidArgumentException(
+ 'Invalid path provided for move operation; must be a non-empty string'
+ );
+ }
+
+ $targetDirectory = dirname($targetPath);
+ if (! is_dir($targetDirectory) || ! is_writable($targetDirectory)) {
+ throw Exception\UploadedFileErrorException::dueToUnwritableTarget($targetDirectory);
+ }
+
+ $sapi = PHP_SAPI;
+ switch (true) {
+ case empty($sapi)
+ || str_starts_with($sapi, 'cli')
+ || str_starts_with($sapi, 'phpdbg')
+ || $this->file === null:
+ // Non-SAPI environment, or no filename present
+ $this->writeFile($targetPath);
+
+ if (isset($this->stream)) {
+ $this->stream->close();
+ }
+ if (is_string($this->file) && file_exists($this->file)) {
+ unlink($this->file);
+ }
+ break;
+ default:
+ // SAPI environment, with file present
+ if (false === move_uploaded_file($this->file, $targetPath)) {
+ throw Exception\UploadedFileErrorException::forUnmovableFile();
+ }
+ break;
+ }
+
+ $this->moved = true;
+ }
+
+ /**
+ * Write internal stream to given path
+ */
+ private function writeFile(string $path): void {
+ $handle = fopen($path, 'wb+');
+ if (false === $handle) {
+ throw Exception\UploadedFileErrorException::dueToUnwritablePath();
+ }
+
+ $stream = $this->stream;
+ $stream->rewind();
+ while (! $stream->eof()) {
+ fwrite($handle, $stream->read(4096));
+ }
+
+ fclose($handle);
+ }
+}
diff --git a/src/UploadedFileFactory.php b/src/UploadedFileFactory.php
new file mode 100644
index 0000000..3b4acb0
--- /dev/null
+++ b/src/UploadedFileFactory.php
@@ -0,0 +1,46 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use Override;
+use Rodas\Psr\Http\Message\StreamInterface;
+use Rodas\Psr\Http\Message\UploadedFileFactoryInterface;
+use Rodas\Psr\Http\Message\UploadedFileInterface;
+
+use const UPLOAD_ERR_OK;
+
+class UploadedFileFactory implements UploadedFileFactoryInterface {
+ /**
+ * {@inheritDoc}
+ */
+ #[Override]
+ public function createUploadedFile(
+ StreamInterface $stream,
+ ?int $size = null,
+ int $error = UPLOAD_ERR_OK,
+ ?string $clientFilename = null,
+ ?string $clientMediaType = null
+ ): UploadedFileInterface {
+ if ($size === null) {
+ $size = $stream->getSize();
+ }
+
+ return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType);
+ }
+}
diff --git a/src/Uri.php b/src/Uri.php
new file mode 100644
index 0000000..acd6f21
--- /dev/null
+++ b/src/Uri.php
@@ -0,0 +1,610 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/psr
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use InvalidArgumentException;
+use Override;
+use Rodas\Psr\Http\Message\UriInterface;
+use SensitiveParameter;
+use Stringable;
+
+use function array_keys;
+use function assert;
+use function explode;
+use function implode;
+use function is_string;
+use function ltrim;
+use function parse_url;
+use function preg_match;
+use function preg_replace;
+use function preg_replace_callback;
+use function rawurlencode;
+use function sprintf;
+use function str_contains;
+use function str_split;
+use function str_starts_with;
+use function strtolower;
+use function substr;
+
+/**
+ * Implementation of Rodas\Psr\Http\UriInterface.
+ *
+ * Provides a value object representing a URI for HTTP requests.
+ *
+ * Instances of this class are considered immutable; all methods that
+ * might change state are implemented such that they retain the internal
+ * state of the current instance and return a new instance that contains the
+ * changed state.
+ *
+ * @psalm-immutable
+ */
+class Uri implements UriInterface, Stringable {
+ /**
+ * Sub-delimiters used in user info, query strings and fragments.
+ *
+ * @const string
+ */
+ public const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
+
+ /**
+ * Unreserved characters used in user info, paths, query strings, and fragments.
+ *
+ * @const string
+ */
+ public const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~\pL';
+
+ /**
+ * Array indexed by valid scheme names to their corresponding ports.
+ *
+ * @var array
+ */
+ protected $allowedSchemes = [
+ 'http' => 80,
+ 'https' => 443,
+ ];
+
+ public private(set) string $scheme = '' {
+ get => $this->scheme;
+ set => $this->scheme = $value;
+ }
+
+ /**
+ * Get the user-info part of the URI.
+ *
+ * This value is percent-encoded, per RFC 3986 Section 3.2.1.
+ *
+ * {@inheritdoc}
+ */
+ public private(set) string $userInfo = '' {
+ get => $this->userInfo;
+ set => $this->userInfo = $value;
+ }
+
+ public private(set) string $host = '' {
+ get => $this->host;
+ set => $this->host = strtolower($value);
+ }
+
+ public private(set) ?int $port = null {
+ get {
+ return $this->isNonStandardPort($this->scheme, $this->host, $this->port)
+ ? $this->port
+ : null;
+ }
+ set => $this->port = $value;
+ }
+
+ public private(set) string $path = '' {
+ get {
+ if ('' === $this->path) {
+ // No path
+ return $this->path;
+ }
+
+ if ($this->path[0] !== '/') {
+ // Relative path
+ return $this->path;
+ }
+
+ // Ensure only one leading slash, to prevent XSS attempts.
+ return '/' . ltrim($this->path, '/');
+ }
+ set => $this->path = $value;
+ }
+
+ public private(set) string $query = '' {
+ get => $this->query;
+ set => $this->query = $value;
+ }
+
+ public private(set) string $fragment = '' {
+ get => $this->fragment;
+ set => $this->fragment = $value;
+ }
+
+ public string $authority {
+ get {
+ if ('' === $this->host) {
+ return '';
+ }
+
+ $authority = $this->host;
+ if ('' !== $this->userInfo) {
+ $authority = $this->userInfo . '@' . $authority;
+ }
+
+ if ($this->isNonStandardPort($this->scheme, $this->host, $this->port)) {
+ $authority .= ':' . $this->port;
+ }
+
+ return $authority;
+ }
+ }
+
+ /**
+ * generated uri string cache
+ */
+ private ?string $uriString = null;
+
+ public function __construct(string $uri = '') {
+ if ('' === $uri) {
+ return;
+ }
+
+ /** @psalm-suppress UnusedMethodCall Called method is not mutation free. Psalm has no impure annotation */
+ $this->parseUri($uri);
+ }
+
+ /**
+ * Operations to perform on clone.
+ *
+ * Since cloning usually is for purposes of mutation, we reset the
+ * $uriString property so it will be re-calculated.
+ */
+ public function __clone() {
+ $this->uriString = null;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function __toString(): string {
+ if (null !== $this->uriString) {
+ return $this->uriString;
+ }
+
+ /** @psalm-suppress ImpureMethodCall, InaccessibleProperty */
+ $this->uriString = static::createUriString(
+ $this->scheme,
+ $this->authority,
+ $this->path, // Absolute URIs should use a "/" for an empty path
+ $this->query,
+ $this->fragment
+ );
+
+ return $this->uriString;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withScheme(string $scheme): UriInterface {
+ $scheme = $this->filterScheme($scheme);
+
+ if ($scheme === $this->scheme) {
+ // Do nothing if no change was made.
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->scheme = $scheme;
+
+ return $new;
+ }
+
+ // The following rule is buggy for parameters attributes
+ // phpcs:disable SlevomatCodingStandard.TypeHints.ParameterTypeHintSpacing.NoSpaceBetweenTypeHintAndParameter
+
+ /**
+ * Create and return a new instance containing the provided user credentials.
+ *
+ * The value will be percent-encoded in the new instance, but with measures
+ * taken to prevent double-encoding.
+ *
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withUserInfo(
+ string $user,
+ #[SensitiveParameter]
+ ?string $password = null
+ ): UriInterface {
+ $info = $this->filterUserInfoPart($user);
+ if (null !== $password) {
+ $info .= ':' . $this->filterUserInfoPart($password);
+ }
+
+ if ($info === $this->userInfo) {
+ // Do nothing if no change was made.
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->userInfo = $info;
+
+ return $new;
+ }
+
+ // phpcs:enable SlevomatCodingStandard.TypeHints.ParameterTypeHintSpacing.NoSpaceBetweenTypeHintAndParameter
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withHost(string $host): UriInterface {
+ if (strtolower($host) === $this->host) {
+ // Do nothing if no change was made.
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->host = $host;
+
+ return $new;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withPort(?int $port): UriInterface {
+ if ($port === $this->port) {
+ // Do nothing if no change was made.
+ return $this;
+ }
+
+ if ($port !== null && ($port < 1 || $port > 65535)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Invalid port "%d" specified; must be a valid TCP/UDP port',
+ $port
+ ));
+ }
+
+ $new = clone $this;
+ $new->port = $port;
+
+ return $new;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withPath(string $path): UriInterface {
+ if (str_contains($path, '?')) {
+ throw new InvalidArgumentException(
+ 'Invalid path provided; must not contain a query string'
+ );
+ }
+
+ if (str_contains($path, '#')) {
+ throw new InvalidArgumentException(
+ 'Invalid path provided; must not contain a URI fragment'
+ );
+ }
+
+ $path = $this->filterPath($path);
+
+ if ($path === $this->path) {
+ // Do nothing if no change was made.
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->path = $path;
+
+ return $new;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withQuery(string $query): UriInterface {
+ if (str_contains($query, '#')) {
+ throw new InvalidArgumentException(
+ 'Query string must not include a URI fragment'
+ );
+ }
+
+ $query = $this->filterQuery($query);
+
+ if ($query === $this->query) {
+ // Do nothing if no change was made.
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->query = $query;
+
+ return $new;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[Override]
+ public function withFragment(string $fragment): UriInterface {
+ $fragment = $this->filterFragment($fragment);
+
+ if ($fragment === $this->fragment) {
+ // Do nothing if no change was made.
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->fragment = $fragment;
+
+ return $new;
+ }
+
+ /**
+ * Parse a URI into its parts, and set the properties
+ *
+ * @psalm-suppress InaccessibleProperty Method is only called in {@see Uri::__construct} and thus immutability is
+ * still given.
+ */
+ private function parseUri(string $uri): void {
+ $parts = parse_url($uri);
+
+ if (false === $parts) {
+ throw new InvalidArgumentException(
+ 'The source URI string appears to be malformed'
+ );
+ }
+
+ $this->scheme = isset($parts['scheme']) ? $this->filterScheme($parts['scheme']) : '';
+ $this->userInfo = isset($parts['user']) ? $this->filterUserInfoPart($parts['user']) : '';
+ $this->host = isset($parts['host']) ? $parts['host'] : '';
+ $this->port = $parts['port'] ?? null;
+ $this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : '';
+ $this->query = isset($parts['query']) ? $this->filterQuery($parts['query']) : '';
+ $this->fragment = isset($parts['fragment']) ? $this->filterFragment($parts['fragment']) : '';
+
+ if (isset($parts['pass'])) {
+ $this->userInfo .= ':' . $parts['pass'];
+ }
+ }
+
+ /**
+ * Create a URI string from its various parts
+ */
+ private static function createUriString(
+ string $scheme,
+ string $authority,
+ string $path,
+ string $query,
+ string $fragment
+ ): string {
+ $uri = '';
+
+ if ('' !== $scheme) {
+ $uri .= sprintf('%s:', $scheme);
+ }
+
+ if ('' !== $authority) {
+ $uri .= '//' . $authority;
+ }
+
+ if ('' !== $path && ! str_starts_with($path, '/')) {
+ $path = '/' . $path;
+ }
+
+ $uri .= $path;
+
+ if ('' !== $query) {
+ $uri .= sprintf('?%s', $query);
+ }
+
+ if ('' !== $fragment) {
+ $uri .= sprintf('#%s', $fragment);
+ }
+
+ return $uri;
+ }
+
+ /**
+ * Is a given port non-standard for the current scheme?
+ *
+ * @psalm-assert-if-true int $port
+ */
+ private function isNonStandardPort(string $scheme, string $host, ?int $port): bool {
+ if ('' === $scheme) {
+ return '' === $host ||
+ null !== $port;
+ }
+
+ if ('' === $host ||
+ null === $port) {
+
+ return false;
+ }
+
+ return ! isset($this->allowedSchemes[$scheme]) ||
+ $port !== $this->allowedSchemes[$scheme];
+ }
+
+ /**
+ * Filters the scheme to ensure it is a valid scheme.
+ *
+ * @param string $scheme Scheme name.
+ * @return string Filtered scheme.
+ */
+ private function filterScheme(string $scheme): string {
+ $scheme = strtolower($scheme);
+ $scheme = preg_replace('#:(//)?$#', '', $scheme);
+ assert(is_string($scheme));
+
+ if ('' === $scheme) {
+ return '';
+ }
+
+ if (! isset($this->allowedSchemes[$scheme])) {
+ throw new InvalidArgumentException(sprintf(
+ 'Unsupported scheme "%s"; must be any empty string or in the set (%s)',
+ $scheme,
+ implode(', ', array_keys($this->allowedSchemes))
+ ));
+ }
+
+ return $scheme;
+ }
+
+ /**
+ * Filters a part of user info in a URI to ensure it is properly encoded.
+ */
+ private function filterUserInfoPart(string $part): string {
+ $part = $this->filterInvalidUtf8($part);
+
+ /**
+ * Note the addition of `%` to initial charset; this allows `|` portion
+ * to match and thus prevent double-encoding.
+ */
+ $result = preg_replace_callback(
+ '/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/u',
+ [$this, 'urlEncodeChar'],
+ $part
+ );
+ assert($result !== null, 'Always true condition for psalm type safety');
+ return $result;
+ }
+
+ /**
+ * Filters the path of a URI to ensure it is properly encoded.
+ */
+ private function filterPath(string $path): string {
+ $path = $this->filterInvalidUtf8($path);
+
+ $result = preg_replace_callback(
+ '/(?:[^' . self::CHAR_UNRESERVED . ')(:@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/u',
+ [$this, 'urlEncodeChar'],
+ $path
+ );
+ assert($result !== null, 'Always true condition for psalm type safety');
+ return $result;
+ }
+
+ /**
+ * Encode invalid UTF-8 characters in given string. All other characters are unchanged.
+ */
+ private function filterInvalidUtf8(string $string): string {
+ // check if given string contains only valid UTF-8 characters
+ if (preg_match('//u', $string)) {
+ return $string;
+ }
+
+ $letters = str_split($string);
+ foreach ($letters as $i => $letter) {
+ if (! preg_match('//u', $letter)) {
+ $letters[$i] = $this->urlEncodeChar([$letter]);
+ }
+ }
+
+ return implode('', $letters);
+ }
+
+ /**
+ * Filter a query string to ensure it is propertly encoded.
+ *
+ * Ensures that the values in the query string are properly urlencoded.
+ */
+ private function filterQuery(string $query): string {
+ if ('' !== $query && str_starts_with($query, '?')) {
+ $query = substr($query, 1);
+ }
+
+ $parts = explode('&', $query);
+ foreach ($parts as $index => $part) {
+ [$key, $value] = $this->splitQueryValue($part);
+ if ($value === null) {
+ $parts[$index] = $this->filterQueryOrFragment($key);
+ continue;
+ }
+ $parts[$index] = sprintf(
+ '%s=%s',
+ $this->filterQueryOrFragment($key),
+ $this->filterQueryOrFragment($value)
+ );
+ }
+
+ return implode('&', $parts);
+ }
+
+ /**
+ * Split a query value into a key/value tuple.
+ *
+ * @return array{0:string, 1:string|null} A value with exactly two elements, key and value
+ */
+ private function splitQueryValue(string $value): array {
+ $data = explode('=', $value, 2);
+ if (! isset($data[1])) {
+ $data[1] = null;
+ }
+ return $data;
+ }
+
+ /**
+ * Filter a fragment value to ensure it is properly encoded.
+ */
+ private function filterFragment(string $fragment): string {
+ if ('' !== $fragment && str_starts_with($fragment, '#')) {
+ $fragment = '%23' . substr($fragment, 1);
+ }
+
+ return $this->filterQueryOrFragment($fragment);
+ }
+
+ /**
+ * Filter a query string key or value, or a fragment.
+ */
+ private function filterQueryOrFragment(string $value): string {
+ $value = $this->filterInvalidUtf8($value);
+
+ $result = preg_replace_callback(
+ '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/u',
+ [$this, 'urlEncodeChar'],
+ $value
+ );
+ assert($result !== null, 'Always true condition for psalm type safety');
+ return $result;
+ }
+
+ /**
+ * URL encode a character returned by a regex.
+ *
+ * @param array $matches
+ * @psalm-pure
+ */
+ private function urlEncodeChar(array $matches): string {
+ return rawurlencode($matches[0]);
+ }
+}
diff --git a/src/UriFactory.php b/src/UriFactory.php
new file mode 100644
index 0000000..5bf0315
--- /dev/null
+++ b/src/UriFactory.php
@@ -0,0 +1,262 @@
+
+ * @license https://opensource.org/license/mit The MIT License
+ * @link https://marcospor.to/repositories/diactoros
+ */
+
+declare(strict_types=1);
+
+namespace Rodas\Diactoros;
+
+use InvalidArgumentException;
+use Override;
+use Rodas\Psr\Http\Message\UriFactoryInterface;
+use Rodas\Psr\Http\Message\UriInterface;
+
+use function array_change_key_case;
+use function array_key_exists;
+use function assert;
+use function count;
+use function explode;
+use function gettype;
+use function implode;
+use function is_bool;
+use function is_scalar;
+use function is_string;
+use function ltrim;
+use function preg_match;
+use function preg_replace;
+use function sprintf;
+use function str_contains;
+use function strlen;
+use function strrpos;
+use function strtolower;
+use function substr;
+
+use const CASE_LOWER;
+
+class UriFactory implements UriFactoryInterface {
+ /**
+ * {@inheritDoc}
+ */
+ #[Override]
+ public function createUri(string $uri = ''): UriInterface {
+ return new Uri($uri);
+ }
+
+ /**
+ * Create a Uri instance based on the headers and $_SERVER data.
+ *
+ * @param array|int|float|string> $server SAPI parameters
+ * @param array> $headers
+ */
+ public static function createFromSapi(array $server, array $headers): Uri {
+ $uri = new Uri('');
+
+ $isHttps = false;
+ if (array_key_exists('HTTPS', $server)) {
+ $isHttps = self::marshalHttpsValue($server['HTTPS']);
+ } elseif (array_key_exists('https', $server)) {
+ $isHttps = self::marshalHttpsValue($server['https']);
+ }
+ $uri = $uri->withScheme($isHttps ? 'https' : 'http');
+
+ [$host, $port] = self::marshalHostAndPort($server, $headers);
+ if (! empty($host)) {
+ $uri = $uri->withHost($host);
+ if ($port !== null) {
+ $uri = $uri->withPort($port);
+ }
+ }
+
+ $path = self::marshalRequestPath($server);
+
+ // Strip query string
+ $path = explode('?', $path, 2)[0];
+
+ $query = '';
+ if (isset($server['QUERY_STRING']) && is_scalar($server['QUERY_STRING'])) {
+ $query = ltrim((string) $server['QUERY_STRING'], '?');
+ }
+
+ $fragment = '';
+ if (str_contains($path, '#')) {
+ $parts = explode('#', $path, 2);
+ assert(count($parts) >= 2);
+ [$path, $fragment] = $parts;
+ }
+
+ return $uri
+ ->withPath($path)
+ ->withFragment($fragment)
+ ->withQuery($query);
+ }
+
+ /**
+ * Retrieve a header value from an array of headers using a case-insensitive lookup.
+ *
+ * @template T
+ * @param array> $headers Key/value header pairs
+ * @param T $default Default value to return if header not found
+ * @return string|T
+ */
+ private static function getHeaderFromArray(string $name, array $headers, $default = null) {
+ $header = strtolower($name);
+ $headers = array_change_key_case($headers, CASE_LOWER);
+ if (! array_key_exists($header, $headers)) {
+ return $default;
+ }
+
+ if (is_string($headers[$header])) {
+ return $headers[$header];
+ }
+
+ return implode(', ', $headers[$header]);
+ }
+
+ /**
+ * Marshal the host and port from the PHP environment.
+ *
+ * @param array> $headers
+ * @return array{0:string, 1:int|null} Array of two items, host and port,
+ * in that order (can be passed to a list() operation).
+ */
+ private static function marshalHostAndPort(array $server, array $headers): array {
+ /** @var array{string, null} $defaults */
+ static $defaults = ['', null];
+
+ $host = self::getHeaderFromArray('host', $headers, false);
+ if ($host !== false) {
+ // Ignore obviously malformed host headers:
+ // - Whitespace is invalid within a hostname and break the URI representation within HTTP.
+ // non-printable characters other than SPACE and TAB are already rejected by HeaderSecurity.
+ // - A comma indicates that multiple host headers have been sent which is not legal
+ // and might be used in an attack where a load balancer sees a different host header
+ // than Diactoros.
+ if (! preg_match('/[\\t ,]/', $host)) {
+ return self::marshalHostAndPortFromHeader($host);
+ }
+ }
+
+ if (! isset($server['SERVER_NAME'])) {
+ return $defaults;
+ }
+
+ $host = (string) $server['SERVER_NAME'];
+ $port = isset($server['SERVER_PORT']) ? (int) $server['SERVER_PORT'] : null;
+
+ if (
+ ! isset($server['SERVER_ADDR'])
+ || ! preg_match('/^\[[0-9a-fA-F\:]+\]$/', $host)
+ ) {
+ return [$host, $port];
+ }
+
+ // Misinterpreted IPv6-Address
+ // Reported for Safari on Windows
+ return self::marshalIpv6HostAndPort($server, $port);
+ }
+
+ /**
+ * @return array{string, int|null} Array of two items, host and port,
+ * in that order (can be passed to a list() operation).
+ */
+ private static function marshalIpv6HostAndPort(array $server, ?int $port): array {
+ $host = '[' . (string) $server['SERVER_ADDR'] . ']';
+ $port ??= 80;
+ $portSeparatorPos = strrpos($host, ':');
+
+ if (false === $portSeparatorPos) {
+ return [$host, $port];
+ }
+
+ if ($port . ']' === substr($host, $portSeparatorPos + 1)) {
+ // The last digit of the IPv6-Address has been taken as port
+ // Unset the port so the default port can be used
+ $port = null;
+ }
+ return [$host, $port];
+ }
+
+ /**
+ * Detect the path for the request
+ *
+ * Looks at a variety of criteria in order to attempt to autodetect the base
+ * request path, including:
+ *
+ * - IIS7 UrlRewrite environment
+ * - REQUEST_URI
+ * - ORIG_PATH_INFO
+ */
+ private static function marshalRequestPath(array $server): string {
+ // IIS7 with URL Rewrite: make sure we get the unencoded url
+ // (double slash problem).
+ /** @var string|array|null $iisUrlRewritten */
+ $iisUrlRewritten = $server['IIS_WasUrlRewritten'] ?? null;
+ /** @var string|array $unencodedUrl */
+ $unencodedUrl = $server['UNENCODED_URL'] ?? '';
+ if ('1' === $iisUrlRewritten && is_string($unencodedUrl) && '' !== $unencodedUrl) {
+ return $unencodedUrl;
+ }
+
+ /** @var string|array|null $requestUri */
+ $requestUri = $server['REQUEST_URI'] ?? null;
+
+ if (is_string($requestUri)) {
+ $result = preg_replace('#^[^/:]+://[^/]+#', '', $requestUri);
+ assert($result !== null, 'Always true condition for psalm type safety');
+ return $result;
+ }
+
+ $origPathInfo = $server['ORIG_PATH_INFO'] ?? '';
+ if (! is_string($origPathInfo) || '' === $origPathInfo) {
+ return '/';
+ }
+
+ return $origPathInfo;
+ }
+
+ private static function marshalHttpsValue(mixed $https): bool {
+ if (is_bool($https)) {
+ return $https;
+ }
+
+ if (! is_string($https)) {
+ throw new InvalidArgumentException(sprintf(
+ 'SAPI HTTPS value MUST be a string or boolean; received %s',
+ gettype($https)
+ ));
+ }
+
+ return 'on' === strtolower($https);
+ }
+
+ /**
+ * @internal
+ *
+ * @return array{string, int|null} Array of two items, host and port, in that order (can be
+ * passed to a list() operation).
+ * @psalm-mutation-free
+ */
+ public static function marshalHostAndPortFromHeader(string $host): array {
+ $port = null;
+
+ // works for regname, IPv4 & IPv6
+ if (preg_match('|\:(\d+)$|', $host, $matches)) {
+ $host = substr($host, 0, -1 * (strlen($matches[1]) + 1));
+ $port = (int) $matches[1];
+ }
+
+ return [$host, $port];
+ }
+}
diff --git a/src/version.txt b/src/version.txt
new file mode 100644
index 0000000..eb4b0da
--- /dev/null
+++ b/src/version.txt
@@ -0,0 +1,2 @@
+Commit: ec91d02
+Version: 0.1.0
diff --git a/tests/CallbackStreamTest.php b/tests/CallbackStreamTest.php
new file mode 100644
index 0000000..3ee2c00
--- /dev/null
+++ b/tests/CallbackStreamTest.php
@@ -0,0 +1,171 @@
+ 'foobarbaz');
+
+ $ret = $stream->__toString();
+ $this->assertSame('foobarbaz', $ret);
+ }
+
+ public function testClose(): void {
+ $stream = new CallbackStream(static function (): void { });
+
+ $stream->close();
+
+ $callback = $stream->detach();
+
+ $this->assertNull($callback);
+ }
+
+ public function testDetach(): void {
+ $callback = static function (): void { };
+ $stream = new CallbackStream($callback);
+ $ret = $stream->detach();
+ $this->assertSame($callback, $ret);
+ }
+
+ public function testEof(): void {
+ $stream = new CallbackStream(static function (): void { });
+ $ret = $stream->eof();
+ $this->assertFalse($ret);
+
+ $stream->getContents();
+ $ret = $stream->eof();
+ $this->assertTrue($ret);
+ }
+
+ public function testGetSize(): void {
+ $stream = new CallbackStream(static function (): void { });
+ $ret = $stream->size;
+ $this->assertNull($ret);
+ }
+
+ public function testTell(): void {
+ $stream = new CallbackStream(static function (): void { });
+
+ $this->expectException(RuntimeException::class);
+
+ $stream->tell();
+ }
+
+ public function testIsSeekable(): void {
+ $stream = new CallbackStream(static function (): void { });
+ $ret = $stream->isSeekable;
+ $this->assertFalse($ret);
+ }
+
+ public function testIsWritable(): void {
+ $stream = new CallbackStream(static function (): void { });
+ $ret = $stream->isWritable;
+ $this->assertFalse($ret);
+ }
+
+ public function testIsReadable(): void {
+ $stream = new CallbackStream(static function (): void { });
+ $ret = $stream->isReadable;
+ $this->assertFalse($ret);
+ }
+
+ public function testSeek(): void {
+ $stream = new CallbackStream(static function (): void { });
+
+ $this->expectException(RuntimeException::class);
+
+ $stream->seek(0);
+ }
+
+ public function testRewind(): void {
+ $stream = new CallbackStream(static function (): void { });
+
+ $this->expectException(RuntimeException::class);
+
+ $stream->rewind();
+ }
+
+ public function testWrite(): void {
+ $stream = new CallbackStream(static function (): void { });
+
+ $this->expectException(RuntimeException::class);
+
+ $stream->write('foobarbaz');
+ }
+
+ public function testRead(): void {
+ $stream = new CallbackStream(static function (): void { });
+
+ $this->expectException(RuntimeException::class);
+
+ $stream->read(3);
+ }
+
+ public function testGetContents(): void {
+ $stream = new CallbackStream(static fn(): string => 'foobarbaz');
+
+ $ret = $stream->getContents();
+ $this->assertSame('foobarbaz', $ret);
+ }
+
+ public function testGetMetadata(): void {
+ $stream = new CallbackStream(static function (): void { });
+
+ $ret = $stream->getMetadata('stream_type');
+ $this->assertSame('callback', $ret);
+
+ $ret = $stream->getMetadata('seekable');
+ $this->assertFalse($ret);
+
+ $ret = $stream->getMetadata('eof');
+ $this->assertFalse($ret);
+
+ $all = $stream->getMetadata();
+ $this->assertSame([
+ 'eof' => false,
+ 'stream_type' => 'callback',
+ 'seekable' => false,
+ ], $all);
+
+ $notExists = $stream->getMetadata('boo');
+ $this->assertNull($notExists);
+ }
+
+ /**
+ * @link \Rodas\Test\Diactoros\TestAsset\CallbacksForCallbackStream::sampleStaticCallback()
+ * @link \Rodas\Test\Diactoros\TestAsset\CallbacksForCallbackStream::sampleCallback()
+ *
+ * @return non-empty-array
+ */
+ public static function phpCallbacksForStreams(): array {
+ $class = TestAsset\CallbacksForCallbackStream::class;
+
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ 'instance-method' => [[new TestAsset\CallbacksForCallbackStream(), 'sampleCallback'], $class . '::sampleCallback'],
+ 'static-method' => [[$class, 'sampleStaticCallback'], $class . '::sampleStaticCallback'],
+ ];
+ // phpcs:enable Generic.Files.LineLength
+ }
+
+ /**
+ * @param callable(): string $callback
+ * @param non-empty-string $expected
+ *
+ #[DataProvider('phpCallbacksForStreams')]
+ public function testAllowsArbitraryPhpCallbacks(callable $callback, string $expected): void {
+ $stream = new CallbackStream($callback);
+ $contents = $stream->contents;
+ $this->assertSame($expected, $contents);
+ }
+ */
+}
diff --git a/tests/HeaderSecurityTest.php b/tests/HeaderSecurityTest.php
new file mode 100644
index 0000000..7fb00e4
--- /dev/null
+++ b/tests/HeaderSecurityTest.php
@@ -0,0 +1,135 @@
+
+ */
+ public static function getFilterValues(): array
+ {
+ return [
+ ["This is a\n test", "This is a test"],
+ ["This is a\r test", "This is a test"],
+ ["This is a\n\r test", "This is a test"],
+ ["This is a\r\n test", "This is a\r\n test"],
+ ["This is a \r\ntest", "This is a test"],
+ ["This is a \r\n\n test", "This is a test"],
+ ["This is a\n\n test", "This is a test"],
+ ["This is a\r\r test", "This is a test"],
+ ["This is a \r\r\n test", "This is a \r\n test"],
+ ["This is a \r\n\r\ntest", "This is a test"],
+ ["This is a \r\n\n\r\n test", "This is a \r\n test"],
+ ["This is a test\n", "This is a test"],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $value
+ * @param non-empty-string $expected
+ */
+ #[DataProvider('getFilterValues')]
+ #[Group('ZF2015-04')]
+ public function testFiltersValuesPerRfc7230(string $value, string $expected): void
+ {
+ $this->assertSame($expected, HeaderSecurity::filter($value));
+ }
+
+ /** @return non-empty-list */
+ public static function validateValues(): array
+ {
+ return [
+ ["This is a\n test", false],
+ ["This is a\r test", false],
+ ["This is a\n\r test", false],
+ ["This is a\r\n test", true],
+ ["This is a \r\ntest", false],
+ ["This is a \r\n\n test", false],
+ ["This is a\n\n test", false],
+ ["This is a\r\r test", false],
+ ["This is a \r\r\n test", false],
+ ["This is a \r\n\r\ntest", false],
+ ["This is a \r\n\n\r\n test", false],
+ ["This is a \xFF test", false],
+ ["This is a \x7F test", false],
+ ["This is a \x7E test", true],
+ ["This is a test\n", false],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $value
+ */
+ #[DataProvider('validateValues')]
+ #[Group('ZF2015-04')]
+ public function testValidatesValuesPerRfc7230(string $value, bool $expected): void
+ {
+ self::assertSame($expected, HeaderSecurity::isValid($value));
+ }
+
+ /** @return non-empty-list */
+ public static function assertValues(): array
+ {
+ return [
+ ["This is a\n test"],
+ ["This is a\r test"],
+ ["This is a\n\r test"],
+ ["This is a \r\ntest"],
+ ["This is a \r\n\n test"],
+ ["This is a\n\n test"],
+ ["This is a\r\r test"],
+ ["This is a \r\r\n test"],
+ ["This is a \r\n\r\ntest"],
+ ["This is a \r\n\n\r\n test"],
+ ["This is a test\n"],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $value
+ */
+ #[DataProvider('assertValues')]
+ #[Group('ZF2015-04')]
+ public function testAssertValidRaisesExceptionForInvalidValue(string $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ HeaderSecurity::assertValid($value);
+ }
+
+ /** @return non-empty-list */
+ public static function assertNames(): array
+ {
+ return [
+ ["test\n"],
+ ["\ntest"],
+ ["foo\r\n bar"],
+ ["f\x00o"],
+ ["foo bar"],
+ [":foo"],
+ ["foo:"],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $value
+ */
+ #[DataProvider('assertNames')]
+ public function testAssertValidNameRaisesExceptionForInvalidName(string $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ HeaderSecurity::assertValidName($value);
+ }
+}
diff --git a/tests/MessageTraitTest.php b/tests/MessageTraitTest.php
new file mode 100644
index 0000000..a969d9a
--- /dev/null
+++ b/tests/MessageTraitTest.php
@@ -0,0 +1,426 @@
+message = new Request(null, null, $this->createMock(StreamInterface::class));
+ }
+
+ public function testProtocolHasAcceptableDefault(): void
+ {
+ $this->assertSame('1.1', $this->message->getProtocolVersion());
+ }
+
+ public function testProtocolMutatorReturnsCloneWithChanges(): void
+ {
+ $message = $this->message->withProtocolVersion('1.0');
+ $this->assertNotSame($this->message, $message);
+ $this->assertSame('1.0', $message->getProtocolVersion());
+ }
+
+ /** @return non-empty-array */
+ public static function invalidProtocolVersionProvider(): array
+ {
+ return [
+ '1-without-minor' => ['1'],
+ '1-with-invalid-minor' => ['1.2'],
+ '1-with-hotfix' => ['1.1.1'],
+ ];
+ }
+
+ #[DataProvider('invalidProtocolVersionProvider')]
+ public function testWithProtocolVersionRaisesExceptionForInvalidVersion(string $version): void
+ {
+ $request = new Request();
+ $this->expectException(InvalidArgumentException::class);
+ $request->withProtocolVersion($version);
+ }
+
+ /** @return non-empty-array */
+ public static function validProtocolVersionProvider(): array
+ {
+ return [
+ '1.0' => ['1.0'],
+ '1.1' => ['1.1'],
+ '2' => ['2'],
+ '2.0' => ['2.0'],
+ ];
+ }
+
+ #[DataProvider('validProtocolVersionProvider')]
+ public function testWithProtocolVersionDoesntRaiseExceptionForValidVersion(string $version): void
+ {
+ $request = (new Request())->withProtocolVersion($version);
+ $this->assertEquals($version, $request->getProtocolVersion());
+ }
+
+ public function testUsesStreamProvidedInConstructorAsBody(): void
+ {
+ $stream = $this->createMock(StreamInterface::class);
+ $message = new Request(null, null, $stream);
+ $this->assertSame($stream, $message->body);
+ }
+
+ public function testBodyMutatorReturnsCloneWithChanges(): void
+ {
+ $stream = $this->createMock(StreamInterface::class);
+ $message = $this->message->withBody($stream);
+ $this->assertNotSame($this->message, $message);
+ $this->assertSame($stream, $message->body);
+ }
+
+ public function testGetHeaderReturnsHeaderValueAsArray(): void
+ {
+ $message = $this->message->withHeader('X-Foo', ['Foo', 'Bar']);
+ $this->assertNotSame($this->message, $message);
+ $this->assertSame(['Foo', 'Bar'], $message->getHeader('X-Foo'));
+ }
+
+ public function testGetHeaderLineReturnsHeaderValueAsCommaConcatenatedString(): void
+ {
+ $message = $this->message->withHeader('X-Foo', ['Foo', 'Bar']);
+ $this->assertNotSame($this->message, $message);
+ $this->assertSame('Foo,Bar', $message->getHeaderLine('X-Foo'));
+ }
+
+ public function testGetHeadersKeepsHeaderCaseSensitivity(): void
+ {
+ $message = $this->message->withHeader('X-Foo', ['Foo', 'Bar']);
+ $this->assertNotSame($this->message, $message);
+ $this->assertSame(['X-Foo' => ['Foo', 'Bar']], $message->getHeaders());
+ }
+
+ public function testGetHeadersReturnsCaseWithWhichHeaderFirstRegistered(): void
+ {
+ $message = $this->message
+ ->withHeader('X-Foo', 'Foo')
+ ->withAddedHeader('x-foo', 'Bar');
+ $this->assertNotSame($this->message, $message);
+ $this->assertSame(['X-Foo' => ['Foo', 'Bar']], $message->getHeaders());
+ }
+
+ public function testHasHeaderReturnsFalseIfHeaderIsNotPresent(): void
+ {
+ $this->assertFalse($this->message->hasHeader('X-Foo'));
+ }
+
+ public function testHasHeaderReturnsTrueIfHeaderIsPresent(): void
+ {
+ $message = $this->message->withHeader('X-Foo', 'Foo');
+ $this->assertNotSame($this->message, $message);
+ $this->assertTrue($message->hasHeader('X-Foo'));
+ }
+
+ public function testAddHeaderAppendsToExistingHeader(): void
+ {
+ $message = $this->message->withHeader('X-Foo', 'Foo');
+ $this->assertNotSame($this->message, $message);
+ $message2 = $message->withAddedHeader('X-Foo', 'Bar');
+ $this->assertNotSame($message, $message2);
+ $this->assertSame('Foo,Bar', $message2->getHeaderLine('X-Foo'));
+ }
+
+ public function testCanRemoveHeaders(): void
+ {
+ $message = $this->message->withHeader('X-Foo', 'Foo');
+ $this->assertNotSame($this->message, $message);
+ $this->assertTrue($message->hasHeader('x-foo'));
+ $message2 = $message->withoutHeader('x-foo');
+ $this->assertNotSame($this->message, $message2);
+ $this->assertNotSame($message, $message2);
+ $this->assertFalse($message2->hasHeader('X-Foo'));
+ }
+
+ public function testHeaderRemovalIsCaseInsensitive(): void
+ {
+ $message = $this->message
+ ->withHeader('X-Foo', 'Foo')
+ ->withAddedHeader('x-foo', 'Bar')
+ ->withAddedHeader('X-FOO', 'Baz');
+ $this->assertNotSame($this->message, $message);
+ $this->assertTrue($message->hasHeader('x-foo'));
+
+ $message2 = $message->withoutHeader('x-foo');
+ $this->assertNotSame($this->message, $message2);
+ $this->assertNotSame($message, $message2);
+ $this->assertFalse($message2->hasHeader('X-Foo'));
+
+ $headers = $message2->getHeaders();
+ $this->assertSame(0, count($headers));
+ }
+
+ /** @return non-empty-array */
+ public static function invalidGeneralHeaderValues(): array
+ {
+ return [
+ 'null' => [null],
+ 'true' => [true],
+ 'false' => [false],
+ 'array' => [['foo' => ['bar']]],
+ 'object' => [(object) ['foo' => 'bar']],
+ ];
+ }
+
+ #[DataProvider('invalidGeneralHeaderValues')]
+ public function testWithHeaderRaisesExceptionForInvalidNestedHeaderValue(mixed $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Invalid header value');
+
+ /** @psalm-suppress MixedArgumentTypeCoercion */
+ $this->message->withHeader('X-Foo', [$value]);
+ }
+
+ /** @return non-empty-array */
+ public static function invalidHeaderValues(): array
+ {
+ return [
+ 'null' => [null],
+ 'true' => [true],
+ 'false' => [false],
+ 'object' => [(object) ['foo' => 'bar']],
+ ];
+ }
+
+ #[DataProvider('invalidHeaderValues')]
+ public function testWithHeaderRaisesExceptionForInvalidValueType(mixed $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Invalid header value');
+
+ /** @psalm-suppress MixedArgument */
+ $this->message->withHeader('X-Foo', $value);
+ }
+
+ public function testWithHeaderReplacesDifferentCapitalization(): void
+ {
+ $this->message = $this->message->withHeader('X-Foo', ['foo']);
+ $new = $this->message->withHeader('X-foo', ['bar']);
+ $this->assertSame(['bar'], $new->getHeader('x-foo'));
+ $this->assertSame(['X-foo' => ['bar']], $new->getHeaders());
+ }
+
+ #[DataProvider('invalidGeneralHeaderValues')]
+ public function testWithAddedHeaderRaisesExceptionForNonStringNonArrayValue(mixed $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('must be a string');
+
+ $this->message->withAddedHeader('X-Foo', $value);
+ }
+
+ public function testWithoutHeaderDoesNothingIfHeaderDoesNotExist(): void
+ {
+ $this->assertFalse($this->message->hasHeader('X-Foo'));
+ $message = $this->message->withoutHeader('X-Foo');
+ $this->assertNotSame($this->message, $message);
+ $this->assertFalse($message->hasHeader('X-Foo'));
+ }
+
+ public function testHeadersInitialization(): void
+ {
+ $headers = ['X-Foo' => ['bar']];
+ $message = new Request(null, null, 'php://temp', $headers);
+ $this->assertSame($headers, $message->getHeaders());
+ }
+
+ public function testGetHeaderReturnsAnEmptyArrayWhenHeaderDoesNotExist(): void
+ {
+ $this->assertSame([], $this->message->getHeader('X-Foo-Bar'));
+ }
+
+ public function testGetHeaderLineReturnsEmptyStringWhenHeaderDoesNotExist(): void
+ {
+ $this->assertEmpty($this->message->getHeaderLine('X-Foo-Bar'));
+ }
+
+ /** @return non-empty-array */
+ public static function headersWithInjectionVectors(): array
+ {
+ return [
+ 'name-with-cr' => ["X-Foo\r-Bar", 'value'],
+ 'name-with-lf' => ["X-Foo\n-Bar", 'value'],
+ 'name-with-crlf' => ["X-Foo\r\n-Bar", 'value'],
+ 'name-with-2crlf' => ["X-Foo\r\n\r\n-Bar", 'value'],
+ 'name-with-trailing-lf' => ["X-Foo-Bar\n", 'value'],
+ 'name-with-leading-lf' => ["\nX-Foo-Bar", 'value'],
+ 'value-with-cr' => ['X-Foo-Bar', "value\rinjection"],
+ 'value-with-lf' => ['X-Foo-Bar', "value\ninjection"],
+ 'value-with-crlf' => ['X-Foo-Bar', "value\r\ninjection"],
+ 'value-with-2crlf' => ['X-Foo-Bar', "value\r\n\r\ninjection"],
+ 'array-value-with-cr' => ['X-Foo-Bar', ["value\rinjection"]],
+ 'array-value-with-lf' => ['X-Foo-Bar', ["value\ninjection"]],
+ 'array-value-with-crlf' => ['X-Foo-Bar', ["value\r\ninjection"]],
+ 'array-value-with-2crlf' => ['X-Foo-Bar', ["value\r\n\r\ninjection"]],
+ 'value-with-trailing-lf' => ['X-Foo-Bar', "value\n"],
+ 'value-with-leading-lf' => ['X-Foo-Bar', "\nvalue"],
+ ];
+ }
+
+ /**
+ * @param string $name
+ * @param string|array{string} $value
+ */
+ #[DataProvider('headersWithInjectionVectors')]
+ #[Group('ZF2015-04')]
+ public function testDoesNotAllowCRLFInjectionWhenCallingWithHeader($name, $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->message->withHeader($name, $value);
+ }
+
+ /**
+ * @param string $name
+ * @param string|array{string} $value
+ */
+ #[DataProvider('headersWithInjectionVectors')]
+ #[Group('ZF2015-04')]
+ public function testDoesNotAllowCRLFInjectionWhenCallingWithAddedHeader($name, $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->message->withAddedHeader($name, $value);
+ }
+
+ public function testWithHeaderAllowsHeaderContinuations(): void
+ {
+ $message = $this->message->withHeader('X-Foo-Bar', "value,\r\n second value");
+ $this->assertSame("value, second value", $message->getHeaderLine('X-Foo-Bar'));
+ }
+
+ public function testWithAddedHeaderAllowsHeaderContinuations(): void
+ {
+ $message = $this->message->withAddedHeader('X-Foo-Bar', "value,\r\n second value");
+ $this->assertSame("value, second value", $message->getHeaderLine('X-Foo-Bar'));
+ }
+
+ /** @return non-empty-array */
+ public static function headersWithWhitespace(): array
+ {
+ return [
+ 'no' => ["Baz"],
+ 'leading' => [" Baz"],
+ 'trailing' => ["Baz "],
+ 'both' => [" Baz "],
+ 'mixed' => [" \t Baz\t \t"],
+ ];
+ }
+
+ #[DataProvider('headersWithWhitespace')]
+ public function testWithHeaderTrimsWhitespace(string $value): void
+ {
+ $message = $this->message->withHeader('X-Foo-Bar', $value);
+ $this->assertSame(trim($value, "\t "), $message->getHeaderLine('X-Foo-Bar'));
+ }
+
+ /** @return non-empty-array */
+ public static function headersWithContinuation(): array
+ {
+ return [
+ 'space' => ["foo\r\n bar"],
+ 'tab' => ["foo\r\n\tbar"],
+ ];
+ }
+
+ #[DataProvider('headersWithContinuation')]
+ public function testWithHeaderNormalizesContinuationToNotContainNewlines(string $value): void
+ {
+ $message = $this->message->withHeader('X-Foo-Bar', $value);
+ // Newlines must no longer appear.
+ $this->assertStringNotContainsString("\r", $message->getHeaderLine('X-Foo-Bar'));
+ $this->assertStringNotContainsString("\n", $message->getHeaderLine('X-Foo-Bar'));
+ // But there must be at least one space.
+ $this->assertStringContainsString(' ', $message->getHeaderLine('X-Foo-Bar'));
+ }
+
+ /** @return non-empty-array */
+ public static function numericHeaderValuesProvider(): array
+ {
+ return [
+ 'integer' => [123],
+ 'float' => [12.3],
+ ];
+ }
+
+ /**
+ * @psalm-suppress InvalidArgument this test
+ * explicitly verifies that pre-type-declaration implicit type
+ * conversion semantics still apply, for BC Compliance
+ */
+ #[DataProvider('numericHeaderValuesProvider')]
+ #[Group('99')]
+ public function testWithHeaderShouldAllowIntegersAndFloats(float $value): void
+ {
+ $message = $this->message
+ ->withHeader('X-Test-Array', [$value])
+ ->withHeader('X-Test-Scalar', $value);
+
+ $this->assertSame([
+ 'X-Test-Array' => [(string) $value],
+ 'X-Test-Scalar' => [(string) $value],
+ ], $message->getHeaders());
+ }
+
+ /** @return non-empty-array */
+ public static function invalidHeaderValueTypes(): array
+ {
+ return [
+ 'null' => [null],
+ 'true' => [true],
+ 'false' => [false],
+ 'object' => [(object) ['header' => ['foo', 'bar']]],
+ ];
+ }
+
+ /** @return non-empty-array */
+ public static function invalidArrayHeaderValues(): array
+ {
+ $values = self::invalidHeaderValueTypes();
+ $values['array'] = [['INVALID']];
+ return $values;
+ }
+
+ #[DataProvider('invalidArrayHeaderValues')]
+ #[Group('99')]
+ public function testWithHeaderShouldRaiseExceptionForInvalidHeaderValuesInArrays(mixed $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('header value type');
+
+ /** @psalm-suppress MixedArgumentTypeCoercion */
+ $this->message->withHeader('X-Test-Array', [$value]);
+ }
+
+ #[DataProvider('invalidHeaderValueTypes')]
+ #[Group('99')]
+ public function testWithHeaderShouldRaiseExceptionForInvalidHeaderScalarValues(mixed $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('header value type');
+
+ /** @psalm-suppress MixedArgument */
+ $this->message->withHeader('X-Test-Scalar', $value);
+ }
+}
diff --git a/tests/RelativeStreamTest.php b/tests/RelativeStreamTest.php
new file mode 100644
index 0000000..1f1f408
--- /dev/null
+++ b/tests/RelativeStreamTest.php
@@ -0,0 +1,200 @@
+createMock(Stream::class);
+ $decorated->method('isSeekable')->willReturn(true);
+ $decorated->method('tell')->willReturn(100);
+ $decorated->expects(self::once())->method('seek')->with(100, SEEK_SET);
+ $decorated->expects(self::once())->method('getContents')->willReturn('foobarbaz');
+
+ $stream = new RelativeStream($decorated, 100);
+ $ret = $stream->__toString();
+ $this->assertSame('foobarbaz', $ret);
+ }
+
+ public function testClose(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->expects(self::once())->method('close');
+ $stream = new RelativeStream($decorated, 100);
+ $stream->close();
+ }
+
+ public function testDetach(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $resource = fopen('php://memory', 'r+');
+ $decorated->expects(self::once())->method('detach')->willReturn($resource);
+ $stream = new RelativeStream($decorated, 100);
+ $ret = $stream->detach();
+ $this->assertSame($resource, $ret);
+ }
+
+ public function testGetSize(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->expects(self::once())->method('getSize')->willReturn(250);
+ $stream = new RelativeStream($decorated, 100);
+ $ret = $stream->size;
+ $this->assertSame(150, $ret);
+ }
+
+ public function testTell(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->expects(self::once())->method('tell')->willReturn(188);
+ $stream = new RelativeStream($decorated, 100);
+ $ret = $stream->tell();
+ $this->assertSame(88, $ret);
+ }
+
+ public function testIsSeekable(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->expects(self::once())->method('isSeekable')->willReturn(true);
+ $stream = new RelativeStream($decorated, 100);
+ $ret = $stream->isSeekable;
+ $this->assertSame(true, $ret);
+ }
+
+ public function testIsWritable(): void {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->expects(self::once())->method('isWritable')->willReturn(true);
+ $stream = new RelativeStream($decorated, 100);
+ $ret = $stream->isWritable;
+ $this->assertSame(true, $ret);
+ }
+
+ public function testIsReadable(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->expects(self::once())->method('isReadable')->willReturn(false);
+ $stream = new RelativeStream($decorated, 100);
+ $ret = $stream->isReadable;
+ $this->assertSame(false, $ret);
+ }
+
+ public function testSeek(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->expects(self::once())->method('seek')->with(126, SEEK_SET);
+ $stream = new RelativeStream($decorated, 100);
+ $this->assertNull($stream->seek(26));
+ }
+
+ public function testRewind(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->expects(self::once())->method('seek')->with(100, SEEK_SET);
+ $stream = new RelativeStream($decorated, 100);
+ $this->assertNull($stream->rewind());
+ }
+
+ public function testWrite(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->method('tell')->willReturn(100);
+ $decorated->expects(self::once())->method('write')->with('foobaz')->willReturn(6);
+ $stream = new RelativeStream($decorated, 100);
+ $ret = $stream->write("foobaz");
+ $this->assertSame(6, $ret);
+ }
+
+ public function testRead(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->method('tell')->willReturn(100);
+ $decorated->expects(self::once())->method('read')->with(3)->willReturn('foo');
+ $stream = new RelativeStream($decorated, 100);
+ $ret = $stream->read(3);
+ $this->assertSame("foo", $ret);
+ }
+
+ public function testGetContents(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->method('tell')->willReturn(100);
+ $decorated->expects(self::once())->method('getContents')->willReturn('foo');
+ $stream = new RelativeStream($decorated, 100);
+ $ret = $stream->getContents();
+ $this->assertSame("foo", $ret);
+ }
+
+ public function testGetMetadata(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->expects(self::once())->method('getMetadata')->with('bar')->willReturn('foo');
+ $stream = new RelativeStream($decorated, 100);
+ $ret = $stream->getMetadata("bar");
+ $this->assertSame("foo", $ret);
+ }
+
+ public function testWriteRaisesExceptionWhenPointerIsBehindOffset(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->expects(self::once())->method('tell')->willReturn(0);
+ $decorated->expects(self::never())->method('write')->with('foobaz');
+ $stream = new RelativeStream($decorated, 100);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Invalid pointer position');
+
+ $stream->write("foobaz");
+ }
+
+ public function testReadRaisesExceptionWhenPointerIsBehindOffset(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->expects(self::once())->method('tell')->willReturn(0);
+ $decorated->expects(self::never())->method('read')->with(3);
+ $stream = new RelativeStream($decorated, 100);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Invalid pointer position');
+
+ $stream->read(3);
+ }
+
+ public function testGetContentsRaisesExceptionWhenPointerIsBehindOffset(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->expects(self::once())->method('tell')->willReturn(0);
+ $decorated->expects(self::never())->method('getContents');
+ $stream = new RelativeStream($decorated, 100);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Invalid pointer position');
+
+ $stream->getContents();
+ }
+
+ public function testCanReadContentFromNotSeekableResource(): void
+ {
+ $decorated = $this->createMock(Stream::class);
+ $decorated->method('isSeekable')->willReturn(false);
+ $decorated->expects(self::never())->method('seek');
+ $decorated->method('tell')->willReturn(3);
+ $decorated->method('getContents')->willReturn('CONTENTS');
+
+ $stream = new RelativeStream($decorated, 3);
+ $this->assertSame('CONTENTS', $stream->__toString());
+ }
+}
diff --git a/tests/Request/ArraySerializerTest.php b/tests/Request/ArraySerializerTest.php
new file mode 100644
index 0000000..4399b1b
--- /dev/null
+++ b/tests/Request/ArraySerializerTest.php
@@ -0,0 +1,115 @@
+write('{"test":"value"}');
+
+ $request = (new Request())
+ ->withMethod('POST')
+ ->withUri(new Uri('http://example.com/foo/bar?baz=bat'))
+ ->withAddedHeader('Accept', 'application/json')
+ ->withAddedHeader('X-Foo-Bar', 'Baz')
+ ->withAddedHeader('X-Foo-Bar', 'Bat')
+ ->withBody($stream);
+
+ $message = ArraySerializer::toArray($request);
+
+ $this->assertSame([
+ 'method' => 'POST',
+ 'request_target' => '/foo/bar?baz=bat',
+ 'uri' => 'http://example.com/foo/bar?baz=bat',
+ 'protocol_version' => '1.1',
+ 'headers' => [
+ 'Host' => [
+ 'example.com',
+ ],
+ 'Accept' => [
+ 'application/json',
+ ],
+ 'X-Foo-Bar' => [
+ 'Baz',
+ 'Bat',
+ ],
+ ],
+ 'body' => '{"test":"value"}',
+ ], $message);
+ }
+
+ public function testDeserializeFromArray(): void
+ {
+ $serializedRequest = [
+ 'method' => 'POST',
+ 'request_target' => '/foo/bar?baz=bat',
+ 'uri' => 'http://example.com/foo/bar?baz=bat',
+ 'protocol_version' => '1.1',
+ 'headers' => [
+ 'Host' => [
+ 'example.com',
+ ],
+ 'Accept' => [
+ 'application/json',
+ ],
+ 'X-Foo-Bar' => [
+ 'Baz',
+ 'Bat',
+ ],
+ ],
+ 'body' => '{"test":"value"}',
+ ];
+
+ $message = ArraySerializer::fromArray($serializedRequest);
+
+ $stream = new Stream('php://memory', 'wb+');
+ $stream->write('{"test":"value"}');
+
+ $request = (new Request())
+ ->withMethod('POST')
+ ->withUri(new Uri('http://example.com/foo/bar?baz=bat'))
+ ->withAddedHeader('Accept', 'application/json')
+ ->withAddedHeader('X-Foo-Bar', 'Baz')
+ ->withAddedHeader('X-Foo-Bar', 'Bat')
+ ->withBody($stream);
+
+ $this->assertSame(Request\Serializer::toString($request), Request\Serializer::toString($message));
+ }
+
+ public function testMissingBodyParamInSerializedRequestThrowsException(): void
+ {
+ $serializedRequest = [
+ 'method' => 'POST',
+ 'request_target' => '/foo/bar?baz=bat',
+ 'uri' => 'http://example.com/foo/bar?baz=bat',
+ 'protocol_version' => '1.1',
+ 'headers' => [
+ 'Host' => [
+ 'example.com',
+ ],
+ 'Accept' => [
+ 'application/json',
+ ],
+ 'X-Foo-Bar' => [
+ 'Baz',
+ 'Bat',
+ ],
+ ],
+ ];
+
+ $this->expectException(UnexpectedValueException::class);
+
+ ArraySerializer::fromArray($serializedRequest);
+ }
+}
diff --git a/tests/Request/SerializerTest.php b/tests/Request/SerializerTest.php
new file mode 100644
index 0000000..8e067e2
--- /dev/null
+++ b/tests/Request/SerializerTest.php
@@ -0,0 +1,405 @@
+withMethod('GET')
+ ->withUri(new Uri('http://example.com/foo/bar?baz=bat'))
+ ->withAddedHeader('Accept', 'text/html');
+
+ $message = Serializer::toString($request);
+ $this->assertSame(
+ "GET /foo/bar?baz=bat HTTP/1.1\r\nHost: example.com\r\nAccept: text/html",
+ $message,
+ );
+ }
+
+ public function testSerializesRequestWithBody(): void
+ {
+ $body = json_encode(['test' => 'value'], JSON_THROW_ON_ERROR);
+ $stream = new Stream('php://memory', 'wb+');
+ $stream->write($body);
+
+ $request = (new Request())
+ ->withMethod('POST')
+ ->withUri(new Uri('http://example.com/foo/bar'))
+ ->withAddedHeader('Accept', 'application/json')
+ ->withAddedHeader('Content-Type', 'application/json')
+ ->withBody($stream);
+
+ $message = Serializer::toString($request);
+ $this->assertStringContainsString("POST /foo/bar HTTP/1.1\r\n", $message);
+ $this->assertStringContainsString("\r\n\r\n" . $body, $message);
+ }
+
+ public function testSerializesMultipleHeadersCorrectly(): void
+ {
+ $request = (new Request())
+ ->withMethod('GET')
+ ->withUri(new Uri('http://example.com/foo/bar?baz=bat'))
+ ->withAddedHeader('X-Foo-Bar', 'Baz')
+ ->withAddedHeader('X-Foo-Bar', 'Bat');
+
+ $message = Serializer::toString($request);
+ $this->assertStringContainsString("X-Foo-Bar: Baz", $message);
+ $this->assertStringContainsString("X-Foo-Bar: Bat", $message);
+ }
+
+ /** @return non-empty-array}> */
+ public static function originForms(): array
+ {
+ return [
+ 'path-only' => [
+ 'GET /foo HTTP/1.1',
+ '/foo',
+ ['getPath' => '/foo'],
+ ],
+ 'path-and-query' => [
+ 'GET /foo?bar HTTP/1.1',
+ '/foo?bar',
+ ['getPath' => '/foo', 'getQuery' => 'bar'],
+ ],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $line
+ * @param non-empty-string $requestTarget
+ * @param array $expectations
+ */
+ #[DataProvider('originForms')]
+ public function testCanDeserializeRequestWithOriginForm(
+ string $line,
+ string $requestTarget,
+ array $expectations
+ ): void {
+ $message = $line . "\r\nX-Foo-Bar: Baz\r\n\r\nContent";
+ $request = Serializer::fromString($message);
+
+ $this->assertSame('GET', $request->method);
+ $this->assertSame(RequestMethod::GET, $request->requestMethod);
+ $this->assertSame($requestTarget, $request->requestTarget);
+
+ $uri = $request->uri;
+ foreach ($expectations as $method => $expect) {
+ $this->assertSame($expect, $uri->{$method}());
+ }
+ }
+
+ /**
+ * @return non-empty-array<
+ * non-empty-string,
+ * array{
+ * non-empty-string,
+ * non-empty-string,
+ * array{
+ * getScheme?: non-empty-string,
+ * getUserInfo?: non-empty-string,
+ * getHost?: non-empty-string,
+ * getPort?: positive-int,
+ * getPath?: non-empty-string,
+ * getQuery?: non-empty-string
+ * }
+ * }
+ * >
+ */
+ public static function absoluteForms(): array
+ {
+ return [
+ 'path-only' => [
+ 'GET http://example.com/foo HTTP/1.1',
+ 'http://example.com/foo',
+ [
+ 'getScheme' => 'http',
+ 'getHost' => 'example.com',
+ 'getPath' => '/foo',
+ ],
+ ],
+ 'path-and-query' => [
+ 'GET http://example.com/foo?bar HTTP/1.1',
+ 'http://example.com/foo?bar',
+ [
+ 'getScheme' => 'http',
+ 'getHost' => 'example.com',
+ 'getPath' => '/foo',
+ 'getQuery' => 'bar',
+ ],
+ ],
+ 'with-port' => [
+ 'GET http://example.com:8080/foo?bar HTTP/1.1',
+ 'http://example.com:8080/foo?bar',
+ [
+ 'getScheme' => 'http',
+ 'getHost' => 'example.com',
+ 'getPort' => 8080,
+ 'getPath' => '/foo',
+ 'getQuery' => 'bar',
+ ],
+ ],
+ 'with-authority' => [
+ 'GET https://me:too@example.com:8080/foo?bar HTTP/1.1',
+ 'https://me:too@example.com:8080/foo?bar',
+ [
+ 'getScheme' => 'https',
+ 'getUserInfo' => 'me:too',
+ 'getHost' => 'example.com',
+ 'getPort' => 8080,
+ 'getPath' => '/foo',
+ 'getQuery' => 'bar',
+ ],
+ ],
+ ];
+ }
+
+ // @codingStandardsIgnoreStart if we split these line, phpcs can't associate parameter name and docblock anymore (phpcs limitation)
+ /**
+ * @param non-empty-string $line
+ * @param non-empty-string $requestTarget
+ * @param array{getScheme?: non-empty-string, getUserInfo?: non-empty-string, getHost?: non-empty-string, getPort?: positive-int, getPath?: non-empty-string, getQuery?: non-empty-string} $expectations
+ */
+ #[DataProvider('absoluteForms')]
+ public function testCanDeserializeRequestWithAbsoluteForm(
+ string $line,
+ string $requestTarget,
+ array $expectations
+ ): void {
+ // @codingStandardsIgnoreEnd
+ $message = $line . "\r\nX-Foo-Bar: Baz\r\n\r\nContent";
+ $request = Serializer::fromString($message);
+
+ $this->assertSame('GET', $request->getMethod());
+
+ $this->assertSame($requestTarget, $request->getRequestTarget());
+
+ $uri = $request->uri;
+ foreach ($expectations as $method => $expect) {
+ $this->assertSame($expect, $uri->{$method}());
+ }
+ }
+
+ public function testCanDeserializeRequestWithAuthorityForm(): void
+ {
+ $message = "CONNECT www.example.com:80 HTTP/1.1\r\nX-Foo-Bar: Baz";
+ $request = Serializer::fromString($message);
+ $this->assertSame('CONNECT', $request->getMethod());
+ $this->assertSame('www.example.com:80', $request->getRequestTarget());
+
+ $uri = $request->uri;
+ $this->assertNotSame('www.example.com', $uri->host);
+ $this->assertNotSame(80, $uri->port);
+ }
+
+ public function testCanDeserializeRequestWithAsteriskForm(): void
+ {
+ $message = "OPTIONS * HTTP/1.1\r\nHost: www.example.com";
+ $request = Serializer::fromString($message);
+ $this->assertSame('OPTIONS', $request->getMethod());
+ $this->assertSame('*', $request->getRequestTarget());
+
+ $uri = $request->uri;
+ $this->assertNotSame('www.example.com', $uri->host);
+
+ $this->assertTrue($request->hasHeader('Host'));
+ $this->assertSame('www.example.com', $request->getHeaderLine('Host'));
+ }
+
+ /** @return non-empty-array */
+ public static function invalidRequestLines(): array
+ {
+ return [
+ 'missing-method' => ['/foo/bar HTTP/1.1'],
+ 'missing-target' => ['GET HTTP/1.1'],
+ 'missing-protocol' => ['GET /foo/bar'],
+ 'simply-malformed' => ['What is this mess?'],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $line
+ */
+ #[DataProvider('invalidRequestLines')]
+ public function testRaisesExceptionDuringDeserializationForInvalidRequestLine(string $line): void
+ {
+ $message = $line . "\r\nX-Foo-Bar: Baz\r\n\r\nContent";
+
+ $this->expectException(UnexpectedValueException::class);
+
+ Serializer::fromString($message);
+ }
+
+ public function testCanDeserializeRequestWithMultipleHeadersOfSameName(): void
+ {
+ $text = "POST /foo HTTP/1.0\r\nContent-Type: text/plain\r\nX-Foo-Bar: Baz\r\nX-Foo-Bar: Bat\r\n\r\nContent!";
+ $request = Serializer::fromString($text);
+
+ $this->assertInstanceOf(RequestInterface::class, $request);
+ $this->assertInstanceOf(Request::class, $request);
+
+ $this->assertTrue($request->hasHeader('X-Foo-Bar'));
+ $values = $request->getHeader('X-Foo-Bar');
+ $this->assertSame(['Baz', 'Bat'], $values);
+ }
+
+ /** @return non-empty-array */
+ public static function headersWithContinuationLines(): array
+ {
+ return [
+ 'space' => ["POST /foo HTTP/1.0\r\nContent-Type: text/plain\r\nX-Foo-Bar: Baz;\r\n Bat\r\n\r\nContent!"],
+ 'tab' => ["POST /foo HTTP/1.0\r\nContent-Type: text/plain\r\nX-Foo-Bar: Baz;\r\n\tBat\r\n\r\nContent!"],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $text
+ */
+ #[DataProvider('headersWithContinuationLines')]
+ public function testCanDeserializeRequestWithHeaderContinuations(string $text): void
+ {
+ $request = Serializer::fromString($text);
+
+ $this->assertInstanceOf(RequestInterface::class, $request);
+ $this->assertInstanceOf(Request::class, $request);
+
+ $this->assertTrue($request->hasHeader('X-Foo-Bar'));
+ $this->assertSame('Baz; Bat', $request->getHeaderLine('X-Foo-Bar'));
+ }
+
+ /** @return non-empty-array */
+ public static function headersWithWhitespace(): array
+ {
+ return [
+ 'no' => ["POST /foo HTTP/1.0\r\nContent-Type: text/plain\r\nX-Foo-Bar:Baz\r\n\r\nContent!"],
+ 'leading' => ["POST /foo HTTP/1.0\r\nContent-Type: text/plain\r\nX-Foo-Bar: Baz\r\n\r\nContent!"],
+ 'trailing' => ["POST /foo HTTP/1.0\r\nContent-Type: text/plain\r\nX-Foo-Bar:Baz \r\n\r\nContent!"],
+ 'both' => ["POST /foo HTTP/1.0\r\nContent-Type: text/plain\r\nX-Foo-Bar: Baz \r\n\r\nContent!"],
+ 'mixed' => ["POST /foo HTTP/1.0\r\nContent-Type: text/plain\r\nX-Foo-Bar: \t Baz\t \t\r\n\r\nContent!"],
+ ];
+ }
+
+ #[DataProvider('headersWithWhitespace')]
+ public function testDeserializationRemovesWhitespaceAroundValues(string $text): void
+ {
+ $request = Serializer::fromString($text);
+
+ $this->assertInstanceOf(Request::class, $request);
+
+ $this->assertSame('Baz', $request->getHeaderLine('X-Foo-Bar'));
+ }
+
+ /** @return non-empty-array */
+ public static function messagesWithInvalidHeaders(): array
+ {
+ return [
+ 'invalid-name' => [
+ "GET /foo HTTP/1.1\r\nThi;-I()-Invalid: value",
+ 'Invalid header detected',
+ ],
+ 'invalid-format' => [
+ "POST /foo HTTP/1.1\r\nThis is not a header\r\n\r\nContent",
+ 'Invalid header detected',
+ ],
+ 'invalid-continuation' => [
+ "POST /foo HTTP/1.1\r\nX-Foo-Bar: Baz\r\nInvalid continuation\r\nContent",
+ 'Invalid header continuation',
+ ],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $message
+ * @param non-empty-string $exceptionMessage
+ */
+ #[DataProvider('messagesWithInvalidHeaders')]
+ public function testDeserializationRaisesExceptionForMalformedHeaders(
+ string $message,
+ string $exceptionMessage
+ ): void {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage($exceptionMessage);
+
+ Serializer::fromString($message);
+ }
+
+ public function testFromStreamThrowsExceptionWhenStreamIsNotReadable(): void
+ {
+ $stream = $this->createMock(StreamInterface::class);
+ $stream
+ ->expects($this->once())
+ ->method('isReadable')
+ ->willReturn(false);
+
+ $this->expectException(InvalidArgumentException::class);
+
+ Serializer::fromStream($stream);
+ }
+
+ public function testFromStreamThrowsExceptionWhenStreamIsNotSeekable(): void
+ {
+ $stream = $this->createMock(StreamInterface::class);
+ $stream
+ ->expects($this->once())
+ ->method('isReadable')
+ ->willReturn(true);
+ $stream
+ ->expects($this->once())
+ ->method('isSeekable')
+ ->willReturn(false);
+
+ $this->expectException(InvalidArgumentException::class);
+
+ Serializer::fromStream($stream);
+ }
+
+ public function testFromStreamStopsReadingAfterScanningHeader(): void
+ {
+ $headers = "POST /foo HTTP/1.0\r\nContent-Type: text/plain\r\nX-Foo-Bar: Baz;\r\n Bat\r\n\r\n";
+ $payload = $headers . "Content!";
+
+ $stream = $this->createMock(StreamInterface::class);
+ $stream
+ ->expects($this->once())
+ ->method('isReadable')
+ ->willReturn(true);
+ $stream
+ ->expects($this->once())
+ ->method('isSeekable')
+ ->willReturn(true);
+
+ // assert that full request body is not read, and returned as RelativeStream instead
+ $stream->expects($this->exactly(strlen($headers)))
+ ->method('read')
+ ->with(1)
+ ->willReturnCallback(static function () use ($payload) {
+ static $i = 0;
+ return $payload[$i++];
+ });
+
+ $stream = Serializer::fromStream($stream);
+
+ $this->assertInstanceOf(RelativeStream::class, $stream->body);
+ }
+}
diff --git a/tests/RequestTest.php b/tests/RequestTest.php
new file mode 100644
index 0000000..c86a21e
--- /dev/null
+++ b/tests/RequestTest.php
@@ -0,0 +1,535 @@
+request = new Request();
+ }
+
+ public function testMethodIsGetByDefault(): void
+ {
+ $this->assertSame('GET', $this->request->method);
+ $this->assertSame(RequestMethod::GET, $this->request->requestMethod);
+ }
+
+ public function testMethodMutatorReturnsCloneWithChangedMethod(): void
+ {
+ $request = $this->request->withMethod('POST');
+ $this->assertNotSame($this->request, $request);
+ $this->assertEquals('POST', $request->method);
+ $this->assertEquals(RequestMethod::POST, $request->requestMethod);
+ }
+
+ /** @return non-empty-list */
+ public static function invalidMethod(): array
+ {
+ return [
+ [''],
+ ];
+ }
+
+ #[DataProvider('invalidMethod')]
+ public function testWithInvalidMethod(mixed $method): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ /** @psalm-suppress MixedArgument */
+ $this->request->withMethod($method);
+ }
+
+ public function testReturnsUnpopulatedUriByDefault(): void
+ {
+ $uri = $this->request->uri;
+ $this->assertInstanceOf(UriInterface::class, $uri);
+ $this->assertInstanceOf(Uri::class, $uri);
+ $this->assertEmpty($uri->scheme);
+ $this->assertEmpty($uri->userInfo);
+ $this->assertEmpty($uri->host);
+ $this->assertNull($uri->port);
+ $this->assertEmpty($uri->path);
+ $this->assertEmpty($uri->query);
+ $this->assertEmpty($uri->fragment);
+ }
+
+ public function testWithUriReturnsNewInstanceWithNewUri(): void
+ {
+ $request = $this->request->withUri(new Uri('https://example.com:10082/foo/bar?baz=bat'));
+ $this->assertNotSame($this->request, $request);
+ $request2 = $request->withUri(new Uri('/baz/bat?foo=bar'));
+ $this->assertNotSame($this->request, $request2);
+ $this->assertNotSame($request, $request2);
+ $this->assertSame('/baz/bat?foo=bar', (string) $request2->uri);
+ }
+
+ public function testConstructorCanAcceptAllMessageParts(): void
+ {
+ $uri = new Uri('http://example.com/');
+ $body = new Stream('php://memory');
+ $headers = [
+ 'x-foo' => ['bar'],
+ ];
+ $request = new Request(
+ $uri,
+ 'POST',
+ $body,
+ $headers
+ );
+
+ $this->assertSame($uri, $request->uri);
+ $this->assertSame('POST', $request->method);
+ $this->assertSame(RequestMethod::POST, $request->requestMethod);
+ $this->assertSame($body, $request->body);
+ $testHeaders = $request->getHeaders();
+ foreach ($headers as $key => $value) {
+ $this->assertArrayHasKey($key, $testHeaders);
+ $this->assertSame($value, $testHeaders[$key]);
+ }
+ }
+
+ public function testDefaultStreamIsWritable(): void
+ {
+ $request = new Request();
+ $request->body->write("test");
+
+ $this->assertSame("test", (string) $request->body);
+ }
+
+ /** @return non-empty-array */
+ public static function invalidRequestMethod(): array
+ {
+ return [
+ 'bad-string' => ['BOGUS METHOD'],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $method
+ */
+ #[DataProvider('invalidRequestMethod')]
+ public function testConstructorRaisesExceptionForInvalidMethod(string $method): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Unsupported HTTP method');
+
+ new Request(null, $method);
+ }
+
+ /** @return non-empty-array */
+ public static function customRequestMethods(): array
+ {
+ return [
+ /* WebDAV methods */
+ 'TRACE' => [RequestMethod::TRACE],
+ 'PROPFIND' => ['PROPFIND'],
+ 'PROPPATCH' => ['PROPPATCH'],
+ 'MKCOL' => ['MKCOL'],
+ 'COPY' => ['COPY'],
+ 'MOVE' => ['MOVE'],
+ 'LOCK' => ['LOCK'],
+ 'UNLOCK' => ['UNLOCK'],
+ /* Arbitrary methods */
+ '#!ALPHA-1234&%' => ['#!ALPHA-1234&%'],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $method
+ */
+ #[DataProvider('customRequestMethods')]
+ #[Group('29')]
+ public function testAllowsCustomRequestMethodsThatFollowSpec(RequestMethod|string $method): void
+ {
+ $request = new Request(null, $method);
+ if ($method instanceof RequestMethod) {
+ $this->assertSame($method->value, $request->method);
+ $this->assertSame($method, $request->requestMethod);
+ } else {
+ $requestMethod = RequestMethod::tryParse($method);
+ $this->assertSame($method, $request->method);
+ if ($requestMethod === null) {
+ $this->assertNull($request->requestMethod);
+ } else {
+ $this->assertSame($requestMethod, $request->requestMethod);
+ }
+ }
+ }
+
+ /** @return non-empty-array */
+ public static function invalidRequestBody(): array
+ {
+ return [
+ 'true' => [true],
+ 'false' => [false],
+ 'int' => [1],
+ 'float' => [1.1],
+ 'array' => [['BODY']],
+ 'stdClass' => [(object) ['body' => 'BODY']],
+ ];
+ }
+
+ #[DataProvider('invalidRequestBody')]
+ public function testConstructorRaisesExceptionForInvalidBody(mixed $body): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('stream');
+
+ /** @psalm-suppress MixedArgument */
+ new Request(null, null, $body);
+ }
+
+ /** @return non-empty-array */
+ public static function invalidHeaderTypes(): array
+ {
+ return [
+ 'indexed-array' => [[['INVALID']], 'header name'],
+ 'null' => [['x-invalid-null' => null]],
+ 'true' => [['x-invalid-true' => true]],
+ 'false' => [['x-invalid-false' => false]],
+ 'object' => [['x-invalid-object' => (object) ['INVALID']]],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $contains
+ */
+ #[DataProvider('invalidHeaderTypes')]
+ #[Group('99')]
+ public function testConstructorRaisesExceptionForInvalidHeaders(
+ mixed $headers,
+ string $contains = 'header value type'
+ ): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage($contains);
+
+ new Request(null, null, 'php://memory', $headers);
+ }
+
+ public function testRequestTargetIsSlashWhenNoUriPresent(): void
+ {
+ $request = new Request();
+ $this->assertSame('/', $request->getRequestTarget());
+ }
+
+ public function testRequestTargetIsSlashWhenUriHasNoPathOrQuery(): void
+ {
+ $request = (new Request())
+ ->withUri(new Uri('http://example.com'));
+ $this->assertSame('/', $request->getRequestTarget());
+ }
+
+ /** @return non-empty-array */
+ public static function requestsWithUri(): array
+ {
+ return [
+ 'absolute-uri' => [
+ (new Request())
+ ->withUri(new Uri('https://api.example.com/user'))
+ ->withMethod('POST'),
+ '/user',
+ ],
+ 'absolute-uri-with-query' => [
+ (new Request())
+ ->withUri(new Uri('https://api.example.com/user?foo=bar'))
+ ->withMethod('POST'),
+ '/user?foo=bar',
+ ],
+ 'relative-uri' => [
+ (new Request())
+ ->withUri(new Uri('/user'))
+ ->withMethod('GET'),
+ '/user',
+ ],
+ 'relative-uri-with-query' => [
+ (new Request())
+ ->withUri(new Uri('/user?foo=bar'))
+ ->withMethod('GET'),
+ '/user?foo=bar',
+ ],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $expected
+ */
+ #[DataProvider('requestsWithUri')]
+ public function testReturnsRequestTargetWhenUriIsPresent(RequestInterface $request, string $expected): void
+ {
+ $this->assertSame($expected, $request->getRequestTarget());
+ }
+
+ /** @return non-empty-array */
+ public static function validRequestTargets(): array
+ {
+ return [
+ 'asterisk-form' => ['*'],
+ 'authority-form' => ['api.example.com'],
+ 'absolute-form' => ['https://api.example.com/users'],
+ 'absolute-form-query' => ['https://api.example.com/users?foo=bar'],
+ 'origin-form-path-only' => ['/users'],
+ 'origin-form' => ['/users?id=foo'],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $requestTarget
+ */
+ #[DataProvider('validRequestTargets')]
+ public function testCanProvideARequestTarget(string $requestTarget): void
+ {
+ $request = (new Request())->withRequestTarget($requestTarget);
+ $this->assertSame($requestTarget, $request->getRequestTarget());
+ }
+
+ public function testRequestTargetCannotContainWhitespace(): void
+ {
+ $request = new Request();
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Invalid request target');
+
+ $request->withRequestTarget('foo bar baz');
+ }
+
+ public function testRequestTargetDoesNotCacheBetweenInstances(): void
+ {
+ $request = (new Request())->withUri(new Uri('https://example.com/foo/bar'));
+ $original = $request->getRequestTarget();
+ $newRequest = $request->withUri(new Uri('http://mwop.net/bar/baz'));
+ $this->assertNotSame($original, $newRequest->getRequestTarget());
+ }
+
+ public function testSettingNewUriResetsRequestTarget(): void
+ {
+ $request = (new Request())->withUri(new Uri('https://example.com/foo/bar'));
+ $newRequest = $request->withUri(new Uri('http://mwop.net/bar/baz'));
+
+ $this->assertNotSame($request->getRequestTarget(), $newRequest->getRequestTarget());
+ }
+
+ #[Group('39')]
+ public function testGetHeadersContainsHostHeaderIfUriWithHostIsPresent(): void
+ {
+ $request = new Request('http://example.com');
+ $headers = $request->getHeaders();
+ $this->assertArrayHasKey('Host', $headers);
+ $this->assertStringContainsString('example.com', $headers['Host'][0]);
+ }
+
+ #[Group('39')]
+ public function testGetHeadersContainsHostHeaderIfUriWithHostIsDeleted(): void
+ {
+ $request = (new Request('http://example.com'))->withoutHeader('host');
+ $headers = $request->getHeaders();
+ $this->assertArrayHasKey('Host', $headers);
+ $this->assertContains('example.com', $headers['Host']);
+ }
+
+ #[Group('39')]
+ public function testGetHeadersContainsNoHostHeaderIfNoUriPresent(): void
+ {
+ $request = new Request();
+ $headers = $request->getHeaders();
+ $this->assertArrayNotHasKey('Host', $headers);
+ }
+
+ #[Group('39')]
+ public function testGetHeadersContainsNoHostHeaderIfUriDoesNotContainHost(): void
+ {
+ $request = new Request(new Uri());
+ $headers = $request->getHeaders();
+ $this->assertArrayNotHasKey('Host', $headers);
+ }
+
+ #[Group('39')]
+ public function testGetHostHeaderReturnsUriHostWhenPresent(): void
+ {
+ $request = new Request('http://example.com');
+ $header = $request->getHeader('host');
+ $this->assertSame(['example.com'], $header);
+ }
+
+ #[Group('39')]
+ public function testGetHostHeaderReturnsUriHostWhenHostHeaderDeleted(): void
+ {
+ $request = (new Request('http://example.com'))->withoutHeader('host');
+ $header = $request->getHeader('host');
+ $this->assertSame(['example.com'], $header);
+ }
+
+ #[Group('39')]
+ public function testGetHostHeaderReturnsEmptyArrayIfNoUriPresent(): void
+ {
+ $request = new Request();
+ $this->assertSame([], $request->getHeader('host'));
+ }
+
+ #[Group('39')]
+ public function testGetHostHeaderReturnsEmptyArrayIfUriDoesNotContainHost(): void
+ {
+ $request = new Request(new Uri());
+ $this->assertSame([], $request->getHeader('host'));
+ }
+
+ #[Group('39')]
+ public function testGetHostHeaderLineReturnsUriHostWhenPresent(): void
+ {
+ $request = new Request('http://example.com');
+ $header = $request->getHeaderLine('host');
+ $this->assertStringContainsString('example.com', $header);
+ }
+
+ #[Group('39')]
+ public function testGetHostHeaderLineReturnsEmptyStringIfNoUriPresent(): void
+ {
+ $request = new Request();
+ $this->assertEmpty($request->getHeaderLine('host'));
+ }
+
+ #[Group('39')]
+ public function testGetHostHeaderLineReturnsEmptyStringIfUriDoesNotContainHost(): void
+ {
+ $request = new Request(new Uri());
+ $this->assertEmpty($request->getHeaderLine('host'));
+ }
+
+ public function testHostHeaderSetFromUriOnCreationIfNoHostHeaderSpecified(): void
+ {
+ $request = new Request('http://www.example.com');
+ $this->assertTrue($request->hasHeader('Host'));
+ $this->assertSame('www.example.com', $request->getHeaderLine('host'));
+ }
+
+ public function testHostHeaderNotSetFromUriOnCreationIfHostHeaderSpecified(): void
+ {
+ $request = new Request('http://www.example.com', null, 'php://memory', ['Host' => 'www.test.com']);
+ $this->assertSame('www.test.com', $request->getHeaderLine('host'));
+ }
+
+ public function testPassingPreserveHostFlagWhenUpdatingUriDoesNotUpdateHostHeader(): void
+ {
+ $request = (new Request())
+ ->withAddedHeader('Host', 'example.com');
+
+ $uri = (new Uri())->withHost('www.example.com');
+ $new = $request->withUri($uri, true);
+
+ $this->assertSame('example.com', $new->getHeaderLine('Host'));
+ }
+
+ public function testNotPassingPreserveHostFlagWhenUpdatingUriWithoutHostDoesNotUpdateHostHeader(): void
+ {
+ $request = (new Request())
+ ->withAddedHeader('Host', 'example.com');
+
+ $uri = new Uri();
+ $new = $request->withUri($uri);
+
+ $this->assertSame('example.com', $new->getHeaderLine('Host'));
+ }
+
+ public function testHostHeaderUpdatesToUriHostAndPortWhenPreserveHostDisabledAndNonStandardPort(): void
+ {
+ $request = (new Request())
+ ->withAddedHeader('Host', 'example.com');
+
+ $uri = (new Uri())
+ ->withHost('www.example.com')
+ ->withPort(10081);
+ $new = $request->withUri($uri);
+
+ $this->assertSame('www.example.com:10081', $new->getHeaderLine('Host'));
+ }
+
+ /** @return non-empty-array */
+ public static function headersWithInjectionVectors(): array
+ {
+ return [
+ 'name-with-cr' => ["X-Foo\r-Bar", 'value'],
+ 'name-with-lf' => ["X-Foo\n-Bar", 'value'],
+ 'name-with-crlf' => ["X-Foo\r\n-Bar", 'value'],
+ 'name-with-2crlf' => ["X-Foo\r\n\r\n-Bar", 'value'],
+ 'value-with-cr' => ['X-Foo-Bar', "value\rinjection"],
+ 'value-with-lf' => ['X-Foo-Bar', "value\ninjection"],
+ 'value-with-crlf' => ['X-Foo-Bar', "value\r\ninjection"],
+ 'value-with-2crlf' => ['X-Foo-Bar', "value\r\n\r\ninjection"],
+ 'array-value-with-cr' => ['X-Foo-Bar', ["value\rinjection"]],
+ 'array-value-with-lf' => ['X-Foo-Bar', ["value\ninjection"]],
+ 'array-value-with-crlf' => ['X-Foo-Bar', ["value\r\ninjection"]],
+ 'array-value-with-2crlf' => ['X-Foo-Bar', ["value\r\n\r\ninjection"]],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $name
+ * @param non-empty-string|array{non-empty-string} $value
+ */
+ #[DataProvider('headersWithInjectionVectors')]
+ public function testConstructorRaisesExceptionForHeadersWithCRLFVectors(string $name, $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ new Request(null, null, 'php://memory', [$name => $value]);
+ }
+
+ /** @return non-empty-array */
+ public static function hostHeaderKeys(): array
+ {
+ return [
+ 'lowercase' => ['host'],
+ 'mixed-4' => ['hosT'],
+ 'mixed-3-4' => ['hoST'],
+ 'reverse-titlecase' => ['hOST'],
+ 'uppercase' => ['HOST'],
+ 'mixed-1-2-3' => ['HOSt'],
+ 'mixed-1-2' => ['HOst'],
+ 'titlecase' => ['Host'],
+ 'mixed-1-4' => ['HosT'],
+ 'mixed-1-2-4' => ['HOsT'],
+ 'mixed-1-3-4' => ['HoST'],
+ 'mixed-1-3' => ['HoSt'],
+ 'mixed-2-3' => ['hOSt'],
+ 'mixed-2-4' => ['hOsT'],
+ 'mixed-2' => ['hOst'],
+ 'mixed-3' => ['hoSt'],
+ ];
+ }
+
+ /**
+ * @param non-empty-string $hostKey
+ */
+ #[DataProvider('hostHeaderKeys')]
+ public function testWithUriAndNoPreserveHostWillOverwriteHostHeaderRegardlessOfOriginalCase(string $hostKey): void
+ {
+ $request = (new Request())
+ ->withHeader($hostKey, 'example.com');
+
+ $uri = new Uri('http://example.org/foo/bar');
+ $new = $request->withUri($uri);
+ $host = $new->getHeaderLine('host');
+ $this->assertSame('example.org', $host);
+ $headers = $new->getHeaders();
+ $this->assertArrayHasKey('Host', $headers);
+ if ($hostKey !== 'Host') {
+ $this->assertArrayNotHasKey($hostKey, $headers);
+ }
+ }
+}
diff --git a/tests/Response/ArraySerializerTest.php b/tests/Response/ArraySerializerTest.php
new file mode 100644
index 0000000..44d69a1
--- /dev/null
+++ b/tests/Response/ArraySerializerTest.php
@@ -0,0 +1,86 @@
+createResponse();
+
+ $message = ArraySerializer::toArray($response);
+
+ $this->assertSame($this->createSerializedResponse(), $message);
+ }
+
+ public function testDeserializeFromArray(): void
+ {
+ $serializedResponse = $this->createSerializedResponse();
+
+ $message = ArraySerializer::fromArray($serializedResponse);
+
+ $response = $this->createResponse();
+
+ $this->assertSame(Response\Serializer::toString($response), Response\Serializer::toString($message));
+ }
+
+ public function testMissingBodyParamInSerializedRequestThrowsException(): void
+ {
+ $serializedRequest = $this->createSerializedResponse();
+ unset($serializedRequest['body']);
+
+ $this->expectException(UnexpectedValueException::class);
+
+ ArraySerializer::fromArray($serializedRequest);
+ }
+
+ private function createResponse(): Response
+ {
+ $stream = new Stream('php://memory', 'wb+');
+ $stream->write('{"test":"value"}');
+
+ return (new Response())
+ ->withStatus(201, 'Custom')
+ ->withProtocolVersion('1.1')
+ ->withAddedHeader('Accept', 'application/json')
+ ->withAddedHeader('X-Foo-Bar', 'Baz')
+ ->withAddedHeader('X-Foo-Bar', 'Bat')
+ ->withBody($stream);
+ }
+
+ /**
+ * @return array{
+ * status_code: positive-int,
+ * reason_phrase: non-empty-string,
+ * protocol_version: non-empty-string,
+ * headers: array>,
+ * body: string,
+ * }
+ */
+ private function createSerializedResponse(): array
+ {
+ return [
+ 'status_code' => 201,
+ 'reason_phrase' => 'Custom',
+ 'protocol_version' => '1.1',
+ 'headers' => [
+ 'Accept' => [
+ 'application/json',
+ ],
+ 'X-Foo-Bar' => [
+ 'Baz',
+ 'Bat',
+ ],
+ ],
+ 'body' => '{"test":"value"}',
+ ];
+ }
+}
diff --git a/tests/Response/EmptyResponseTest.php b/tests/Response/EmptyResponseTest.php
new file mode 100644
index 0000000..bb14136
--- /dev/null
+++ b/tests/Response/EmptyResponseTest.php
@@ -0,0 +1,29 @@
+assertInstanceOf(Response::class, $response);
+ $this->assertSame('', (string) $response->body);
+ $this->assertSame(201, $response->getStatusCode());
+ }
+
+ public function testHeaderConstructor(): void
+ {
+ $response = EmptyResponse::withHeaders(['x-empty' => ['true']]);
+ $this->assertInstanceOf(Response::class, $response);
+ $this->assertSame('', (string) $response->body);
+ $this->assertSame(204, $response->getStatusCode());
+ $this->assertSame('true', $response->getHeaderLine('x-empty'));
+ }
+}
diff --git a/tests/Response/HtmlResponseTest.php b/tests/Response/HtmlResponseTest.php
new file mode 100644
index 0000000..47ca8fd
--- /dev/null
+++ b/tests/Response/HtmlResponseTest.php
@@ -0,0 +1,89 @@
+Uh oh not found