From ab61aa9e8568680465ecd7c58cc95a2c9f547ccd Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 22 Jul 2026 16:12:59 +0300 Subject: [PATCH 1/6] Open-source the PhpServicePlatform HTTP layer Extract the kernel HTTP layer into a standalone MIT-licensed package: immutable Request/Response value objects, PSR-7 Uri, SiteUri, content negotiation, Method enum, UserAgent, and FPM-/Swoole-safe UploadedFile. Add community health files (README, LICENSE, CODE_OF_CONDUCT, CONTRIBUTING, SECURITY, SUPPORT, issue/PR templates, CODEOWNERS, dependabot), CI matrix (PHP 8.2/8.3/8.4 -> validate + PHPStan + PHPUnit), phpunit + phpstan config, tests, and a kernel-styled .gitignore. --- .gitattributes | 14 + .github/CODEOWNERS | 5 + .github/ISSUE_TEMPLATE/bug_report.yml | 50 ++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 25 + .github/PULL_REQUEST_TEMPLATE.md | 24 + .github/dependabot.yml | 16 + .github/workflows/ci.yml | 38 ++ .gitignore | 54 ++ CHANGELOG.md | 16 + CODE_OF_CONDUCT.md | 133 +++++ CONTRIBUTING.md | 37 ++ LICENSE | 222 +------- README.md | 181 +++++- SECURITY.md | 37 ++ SUPPORT.md | 26 + composer.json | 64 +++ phpstan.neon | 8 + phpunit.xml | 18 + src/Concerns/ManagesResponse.php | 110 ++++ src/Contracts/RequestAware.php | 24 + src/Method.php | 53 ++ src/Negotiate.php | 90 +++ src/Request.php | 633 +++++++++++++++++++++ src/Response.php | 335 +++++++++++ src/SiteUri.php | 66 +++ src/UploadedFile.php | 94 +++ src/Uri.php | 198 +++++++ src/UserAgent.php | 126 ++++ tests/MethodTest.php | 45 ++ tests/RequestTest.php | 55 ++ tests/UriTest.php | 44 ++ 32 files changed, 2647 insertions(+), 202 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 SUPPORT.md create mode 100644 composer.json create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 src/Concerns/ManagesResponse.php create mode 100644 src/Contracts/RequestAware.php create mode 100644 src/Method.php create mode 100644 src/Negotiate.php create mode 100644 src/Request.php create mode 100644 src/Response.php create mode 100644 src/SiteUri.php create mode 100644 src/UploadedFile.php create mode 100644 src/Uri.php create mode 100644 src/UserAgent.php create mode 100644 tests/MethodTest.php create mode 100644 tests/RequestTest.php create mode 100644 tests/UriTest.php 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..5a39975 --- /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.2', '8.3', '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/.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..1e50192 --- /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.2+, `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..12f6560 100644 --- a/README.md +++ b/README.md @@ -1 +1,180 @@ -# http \ No newline at end of file +# AlfaCode HTTP + +[![CI](https://github.com/AlfaCode-Team/http/actions/workflows/ci.yml/badge.svg)](https://github.com/AlfaCode-Team/http/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![PHP ^8.2](https://img.shields.io/badge/php-%5E8.2-777bb4.svg)](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.2+ 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..d57dd20 --- /dev/null +++ b/composer.json @@ -0,0 +1,64 @@ +{ + "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.2", + "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 + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..8ebfe36 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + level: 6 + paths: + - src + # Identity and ModuleContainer are provided by the host kernel at runtime; they + # are optional soft dependencies of this standalone package. + ignoreErrors: + - '#AlfacodeTeam\\PhpServicePlatform\\Kernel\\(Security\\Identity|Container\\ModuleContainer)#' 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); + } +} From 82ca7fc902b1d5dca74ba1a461dbf48790621368 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 22 Jul 2026 16:29:58 +0300 Subject: [PATCH 2/6] ci: silence pre-existing PHPStan generics/null-coalesce (carried from kernel baseline) --- phpstan.neon | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 8ebfe36..bafa59d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,7 +2,14 @@ parameters: level: 6 paths: - src - # Identity and ModuleContainer are provided by the host kernel at runtime; they - # are optional soft dependencies of this standalone package. + # 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 From 51a839473e6ad1b5fd3096396e62d1e45885b1c3 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 22 Jul 2026 16:32:22 +0300 Subject: [PATCH 3/6] ci: require PHP 8.4 (Symfony 8 needs >=8.4.1); drop 8.2/8.3 from matrix --- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 2 +- README.md | 4 ++-- composer.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a39975..9a80205 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.2', '8.3', '8.4'] + php: ['8.4'] name: PHP ${{ matrix.php }} steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e50192..7cd5bd4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ composer install ## Coding standards -- PHP 8.2+, `declare(strict_types=1);` in every file. +- PHP 8.4+, `declare(strict_types=1);` in every file. - Follow the existing formatting (PSR-12-ish, aligned to the surrounding code). ## Reporting issues diff --git a/README.md b/README.md index 12f6560..8e0f618 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/AlfaCode-Team/http/actions/workflows/ci.yml/badge.svg)](https://github.com/AlfaCode-Team/http/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![PHP ^8.2](https://img.shields.io/badge/php-%5E8.2-777bb4.svg)](https://www.php.net/) +[![PHP ^8.4](https://img.shields.io/badge/php-%5E8.4-777bb4.svg)](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 @@ -32,7 +32,7 @@ depend on Symfony types directly. composer require alfacode-team/http ``` -Requires PHP 8.2+ and the `symfony/http-foundation` + `symfony/mime` runtime deps +Requires PHP 8.4+ and the `symfony/http-foundation` + `symfony/mime` runtime deps (installed automatically). ## Usage diff --git a/composer.json b/composer.json index d57dd20..2b3b1fd 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "source": "https://github.com/AlfaCode-Team/http" }, "require": { - "php": "^8.2", + "php": "^8.4", "psr/http-message": "^1.1 || ^2.0", "symfony/http-foundation": "^8.1", "symfony/mime": "^8.1" From c4b0a2ced9315e4908c13e9336c55122aa45466f Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 22 Jul 2026 16:42:50 +0300 Subject: [PATCH 4/6] release: add dev-master branch alias (1.0.x-dev) and tag-triggered Release workflow --- .github/workflows/release.yml | 44 +++++++++++++++++++++++++++++++++++ composer.json | 5 ++++ 2 files changed, 49 insertions(+) create mode 100644 .github/workflows/release.yml 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/composer.json b/composer.json index 2b3b1fd..5b83d44 100644 --- a/composer.json +++ b/composer.json @@ -59,6 +59,11 @@ "config": { "sort-packages": true }, + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, "minimum-stability": "stable", "prefer-stable": true } From f482c203c8b6d15c65ae995ae2d89bdbe6525631 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 22 Jul 2026 17:26:46 +0300 Subject: [PATCH 5/6] release: auto-release on merge to main (CHANGELOG-driven), make release.yml callable --- .github/workflows/auto-release.yml | 71 ++++++++++++++++++++++++++++++ .github/workflows/release.yml | 31 +++++++++++-- CHANGELOG.md | 2 + 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/auto-release.yml diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..3b5a9bf --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,71 @@ +name: Auto Release + +# When a version bump lands on main, read the top CHANGELOG version and, if it +# has no matching tag yet, create the tag `vX.Y.Z` and call release.yml to build +# + publish it. No PAT needed — release.yml is invoked via workflow_call because +# the default token cannot let a tag push trigger another workflow. + +"on": + push: + branches: + - main + paths: + - CHANGELOG.md # only a version bump can start a release + +permissions: + contents: write + +concurrency: + group: auto-release + cancel-in-progress: false + +jobs: + detect: + name: Detect new version + runs-on: ubuntu-latest + outputs: + release: ${{ steps.ver.outputs.release }} + version: ${{ steps.ver.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need all tags + + - name: Read top CHANGELOG version + id: ver + run: | + # First "## [x.y.z]" heading — skips "## [Unreleased]". + VERSION="$(grep -m1 -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' CHANGELOG.md | tr -d '## []')" + if [ -z "$VERSION" ]; then + echo "No versioned CHANGELOG entry found — nothing to release." + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if git rev-parse "v$VERSION" >/dev/null 2>&1; then + echo "Tag v$VERSION already exists — skipping." + echo "release=false" >> "$GITHUB_OUTPUT" + else + echo "New version detected: v$VERSION" + echo "release=true" >> "$GITHUB_OUTPUT" + fi + + - name: Create and push tag + if: steps.ver.outputs.release == 'true' + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "v$VERSION" -m "Release v$VERSION" + git push origin "v$VERSION" + + release: + name: Build & publish + needs: detect + if: needs.detect.outputs.release == 'true' + uses: ./.github/workflows/release.yml + permissions: + contents: write + with: + version: ${{ needs.detect.outputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 701d0a3..3b4c976 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,21 @@ name: Release +# Publishes a GitHub Release. Two entry points: +# • a pushed tag `v*` — manual escape hatch +# • workflow_call(version) — invoked by auto-release.yml on merge to main +# (the default token cannot let a tag push trigger this workflow, so the +# auto-release path calls it directly). + on: push: tags: - 'v*' + workflow_call: + inputs: + version: + description: 'Release version without the leading v (e.g. 1.2.0)' + required: true + type: string permissions: contents: write @@ -34,11 +46,24 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Create release + - name: Resolve tag + id: tag + run: | + if [ -n "${{ inputs.version }}" ]; then + echo "tag=v${{ inputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi + - name: Create release (idempotent) env: GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.tag.outputs.tag }} run: | - gh release create "${GITHUB_REF_NAME}" \ - --title "${GITHUB_REF_NAME}" \ + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists — nothing to do." + exit 0 + fi + gh release create "$TAG" \ + --title "$TAG" \ --generate-notes \ --verify-tag diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ee22a4..1845e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [1.0.0] - 2026-07-22 + ### Added - Initial extraction of the PhpServicePlatform kernel HTTP layer into a standalone, From 5bc998ac4a9575a560a027073b8e2792bfff27ae Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 22 Jul 2026 17:48:04 +0300 Subject: [PATCH 6/6] ci: bump actions/checkout to v7 in auto-release (Node 20 deprecation) --- .github/workflows/auto-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 3b5a9bf..21fa51c 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -27,7 +27,7 @@ jobs: release: ${{ steps.ver.outputs.release }} version: ${{ steps.ver.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 # need all tags