diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..8aa2133
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,14 @@
+# Keep the composer dist tarball lean — exclude dev-only files from `composer install`
+/.github export-ignore
+/tests export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/phpunit.xml export-ignore
+/phpstan.neon export-ignore
+/CONTRIBUTING.md export-ignore
+/CHANGELOG.md export-ignore
+/CODE_OF_CONDUCT.md export-ignore
+/SECURITY.md export-ignore
+/SUPPORT.md export-ignore
+
+* text=auto eol=lf
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..2a9fe85
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,5 @@
+# Code owners — required reviewers for every change into `main`.
+# A PR needs approval from at least one of these owners (branch protection
+# enforces 1 code-owner review). Authors cannot approve their own PR, so keep
+# more than one owner listed.
+* @hakeemRash @Alshatri @craftdevscommunity
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..77bfe20
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,50 @@
+name: Bug report
+description: Report a defect in alfacode-team/http
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to file a report. Please do **not** report
+ security issues here — see the [Security Policy](../SECURITY.md).
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened?
+ description: A clear description of the bug, including the expected vs. actual result.
+ placeholder: When I call Response::json(...) with ..., I get ... but expected ...
+ validations:
+ required: true
+ - type: textarea
+ id: reproduction
+ attributes:
+ label: Steps to reproduce
+ description: A minimal code snippet that reproduces the problem.
+ render: php
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: Package version
+ placeholder: "1.0.0"
+ validations:
+ required: true
+ - type: input
+ id: php-version
+ attributes:
+ label: PHP version
+ placeholder: "8.4.0"
+ validations:
+ required: true
+ - type: dropdown
+ id: sapi
+ attributes:
+ label: Runtime
+ options:
+ - PHP-FPM / mod_php
+ - CLI
+ - OpenSwoole / Swoole
+ - Other
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..f64f12f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Questions & usage help
+ url: https://github.com/AlfaCode-Team/http/discussions
+ about: Ask usage questions in Discussions instead of opening an issue.
+ - name: Security vulnerability
+ url: https://github.com/AlfaCode-Team/http/security/advisories/new
+ about: Report security issues privately — never in a public issue.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..e3aa340
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,25 @@
+name: Feature request
+description: Suggest an idea or improvement for alfacode-team/http
+labels: ["enhancement"]
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: What problem does this solve?
+ description: Describe the use case and why the current API falls short.
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed API
+ description: Sketch the method(s) / behaviour you have in mind.
+ render: php
+ validations:
+ required: false
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+ validations:
+ required: false
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..738196a
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,24 @@
+
+
+## Summary
+
+
+
+## Related issue
+
+
+
+## Type of change
+
+- [ ] Bug fix (non-breaking change that fixes an issue)
+- [ ] New feature (non-breaking change that adds functionality)
+- [ ] Breaking change (fix or feature that changes existing behaviour)
+- [ ] Documentation only
+
+## Checklist
+
+- [ ] I ran `composer check` (PHPStan + PHPUnit) and it passes
+- [ ] I added/updated tests for my change
+- [ ] Public mutators remain immutable (return a new instance, never mutate `$this`)
+- [ ] No hidden globals introduced (dependencies are passed in)
+- [ ] I updated the `CHANGELOG.md` (Unreleased section)
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..72df871
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,16 @@
+version: 2
+updates:
+ - package-ecosystem: "composer"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 5
+ labels:
+ - "dependencies"
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ labels:
+ - "dependencies"
+ - "ci"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..9a80205
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,38 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.4']
+
+ name: PHP ${{ matrix.php }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: mbstring, ctype, json, fileinfo
+ coverage: none
+ tools: composer:v2
+
+ - name: Validate composer.json
+ run: composer validate --strict
+
+ - name: Install dependencies
+ run: composer update --prefer-dist --no-interaction --no-progress
+
+ - name: Static analysis
+ run: composer analyse
+
+ - name: Tests
+ run: composer test
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..701d0a3
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,44 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ name: Test
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ extensions: mbstring, ctype, json, fileinfo
+ coverage: none
+ tools: composer:v2
+ - run: composer validate --strict
+ - run: composer update --prefer-dist --no-interaction --no-progress
+ - run: composer analyse
+ - run: composer test
+
+ release:
+ needs: test
+ runs-on: ubuntu-latest
+ name: Publish GitHub Release
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Create release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release create "${GITHUB_REF_NAME}" \
+ --title "${GITHUB_REF_NAME}" \
+ --generate-notes \
+ --verify-tag
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e95679a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,54 @@
+# ── COMPOSER ──────────────────────────────────────────────────
+/vendor/
+# Library: the lock file is not committed (apps commit theirs).
+composer.lock
+
+
+# ── TESTING & COVERAGE ────────────────────────────────────────
+/.phpunit.cache/
+.phpunit.result.cache
+/coverage/
+/coverage-html/
+coverage.xml
+clover.xml
+infection.log
+
+
+# ── STATIC ANALYSIS & REFACTORING CACHES ──────────────────────
+.php-cs-fixer.cache
+/.phpstan.cache/
+/.rector/
+phpstan-results.json
+
+
+# ── PROFILING & DEBUGGING (Xdebug) ────────────────────────────
+cachegrind.out.*
+xdebug.log
+error_log
+php_errors.log
+
+
+# ── BUILD ARTIFACTS ───────────────────────────────────────────
+/build/
+/dist/
+*.phar
+
+
+# ── ENVIRONMENT ───────────────────────────────────────────────
+.env
+.env.*.local
+
+
+# ── OS & IDEs ─────────────────────────────────────────────────
+.DS_Store
+.AppleDouble
+.LSOverride
+Icon
+._*
+Thumbs.db
+ehthumbs.db
+/.idea/
+/.vscode/
+*.swp
+*.swo
+*~
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..6ee22a4
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,16 @@
+# Changelog
+
+All notable changes to `alfacode-team/http` are documented here. The format is
+based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
+adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+
+- Initial extraction of the PhpServicePlatform kernel HTTP layer into a standalone,
+ MIT-licensed package.
+- Immutable `Request` and single-type `Response` value objects.
+- PSR-7 `Uri`, host-aware `SiteUri`, Accept-* `Negotiate`, HTTP `Method` enum,
+ `UserAgent` parser, FPM-/Swoole-safe `UploadedFile`.
+- `Contracts\RequestAware` seam and `Concerns\ManagesResponse` shared accessors.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..2795e13
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,133 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+- Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+- The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+- Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official email address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+**shamavurasheed@gmail.com**.
+
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
+[https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..7cd5bd4
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,37 @@
+# Contributing
+
+Thanks for your interest in improving `alfacode-team/http`.
+
+## Getting started
+
+```bash
+git clone git@github.com:AlfaCode-Team/http.git
+cd http
+composer install
+```
+
+## Before you open a pull request
+
+- Run the full check: `composer check` (PHPStan + PHPUnit).
+- Add or update tests for any behaviour you change.
+- Keep the public API immutable — mutators must return a **new** instance, never
+ mutate `$this`. This is required for OpenSwoole/coroutine safety.
+- Do not introduce hidden globals (no container/config singletons in this layer);
+ dependencies are passed in.
+- Consumers must be able to depend on this package's own method surface, never on
+ Symfony types directly.
+
+## Coding standards
+
+- PHP 8.4+, `declare(strict_types=1);` in every file.
+- Follow the existing formatting (PSR-12-ish, aligned to the surrounding code).
+
+## Reporting issues
+
+Please use the [issue tracker](https://github.com/AlfaCode-Team/http/issues) and
+include the PHP version, a minimal reproduction, and the expected vs. actual result.
+
+## License
+
+By contributing, you agree that your contributions will be licensed under the MIT
+License.
diff --git a/LICENSE b/LICENSE
index 261eeb9..4872aa0 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,201 +1,21 @@
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
+MIT License
+
+Copyright (c) 2026 AlfaCode Team
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 08154d8..8e0f618 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,180 @@
-# http
\ No newline at end of file
+# AlfaCode HTTP
+
+[](https://github.com/AlfaCode-Team/http/actions/workflows/ci.yml)
+[](LICENSE)
+[](https://www.php.net/)
+
+The HTTP layer of the **PhpServicePlatform** kernel: a small, immutable
+`Request`/`Response` surface with a PSR-7 `Uri`, content negotiation, absolute-URL
+generation, and FPM- **and** OpenSwoole-safe file uploads. Built on
+[Symfony HttpFoundation](https://symfony.com/doc/current/components/http_foundation.html)
+for a battle-tested parser/emitter, but exposed through its own API so callers never
+depend on Symfony types directly.
+
+> Package: `alfacode-team/http` ·
+> Namespace: `AlfacodeTeam\PhpServicePlatform\Kernel\Http\`
+
+## Why
+
+- **Immutable `Request`.** Every mutator (`withHeader`, `withAttribute`, `merge`, …)
+ returns a *new* instance; `__clone()` deep-clones parameter bags so clones are fully
+ isolated — required for OpenSwoole/coroutine safety.
+- **One `Response` type.** Named constructors (`json`, `html`, `redirect`, `stream`,
+ `download`, …) instead of a zoo of response classes.
+- **No hidden globals.** Nothing in this layer reaches for a container or config
+ singleton; dependencies are passed in.
+- **Engine-agnostic API.** Consumers use `$request->input()` / `Response::json()`,
+ never `Symfony\...\Request` — so the underlying engine can change without breaking you.
+
+## Install
+
+```bash
+composer require alfacode-team/http
+```
+
+Requires PHP 8.4+ and the `symfony/http-foundation` + `symfony/mime` runtime deps
+(installed automatically).
+
+## Usage
+
+### Reading the request (immutable)
+
+```php
+use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request;
+
+$request = Request::capture();
+
+$request->method(); // 'POST' (always upper-case)
+$request->path(); // '/api/invoices'
+$request->isMethod('post'); // true
+$request->isSecure(); // honours X-Forwarded-Proto
+
+// Input — body + query merged; JSON bodies decoded automatically
+$request->input('title', 'Untitled');
+$request->all();
+$request->only(['title', 'amount']);
+$request->boolean('active'); // "1"/"true"/"on"/"yes" → true
+$request->integer('page');
+$request->query('page');
+$request->header('Accept'); // case-insensitive
+$request->bearerToken(); // from Authorization: Bearer …
+$file = $request->file('avatar'); // ?UploadedFile
+
+// Routing / negotiation helpers
+$request->segments(); // ['api', 'invoices']
+$request->is('api/*'); // wildcard path match
+$request->expectsJson();
+```
+
+Immutable mutators return a **new** request, leaving the original untouched:
+
+```php
+$request = $request->withAttribute('locale', 'fr')
+ ->withHeader('X-Trace', $id)
+ ->merge(['source' => 'import']);
+```
+
+### Building a response (one type, immutable)
+
+```php
+use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response;
+
+return Response::json($data, 201);
+return Response::created($data, location: "/api/invoices/{$id}");
+return Response::noContent(); // 204
+return Response::redirect('/login');
+
+// Error envelopes: { "error": { "code", "message"[, "fields"] } }
+return Response::notFound();
+return Response::unprocessable(['email' => 'Required.']); // 422
+return Response::tooManyRequests(retryAfter: 30);
+
+// Streaming / files — work on BOTH PHP-FPM and OpenSwoole
+return Response::stream(fn () => print(generateCsv()));
+return Response::download($path, 'report.pdf');
+
+// Immutable chaining
+return Response::json($data)
+ ->withHeader('Cache-Control', 'no-store')
+ ->withCookie('sid', $token, maxAge: 3600);
+```
+
+### URLs & negotiation
+
+```php
+// PSR-7 Uri from the current request
+$login = (string) $request->uri()->withPath('/login')->withQuery('');
+
+// Host-aware absolute URLs (OAuth callbacks, email links, sitemaps)
+$callback = $request->site()->to('auth/callback');
+
+// Content negotiation from Accept-* headers
+$locale = $request->negotiate()->language(['en', 'fr', 'ar']);
+$type = $request->negotiate()->media(['application/json', 'text/csv']);
+```
+
+### Typed HTTP methods
+
+```php
+use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Method;
+
+$m = Method::from($request->method());
+$m->isSafe(); // GET/HEAD/OPTIONS/TRACE
+$m->isIdempotent(); // + PUT/DELETE
+```
+
+### Uploads — FPM-safe and Swoole-safe
+
+```php
+$file = $request->file('avatar');
+if ($file !== null && $file->isValid()) {
+ $file->move($dir, $generatedName); // move_uploaded_file on FPM; rename on Swoole
+}
+```
+
+## What's inside
+
+| Class | Role |
+| --- | --- |
+| `Request` | Final, immutable request; extends Symfony's `Request` behind the kernel API |
+| `Response` | Single response type — json/html/text/stream/download/redirect/… |
+| `UploadedFile` | FPM-safe (`is_uploaded_file` check) and Swoole-safe (`fromSwoole`) |
+| `Uri` | Immutable PSR-7 `UriInterface` → `Request::uri()` |
+| `SiteUri` | Absolute-URL generator (host-aware) → `Request::site()` |
+| `Negotiate` | Accept-* content negotiation → `Request::negotiate()` |
+| `Method` | HTTP method enum with `isSafe()` / `isIdempotent()` semantics |
+| `UserAgent` | Parsed user-agent value object |
+| `Concerns\ManagesResponse` | Shared immutable response accessors/mutators |
+| `Contracts\RequestAware` | `setRequest(Request): static` — the only kernel↔controller seam |
+
+## Notes for standalone use
+
+This package is extracted from the PhpServicePlatform kernel. A couple of methods —
+`Request::withIdentity()`/`identity()` and `Request::withContainer()`/`container()` —
+type-hint the kernel's `Identity` and `ModuleContainer`. Those are **optional**: they
+are only resolved when a host framework calls them, so the request/response/URI/upload
+surface is fully usable on its own.
+
+## Testing
+
+```bash
+composer install
+composer test # phpunit
+composer analyse # phpstan
+composer check # both
+```
+
+## Contributing
+
+Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Please run
+`composer check` before opening a pull request. This project follows a
+[Code of Conduct](CODE_OF_CONDUCT.md); by participating you agree to uphold it.
+
+## Security
+
+Found a vulnerability? Please follow the [Security Policy](SECURITY.md) and report
+it privately — never in a public issue.
+
+## License
+
+MIT © AlfaCode Team — see [LICENSE](LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..35383bf
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,37 @@
+# Security Policy
+
+## Supported Versions
+
+The latest released minor version receives security fixes. Because this package
+sits on the request/response boundary, we take reports seriously and aim to
+respond quickly.
+
+| Version | Supported |
+| ------- | ------------------ |
+| latest | :white_check_mark: |
+| older | :x: |
+
+## Reporting a Vulnerability
+
+**Please do not open a public issue for security vulnerabilities.**
+
+Instead, report privately via one of:
+
+- GitHub's [private vulnerability reporting](https://github.com/AlfaCode-Team/http/security/advisories/new)
+ (preferred), or
+- email **shamavurasheed@gmail.com** with the details.
+
+Please include:
+
+- a description of the vulnerability and its impact,
+- steps to reproduce (a minimal proof of concept if possible),
+- the affected version(s) and PHP version.
+
+### What to expect
+
+- We will acknowledge your report within **72 hours**.
+- We will provide an assessment and a remediation timeline as soon as possible.
+- We will credit you in the release notes once a fix is published, unless you
+ prefer to remain anonymous.
+
+Thank you for helping keep the community safe.
diff --git a/SUPPORT.md b/SUPPORT.md
new file mode 100644
index 0000000..b14d54c
--- /dev/null
+++ b/SUPPORT.md
@@ -0,0 +1,26 @@
+# Support
+
+Thanks for using `alfacode-team/http`. Here is how to get help.
+
+## Questions & usage help
+
+- Read the [README](README.md) — it covers install and the full request/response,
+ URI, negotiation, method, and upload APIs.
+- Search [existing issues](https://github.com/AlfaCode-Team/http/issues) — your
+ question may already be answered.
+- Open a [Q&A discussion](https://github.com/AlfaCode-Team/http/discussions) for
+ usage questions.
+
+## Bugs
+
+Open a [bug report](https://github.com/AlfaCode-Team/http/issues/new/choose) with
+a minimal reproduction, the package version, and your PHP version.
+
+## Feature requests
+
+Open a [feature request](https://github.com/AlfaCode-Team/http/issues/new/choose)
+describing the use case and the API you have in mind.
+
+## Security issues
+
+Do **not** open a public issue — follow the [Security Policy](SECURITY.md).
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..5b83d44
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,69 @@
+{
+ "name": "alfacode-team/http",
+ "description": "PhpServicePlatform kernel HTTP layer — immutable Request/Response value objects, PSR-7 URI, content negotiation and Swoole-safe uploads, built on Symfony HttpFoundation.",
+ "type": "library",
+ "keywords": [
+ "http",
+ "request",
+ "response",
+ "psr-7",
+ "uri",
+ "content-negotiation",
+ "http-foundation",
+ "phpserviceplatform"
+ ],
+ "homepage": "https://github.com/AlfaCode-Team/http",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "AlfaCode Team",
+ "homepage": "https://github.com/AlfaCode-Team"
+ },
+ {
+ "name": "Hakeem Shamavu",
+ "email": "shamavurasheed@gmail.com"
+ }
+ ],
+ "support": {
+ "issues": "https://github.com/AlfaCode-Team/http/issues",
+ "source": "https://github.com/AlfaCode-Team/http"
+ },
+ "require": {
+ "php": "^8.4",
+ "psr/http-message": "^1.1 || ^2.0",
+ "symfony/http-foundation": "^8.1",
+ "symfony/mime": "^8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0",
+ "phpstan/phpstan": "^2.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "AlfacodeTeam\\PhpServicePlatform\\Kernel\\Http\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "AlfacodeTeam\\PhpServicePlatform\\Kernel\\Http\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit",
+ "analyse": "phpstan analyse",
+ "check": [
+ "@analyse",
+ "@test"
+ ]
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true
+}
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..bafa59d
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,15 @@
+parameters:
+ level: 6
+ paths:
+ - src
+ # Ignores that do not match must not fail the build (host-dependent symbols).
+ reportUnmatchedIgnoredErrors: false
+ ignoreErrors:
+ # Identity and ModuleContainer are provided by the host kernel at runtime;
+ # they are optional soft dependencies of this standalone package.
+ - '#AlfacodeTeam\\PhpServicePlatform\\Kernel\\(Security\\Identity|Container\\ModuleContainer)#'
+ # Symfony's InputBag is generic; the kernel Request wraps it without a
+ # narrowed TInput. Carried over from the kernel's PHPStan baseline.
+ - identifier: missingType.generics
+ # UserAgent parsing — offset is guaranteed present; harmless defensive ??.
+ - identifier: nullCoalesce.offset
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..bd62418
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/src/Concerns/ManagesResponse.php b/src/Concerns/ManagesResponse.php
new file mode 100644
index 0000000..44ed46c
--- /dev/null
+++ b/src/Concerns/ManagesResponse.php
@@ -0,0 +1,110 @@
+headers is a
+ * ResponseHeaderBag). Every "with*" returns a clone; the original is untouched.
+ */
+trait ManagesResponse
+{
+ public function __clone(): void
+ {
+ $this->headers = clone $this->headers;
+ }
+
+ public function withHeader(string $name, string $value): static
+ {
+ $clone = clone $this;
+ $clone->headers->set($name, $value);
+
+ return $clone;
+ }
+
+ /** @param array $headers */
+ public function withHeaders(array $headers): static
+ {
+ $clone = clone $this;
+ foreach ($headers as $name => $value) {
+ $clone->headers->set($name, $value);
+ }
+
+ return $clone;
+ }
+
+ public function withStatus(int $status): static
+ {
+ $clone = clone $this;
+ $clone->setStatusCode($status);
+
+ return $clone;
+ }
+
+ /** Queue a Set-Cookie header with secure defaults. Returns a clone. */
+ public function withCookie(
+ string $name,
+ string $value,
+ int $maxAge = 0,
+ string $path = '/',
+ ?string $domain = null,
+ bool $secure = true,
+ bool $httpOnly = true,
+ string $sameSite = Cookie::SAMESITE_LAX,
+ ): static {
+ $clone = clone $this;
+ $clone->headers->setCookie(Cookie::create(
+ name: $name,
+ value: $value,
+ expire: $maxAge === 0 ? 0 : time() + $maxAge,
+ path: $path,
+ domain: $domain,
+ secure: $secure,
+ httpOnly: $httpOnly,
+ sameSite: $sameSite,
+ ));
+
+ return $clone;
+ }
+
+ /** Expire a cookie immediately. */
+ public function withoutCookie(string $name, string $path = '/', ?string $domain = null): static
+ {
+ $clone = clone $this;
+ $clone->headers->clearCookie($name, $path, $domain);
+
+ return $clone;
+ }
+
+ public function status(): int
+ {
+ return $this->getStatusCode();
+ }
+
+ public function body(): string
+ {
+ return (string) $this->getContent();
+ }
+
+ /** @return array flattened response headers (original case, excluding cookies) */
+ public function headers(): array
+ {
+ $out = [];
+ foreach ($this->headers->allPreserveCaseWithoutCookies() as $name => $values) {
+ $out[$name] = \is_array($values) ? (string) ($values[0] ?? '') : (string) $values;
+ }
+
+ return $out;
+ }
+
+ /** @return string[] raw Set-Cookie header lines (for Swoole adapters) */
+ public function cookies(): array
+ {
+ return array_map(static fn (Cookie $c): string => (string) $c, $this->headers->getCookies());
+ }
+}
diff --git a/src/Contracts/RequestAware.php b/src/Contracts/RequestAware.php
new file mode 100644
index 0000000..e4c47bc
--- /dev/null
+++ b/src/Contracts/RequestAware.php
@@ -0,0 +1,24 @@
+method()) to lift a Request into a typed value.
+ */
+enum Method: string
+{
+ case CONNECT = 'CONNECT';
+ case DELETE = 'DELETE';
+ case GET = 'GET';
+ case HEAD = 'HEAD';
+ case OPTIONS = 'OPTIONS';
+ case PATCH = 'PATCH';
+ case POST = 'POST';
+ case PUT = 'PUT';
+ case TRACE = 'TRACE';
+
+ /** Safe methods do not mutate state (RFC 9110 §9.2.1). */
+ public function isSafe(): bool
+ {
+ return match ($this) {
+ self::GET, self::HEAD, self::OPTIONS, self::TRACE => true,
+ default => false,
+ };
+ }
+
+ /** Idempotent methods may be retried without additional effect. */
+ public function isIdempotent(): bool
+ {
+ return match ($this) {
+ self::GET, self::HEAD, self::OPTIONS, self::TRACE, self::PUT, self::DELETE => true,
+ default => false,
+ };
+ }
+
+ /** Whether responses to this method are cacheable by default. */
+ public function isCacheable(): bool
+ {
+ return $this === self::GET || $this === self::HEAD;
+ }
+
+ /** @return list all method values */
+ public static function all(): array
+ {
+ return array_map(static fn (self $m): string => $m->value, self::cases());
+ }
+}
diff --git a/src/Negotiate.php b/src/Negotiate.php
new file mode 100644
index 0000000..a28a90b
--- /dev/null
+++ b/src/Negotiate.php
@@ -0,0 +1,90 @@
+negotiate()->language(['en','fr'])`.
+ * - Representation selection: serve JSON vs HTML vs CSV from one endpoint by the
+ * best acceptable media type, beyond the boolean Request::wantsJson().
+ * - Transport: choose a response charset / compression encoding (gzip, br) the
+ * client actually accepts.
+ *
+ * Each method returns the best supported value, falling back to the first
+ * supported option (or the supplied default) when the client expresses no usable
+ * preference — the conservative behaviour servers want. Stateless / immutable.
+ */
+final readonly class Negotiate
+{
+ public function __construct(private Request $request) {}
+
+ public static function for(Request $request): self
+ {
+ return new self($request);
+ }
+
+ /**
+ * Best matching media (content) type.
+ *
+ * @param string[] $supported
+ */
+ public function media(array $supported, ?string $default = null): ?string
+ {
+ return $this->request->accepts($supported) ?? $default ?? ($supported[0] ?? null);
+ }
+
+ /**
+ * Best matching charset.
+ *
+ * @param string[] $supported
+ */
+ public function charset(array $supported, ?string $default = null): ?string
+ {
+ return $this->best($this->request->getCharsets(), $supported, $default);
+ }
+
+ /**
+ * Best matching content encoding (gzip, br, …).
+ *
+ * @param string[] $supported
+ */
+ public function encoding(array $supported, ?string $default = null): ?string
+ {
+ return $this->best($this->request->getEncodings(), $supported, $default);
+ }
+
+ /**
+ * Best matching language.
+ *
+ * @param string[] $supported
+ */
+ public function language(array $supported, ?string $default = null): ?string
+ {
+ return $this->best($this->request->getLanguages(), $supported, $default);
+ }
+
+ /**
+ * @param string[] $accepted client-ranked acceptable values
+ * @param string[] $supported values the server can produce
+ */
+ private function best(array $accepted, array $supported, ?string $default): ?string
+ {
+ if ($supported === []) {
+ return $default;
+ }
+ foreach ($accepted as $value) {
+ foreach ($supported as $candidate) {
+ if (strtolower($value) === strtolower($candidate) || $value === '*') {
+ return $candidate;
+ }
+ }
+ }
+
+ return $default ?? $supported[0];
+ }
+}
diff --git a/src/Request.php b/src/Request.php
new file mode 100644
index 0000000..15cd8cd
--- /dev/null
+++ b/src/Request.php
@@ -0,0 +1,633 @@
+json !== null && $this->json === $this->request;
+
+ $this->query = clone $this->query;
+ $this->request = clone $this->request;
+ $this->attributes = clone $this->attributes;
+ $this->cookies = clone $this->cookies;
+ $this->files = clone $this->files;
+ $this->server = clone $this->server;
+ $this->headers = clone $this->headers;
+
+ if ($this->json !== null) {
+ $this->json = $jsonAliasedToRequest ? $this->request : clone $this->json;
+ }
+ }
+
+ // ── Factory ───────────────────────────────────────────────────────────────
+
+ /**
+ * Build a Request from PHP superglobals (classic SAPI entry point).
+ * Swoole adapters construct the Request directly instead.
+ */
+ public static function capture(): static
+ {
+ static::enableHttpMethodParameterOverride();
+
+ return static::createFromBase(SymfonyRequest::createFromGlobals());
+ }
+
+ /**
+ * Build a Request from discrete components (Swoole / test adapters).
+ *
+ * Maps the framework's component-style inputs onto the Symfony engine so
+ * non-SAPI transports never touch superglobals.
+ *
+ * @param array $headers
+ * @param array $query
+ * @param array $body
+ * @param array $cookies
+ * @param array $files
+ * @param array $server
+ */
+ public static function build(
+ string $method,
+ string $path,
+ array $headers = [],
+ array $query = [],
+ array $body = [],
+ string $rawBody = '',
+ array $cookies = [],
+ array $files = [],
+ array $server = [],
+ ): static {
+ $serverParams = $server;
+ $serverParams['REQUEST_METHOD'] = strtoupper($method);
+ $serverParams['REQUEST_URI'] ??= $path;
+
+ // Adapters (e.g. Swoole) pass the path and query separately, so the
+ // QUERY_STRING server param is absent — without it getQueryString() and
+ // therefore fullUrl()/uri() would drop the query. Derive it from $query.
+ if ($query !== [] && !isset($serverParams['QUERY_STRING'])) {
+ $serverParams['QUERY_STRING'] = http_build_query($query);
+ }
+
+ foreach ($headers as $name => $value) {
+ $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
+ $serverParams[$key] = $value;
+ if (strtolower($name) === 'content-type') {
+ $serverParams['CONTENT_TYPE'] = $value;
+ }
+ if (strtolower($name) === 'content-length') {
+ $serverParams['CONTENT_LENGTH'] = $value;
+ }
+ }
+
+ $base = new SymfonyRequest($query, $body, [], $cookies, $files, $serverParams, $rawBody);
+
+ return static::createFromBase($base);
+ }
+
+ /** Promote a base Symfony request into a kernel Request. */
+ public static function createFromBase(SymfonyRequest $request): static
+ {
+ $new = new static(
+ $request->query->all(),
+ $request->request->all(),
+ $request->attributes->all(),
+ $request->cookies->all(),
+ $request->files->all(),
+ $request->server->all(),
+ $request->getContent(),
+ );
+
+ $new->headers->replace($request->headers->all());
+
+ if ($new->isJson()) {
+ $new->request = $new->json();
+ }
+
+ return $new;
+ }
+
+ // ── Core accessors (GDA contract) ───────────────────────────────────────────
+
+ public function method(): string
+ {
+ return $this->getMethod();
+ }
+
+ /** Path info with leading slash, e.g. "/api/invoices". */
+ public function path(): string
+ {
+ return $this->getPathInfo();
+ }
+
+ /** Decoded, normalised path without surrounding slashes ("/" stays "/"). */
+ public function decodedPath(): string
+ {
+ $path = trim(rawurldecode($this->getPathInfo()), '/');
+
+ return $path === '' ? '/' : $path;
+ }
+
+ public function rawBody(): string
+ {
+ return $this->getContent();
+ }
+
+ public function header(string $name, ?string $default = null): ?string
+ {
+ return $this->headers->get($name, $default);
+ }
+
+ public function hasHeader(string $name): bool
+ {
+ return $this->headers->has($name);
+ }
+
+ /** @return array */
+ public function headersAll(): array
+ {
+ return $this->headers->all();
+ }
+
+ public function cookie(string $name, ?string $default = null): ?string
+ {
+ return $this->cookies->get($name, $default);
+ }
+
+ public function hasCookie(string $name): bool
+ {
+ return $this->cookies->has($name);
+ }
+
+ /** @return array */
+ public function cookiesAll(): array
+ {
+ return $this->cookies->all();
+ }
+
+ public function attribute(string $key, mixed $default = null): mixed
+ {
+ return $this->attributes->get($key, $default);
+ }
+
+ /** @return array */
+ public function attributesAll(): array
+ {
+ return $this->attributes->all();
+ }
+
+ public function identity(): ?Identity
+ {
+ return $this->identity;
+ }
+
+ public function container(): ?ModuleContainer
+ {
+ return $this->container;
+ }
+
+ // ── Input ───────────────────────────────────────────────────────────────────
+
+ /** Active input source: JSON body for JSON requests, query for GET/HEAD, else body. */
+ public function getInputSource(): InputBag
+ {
+ if ($this->isJson()) {
+ return $this->json();
+ }
+
+ return \in_array($this->getRealMethod(), ['GET', 'HEAD'], true) ? $this->query : $this->request;
+ }
+
+ public function input(string $key, mixed $default = null): mixed
+ {
+ return $this->getInputSource()->all()[$key]
+ ?? $this->query->all()[$key]
+ ?? $default;
+ }
+
+ /** @return array merged body + query (+ files) */
+ public function all(): array
+ {
+ return $this->getInputSource()->all() + $this->query->all();
+ }
+
+ /**
+ * The parsed request BODY only (decoded JSON, or form fields) — excludes the
+ * query string. Empty for bodyless methods. Convenient for DTO::fromRequest().
+ *
+ * @return array
+ */
+ public function body(): array
+ {
+ return $this->isJson() ? $this->json()->all() : $this->request->all();
+ }
+
+ public function query(string $key, mixed $default = null): mixed
+ {
+ return $this->query->all()[$key] ?? $default;
+ }
+
+ /** @return array */
+ public function queryAll(): array
+ {
+ return $this->query->all();
+ }
+
+ public function post(string $key, mixed $default = null): mixed
+ {
+ return $this->request->all()[$key] ?? $default;
+ }
+
+ public function server(string $key, mixed $default = null): mixed
+ {
+ return $this->server->get($key, $default);
+ }
+
+ /** Decoded JSON body as an InputBag (empty bag when not JSON / unparsable). */
+ public function json(): InputBag
+ {
+ if ($this->json === null) {
+ $decoded = json_decode($this->getContent(), true);
+ $this->json = new InputBag(\is_array($decoded) ? $decoded : []);
+ }
+
+ return $this->json;
+ }
+
+ public function has(string $key): bool
+ {
+ $all = $this->all();
+
+ return \array_key_exists($key, $all);
+ }
+
+ public function filled(string $key): bool
+ {
+ $value = $this->input($key);
+
+ return $value !== null && $value !== '' && $value !== [];
+ }
+
+ public function missing(string $key): bool
+ {
+ return !$this->has($key);
+ }
+
+ public function boolean(string $key, bool $default = false): bool
+ {
+ $value = $this->input($key);
+
+ return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
+ }
+
+ public function integer(string $key, int $default = 0): int
+ {
+ $value = $this->input($key);
+
+ return $value === null ? $default : (int) $value;
+ }
+
+ public function float(string $key, float $default = 0.0): float
+ {
+ $value = $this->input($key);
+
+ return $value === null ? $default : (float) $value;
+ }
+
+ public function string(string $key, string $default = ''): string
+ {
+ $value = $this->input($key);
+
+ return \is_scalar($value) ? (string) $value : $default;
+ }
+
+ /**
+ * @param string[] $keys
+ * @return array
+ */
+ public function only(array $keys): array
+ {
+ $all = $this->all();
+ $out = [];
+ foreach ($keys as $key) {
+ if (\array_key_exists($key, $all)) {
+ $out[$key] = $all[$key];
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * @param string[] $keys
+ * @return array
+ */
+ public function except(array $keys): array
+ {
+ return array_diff_key($this->all(), array_flip($keys));
+ }
+
+ // ── Files ─────────────────────────────────────────────────────────────────
+
+ public function file(string $key): ?UploadedFile
+ {
+ $file = $this->files->get($key);
+
+ // Already a kernel UploadedFile (e.g. injected by the Swoole adapter in
+ // test mode) — return as-is to preserve its move semantics.
+ if ($file instanceof UploadedFile) {
+ return $file;
+ }
+
+ // A real PHP-FPM upload — wrap it preserving is_uploaded_file() safety.
+ return $file instanceof \Symfony\Component\HttpFoundation\File\UploadedFile
+ ? UploadedFile::createFromBase($file)
+ : null;
+ }
+
+ public function hasFile(string $key): bool
+ {
+ $file = $this->files->get($key);
+
+ return $file instanceof \Symfony\Component\HttpFoundation\File\UploadedFile && $file->getPathname() !== '';
+ }
+
+ // ── URL / connection ──────────────────────────────────────────────────────
+
+ public function isMethod(string $method): bool
+ {
+ return $this->getMethod() === strtoupper($method);
+ }
+
+ public function isSecure(): bool
+ {
+ return parent::isSecure();
+ }
+
+ public function scheme(): string
+ {
+ return $this->getScheme();
+ }
+
+ public function host(): string
+ {
+ return $this->getHost();
+ }
+
+ public function url(): string
+ {
+ return rtrim(preg_replace('/\?.*/', '', $this->getUri()) ?? '', '/');
+ }
+
+ public function fullUrl(): string
+ {
+ $query = $this->getQueryString();
+
+ return $query === null ? $this->url() : $this->url() . '?' . $query;
+ }
+
+ /**
+ * Immutable PSR-7 view of the current full URL, for safe manipulation —
+ * e.g. `$request->uri()->withPath('/login')->withQuery('')` to build a
+ * redirect target, or `->withQuery('')` for a canonical URL. See Uri.
+ */
+ public function uri(): Uri
+ {
+ return Uri::fromRequest($this);
+ }
+
+ /**
+ * Absolute-URL generator rooted at this request's scheme://host, for links
+ * that must not hardcode the host (OAuth callbacks, email links, sitemaps) —
+ * e.g. `$request->site()->to('auth/callback')`. See SiteUri.
+ */
+ public function site(): SiteUri
+ {
+ return SiteUri::fromRequest($this);
+ }
+
+ /**
+ * Content negotiator over this request's Accept-* headers — pick the best
+ * response language / media type / charset / encoding the client accepts,
+ * e.g. `$request->negotiate()->language(['en', 'fr'])`. See Negotiate.
+ */
+ public function negotiate(): Negotiate
+ {
+ return Negotiate::for($this);
+ }
+
+ public function ip(): ?string
+ {
+ return $this->getClientIp();
+ }
+
+ public function userAgent(): ?string
+ {
+ return $this->headers->get('User-Agent');
+ }
+
+ public function contentType(): ?string
+ {
+ return $this->headers->get('Content-Type');
+ }
+
+
+ /** Extract a Bearer token from the Authorization header (no global lookups). */
+ public function bearerToken(): ?string
+ {
+ $header = (string) $this->headers->get('Authorization', '');
+ if (stripos($header, 'Bearer ') === 0) {
+ $token = substr($header, 7);
+ $token = str_contains($token, ',') ? strstr($token, ',', true) : $token;
+
+ return $token !== '' ? trim((string) $token) : null;
+ }
+
+ return null;
+ }
+
+ // ── Path matching ───────────────────────────────────────────────────────────
+
+ /** @return string[] non-empty path segments */
+ public function segments(): array
+ {
+ return array_values(array_filter(
+ explode('/', $this->decodedPath()),
+ static fn($s): bool => $s !== '',
+ ));
+ }
+
+ public function segment(int $index, ?string $default = null): ?string
+ {
+ return $this->segments()[$index - 1] ?? $default;
+ }
+
+ /** Match the path against shell-style wildcard patterns (e.g. "api/*"). */
+ public function is(string ...$patterns): bool
+ {
+ $path = $this->decodedPath();
+ foreach ($patterns as $pattern) {
+ $pattern = trim($pattern, '/') ?: '/';
+ if ($pattern === $path) {
+ return true;
+ }
+ $regex = '#^' . str_replace('\*', '.*', preg_quote($pattern, '#')) . '$#';
+ if (preg_match($regex, $path) === 1) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // ── Content negotiation ─────────────────────────────────────────────────────
+
+ public function isJson(): bool
+ {
+ $type = (string) $this->headers->get('Content-Type');
+
+ return str_contains($type, '/json') || str_contains($type, '+json');
+ }
+
+ public function isXmlHttpRequest(): bool
+ {
+ return parent::isXmlHttpRequest();
+ }
+
+ public function wantsJson(): bool
+ {
+ $acceptable = $this->getAcceptableContentTypes();
+ $first = isset($acceptable[0]) ? strtolower($acceptable[0]) : '';
+
+ return $first !== '' && (str_contains($first, '/json') || str_contains($first, '+json'));
+ }
+
+ public function expectsJson(): bool
+ {
+ return $this->isXmlHttpRequest() || $this->wantsJson() || $this->isJson();
+ }
+
+ /**
+ * Pick the best supported content type against the Accept header; falls back
+ * to the first supported type when nothing matches.
+ *
+ * @param string[] $supported
+ */
+ public function accepts(array $supported): ?string
+ {
+ if ($supported === []) {
+ return null;
+ }
+ $accepts = $this->getAcceptableContentTypes();
+ if ($accepts === []) {
+ return $supported[0];
+ }
+ foreach ($accepts as $accept) {
+ if ($accept === '*/*' || $accept === '*') {
+ return $supported[0];
+ }
+ foreach ($supported as $type) {
+ if (strtolower($accept) === strtolower($type)) {
+ return $type;
+ }
+ }
+ }
+
+ return $supported[0];
+ }
+
+ // ── Immutable mutators (return clones) ────────────────────────────────────
+
+ public function withHeader(string $name, string $value): static
+ {
+ $clone = clone $this;
+ $clone->headers->set($name, $value);
+
+ return $clone;
+ }
+
+ public function withAttribute(string $key, mixed $value): static
+ {
+ $clone = clone $this;
+ $clone->attributes->set($key, $value);
+
+ return $clone;
+ }
+
+ public function withIdentity(Identity $identity): static
+ {
+ $clone = clone $this;
+ $clone->identity = $identity;
+
+ return $clone;
+ }
+
+ public function withContainer(ModuleContainer $container): static
+ {
+ $clone = clone $this;
+ $clone->container = $container;
+
+ return $clone;
+ }
+
+ /**
+ * Return a NEW request with $input merged into the active input source.
+ *
+ * @param array $input
+ */
+ public function merge(array $input): static
+ {
+ $clone = clone $this;
+ $clone->getInputSource()->add($input);
+
+ return $clone;
+ }
+
+ /**
+ * Return a NEW request whose active input source is replaced by $input.
+ *
+ * @param array $input
+ */
+ public function replace(array $input): static
+ {
+ $clone = clone $this;
+ $clone->getInputSource()->replace($input);
+
+ return $clone;
+ }
+}
diff --git a/src/Response.php b/src/Response.php
new file mode 100644
index 0000000..d27ba5b
--- /dev/null
+++ b/src/Response.php
@@ -0,0 +1,335 @@
+ $headers */
+ public static function json(mixed $data, int $status = 200, array $headers = []): self
+ {
+ return new self(
+ json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}',
+ $status,
+ ['Content-Type' => 'application/json'] + $headers,
+ );
+ }
+
+ public static function text(string $body, int $status = 200): self
+ {
+ return new self($body, $status, ['Content-Type' => 'text/plain; charset=utf-8']);
+ }
+
+ public static function html(string $body, int $status = 200): self
+ {
+ return new self($body, $status, ['Content-Type' => 'text/html; charset=utf-8']);
+ }
+
+ /** Convenience: a 200 JSON success envelope. */
+ public static function success(mixed $data = null, int $status = 200): self
+ {
+ return self::json(['success' => true, 'data' => $data], $status);
+ }
+
+ /** 201 Created — optional Location header for the new resource. */
+ public static function created(mixed $data, ?string $location = null): self
+ {
+ $response = self::json($data, 201);
+
+ return $location !== null ? $response->withHeader('Location', $location) : $response;
+ }
+
+ /** 202 Accepted — request queued for async processing. */
+ public static function accepted(mixed $data = null): self
+ {
+ return $data === null ? self::empty(202) : self::json($data, 202);
+ }
+
+ public static function empty(int $status = 204): self
+ {
+ return new self('', $status, []);
+ }
+
+ /** Alias for empty(204). */
+ public static function noContent(): self
+ {
+ return self::empty(204);
+ }
+
+ public static function notFound(string $message = 'Resource not found.'): self
+ {
+ return self::error('not_found', $message, 404);
+ }
+
+ public static function unauthorized(string $message = 'Unauthenticated.'): self
+ {
+ return self::error('unauthorized', $message, 401);
+ }
+
+ public static function forbidden(string $message = 'Forbidden.'): self
+ {
+ return self::error('forbidden', $message, 403);
+ }
+
+ public static function badRequest(string $message = 'Bad request.'): self
+ {
+ return self::error('bad_request', $message, 400);
+ }
+
+ public static function conflict(string $message = 'Conflict.'): self
+ {
+ return self::error('conflict', $message, 409);
+ }
+
+ /** 429 — sets Retry-After when a delay is supplied. */
+ public static function tooManyRequests(string $message = 'Too many requests.', ?int $retryAfter = null): self
+ {
+ $response = self::error('too_many_requests', $message, 429);
+
+ return $retryAfter !== null ? $response->withHeader('Retry-After', (string) $retryAfter) : $response;
+ }
+
+ /** @param array $errors */
+ public static function unprocessable(array $errors, string $message = 'Validation failed.'): self
+ {
+ return self::json([
+ 'error' => [
+ 'code' => 'validation_failed',
+ 'message' => $message,
+ 'fields' => $errors,
+ ],
+ ], 422);
+ }
+
+ public static function serverError(string $message = 'An internal error occurred.'): self
+ {
+ return self::error('server_error', $message, 500);
+ }
+
+ public static function redirect(string $url, int $status = 302): self
+ {
+ return new self('', $status, ['Location' => $url]);
+ }
+
+ /** 301 — permanent redirect (cacheable, changes the canonical URL). */
+ public static function permanentRedirect(string $url): self
+ {
+ return self::redirect($url, 301);
+ }
+
+ /**
+ * Redirect back to where the request came from. Pass the request's Referer
+ * (e.g. $request->header('referer')); falls back to $fallback when absent.
+ */
+ public static function back(?string $referer, string $fallback = '/', int $status = 302): self
+ {
+ return self::redirect($referer !== null && $referer !== '' ? $referer : $fallback, $status);
+ }
+
+ private static function error(string $code, string $message, int $status): self
+ {
+ return self::json(['error' => ['code' => $code, 'message' => $message]], $status);
+ }
+
+ /** JSONP — wraps a JSON payload in a JavaScript callback invocation. */
+ public static function jsonp(string $callback, mixed $data, int $status = 200): self
+ {
+ $json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: 'null';
+
+ return new self(
+ sprintf('/**/%s(%s);', $callback, $json),
+ $status,
+ ['Content-Type' => 'application/javascript'],
+ );
+ }
+
+ /**
+ * Stream output produced by a callback (no in-memory body).
+ *
+ * @param array $headers
+ */
+ public static function stream(callable $callback, int $status = 200, array $headers = []): self
+ {
+ $response = new self('', $status, $headers);
+ $response->streamCallback = \Closure::fromCallable($callback);
+
+ return $response;
+ }
+
+ /**
+ * Stream a download produced by a callback (no file on disk).
+ *
+ * @param array $headers
+ */
+ public static function streamDownload(callable $callback, string $name, array $headers = []): self
+ {
+ $response = self::stream($callback, 200, $headers);
+ $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(
+ ResponseHeaderBag::DISPOSITION_ATTACHMENT,
+ $name,
+ str_replace('%', '', (string) iconv('UTF-8', 'ASCII//TRANSLIT', $name)),
+ ));
+
+ return $response;
+ }
+
+ /**
+ * Force a file download from disk.
+ *
+ * @param array $headers
+ */
+ public static function download(\SplFileInfo|string $file, ?string $name = null, array $headers = []): self
+ {
+ return self::fileResponse($file, $name, $headers, ResponseHeaderBag::DISPOSITION_ATTACHMENT);
+ }
+
+ /** Serve a file inline (rendered in-browser). */
+ public static function file(\SplFileInfo|string $file, ?string $name = null): self
+ {
+ return self::fileResponse($file, $name, [], ResponseHeaderBag::DISPOSITION_INLINE);
+ }
+
+ /** @param array $headers */
+ private static function fileResponse(\SplFileInfo|string $file, ?string $name, array $headers, string $disposition): self
+ {
+ $path = $file instanceof \SplFileInfo ? $file->getPathname() : $file;
+ $name ??= basename($path);
+
+ $response = new self('', 200, $headers);
+ $response->filePath = $path;
+ if (! $response->headers->has('Content-Type')) {
+ $response->headers->set('Content-Type', 'application/octet-stream');
+ }
+ $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(
+ $disposition,
+ $name,
+ str_replace('%', '', (string) iconv('UTF-8', 'ASCII//TRANSLIT', $name)),
+ ));
+
+ return $response;
+ }
+
+ // ── Sending (SAPI) ──────────────────────────────────────────────────────────
+
+ public function send(bool $flush = true): static
+ {
+ if ($this->streamCallback !== null) {
+ if (! headers_sent()) {
+ $this->sendHeaders();
+ }
+ ($this->streamCallback)();
+
+ return $this;
+ }
+
+ if ($this->filePath !== null) {
+ if (! headers_sent()) {
+ if (! $this->headers->has('Content-Length') && is_file($this->filePath)) {
+ $this->headers->set('Content-Length', (string) filesize($this->filePath));
+ }
+ $this->sendHeaders();
+ }
+ if (is_file($this->filePath)) {
+ readfile($this->filePath);
+ }
+
+ return $this;
+ }
+
+ return parent::send($flush);
+ }
+
+ /**
+ * Materialise the body as a string (used by Swoole adapters that read
+ * status()/headers()/body()/cookies() instead of calling send()).
+ */
+ public function body(): string
+ {
+ if ($this->filePath !== null) {
+ return is_file($this->filePath) ? (file_get_contents($this->filePath) ?: '') : '';
+ }
+
+ if ($this->streamCallback !== null) {
+ ob_start();
+ ($this->streamCallback)();
+
+ return (string) ob_get_clean();
+ }
+
+ return (string) $this->getContent();
+ }
+
+ // ── Transport-agnostic emission (Swoole adapters) ────────────────────────────
+
+ /** True when this response streams a file from disk (use sendfile()). */
+ public function isFile(): bool
+ {
+ return $this->filePath !== null;
+ }
+
+ /** True when this response streams output from a callback. */
+ public function isStreamed(): bool
+ {
+ return $this->streamCallback !== null;
+ }
+
+ /** Absolute path of the file to stream, or null for non-file responses. */
+ public function filePath(): ?string
+ {
+ return $this->filePath;
+ }
+
+ /**
+ * Emit a streamed response in chunks via $writer, without buffering the whole
+ * body in memory. For non-streamed responses, $writer receives the full body
+ * once. Lets a Swoole adapter pipe output through $res->write().
+ *
+ * @param callable(string): void $writer
+ */
+ public function streamTo(callable $writer): void
+ {
+ if ($this->streamCallback === null) {
+ $writer($this->body());
+
+ return;
+ }
+
+ ob_start(static function (string $buffer) use ($writer): string {
+ if ($buffer !== '') {
+ $writer($buffer);
+ }
+
+ return '';
+ }, 8192);
+
+ ($this->streamCallback)();
+
+ ob_end_flush();
+ }
+}
diff --git a/src/SiteUri.php b/src/SiteUri.php
new file mode 100644
index 0000000..295f32a
--- /dev/null
+++ b/src/SiteUri.php
@@ -0,0 +1,66 @@
+to('auth/callback')` for an absolute URL. Immutable and side-effect free.
+ */
+final readonly class SiteUri
+{
+ private string $baseUrl;
+
+ public function __construct(string $baseUrl)
+ {
+ $this->baseUrl = rtrim($baseUrl, '/');
+ }
+
+ /** Base URL = scheme://host[:port] of the incoming request. */
+ public static function fromRequest(Request $request): self
+ {
+ return new self($request->scheme() . '://' . $request->getHttpHost());
+ }
+
+ public function base(): string
+ {
+ return $this->baseUrl;
+ }
+
+ /**
+ * Absolute URL for a path, with an optional query.
+ *
+ * @param array $query
+ */
+ public function to(string $path = '', array $query = []): string
+ {
+ $url = $this->baseUrl . '/' . ltrim($path, '/');
+ if ($query !== []) {
+ $url .= '?' . http_build_query($query);
+ }
+
+ return $url;
+ }
+
+ /** Absolute URL for a static asset (alias of to() for readability). */
+ public function asset(string $path): string
+ {
+ return $this->to($path);
+ }
+
+ /** As a PSR-7 Uri value object (for further immutable manipulation). */
+ public function uri(string $path = ''): Uri
+ {
+ return new Uri($this->to($path));
+ }
+}
diff --git a/src/UploadedFile.php b/src/UploadedFile.php
new file mode 100644
index 0000000..6cb2244
--- /dev/null
+++ b/src/UploadedFile.php
@@ -0,0 +1,94 @@
+getPathname(),
+ $file->getClientOriginalName(),
+ $file->getClientMimeType(),
+ $file->getError(),
+ $test,
+ );
+ }
+
+ /**
+ * Build a kernel UploadedFile from an OpenSwoole file entry.
+ *
+ * Swoole's per-file array is $_FILES-shaped (name/type/tmp_name/error/size)
+ * but the temp file was NOT created by PHP's multipart handler, so
+ * `is_uploaded_file()` would reject it. We therefore construct in test mode
+ * (test=true) so moveTo() uses rename() instead of move_uploaded_file().
+ *
+ * @param array{name?: string, type?: string, tmp_name?: string, error?: int} $file
+ */
+ public static function fromSwoole(array $file): static
+ {
+ return new static(
+ $file['tmp_name'] ?? '',
+ $file['name'] ?? '',
+ $file['type'] ?? null,
+ $file['error'] ?? UPLOAD_ERR_OK,
+ true,
+ );
+ }
+
+ public function clientName(): string
+ {
+ return $this->getClientOriginalName();
+ }
+
+ public function clientMimeType(): string
+ {
+ return $this->getClientMimeType();
+ }
+
+ public function size(): int
+ {
+ return (int) $this->getSize();
+ }
+
+ public function tempPath(): string
+ {
+ return $this->getPathname();
+ }
+
+ public function isValid(): bool
+ {
+ return parent::isValid();
+ }
+
+ public function contents(): string
+ {
+ $path = $this->getPathname();
+
+ return is_readable($path) ? (file_get_contents($path) ?: '') : '';
+ }
+
+ public function extension(): string
+ {
+ return strtolower($this->getClientOriginalExtension());
+ }
+}
diff --git a/src/Uri.php b/src/Uri.php
new file mode 100644
index 0000000..8d1d463
--- /dev/null
+++ b/src/Uri.php
@@ -0,0 +1,198 @@
+uri()->withPath('/login')->withQuery('')`.
+ * - Canonical URLs: strip the query / force https / drop a default port for
+ * cache keys, , sitemaps, signed-URL bases.
+ * - Interop: implementing the PSR interface means it drops straight into any
+ * PSR-7 aware code (middleware, HTTP clients) without adapters.
+ *
+ * Get one from the current request via Request::uri(), or parse any string with
+ * `new Uri($string)`. Every "with*" returns a new instance; nothing mutates.
+ */
+final class Uri implements UriInterface, \Stringable
+{
+ private string $scheme = '';
+ private string $userInfo = '';
+ private string $host = '';
+ private ?int $port = null;
+ private string $path = '';
+ private string $query = '';
+ private string $fragment = '';
+
+ public function __construct(string $uri = '')
+ {
+ if ($uri === '') {
+ return;
+ }
+ $parts = parse_url($uri);
+ if ($parts === false) {
+ throw new \InvalidArgumentException("Unable to parse URI: {$uri}");
+ }
+ $this->scheme = isset($parts['scheme']) ? strtolower($parts['scheme']) : '';
+ $this->host = isset($parts['host']) ? strtolower($parts['host']) : '';
+ $this->port = $this->filterPort($parts['port'] ?? null);
+ $this->path = $parts['path'] ?? '';
+ $this->query = $parts['query'] ?? '';
+ $this->fragment = $parts['fragment'] ?? '';
+ $this->userInfo = $parts['user'] ?? '';
+ if (isset($parts['pass'])) {
+ $this->userInfo .= ':' . $parts['pass'];
+ }
+ }
+
+ /** Build a Uri from the current request's full URL. */
+ public static function fromRequest(Request $request): self
+ {
+ return new self($request->fullUrl());
+ }
+
+ public function getScheme(): string
+ {
+ return $this->scheme;
+ }
+
+ public function getAuthority(): string
+ {
+ if ($this->host === '') {
+ return '';
+ }
+ $authority = $this->host;
+ if ($this->userInfo !== '') {
+ $authority = $this->userInfo . '@' . $authority;
+ }
+ if ($this->port !== null) {
+ $authority .= ':' . $this->port;
+ }
+
+ return $authority;
+ }
+
+ public function getUserInfo(): string
+ {
+ return $this->userInfo;
+ }
+
+ public function getHost(): string
+ {
+ return $this->host;
+ }
+
+ public function getPort(): ?int
+ {
+ return $this->port;
+ }
+
+ public function getPath(): string
+ {
+ return $this->path;
+ }
+
+ public function getQuery(): string
+ {
+ return $this->query;
+ }
+
+ public function getFragment(): string
+ {
+ return $this->fragment;
+ }
+
+ public function withScheme(string $scheme): UriInterface
+ {
+ $clone = clone $this;
+ $clone->scheme = strtolower($scheme);
+ $clone->port = $clone->filterPort($clone->port);
+
+ return $clone;
+ }
+
+ public function withUserInfo(string $user, ?string $password = null): UriInterface
+ {
+ $clone = clone $this;
+ $clone->userInfo = $password !== null && $password !== '' ? "{$user}:{$password}" : $user;
+
+ return $clone;
+ }
+
+ public function withHost(string $host): UriInterface
+ {
+ $clone = clone $this;
+ $clone->host = strtolower($host);
+
+ return $clone;
+ }
+
+ public function withPort(?int $port): UriInterface
+ {
+ $clone = clone $this;
+ $clone->port = $clone->filterPort($port);
+
+ return $clone;
+ }
+
+ public function withPath(string $path): UriInterface
+ {
+ $clone = clone $this;
+ $clone->path = $path;
+
+ return $clone;
+ }
+
+ public function withQuery(string $query): UriInterface
+ {
+ $clone = clone $this;
+ $clone->query = ltrim($query, '?');
+
+ return $clone;
+ }
+
+ public function withFragment(string $fragment): UriInterface
+ {
+ $clone = clone $this;
+ $clone->fragment = ltrim($fragment, '#');
+
+ return $clone;
+ }
+
+ public function __toString(): string
+ {
+ $uri = '';
+ if ($this->scheme !== '') {
+ $uri .= $this->scheme . ':';
+ }
+ $authority = $this->getAuthority();
+ if ($authority !== '' || $this->scheme === 'file') {
+ $uri .= '//' . $authority;
+ }
+ $uri .= $this->path;
+ if ($this->query !== '') {
+ $uri .= '?' . $this->query;
+ }
+ if ($this->fragment !== '') {
+ $uri .= '#' . $this->fragment;
+ }
+
+ return $uri;
+ }
+
+ /** Drop the port when it equals the scheme's default. */
+ private function filterPort(?int $port): ?int
+ {
+ if ($port === null) {
+ return null;
+ }
+ $defaults = ['http' => 80, 'https' => 443, 'ftp' => 21];
+
+ return ($defaults[$this->scheme] ?? null) === $port ? null : $port;
+ }
+}
diff --git a/src/UserAgent.php b/src/UserAgent.php
new file mode 100644
index 0000000..74e6c4b
--- /dev/null
+++ b/src/UserAgent.php
@@ -0,0 +1,126 @@
+ platform name => match needle (lower-case) */
+ private const PLATFORMS = [
+ 'Windows' => 'windows',
+ 'Android' => 'android',
+ 'iOS' => 'iphone',
+ 'iPadOS' => 'ipad',
+ 'macOS' => 'macintosh',
+ 'Linux' => 'linux',
+ 'Chrome OS' => 'cros',
+ ];
+
+ /** Browser tokens in priority order (first match wins). */
+ private const BROWSERS = [
+ 'Edge' => '/edg(?:e|ios|a)?\/([\d.]+)/i',
+ 'Opera' => '/(?:opera|opr)\/([\d.]+)/i',
+ 'Firefox' => '/firefox\/([\d.]+)/i',
+ 'Chrome' => '/(?:chrome|crios)\/([\d.]+)/i',
+ 'Safari' => '/version\/([\d.]+).*safari/i',
+ ];
+
+ private function __construct(
+ private readonly string $raw,
+ private readonly string $platform,
+ private readonly string $browser,
+ private readonly string $version,
+ private readonly bool $robot,
+ private readonly bool $mobile,
+ ) {}
+
+ public static function fromRequest(Request $request): self
+ {
+ return self::parse($request->userAgent() ?? '');
+ }
+
+ public static function parse(string $ua): self
+ {
+ $lower = strtolower($ua);
+
+ $platform = '';
+ foreach (self::PLATFORMS as $name => $needle) {
+ if (str_contains($lower, $needle)) {
+ $platform = $name;
+ break;
+ }
+ }
+
+ $browser = '';
+ $version = '';
+ foreach (self::BROWSERS as $name => $pattern) {
+ if (preg_match($pattern, $ua, $m) === 1) {
+ $browser = $name;
+ $version = $m[1] ?? '';
+ break;
+ }
+ }
+
+ $robot = $ua !== '' && preg_match('/bot|crawl|slurp|spider|mediapartners|facebookexternalhit|curl|wget|python-requests/i', $ua) === 1;
+ $mobile = preg_match('/mobile|android|iphone|ipod|blackberry|windows phone/i', $ua) === 1;
+
+ return new self($ua, $platform, $browser, $version, $robot, $mobile);
+ }
+
+ public function raw(): string
+ {
+ return $this->raw;
+ }
+
+ public function platform(): string
+ {
+ return $this->platform;
+ }
+
+ public function browser(): string
+ {
+ return $this->browser;
+ }
+
+ public function version(): string
+ {
+ return $this->version;
+ }
+
+ public function isRobot(): bool
+ {
+ return $this->robot;
+ }
+
+ public function isMobile(): bool
+ {
+ return $this->mobile;
+ }
+
+ /** A real browser: a known browser token and not a bot. */
+ public function isBrowser(): bool
+ {
+ return $this->browser !== '' && !$this->robot;
+ }
+
+ public function isEmpty(): bool
+ {
+ return $this->raw === '';
+ }
+
+ public function __toString(): string
+ {
+ return $this->raw;
+ }
+}
diff --git a/tests/MethodTest.php b/tests/MethodTest.php
new file mode 100644
index 0000000..ab655cc
--- /dev/null
+++ b/tests/MethodTest.php
@@ -0,0 +1,45 @@
+isSafe());
+ self::assertTrue(Method::HEAD->isSafe());
+ self::assertFalse(Method::POST->isSafe());
+ self::assertFalse(Method::DELETE->isSafe());
+ }
+
+ public function testIdempotentMethods(): void
+ {
+ self::assertTrue(Method::PUT->isIdempotent());
+ self::assertTrue(Method::DELETE->isIdempotent());
+ self::assertFalse(Method::POST->isIdempotent());
+ }
+
+ public function testFromStringValue(): void
+ {
+ self::assertSame(Method::POST, Method::from('POST'));
+ }
+
+ public function testCacheableMethods(): void
+ {
+ self::assertTrue(Method::GET->isCacheable());
+ self::assertFalse(Method::POST->isCacheable());
+ }
+
+ public function testAllReturnsEveryMethodValue(): void
+ {
+ $all = Method::all();
+ self::assertContains('GET', $all);
+ self::assertContains('DELETE', $all);
+ self::assertCount(count(Method::cases()), $all);
+ }
+}
diff --git a/tests/RequestTest.php b/tests/RequestTest.php
new file mode 100644
index 0000000..bebb0a1
--- /dev/null
+++ b/tests/RequestTest.php
@@ -0,0 +1,55 @@
+method());
+ self::assertSame('/api/invoices', $request->path());
+ self::assertTrue($request->isMethod('post'));
+ }
+
+ public function testMergesQueryAndBodyInput(): void
+ {
+ $request = Request::create('/search?page=2', 'POST', ['title' => 'Hello']);
+
+ self::assertSame('Hello', $request->input('title'));
+ self::assertSame('2', $request->input('page'));
+ self::assertSame('fallback', $request->input('missing', 'fallback'));
+ }
+
+ public function testTypedAccessors(): void
+ {
+ $request = Request::create('/', 'GET', ['active' => 'yes', 'page' => '3']);
+
+ self::assertTrue($request->boolean('active'));
+ self::assertSame(3, $request->integer('page'));
+ }
+
+ public function testWithAttributeIsImmutable(): void
+ {
+ $request = Request::create('/', 'GET');
+ $next = $request->withAttribute('locale', 'fr');
+
+ self::assertNotSame($request, $next);
+ self::assertNull($request->attribute('locale'), 'original must be untouched');
+ self::assertSame('fr', $next->attribute('locale'));
+ }
+
+ public function testNegotiatesLanguage(): void
+ {
+ $request = Request::create('/', 'GET');
+ $request->headers->set('Accept-Language', 'fr-FR,fr;q=0.9,en;q=0.5');
+
+ self::assertSame('fr', $request->negotiate()->language(['en', 'fr', 'ar']));
+ }
+}
diff --git a/tests/UriTest.php b/tests/UriTest.php
new file mode 100644
index 0000000..0394c61
--- /dev/null
+++ b/tests/UriTest.php
@@ -0,0 +1,44 @@
+getScheme());
+ self::assertSame('example.com', $uri->getHost());
+ self::assertSame(8443, $uri->getPort());
+ self::assertSame('/api/invoices', $uri->getPath());
+ self::assertSame('page=2', $uri->getQuery());
+ self::assertSame('top', $uri->getFragment());
+ self::assertSame('user:pass', $uri->getUserInfo());
+ }
+
+ public function testIsImmutable(): void
+ {
+ $uri = new Uri('https://example.com/a');
+ $changed = $uri->withPath('/b')->withQuery('x=1');
+
+ self::assertInstanceOf(UriInterface::class, $changed);
+ self::assertSame('/a', $uri->getPath(), 'original must be untouched');
+ self::assertSame('/b', $changed->getPath());
+ self::assertSame('x=1', $changed->getQuery());
+ }
+
+ public function testStringifies(): void
+ {
+ $uri = (new Uri('https://example.com'))->withPath('/login');
+
+ self::assertStringContainsString('https://example.com', (string) $uri);
+ self::assertStringContainsString('/login', (string) $uri);
+ }
+}