From 29f5a10647e82e163f9af2e794d67c4df8d9054d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 15:12:23 +0300 Subject: [PATCH 1/4] feat: Add EditorConfig, GitHub workflows, update PHP version, PSR-7, PSR-17, PSR-18, PSR-18 Client Configuration documentation, unit tests, src exclusion in phpunit.xml.dist, refactor Client.php, PSR-17 Factory and Facade classes, add FacadableInterface, unit tests for PSR-7 RequestInterface immutability & Host header sync, tests for ResponseReasonPhraseTable, ResponseRedirect, ServerRequestCreateFromGlobals, ServerRequestNormalizeFiles, StreamDetachPreservesPosition, remove message bag, custom interfaces, update to PSR-7, refactor code, remove deprecated interfaces and methods from Message package, update PSR-7 Uri implementation and ClientConfiguration tests --- .editorconfig | 18 + .github/workflows/static-analysis.yml | 30 + .github/workflows/tests.yml | 48 + CHANGELOG.md | 72 + README.md | 262 +++- composer.json | 89 +- docs/README.md | 39 + docs/emitter/basic-emission.md | 55 + docs/emitter/chunked-bodies.md | 43 + docs/emitter/content-range.md | 55 + docs/facades/customization.md | 59 + docs/facades/overview.md | 53 + docs/getting-started.md | 66 + docs/psr17/factory.md | 78 ++ docs/psr18/client.md | 78 ++ docs/psr18/configuration.md | 77 + docs/psr18/exceptions.md | 71 + docs/psr7/messages.md | 140 ++ docs/psr7/server-request.md | 100 ++ docs/psr7/streams.md | 97 ++ docs/psr7/uploaded-files.md | 109 ++ docs/psr7/uri.md | 66 + docs/recipes/file-upload.md | 72 + docs/recipes/json-response.md | 74 + docs/recipes/proxying-requests.md | 74 + docs/recipes/redirect.md | 63 + docs/recipes/streaming-large-files.md | 83 ++ docs/reference/http-status-codes.md | 89 ++ docs/upgrade-guide.md | 152 ++ phpstan.neon.dist | 26 + phpunit.xml.dist | 11 + src/Client/Client.php | 844 +++++++---- src/Client/Exceptions/ClientException.php | 45 +- src/Client/Exceptions/NetworkException.php | 92 +- src/Client/Exceptions/RequestException.php | 93 +- src/Emitter/Emitter.php | 372 +++-- src/Emitter/Exceptions/EmitBodyException.php | 45 +- .../Exceptions/EmitHeaderException.php | 46 +- src/Facade/Client.php | 118 +- src/Facade/Emitter.php | 92 +- src/Facade/Factory.php | 119 +- src/Facade/Interfaces/FacadableInterface.php | 50 + src/Facade/Interfaces/FacadebleInterface.php | 51 +- src/Facade/Traits/Facadable.php | 43 + src/Facade/Traits/Facadeble.php | 56 +- src/Factory/Factory.php | 310 ++-- src/Helpers.php | 62 +- src/Message/Interfaces/MessageInterface.php | 64 - src/Message/Interfaces/RequestInterface.php | 85 -- src/Message/Interfaces/ResponseInterface.php | 52 - .../Interfaces/ServerRequestInterface.php | 65 - src/Message/Interfaces/StreamInterface.php | 31 - src/Message/Interfaces/UriInterface.php | 64 - src/Message/Request.php | 221 +-- src/Message/Response.php | 537 ++++--- src/Message/ServerRequest.php | 861 ++++++++---- src/Message/Stream.php | 1247 +++++++++-------- src/Message/Traits/MessageTrait.php | 623 ++++---- src/Message/Traits/RequestTrait.php | 522 ++++--- src/Message/UploadedFile.php | 456 +++--- src/Message/Uri.php | 930 ++++++------ tests/Unit/Client/ClientConfigurationTest.php | 136 ++ tests/Unit/Client/ClientHttpVerbsTest.php | 137 ++ .../Unit/Client/ClientPrepareRequestTest.php | 110 ++ tests/Unit/Emitter/EmitterBodyTest.php | 100 ++ .../Unit/Emitter/EmitterContentRangeTest.php | 120 ++ tests/Unit/Emitter/EmitterHeadersTest.php | 84 ++ tests/Unit/Emitter/EmitterStatusLineTest.php | 86 ++ tests/Unit/Emitter/EmitterStrictModeTest.php | 161 +++ tests/Unit/Facade/ClientFacadeTest.php | 56 + tests/Unit/Facade/EmitterFacadeTest.php | 33 + tests/Unit/Facade/FactoryFacadeTest.php | 143 ++ .../FactoryCreateUploadedFileNullSizeTest.php | 61 + .../Factory/FactoryStreamFromFileTest.php | 91 ++ .../Factory/FactoryStreamFromResourceTest.php | 39 + tests/Unit/FixtureServerTrait.php | 118 ++ tests/Unit/Helpers/SendRequestTest.php | 204 +++ .../Unit/Message/RequestImmutabilityTest.php | 126 ++ .../Unit/Message/ResponseHttpVersionTest.php | 69 + tests/Unit/Message/ResponseJsonTest.php | 84 ++ .../Message/ResponseReasonPhraseTableTest.php | 129 ++ tests/Unit/Message/ResponseRedirectTest.php | 92 ++ .../ServerRequestCreateFromGlobalsTest.php | 265 ++++ .../ServerRequestNormalizeFilesTest.php | 194 +++ .../StreamDetachPreservesPositionTest.php | 106 ++ tests/Unit/Message/StreamIsEmptyTest.php | 67 + .../Message/StreamStringBackendWriteTest.php | 105 ++ .../Message/StreamToStringNeverThrowsTest.php | 88 ++ tests/Unit/Message/UploadedFileMoveToTest.php | 125 ++ 89 files changed, 10021 insertions(+), 3653 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/static-analysis.yml create mode 100644 .github/workflows/tests.yml create mode 100644 CHANGELOG.md create mode 100644 docs/README.md create mode 100644 docs/emitter/basic-emission.md create mode 100644 docs/emitter/chunked-bodies.md create mode 100644 docs/emitter/content-range.md create mode 100644 docs/facades/customization.md create mode 100644 docs/facades/overview.md create mode 100644 docs/getting-started.md create mode 100644 docs/psr17/factory.md create mode 100644 docs/psr18/client.md create mode 100644 docs/psr18/configuration.md create mode 100644 docs/psr18/exceptions.md create mode 100644 docs/psr7/messages.md create mode 100644 docs/psr7/server-request.md create mode 100644 docs/psr7/streams.md create mode 100644 docs/psr7/uploaded-files.md create mode 100644 docs/psr7/uri.md create mode 100644 docs/recipes/file-upload.md create mode 100644 docs/recipes/json-response.md create mode 100644 docs/recipes/proxying-requests.md create mode 100644 docs/recipes/redirect.md create mode 100644 docs/recipes/streaming-large-files.md create mode 100644 docs/reference/http-status-codes.md create mode 100644 docs/upgrade-guide.md create mode 100644 phpstan.neon.dist create mode 100644 src/Facade/Interfaces/FacadableInterface.php create mode 100644 src/Facade/Traits/Facadable.php delete mode 100644 src/Message/Interfaces/MessageInterface.php delete mode 100644 src/Message/Interfaces/RequestInterface.php delete mode 100644 src/Message/Interfaces/ResponseInterface.php delete mode 100644 src/Message/Interfaces/ServerRequestInterface.php delete mode 100644 src/Message/Interfaces/StreamInterface.php delete mode 100644 src/Message/Interfaces/UriInterface.php create mode 100644 tests/Unit/Client/ClientConfigurationTest.php create mode 100644 tests/Unit/Client/ClientHttpVerbsTest.php create mode 100644 tests/Unit/Client/ClientPrepareRequestTest.php create mode 100644 tests/Unit/Emitter/EmitterBodyTest.php create mode 100644 tests/Unit/Emitter/EmitterContentRangeTest.php create mode 100644 tests/Unit/Emitter/EmitterHeadersTest.php create mode 100644 tests/Unit/Emitter/EmitterStatusLineTest.php create mode 100644 tests/Unit/Emitter/EmitterStrictModeTest.php create mode 100644 tests/Unit/Facade/ClientFacadeTest.php create mode 100644 tests/Unit/Facade/EmitterFacadeTest.php create mode 100644 tests/Unit/Facade/FactoryFacadeTest.php create mode 100644 tests/Unit/Factory/FactoryCreateUploadedFileNullSizeTest.php create mode 100644 tests/Unit/Factory/FactoryStreamFromFileTest.php create mode 100644 tests/Unit/Factory/FactoryStreamFromResourceTest.php create mode 100644 tests/Unit/FixtureServerTrait.php create mode 100644 tests/Unit/Helpers/SendRequestTest.php create mode 100644 tests/Unit/Message/RequestImmutabilityTest.php create mode 100644 tests/Unit/Message/ResponseHttpVersionTest.php create mode 100644 tests/Unit/Message/ResponseJsonTest.php create mode 100644 tests/Unit/Message/ResponseReasonPhraseTableTest.php create mode 100644 tests/Unit/Message/ResponseRedirectTest.php create mode 100644 tests/Unit/Message/ServerRequestCreateFromGlobalsTest.php create mode 100644 tests/Unit/Message/ServerRequestNormalizeFilesTest.php create mode 100644 tests/Unit/Message/StreamDetachPreservesPositionTest.php create mode 100644 tests/Unit/Message/StreamIsEmptyTest.php create mode 100644 tests/Unit/Message/StreamStringBackendWriteTest.php create mode 100644 tests/Unit/Message/StreamToStringNeverThrowsTest.php create mode 100644 tests/Unit/Message/UploadedFileMoveToTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c3a6bb1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml,json}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..066c29b --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,30 @@ +name: Static Analysis + +on: + push: + branches: [main, 2.x] + pull_request: + branches: [main, 2.x] + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: json, curl, mbstring + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..bbf4b1a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,48 @@ +name: Tests + +on: + push: + branches: [main, 2.x] + pull_request: + branches: [main, 2.x] + +jobs: + phpunit: + name: PHPUnit (PHP ${{ matrix.php }} / ${{ matrix.dependency-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + dependency-version: [prefer-stable] + include: + - php: '7.4' + dependency-version: prefer-lowest + - php: '8.4' + dependency-version: prefer-stable + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: json, curl, mbstring + coverage: none + tools: composer:v2 + + - name: Resolve PHPUnit constraint + run: | + if php -r 'exit((int) version_compare(PHP_VERSION, "8.1.0", "<"));'; then + composer require --dev --no-update --no-interaction "phpunit/phpunit:^9.6" + else + composer require --dev --no-update --no-interaction "phpunit/phpunit:^10.5" + fi + + - name: Install dependencies + run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --prefer-dist + + - name: Run PHPUnit + run: vendor/bin/phpunit --testdox diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6de5152 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,72 @@ +# Changelog + +All notable changes to **initphp/http** will be documented in this file. + +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). + +## [3.0.0] - Unreleased + +### Added + +- **PSR-7 v2 support.** `psr/http-message` constraint widened to `^1.0 || ^2.0`. +- **Stateless `ServerRequest::createFromGlobals()`** that accepts optional `$server/$get/$post/$cookies/$files` arrays and never caches its result. Safe under Swoole, RoadRunner, Octane, FrankenPHP. +- **Content-Type-aware body parsing** in `createFromGlobals` (JSON / urlencoded / multipart). +- **nginx + php-fpm header fallback** in `createFromGlobals` for environments without `apache_request_headers()`. +- **`Client::withTimeout()` / `withConnectTimeout()` / `withFollowRedirects()` / `withCurlOptions()`** for production-grade configuration. +- **`Facadable` trait and `FacadableInterface`** — canonical names replacing the misspelled `Facadeble[Interface]` (old names kept as `@deprecated` aliases). +- **`docs/` directory** with PSR-7/17/18 guides, emitter walk-throughs, recipes, status-code reference and an upgrade guide. +- **Comprehensive `tests/Unit/` suite** complementing the upstream PSR integration tests. +- **GitHub Actions CI** matrix covering PHP 7.4 – 8.4 (PHPUnit + PHPStan). +- **PHPStan level 5** static analysis with a baseline of intentional ignores. +- Packagist metadata enrichment (description, keywords, homepage, support links). +- Composer scripts: `composer test`, `composer test:coverage`, `composer phpstan`, `composer ci`. +- EditorConfig, CHANGELOG, contributor-friendly docblocks across `src/`. + +### Changed (breaking) + +- **`Request::createFromGlobals()` removed.** Use `ServerRequest::createFromGlobals()`. +- **`Request::_parameters` bag removed.** All of `__get/__set/__isset/all/get/has/merge/sendRequest` are gone. Use `getParsedBody()` / attributes / explicit `(new Client())->sendRequest($request)`. +- **Custom `InitPHP\HTTP\Message\Interfaces\*` interfaces removed.** Type-hint against `Psr\Http\Message\*Interface` directly. The old interfaces forced mutator contracts that conflicted with PSR-7 immutability. +- **`Client::sendRequest()`** no longer special-cases the concrete `Request` class or silently JSON-encodes a `_parameters` bag. +- **`Client::prepareRequest()`** (used by the verb helpers and `fetch()`) no longer accepts `array`, `DOMDocument`, `SimpleXMLElement`, or "any object". Body must be `string | resource | StreamInterface | null`. The `send_request()` helper still encodes arrays/`toArray()` objects as JSON. +- **`Client` default timeout** changed from 0 (no timeout) to 30 s; default connect timeout 10 s. Pass `->withTimeout(0)` to keep the legacy infinite wait. +- **`Response::redirect()`** always sets `Location`; `Refresh` is added on top when a non-zero delay is requested. +- **`Response::json()`** uses `JSON_THROW_ON_ERROR`; unencodable payloads now raise `InvalidArgumentException` instead of silently producing `false`. Content-Type is `application/json; charset=utf-8`. +- **`UploadedFile::__construct()` `$size` is now `?int`**, matching the PSR-7 nullable contract. +- **`UploadedFile::getSize(): ?int`**, matching the PSR-7 contract. +- **`Stream::__toString()` no longer throws** under any circumstance, per PSR-7's hard requirement. +- **`Stream::write()` string backend** now obeys `fwrite()` semantics (overwrite from cursor, advance position). The legacy prepend-at-position-0 behaviour is gone. +- **`Stream::str2resorce()` renamed** to `Stream::stringToResource()` (private). Materialised detach handle now preserves cursor position. +- **`RequestTrait::updateHostFormUri()` renamed** to `updateHostFromUri()`. +- **HTTP version whitelist widened** to accept `2`, `2.0`, `3`, `3.0` (alongside `1.0` and `1.1`). + +### Fixed + +- 500 reason phrase corrected from `'Internal ServerRequest Error'` to `'Internal Server Error'`. +- `Stream::isEmpty()` / `isNotEmpty()` return `false` for streams whose size is null (pipes, sockets), instead of mis-classifying them as empty. +- `Emitter` reason-phrase status-line check uses `!== ''` so a literal `'0'` reason is preserved. +- `Emitter` raises `EmitHeaderException` (not `EmitBodyException`) when `headers_sent()` is true. +- `Client` sets `CURLOPT_POSTFIELDS` for body-bearing methods (POST/PUT/PATCH/DELETE) even when the body is empty, so cURL doesn't silently downgrade the request. +- `Client::sendRequest()` wraps the response body in `php://temp` instead of an in-memory PHP string, avoiding OOM on multi-megabyte downloads. +- `UploadedFile::moveTo()` survives partial-write iterations and retries until the chunk is flushed. +- `ServerRequest::normalizeFiles()` recurses into arbitrarily nested file input names (`file[parent][child][…]`). +- `send_request()` (global helper) requires a URL when the first arg is a method string instead of silently sending to `null`. + +### Removed (in addition to breaking changes above) + +- `Request::__set/__get/__isset/all/get/has/merge/sendRequest/createFromGlobals` and the static singleton property that backed `createFromGlobals`. +- `Client::sendRequest`'s `instanceof Request` branch. +- `Client::prepareRequest`'s `DOMDocument`/`SimpleXMLElement`/`toArray()`/`get_object_vars()` cascade. +- `src/Message/Interfaces/` directory (six custom interfaces). + +### Deprecated + +- `InitPHP\HTTP\Facade\Interfaces\FacadebleInterface` (use `FacadableInterface`). +- `InitPHP\HTTP\Facade\Traits\Facadeble` (use `Facadable`). + +Both will be removed in 4.0. + +## [2.x] - prior releases + +See Git history for entries prior to this changelog. diff --git a/README.md b/README.md index 601583d..a2a2839 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,249 @@ # InitPHP HTTP -This library provides HTTP Message and HTTP Factory solution following PSR-7 and PSR-17 standards. It also includes an Emitter class for the PSR-7. +Standards-compliant **PSR-7** message, **PSR-17** factory, **PSR-18** client and SAPI response **emitter** for PHP 7.4+. -[![Latest Stable Version](http://poser.pugx.org/initphp/http/v)](https://packagist.org/packages/initphp/http) [![Total Downloads](http://poser.pugx.org/initphp/http/downloads)](https://packagist.org/packages/initphp/http) [![Latest Unstable Version](http://poser.pugx.org/initphp/http/v/unstable)](https://packagist.org/packages/initphp/http) [![License](http://poser.pugx.org/initphp/http/license)](https://packagist.org/packages/initphp/http) [![PHP Version Require](http://poser.pugx.org/initphp/http/require/php)](https://packagist.org/packages/initphp/http) +[![Latest Stable Version](https://poser.pugx.org/initphp/http/v)](https://packagist.org/packages/initphp/http) +[![Total Downloads](https://poser.pugx.org/initphp/http/downloads)](https://packagist.org/packages/initphp/http) +[![License](https://poser.pugx.org/initphp/http/license)](https://packagist.org/packages/initphp/http) +[![PHP Version Require](https://poser.pugx.org/initphp/http/require/php)](https://packagist.org/packages/initphp/http) +[![Tests](https://github.com/InitPHP/HTTP/actions/workflows/tests.yml/badge.svg)](https://github.com/InitPHP/HTTP/actions/workflows/tests.yml) +[![Static Analysis](https://github.com/InitPHP/HTTP/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/InitPHP/HTTP/actions/workflows/static-analysis.yml) + +A single dependency-light package that ships the four building blocks every PHP project ends up wiring by hand: immutable HTTP messages, a unified factory, a cURL-backed client, and an emitter that converts a `ResponseInterface` into bytes on the wire. + +```bash +composer require initphp/http +``` + +```php +use InitPHP\HTTP\Facade\Factory; +use InitPHP\HTTP\Facade\Emitter; + +$response = Factory::createResponse(200, 'OK') + ->withHeader('Content-Type', 'text/plain; charset=utf-8'); +$response->getBody()->write('Hello, world!'); + +Emitter::emit($response); +``` + +--- + +## Table of Contents + +- [Features](#features) +- [Requirements](#requirements) +- [Installation](#installation) +- [Quick Start](#quick-start) + - [Building a PSR-7 Request](#building-a-psr-7-request) + - [Building a PSR-7 Response](#building-a-psr-7-response) + - [Sending an HTTP request (PSR-18)](#sending-an-http-request-psr-18) + - [Emitting a response (SAPI)](#emitting-a-response-sapi) + - [Hydrating a ServerRequest from globals](#hydrating-a-serverrequest-from-globals) + - [Using the static facades](#using-the-static-facades) +- [Documentation](#documentation) +- [PSR Compliance](#psr-compliance) +- [Migration from 2.x](#migration-from-2x) +- [Contributing](#contributing) +- [Security](#security) +- [License](#license) + +--- + +## Features + +- **PSR-7 v1 & v2 compatible** — works with the entire PSR-7 ecosystem. +- **PSR-17 factory** — one class implements every factory interface (`Request`, `Response`, `ServerRequest`, `Stream`, `UploadedFile`, `Uri`). +- **PSR-18 client** backed by **cURL** with sane production defaults: 30 s request timeout, 10 s connect timeout, redirect following, raw `CURLOPT_*` overrides without subclassing. +- **SAPI emitter** that streams the response to the browser with optional chunked output and `Content-Range` support. +- **Lazy static facades** for projects that prefer `Factory::createResponse()` over instantiating helpers explicitly. +- **Strict PSR-7 immutability** — `with*()` returns deep-cloned messages; mutating the clone never touches the original (verified by a dedicated immutability test suite). +- **Zero runtime dependencies** outside `psr/http-*`. `ext-curl` is only required when you actually use the client. +- Passes the upstream **`php-http/psr7-integration-tests`** and **`http-interop/http-factory-tests`** suites. ## Requirements -- PHP 7.4 or higher -- PSR-7 HTTP Message Interfaces -- PSR-17 HTTP Factories Interfaces -- PSR-18 HTTP Client Interfaces +| Component | Minimum | +|-----------------|--------------------| +| PHP | 7.4 | +| `ext-json` | always required | +| `ext-curl` | required by `InitPHP\HTTP\Client\Client` (PSR-18 transport) | +| `psr/http-message` | `^1.0 || ^2.0` | +| `psr/http-factory` | `^1.0` | +| `psr/http-client` | `^1.0` | + +Tested on PHP 7.4, 8.0, 8.1, 8.2, 8.3 and 8.4 in CI. ## Installation -``` +```bash composer require initphp/http ``` -## Usage - -It adheres to the PSR-7, PSR-17, PSR-18 standards and strictly implements these interfaces to a large extent. +## Quick Start -### PSR-7 Emitter Usage +### Building a PSR-7 Request ```php -use \InitPHP\HTTP\Message\{Response, Stream}; -use \InitPHP\HTTP\Emitter\Emitter; +use InitPHP\HTTP\Message\Request; +$request = new Request( + 'POST', + 'https://api.example.com/users', + ['Accept' => 'application/json'], + json_encode(['name' => 'Ada']), + '1.1' +); -$response = new Response(200, [], new Stream('Hello World', null), '1.1'); - -$emitter = new Emitter(); -$emitter->emit($response); +$request = $request->withHeader('Content-Type', 'application/json; charset=utf-8'); ``` -or +### Building a PSR-7 Response ```php -use \InitPHP\HTTP\Facade\Factory; -use \InitPHP\HTTP\Facade\Emitter; +use InitPHP\HTTP\Message\Response; -$response = Factory::createResponse(200); -$response->getBody()->write('Hello World'); +$response = (new Response()) + ->withStatus(201, 'Created') + ->withHeader('Location', '/users/42'); -Emitter::emit($response); +$response->getBody()->write('{"id":42}'); +``` + +Convenience producers on the concrete `Response`: + +```php +$response = (new Response())->json(['id' => 42], 201); +$response = (new Response())->redirect('https://example.com/welcome', 302); ``` -### PSR-17 Factory Usage +### Sending an HTTP request (PSR-18) ```php -use \InitPHP\HTTP\Factory\Factory; +use InitPHP\HTTP\Client\Client; +use InitPHP\HTTP\Message\Request; -$httpFactory = new Factory(); +$client = (new Client()) + ->withTimeout(10) + ->withConnectTimeout(3) + ->withUserAgent('my-app/1.0'); -/** @var \Psr\Http\Message\RequestInterface $request */ -$request = $httpFactory->createRequest('GET', 'http://example.com'); +$response = $client->sendRequest( + new Request('GET', 'https://httpbin.org/get') +); -// ... +echo $response->getStatusCode(); // 200 +echo (string) $response->getBody(); // {"args":{},"headers":{...},...} ``` -or +Higher-level helpers when you don't want to build a `Request` by hand: ```php -use InitPHP\HTTP\Facade\Factory; - -/** @var \Psr\Http\Message\RequestInterface $request */ -$request = Factory::createRequest('GET', 'http://example.com'); +$response = $client->get('https://api.example.com/users'); +$response = $client->post('https://api.example.com/users', '{"name":"Ada"}', [ + 'Content-Type' => 'application/json', +]); ``` -### PSR-18 Client Usage +PSR-18 contract is honoured: **4xx/5xx responses are returned, not thrown**. Only transport failures raise `Psr\Http\Client\NetworkExceptionInterface`. -```php -use \InitPHP\HTTP\Message\Request; -use \InitPHP\HTTP\Client\Client; +### Emitting a response (SAPI) -$request = new Request('GET', 'http://example.com'); +```php +use InitPHP\HTTP\Emitter\Emitter; -$client = new Client(); +$emitter = new Emitter(/* strictMode: */ true); +$emitter->emit($response); // echoes body in one go -/** @var \Psr\Http\Message\ResponseInterface $response */ -$response = $client->sendRequest($request); +// For large bodies, stream in chunks: +$emitter->emit($response, 8192); ``` -or +Range requests (`Content-Range: bytes 0-1023/...`) are honoured automatically. + +### Hydrating a ServerRequest from globals ```php -use \InitPHP\HTTP\Facade\Factory; -use \InitPHP\HTTP\Facade\Client; +use InitPHP\HTTP\Message\ServerRequest; + +$request = ServerRequest::createFromGlobals(); +// or with explicit data (recommended for tests and long-running runtimes): +$request = ServerRequest::createFromGlobals($server, $get, $post, $cookies, $files); +``` + +The factory is **stateless** — every call returns a fresh instance computed from the supplied arrays. Safe under Swoole, RoadRunner, Octane and FrankenPHP. + +### Using the static facades -$request = Factory::createRequest('GET', 'http://example.com'); +For projects that prefer terseness over explicit DI: -/** @var \Psr\Http\Message\ResponseInterface $response */ +```php +use InitPHP\HTTP\Facade\Factory; +use InitPHP\HTTP\Facade\Client; +use InitPHP\HTTP\Facade\Emitter; + +$request = Factory::createRequest('GET', 'https://example.com'); $response = Client::sendRequest($request); +Emitter::emit($response); ``` +Each facade lazily resolves a singleton on first call; subsequent calls return the same instance. Facades are entirely optional — every facade is just a thin static wrapper over a concrete service class you can instantiate yourself. +## Documentation -#### A Small Difference For PSR-7 Stream +In-depth guides live under [`docs/`](docs/): -If you are working with small content; The PSR-7 Stream interface may be cumbersome for you. This is because the PSR-7 stream interface writes the content "`php://temp`" or "`php://memory`". By default this library will also overwrite `php://temp` with your content. To change this behavior, this must be declared as the second parameter to the constructor method when creating the Stream object. +- [Getting Started](docs/getting-started.md) +- PSR-7 — [Messages](docs/psr7/messages.md), [ServerRequest](docs/psr7/server-request.md), [Streams](docs/psr7/streams.md), [Uri](docs/psr7/uri.md), [Uploaded Files](docs/psr7/uploaded-files.md) +- PSR-17 — [Factory](docs/psr17/factory.md) +- PSR-18 — [Client](docs/psr18/client.md), [Exceptions](docs/psr18/exceptions.md), [Configuration](docs/psr18/configuration.md) +- Emitter — [Basics](docs/emitter/basic-emission.md), [Chunked bodies](docs/emitter/chunked-bodies.md), [Content-Range](docs/emitter/content-range.md) +- Facades — [Overview](docs/facades/overview.md), [Customisation](docs/facades/customization.md) +- Recipes — [JSON responses](docs/recipes/json-response.md), [Redirects](docs/recipes/redirect.md), [File uploads](docs/recipes/file-upload.md), [Streaming large files](docs/recipes/streaming-large-files.md), [Proxying requests](docs/recipes/proxying-requests.md) +- [HTTP status code reference](docs/reference/http-status-codes.md) +- [Upgrade guide (2.x → 3.x)](docs/upgrade-guide.md) -```php -use \InitPHP\HTTP\Stream; - -/** - * This content is kept in memory as a variable. - */ -$variableStream = new Stream('String Content', null); - -/** - * Content; "php://memory" is overwritten. - */ -$memoryStream = new Stream('Content', 'php://memory'); - -/** - * Content; "php://temp" is overwritten. - */ -$tempStream = new Stream('Content', 'php://temp'); -// or new Stream('Content'); -``` +## PSR Compliance + +This package is verified against the official compliance suites: + +| Suite | Result | +|--------------------------------------------------------------------------------------------------------|----------| +| [`php-http/psr7-integration-tests`](https://github.com/php-http/psr7-integration-tests) | passing | +| [`http-interop/http-factory-tests`](https://github.com/http-interop/http-factory-tests) (PSR-17) | passing | +| In-house PSR-18 smoke suite against the PHP built-in test server | passing | +| In-house PSR-7 immutability suite | passing | + +Relevant specs: [PSR-7](https://www.php-fig.org/psr/psr-7/), [PSR-17](https://www.php-fig.org/psr/psr-17/), [PSR-18](https://www.php-fig.org/psr/psr-18/). + +## Migration from 2.x + +Version 3.0 is a breaking release. The highlights: + +- The custom `InitPHP\HTTP\Message\Interfaces\*` interfaces have been **removed**. Type-hint against the PSR-7 interfaces directly (`Psr\Http\Message\RequestInterface`, etc.). +- `Request::createFromGlobals()` and the magic `$request->name = $value` parameter bag have been **removed**. Use `ServerRequest::createFromGlobals()` (now stateless) and `$request->getParsedBody()` instead. +- `Client::sendRequest()` no longer inspects the concrete `Request` class — it only sees the PSR-7 contract. Pre-encode array/DOM/XML payloads before calling. +- `Client` now defaults to a 30 s request timeout and 10 s connect timeout. Pass `->withTimeout(0)` if you need the legacy "no timeout" behaviour. +- The misspelled facade trait/interface names `Facadeble[Interface]` are now `Facadable[Interface]`; the old names remain as `@deprecated` aliases and will be removed in 4.0. + +See [`docs/upgrade-guide.md`](docs/upgrade-guide.md) for the full migration walk-through and a copy-pasteable code-mod list. + +## Contributing + +Contributions are welcome — bug reports, doc improvements, performance patches, anything. + +- Open issues and pull requests against [InitPHP/HTTP](https://github.com/InitPHP/HTTP). +- The project's organisation-wide [Contributing Guide](https://github.com/InitPHP/.github/blob/main/CONTRIBUTING.md) and [Code of Conduct](https://github.com/InitPHP/.github/blob/main/CODE_OF_CONDUCT.md) apply. +- Local development: + + ```bash + composer install + composer ci # phpstan + phpunit + ``` -## Credits +## Security -- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) <> +If you discover a security vulnerability please review the project's [Security Policy](https://github.com/InitPHP/.github/blob/main/SECURITY.md) and report it privately. **Please do not file public GitHub issues for security problems.** ## License -Copyright © 2022 [MIT License](./LICENSE) +[MIT License](./LICENSE) — Copyright © Muhammet ŞAFAK and contributors. diff --git a/composer.json b/composer.json index f2dff45..3ac632c 100644 --- a/composer.json +++ b/composer.json @@ -1,23 +1,31 @@ { "name": "initphp/http", - "description": "HTTP", + "description": "Standards-compliant PSR-7 / PSR-17 / PSR-18 HTTP message, factory, client and response emitter implementation for PHP 7.4+.", "type": "library", "license": "MIT", - "autoload": { - "psr-4": { - "InitPHP\\HTTP\\": "src/" - }, - "files": [ - "src/Helpers.php" - ] - }, - "autoload-dev": { - "psr-4": { - "InitPHP\\HTTP\\Tests\\": "tests/" - } - }, - "scripts": { - "test": "phpunit" + "keywords": [ + "http", + "psr-7", + "psr-17", + "psr-18", + "http-message", + "http-factory", + "http-client", + "psr-7-message", + "psr-7-factory", + "psr-7-client", + "emitter", + "request", + "response", + "stream", + "uri", + "uploaded-file", + "curl" + ], + "homepage": "https://github.com/InitPHP/HTTP", + "support": { + "issues": "https://github.com/InitPHP/HTTP/issues", + "source": "https://github.com/InitPHP/HTTP" }, "authors": [ { @@ -27,17 +35,54 @@ "homepage": "https://www.muhammetsafak.com.tr" } ], - "minimum-stability": "stable", "require": { "php": ">=7.4", "ext-json": "*", - "psr/http-message": "^1.0", + "psr/http-client": "^1.0", "psr/http-factory": "^1.0", - "psr/http-client": "^1.0" + "psr/http-message": "^1.0 || ^2.0" }, "require-dev": { - "phpunit/phpunit": "^10.5", + "ext-curl": "*", + "http-interop/http-factory-tests": "^2.2", "php-http/psr7-integration-tests": "^1.3", - "http-interop/http-factory-tests": "^2.2" - } + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.6 || ^10.5" + }, + "suggest": { + "ext-curl": "Required by InitPHP\\HTTP\\Client\\Client (PSR-18) for outbound HTTP requests." + }, + "autoload": { + "psr-4": { + "InitPHP\\HTTP\\": "src/" + }, + "files": [ + "src/Helpers.php" + ] + }, + "autoload-dev": { + "psr-4": { + "InitPHP\\HTTP\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-text --coverage-html=build/coverage", + "phpstan": "phpstan analyse --memory-limit=512M", + "ci": [ + "@phpstan", + "@test" + ] + }, + "scripts-descriptions": { + "test": "Run the PHPUnit test suite.", + "test:coverage": "Run the PHPUnit test suite with HTML coverage report.", + "phpstan": "Run PHPStan static analysis.", + "ci": "Run the full CI gate (PHPStan + PHPUnit)." + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d2b9a07 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,39 @@ +# InitPHP HTTP — Documentation + +This directory contains the long-form documentation. The top-level [`README.md`](../README.md) covers installation and a tour; the pages here go deeper. + +## Reading order + +1. [Getting Started](getting-started.md) — install, run the test suite, the smallest possible example. +2. PSR-7 — building blocks + - [Messages (Request / Response)](psr7/messages.md) + - [ServerRequest](psr7/server-request.md) + - [Streams](psr7/streams.md) + - [Uri](psr7/uri.md) + - [Uploaded Files](psr7/uploaded-files.md) +3. PSR-17 — factories + - [The unified Factory](psr17/factory.md) +4. PSR-18 — clients + - [Client basics](psr18/client.md) + - [Configuration (timeouts, redirects, raw cURL options)](psr18/configuration.md) + - [Exceptions and PSR-18 error contract](psr18/exceptions.md) +5. Emitter — turning a Response into bytes + - [Basic emission](emitter/basic-emission.md) + - [Chunked bodies](emitter/chunked-bodies.md) + - [Content-Range / partial content](emitter/content-range.md) +6. Static facades + - [Overview and when to use them](facades/overview.md) + - [Replacing the singleton with a custom instance](facades/customization.md) +7. Recipes (task-oriented) + - [JSON responses](recipes/json-response.md) + - [Redirects](recipes/redirect.md) + - [File uploads](recipes/file-upload.md) + - [Streaming large files](recipes/streaming-large-files.md) + - [Proxying requests](recipes/proxying-requests.md) +8. Reference + - [HTTP status code phrases](reference/http-status-codes.md) +9. [Upgrade guide (2.x → 3.x)](upgrade-guide.md) + +## A note on the examples + +Every code block in these pages was written against PHP 7.4 and is type-safe under PHPStan level 5. Newer PHP versions are obviously supported but no example uses 8.x-only syntax (no constructor promotion, no `mixed`, no enums) so you can drop snippets into a 7.4 codebase verbatim. diff --git a/docs/emitter/basic-emission.md b/docs/emitter/basic-emission.md new file mode 100644 index 0000000..c667b89 --- /dev/null +++ b/docs/emitter/basic-emission.md @@ -0,0 +1,55 @@ +# Emitter — Basics + +`InitPHP\HTTP\Emitter\Emitter` converts a PSR-7 `ResponseInterface` into bytes on the wire under any SAPI that exposes `header()` and stdout. + +```php +use InitPHP\HTTP\Emitter\Emitter; + +(new Emitter())->emit($response); +``` + +That's everything for the common case. Three things happen, in order: + +1. **Output sanity check** (strict mode only). If anything has already been written or `header()` already called, the emitter throws — better than silently sending a corrupt response. +2. **Status line** — `header("HTTP/1.1 200 OK", true, 200)`. +3. **Headers** — every entry from `$response->getHeaders()` becomes a `header()` call. The key is normalised to title-case-with-dash (`x-trace-id` → `X-Trace-Id`); `Set-Cookie` is the one special case that's allowed to repeat. +4. **Body** — the whole body is `echo`-ed in one go, or streamed in chunks if you pass a buffer length. + +## Strict mode + +```php +$emitter = new Emitter(/* strictMode: */ true); // default +``` + +When strict mode is on: + +- `headers_sent($file, $line)` is checked first; if `true`, the emitter throws `EmitHeaderException` naming the file and line that flushed. +- The active output buffer (if any) is inspected; non-empty buffers raise `EmitBodyException`. + +Turn strict mode off only when you're integrating with something that already wrote partial output (e.g. a legacy script that `echo`-ed before passing control to your framework). + +```php +$emitter = new Emitter(/* strictMode: */ false); +``` + +## When to use each exception + +| Exception | Cause | Typical fix | +|------------------------|---------------------------------------------|--------------------------------------------------| +| `EmitHeaderException` | `headers_sent()` returned true | Find the early-`echo` / BOM / un-suppressed warning | +| `EmitBodyException` | Output buffer has un-flushed content | `ob_clean()` before emit, or unwind the layer that started buffering | + +Both extend `\RuntimeException`, so a single catch is fine if you don't care which side triggered: + +```php +try { + (new Emitter())->emit($response); +} catch (\RuntimeException $e) { + error_log($e->getMessage()); +} +``` + +## See also + +- [Chunked bodies](chunked-bodies.md) for large responses. +- [Content-Range](content-range.md) for serving partial content / byte ranges. diff --git a/docs/emitter/chunked-bodies.md b/docs/emitter/chunked-bodies.md new file mode 100644 index 0000000..9df7d22 --- /dev/null +++ b/docs/emitter/chunked-bodies.md @@ -0,0 +1,43 @@ +# Emitter — Chunked Bodies + +`Emitter::emit()` accepts an optional second argument that turns the default "echo the whole body" into a chunked stream: + +```php +$emitter = new Emitter(); +$emitter->emit($response, /* bufferLength: */ 8192); +``` + +With a non-null, non-zero `$bufferLength`: + +1. The output buffer is flushed up front (`flush()`). +2. If the response carries a `Content-Range: bytes ...` header, only the requested byte range is emitted (see [Content-Range](content-range.md)). +3. Otherwise the body is rewound (if seekable) and read in `$bufferLength`-byte chunks until EOF, echoing each chunk as it arrives. + +This avoids materialising the entire body as a single PHP string — critical for multi-megabyte file downloads or streaming JSON. + +## Picking a buffer length + +There's no universally right answer, but: + +| Use case | Suggested `bufferLength` | +|--------------------------|--------------------------------------| +| Small dynamic responses | `null` (default — single echo) | +| Static file downloads | 8192 — 65536 (8 KiB – 64 KiB) | +| Long-poll / SSE | 1024 (tight latency) | +| Backed by a slow network | match downstream MTU minus headroom | + +The cost of "too small" is more `write()` syscalls; the cost of "too big" is extra memory residence per chunk. 8–64 KiB is a safe default for almost everything. + +## Disabling output buffering + +`flush()` only works if PHP's output buffer is empty (or short enough that `ob_flush()` was called for you). If you've wrapped your application in `ob_start()` and want chunked emission to actually reach the browser, close the buffer first: + +```php +while (ob_get_level() > 0) { + ob_end_flush(); +} + +$emitter->emit($response, 8192); +``` + +For long-running streams (SSE, log tail) you also want to disable nginx's `gzip` and `proxy_buffering` for the location — that's an upstream concern, not the emitter's. diff --git a/docs/emitter/content-range.md b/docs/emitter/content-range.md new file mode 100644 index 0000000..55dd700 --- /dev/null +++ b/docs/emitter/content-range.md @@ -0,0 +1,55 @@ +# Emitter — Content-Range + +When a response carries `Content-Range: bytes -/`, the chunked emit path honours the range automatically: + +```php +$range = 'bytes 1024-2047/4096'; +$response = (new Response(206)) + ->withHeader('Content-Range', $range) + ->withHeader('Content-Type', 'application/octet-stream'); + +$response->getBody()->write($fullPayload); + +(new Emitter())->emit($response, /* bufferLength: */ 8192); +``` + +Under the hood: + +1. `Content-Range` is parsed into `unit`, `first`, `last`, `length`. +2. If `unit` is `bytes`, the body is seeked to `first` (when seekable) and read in `$bufferLength`-byte chunks until `last - first + 1` bytes have been emitted. +3. If `unit` is anything else (or the header is absent / malformed), the emitter falls back to a regular rewind-and-stream. + +This means you can serve byte ranges to clients like: + +```bash +curl -H 'Range: bytes=1024-2047' http://localhost/big.bin +``` + +…by computing the range on the request side, setting `Content-Range` on the response, and letting the emitter slice the body for you. + +## Full example: a tiny static-file responder + +```php +function serveFile(string $path, ServerRequestInterface $request): ResponseInterface +{ + $total = filesize($path); + $body = (new \InitPHP\HTTP\Message\Stream(fopen($path, 'rb'))); + $headers = ['Content-Type' => mime_content_type($path) ?: 'application/octet-stream']; + + $range = $request->getHeaderLine('Range'); + if (preg_match('/^bytes=(\d+)-(\d*)$/', $range, $m)) { + $first = (int) $m[1]; + $last = $m[2] !== '' ? (int) $m[2] : $total - 1; + $headers['Content-Range'] = sprintf('bytes %d-%d/%d', $first, $last, $total); + $headers['Content-Length'] = (string) ($last - $first + 1); + return (new \InitPHP\HTTP\Message\Response(206, $headers, $body)); + } + + $headers['Content-Length'] = (string) $total; + return new \InitPHP\HTTP\Message\Response(200, $headers, $body); +} + +(new \InitPHP\HTTP\Emitter\Emitter())->emit(serveFile('/var/files/big.bin', $request), 65536); +``` + +For media-class workloads (audio/video seeking) you'll also want to set `Accept-Ranges: bytes` on the **first** 200 response so the client knows it can negotiate ranges next time. diff --git a/docs/facades/customization.md b/docs/facades/customization.md new file mode 100644 index 0000000..c88b327 --- /dev/null +++ b/docs/facades/customization.md @@ -0,0 +1,59 @@ +# Static Facades — Customisation + +The facades cache their wrapped instance in a private static property and create it lazily on first call. That works for "default everything" but breaks the moment you want a `Client` with custom timeouts or a custom user agent. + +You have two options. + +## Option 1 — Bypass the facade + +If you only need the custom instance in one place, instantiate the service directly: + +```php +use InitPHP\HTTP\Client\Client; + +$client = (new Client())->withTimeout(5); +$response = $client->sendRequest($request); +``` + +Mixing both is fine — the facade keeps a single shared instance; the explicit instance is your own. + +## Option 2 — Subclass the facade + +Each facade declares its `getInstance()` method on the **concrete** facade class (not the trait), so you can subclass and override it: + +```php +namespace App\Http; + +final class Client extends \InitPHP\HTTP\Facade\Client +{ + public static function getInstance(): object + { + static $instance; + if ($instance === null) { + $instance = (new \InitPHP\HTTP\Client\Client()) + ->withTimeout(5) + ->withUserAgent('app/1.0'); + } + return $instance; + } +} + +// Then use App\Http\Client::sendRequest(...) instead of the package facade. +``` + +The `@mixin` PHPDoc on the facade carries over, so IDE autocomplete keeps working. + +Note: the package's own facades are `final` because we want a single canonical short-import surface — your subclass effectively becomes a separate facade in a separate namespace. That's deliberate; it stops one application's facade configuration from leaking into another's via a shared static property. + +## Option 3 — Reset between requests in long-running PHP + +Under Swoole / RoadRunner / Octane the static property persists across requests. If you've stashed per-request state on the wrapped instance (you really shouldn't, but it happens) you can null it out manually: + +```php +$ref = new \ReflectionClass(\InitPHP\HTTP\Facade\Client::class); +$prop = $ref->getProperty('instance'); +$prop->setAccessible(true); +$prop->setValue(null, null); // forces re-creation on next call +``` + +This is the kind of escape hatch you reach for *once*, while you migrate the offending code to direct DI. Don't ship it as architecture. diff --git a/docs/facades/overview.md b/docs/facades/overview.md new file mode 100644 index 0000000..8b31dc0 --- /dev/null +++ b/docs/facades/overview.md @@ -0,0 +1,53 @@ +# Static Facades — Overview + +This package ships three static facades: + +| Facade | Wraps | +|-------------------------------------|-----------------------------------------| +| `InitPHP\HTTP\Facade\Client` | `InitPHP\HTTP\Client\Client` | +| `InitPHP\HTTP\Facade\Emitter` | `InitPHP\HTTP\Emitter\Emitter` | +| `InitPHP\HTTP\Facade\Factory` | `InitPHP\HTTP\Factory\Factory` | + +Each is a `final` class with a single static method (`getInstance()`) plus the magic `__call`/`__callStatic` machinery from the `Facadable` trait. The first static call creates the wrapped service via `new`, caches it in a private static property, and forwards the call; every subsequent call reuses the same instance. + +```php +use InitPHP\HTTP\Facade\Factory; +use InitPHP\HTTP\Facade\Client; +use InitPHP\HTTP\Facade\Emitter; + +$request = Factory::createRequest('GET', 'https://example.com'); +$response = Client::sendRequest($request); +Emitter::emit($response); +``` + +## When to use them + +- **Quick scripts and prototypes.** Saves you wiring an instance. +- **Legacy codebases without a DI container.** Provides a single source of truth without you owning lifecycle. +- **Library code that needs a "fall through" default** — pass the facade class name as a default factory and let consumers override per-instance. + +## When *not* to use them + +- **Anywhere you'd otherwise inject a `ClientInterface`** — direct DI is testable without `runInSeparateProcess`, the facade isn't. +- **Multiple concurrent configurations** of the same service (e.g. two clients with different timeouts). Facades are singletons; create separate instances yourself. +- **Inside libraries you ship to other people** — leave the lifecycle choice to the consumer. + +## Customising the underlying instance + +See [Customisation](customization.md) for how to inject a pre-configured service into a facade (e.g. a `Client` with a 5-second timeout) before the first use. + +## The deprecated `Facadeble` naming + +Earlier versions misspelled the trait and interface as `Facadeble` (note the missing `a`). The canonical names are now `Facadable` and `FacadableInterface`. The old symbols remain as `@deprecated` aliases: + +```php +// New (canonical): +use InitPHP\HTTP\Facade\Traits\Facadable; +use InitPHP\HTTP\Facade\Interfaces\FacadableInterface; + +// Old (works, but deprecated): +use InitPHP\HTTP\Facade\Traits\Facadeble; +use InitPHP\HTTP\Facade\Interfaces\FacadebleInterface; +``` + +The deprecated names will be removed in the next major release. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..ee791a1 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,66 @@ +# Getting Started + +## Install + +```bash +composer require initphp/http +``` + +Optional but recommended: + +```bash +composer require --dev phpunit/phpunit "^9.6 || ^10.5" +composer require --dev phpstan/phpstan "^1.10" +``` + +## A complete round-trip in 12 lines + +```php +sendRequest(new Request('GET', 'https://httpbin.org/uuid')); + +echo 'HTTP ' . $response->getStatusCode() . PHP_EOL; +echo (string) $response->getBody() . PHP_EOL; +``` + +Three things just happened: + +1. `Request` constructed a PSR-7 request value object. +2. `Client::sendRequest()` shipped it over the wire via cURL. +3. The returned `Response` is a PSR-7 value object you can pass through middlewares, log, or transform. + +## What this package is — and isn't + +It **is** the four PSR building blocks a PHP service needs to speak HTTP without manually writing cURL: + +| Layer | Class / Facade | Purpose | +|--------|-----------------------------------------------|----------------------------------------------| +| PSR-7 | `Message\{Request,Response,ServerRequest,Stream,Uri,UploadedFile}` | Immutable HTTP messages | +| PSR-17 | `Factory\Factory` | A single factory that satisfies every PSR-17 sub-interface | +| PSR-18 | `Client\Client` | cURL-backed PSR-18 transport | +| SAPI | `Emitter\Emitter` | Push a `Response` to the running web server | + +It **isn't** a router, a middleware dispatcher, or a request-handler pipeline (PSR-15). Those concerns live in higher-level packages — this library focuses on the message layer. + +## Running the test suite + +```bash +git clone https://github.com/InitPHP/HTTP.git +cd HTTP +composer install +composer ci # phpstan + phpunit +``` + +The suite includes the upstream `php-http/psr7-integration-tests` and `http-interop/http-factory-tests` compliance packs, plus an in-house PSR-18 smoke test against the PHP built-in server and a dedicated immutability suite. + +## Next steps + +- Read [PSR-7 Messages](psr7/messages.md) for the immutability rules and the `with*()` vs `set*()` distinction. +- Read [PSR-18 Configuration](psr18/configuration.md) before shipping the client to production — the defaults are sane but not exhaustive. +- Skim the [Recipes](README.md#recipes) for one-page solutions to the common shapes. diff --git a/docs/psr17/factory.md b/docs/psr17/factory.md new file mode 100644 index 0000000..d6c06df --- /dev/null +++ b/docs/psr17/factory.md @@ -0,0 +1,78 @@ +# PSR-17 Factory + +`InitPHP\HTTP\Factory\Factory` implements every PSR-17 sub-factory interface in one class: + +- `RequestFactoryInterface::createRequest($method, $uri)` +- `ResponseFactoryInterface::createResponse($code, $reasonPhrase)` +- `ServerRequestFactoryInterface::createServerRequest($method, $uri, $serverParams)` +- `StreamFactoryInterface::createStream($content)` +- `StreamFactoryInterface::createStreamFromFile($filename, $mode)` +- `StreamFactoryInterface::createStreamFromResource($resource)` +- `UriFactoryInterface::createUri($uri)` +- `UploadedFileFactoryInterface::createUploadedFile($stream, $size, $error, $clientFilename, $clientMediaType)` + +One concrete factory + every interface means a single instance can be type-hinted against any of them — useful when you DI-inject "the factory" into many consumers that each only care about one sub-interface. + +## Construction + +```php +use InitPHP\HTTP\Factory\Factory; + +$factory = new Factory(); +``` + +No constructor arguments, no configuration. Wire it as a singleton in your container: + +```php +// any PSR-11 container +$container->set( + \Psr\Http\Message\RequestFactoryInterface::class, + fn () => new \InitPHP\HTTP\Factory\Factory() +); +``` + +## Reference + +### `createRequest(string $method, $uri): RequestInterface` + +Returns a `Message\Request` with empty headers, an empty body and HTTP/1.1. + +### `createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface` + +Returns a `Message\Response`. If `$reasonPhrase` is empty and an IANA phrase is known for `$code`, the IANA phrase is used. + +### `createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface` + +Returns a `Message\ServerRequest` with the supplied server params. Cookies / query / parsedBody / uploadedFiles default to empty; set them with the `with*Params()` family. + +### `createStream(string $content = ''): StreamInterface` + +Returns a `Stream` backed by `php://temp` and seeded with `$content`. Use this for "in-memory but might spill to disk" payloads — the most common default. + +### `createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface` + +Opens `$filename` with `$mode` and wraps the resulting resource. `$mode` is validated against the standard `fopen()` modes; an empty path raises `RuntimeException`, an unreadable file raises `RuntimeException` carrying the underlying `error_get_last()` message, and an unrecognised mode raises `InvalidArgumentException`. + +### `createStreamFromResource($resource): StreamInterface` + +Wraps an already-open resource. If the argument is already a `StreamInterface`, it's returned verbatim (no double-wrapping). + +### `createUploadedFile(StreamInterface $stream, ?int $size = null, int $error = UPLOAD_ERR_OK, ?string $clientFilename = null, ?string $clientMediaType = null): UploadedFileInterface` + +If `$size` is `null`, `$stream->getSize()` is used (which itself may be `null` for pipes/sockets — that's fine, `UploadedFile::getSize()` is nullable per PSR-7). + +### `createUri(string $uri = ''): UriInterface` + +Parses `$uri` via PHP's `parse_url()`. An unparseable string raises `InvalidArgumentException`. + +## Static facade + +There's also a static facade if you'd rather not instantiate: + +```php +use InitPHP\HTTP\Facade\Factory; + +$response = Factory::createResponse(200); +``` + +See [Facades / Overview](../facades/overview.md) for the trade-offs. diff --git a/docs/psr18/client.md b/docs/psr18/client.md new file mode 100644 index 0000000..6b37664 --- /dev/null +++ b/docs/psr18/client.md @@ -0,0 +1,78 @@ +# PSR-18 Client + +`InitPHP\HTTP\Client\Client` is the PSR-18 transport. It sends a `Psr\Http\Message\RequestInterface` over cURL and returns a `Psr\Http\Message\ResponseInterface`. + +```php +use InitPHP\HTTP\Client\Client; +use InitPHP\HTTP\Message\Request; + +$client = new Client(); +$response = $client->sendRequest(new Request('GET', 'https://httpbin.org/uuid')); +``` + +The constructor verifies `ext-curl` is loaded; if it isn't, it raises `Psr\Http\Client\ClientExceptionInterface`. + +## High-level helpers + +`sendRequest()` is the strict PSR-18 entry point. For the common case where you don't want to build a `Request` by hand, the client exposes verb-named helpers: + +```php +$client->get(string $url, $body = null, array $headers = [], string $version = '1.1'); +$client->post(string $url, $body = null, array $headers = [], string $version = '1.1'); +$client->put(string $url, $body = null, array $headers = [], string $version = '1.1'); +$client->patch(string $url, $body = null, array $headers = [], string $version = '1.1'); +$client->delete(string $url, $body = null, array $headers = [], string $version = '1.1'); +$client->head(string $url, $body = null, array $headers = [], string $version = '1.1'); +``` + +And a generic `fetch()` for fully-described calls: + +```php +$response = $client->fetch('https://api.example.com/users', [ + 'method' => 'POST', + 'body' => $jsonBody, // string|resource|StreamInterface|null + 'headers' => ['Content-Type' => 'application/json'], + 'version' => '1.1', +]); +``` + +The keys are case-insensitive; both `body` and `data` work. + +## Body coercion + +The client only accepts these body shapes: + +| Type | Behaviour | +|---------------------|------------------------------------------| +| `null` | Empty body | +| `string` | Sent verbatim | +| `resource` | Read into a `Stream` and sent | +| `StreamInterface` | Used directly | +| **anything else** | `InvalidArgumentException` | + +This is deliberate: a PSR-18 client should not silently serialise objects — that responsibility lives in the application layer where the codec choice (JSON, XML, form-encoded, MessagePack, ...) is actually decided. If you want a convenience layer that auto-encodes arrays and `toArray()`-able objects as JSON, use the `send_request()` helper. + +## Response bodies + +The returned `Response` carries a `Stream` backed by `php://temp` — small responses stay in memory, larger ones spill to disk transparently. Don't assume the body fits in a string; use `getBody()->read($bufferLen)` in a loop when you're piping the response to another sink. + +## Redirects + +Redirects are followed by default (cURL's `CURLOPT_FOLLOWLOCATION = true`, up to 10 hops). The returned response represents the **final** leg — status, headers, and body all come from the final URL. The intermediate responses are not exposed. + +Disable or tune: + +```php +$client = $client->withFollowRedirects(false); +// or +$client = $client->withFollowRedirects(true, $maxRedirects = 3); +``` + +## HTTP/2 and HTTP/3 + +The client maps the message's `getProtocolVersion()` to the corresponding `CURL_HTTP_VERSION_*` constant. Versions `'2'` and `'2.0'` both select HTTP/2; `'1.0'`, `'1.1'` and the default cover the other cases. HTTP/3 selection depends on your cURL build — add it through `withCurlOptions([CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_3])` if your linker supports it. + +## See also + +- [Configuration](configuration.md) — timeouts, user agent, custom cURL options. +- [Exceptions](exceptions.md) — PSR-18 error contract and the difference between `NetworkException` and `RequestException`. diff --git a/docs/psr18/configuration.md b/docs/psr18/configuration.md new file mode 100644 index 0000000..5706d5b --- /dev/null +++ b/docs/psr18/configuration.md @@ -0,0 +1,77 @@ +# PSR-18 Client Configuration + +The `Client` class exposes a small set of knobs. Each has both a fluent `set*` mutator and an immutable `with*` wither. + +| Setting | Default | Mutator | Wither | +|-------------------|---------|-----------------------------------------------|-------------------------------------------------| +| Request timeout | 30 s | `setTimeout(int $seconds)` | `withTimeout(int $seconds)` | +| Connect timeout | 10 s | `setConnectTimeout(int $seconds)` | `withConnectTimeout(int $seconds)` | +| Follow redirects | `true` | `setFollowRedirects(bool, int $max = 10)` | `withFollowRedirects(bool, int $max = 10)` | +| User-Agent | `'InitPHP HTTP PSR-18 Client cURL'` | `setUserAgent(?string)` | `withUserAgent(?string)` | +| Custom cURL opts | `[]` | `setCurlOptions(array $options)` | `withCurlOptions(array $options)` | + +```php +$client = (new Client()) + ->withTimeout(15) + ->withConnectTimeout(3) + ->withFollowRedirects(true, 5) + ->withUserAgent('my-service/1.0') + ->withCurlOptions([ + CURLOPT_CAINFO => '/etc/ssl/certs/ca-bundle.crt', + CURLOPT_PROXY => 'http://proxy.internal:3128', + CURLOPT_SSL_VERIFYPEER => true, + ]); +``` + +## Setting `setUserAgent(null)` or `''` + +Both no-op. The User-Agent is never *cleared* — the constant default is what's sent if the request itself didn't carry a `User-Agent` header. If you need an empty UA explicitly, do it at the request level (`$request = $request->withHeader('User-Agent', '')`). + +## Custom cURL options take last priority + +`curlOptions` is merged on top of the defaults computed from the message + the timeout/redirect settings. Last-write-wins: if you set `CURLOPT_FOLLOWLOCATION` in `withCurlOptions`, that wins over `setFollowRedirects`. Use this escape hatch sparingly; prefer the explicit knobs. + +Useful overrides you might reach for: + +```php +// Force SNI hostname for self-signed cert testing +[CURLOPT_RESOLVE => ['api.example.test:443:127.0.0.1']] + +// Pin TLS version +[CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_3] + +// Capture verbose debug output +[CURLOPT_VERBOSE => true, CURLOPT_STDERR => fopen('php://stderr', 'w')] + +// Bind to a specific interface (server with multiple IPs) +[CURLOPT_INTERFACE => '203.0.113.10'] +``` + +## Mutator vs wither + +`set*` mutates the client in place and returns `$this` for chaining: + +```php +$client = new Client(); +$client->setTimeout(15); // $client now has timeout=15 +``` + +`with*` returns a clone: + +```php +$client = new Client(); +$shortClient = $client->withTimeout(5); +// $client still has timeout=30; $shortClient has timeout=5 +``` + +Use the wither when the client is shared across request paths and each path needs its own override. Use the mutator when you're building the client once at boot. + +## Timeouts + +`CURLOPT_TIMEOUT = 0` disables the timeout entirely. The default of 30 s exists because a runaway request will tie up an FPM worker indefinitely otherwise — production HTTP clients without a timeout are an outage waiting to happen. If you really need infinite wait (long-poll, server-sent events), be explicit: + +```php +$longPoll = $client->withTimeout(0)->withConnectTimeout(10); +``` + +The connect timeout is separate from the total timeout. `CURLOPT_CONNECTTIMEOUT = 10` means we'll spend at most 10 s establishing the TCP/TLS handshake before failing fast; the remaining time budget is reserved for the response. diff --git a/docs/psr18/exceptions.md b/docs/psr18/exceptions.md new file mode 100644 index 0000000..3afacb4 --- /dev/null +++ b/docs/psr18/exceptions.md @@ -0,0 +1,71 @@ +# PSR-18 Exceptions + +PSR-18 distinguishes three failure shapes, each represented by an interface in `Psr\Http\Client\`: + +| Interface | When the client throws it | +|-------------------------------------|------------------------------------------------------------------------------------------| +| `ClientExceptionInterface` | Parent of the two below; also raised on infrastructure failure (cURL won't initialise). | +| `RequestExceptionInterface` | The supplied `RequestInterface` is malformed — the request never went on the wire. | +| `NetworkExceptionInterface` | A transport-level failure prevented the response from arriving (DNS, TCP, TLS, timeout). | + +This package's concrete classes: + +``` +InitPHP\HTTP\Client\Exceptions\ClientException implements ClientExceptionInterface +InitPHP\HTTP\Client\Exceptions\RequestException extends ClientException implements RequestExceptionInterface +InitPHP\HTTP\Client\Exceptions\NetworkException extends ClientException implements NetworkExceptionInterface +``` + +Both `RequestException` and `NetworkException` carry the originating request — fetch it with `->getRequest()`. + +## What is NOT an exception + +**4xx and 5xx responses are NOT exceptions** under PSR-18. The client returns them as a regular `ResponseInterface`. If you want to throw on a 4xx, do it explicitly: + +```php +$response = $client->sendRequest($request); + +if ($response->getStatusCode() >= 400) { + throw new \DomainException( + 'Upstream returned ' . $response->getStatusCode() . ': ' . (string) $response->getBody() + ); +} +``` + +## Catch surface + +Use the **interfaces**, not the concrete classes, so substituting another PSR-18 client later doesn't require touching the `catch` blocks: + +```php +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Http\Client\RequestExceptionInterface; +use Psr\Http\Client\ClientExceptionInterface; + +try { + $response = $client->sendRequest($request); +} catch (RequestExceptionInterface $e) { + // Bad request shape — don't retry as-is. + $logger->error('Malformed outbound request', ['err' => $e->getMessage()]); + throw $e; +} catch (NetworkExceptionInterface $e) { + // Transient — caller may retry with backoff. + $logger->warning('Upstream unreachable', ['err' => $e->getMessage()]); + throw $e; +} catch (ClientExceptionInterface $e) { + // ext-curl missing, cURL init failed — infrastructure. + $logger->critical('Client setup failure', ['err' => $e->getMessage()]); + throw $e; +} +``` + +## Mapping in this client + +| Failure | Concrete exception | +|-----------------------------------------------|---------------------------------------------| +| `ext-curl` is not loaded | `ClientException` (in the constructor) | +| `curl_init()` returned `false` | `ClientException` | +| `Request::getUri()` produced an invalid URL | `RequestException` (request never sent) | +| `Request::getBody()->getContents()` threw | `RequestException` (request never sent) | +| `curl_exec()` returned `false` for any reason | `NetworkException` carrying the cURL error | + +The `NetworkException` constructor wraps `curl_error()` (or a fallback "cURL error" message) and stores `(int) curl_errno()` in `getCode()`. Match on the code if you need to distinguish DNS failure (6) from TLS errors (35, 60) from timeouts (28). diff --git a/docs/psr7/messages.md b/docs/psr7/messages.md new file mode 100644 index 0000000..60a2e98 --- /dev/null +++ b/docs/psr7/messages.md @@ -0,0 +1,140 @@ +# PSR-7 Messages: Request and Response + +PSR-7 messages are **immutable**. Every method whose name starts with `with` (e.g. `withHeader`, `withStatus`, `withUri`) returns a **new** instance — the original is left untouched. The concrete classes in this package also expose `set*()` mutators for ergonomics, but you should reach for those only inside a builder; once a message has left your construction site, treat it as frozen. + +## Request + +```php +use InitPHP\HTTP\Message\Request; + +$request = new Request( + 'POST', // method + 'https://api.example.com/users', // URI as string or UriInterface + ['Accept' => 'application/json'], // headers (string or list of strings) + json_encode(['name' => 'Ada']), // body (string|resource|StreamInterface|null) + '1.1' // HTTP protocol version +); +``` + +The constructor accepts both a string URI and a `Psr\Http\Message\UriInterface`. Headers can be `string|string[]` per RFC 7230 (multi-value headers like `Set-Cookie` arrive as arrays). + +### Reading + +```php +$request->getMethod(); // "POST" +$request->getUri(); // UriInterface +$request->getRequestTarget(); // "/users" (path + query) +$request->getHeaders(); // array +$request->getHeader('Accept'); // string[] — empty array if missing +$request->getHeaderLine('Accept'); // "application/json, text/plain" +$request->getProtocolVersion(); // "1.1" +$request->getBody(); // StreamInterface +``` + +`getHeader()` is case-insensitive. The keys returned from `getHeaders()` preserve the case the header was originally stored with (PSR-7 mandates only that the case is consistent on the wire). + +### Convenience predicates + +The concrete `Request` adds a handful of method-check helpers on top of PSR-7: + +```php +$request->isGet(); // bool +$request->isPost(); +$request->isPut(); +$request->isPatch(); +$request->isDelete(); +$request->isHead(); +$request->isMethod('PUT', 'PATCH'); // matches either +``` + +All are case-insensitive. + +### Mutating (returns a new instance) + +```php +$with = $request + ->withMethod('PUT') + ->withUri(new Uri('https://api.example.com/users/42')) + ->withHeader('Content-Type', 'application/json; charset=utf-8') + ->withAddedHeader('X-Trace-Id', '0f1e2d') + ->withoutHeader('Accept') + ->withProtocolVersion('2'); +``` + +Every `with*()` call returns a fresh message; the original `$request` keeps its original shape. This is how all the PSR-15 middleware in the ecosystem assumes messages behave. + +### Bodies + +`withBody()` accepts any `Psr\Http\Message\StreamInterface`: + +```php +use InitPHP\HTTP\Message\Stream; + +$request = $request->withBody(new Stream(json_encode($payload), 'php://temp')); +``` + +See [Streams](streams.md) for the details on `target` selection (`php://temp`, `php://memory`, or `null` for an in-memory string backend). + +## Response + +```php +use InitPHP\HTTP\Message\Response; + +$response = new Response( + 200, // status + ['Content-Type' => 'text/plain'], // headers + null, // body + '1.1', // version + null // reason phrase override (null => IANA default) +); +``` + +If `reason` is `null` and `status` is a recognised code (100..511), the canonical IANA phrase is used (`'OK'`, `'Internal Server Error'`, `'Not Found'`, ...). + +```php +$response->getStatusCode(); // 200 +$response->getReasonPhrase(); // "OK" +$response = $response->withStatus(404); +$response = $response->withStatus(418, "I'm a teapot"); +``` + +Supported HTTP versions: `1.0`, `1.1`, `2`, `2.0`, `3`, `3.0`. + +### Convenience producers + +```php +$response = (new Response())->json(['ok' => true, 'data' => $rows], 200); +``` + +- Sets `Content-Type: application/json; charset=utf-8`. +- Uses `JSON_THROW_ON_ERROR`; an unencodable payload raises `InvalidArgumentException` instead of silently producing `false`. +- Optional third argument `$flags` is ORed into `json_encode()`. + +```php +$response = (new Response()) + ->redirect('https://example.com/welcome', 302); + +// With a "thanks for shopping" countdown: +$response = (new Response()) + ->redirect('https://example.com/welcome', 302, 5); // Refresh: 5; url=... +``` + +The Location header is **always** set so non-browser clients (crawlers, HTTP libraries, monitoring) can follow the redirect. When the countdown argument is non-zero, a `Refresh` header is added in addition to `Location`. + +## Why deep cloning matters + +The default PHP clone is shallow: cloning a `Request` would leave both copies pointing at the same `Stream` and `Uri` objects. Writing to the clone's body would corrupt the original — exactly the bug PSR-7 immutability is designed to prevent. + +Concrete classes in this package implement `__clone()` to deep-clone the body and URI: + +```php +$request = new Request('GET', '/'); +$clone = $request->withHeader('X-Test', 'yes'); + +$clone->getBody()->write('payload'); + +(string) $request->getBody(); // "" — original untouched +(string) $clone->getBody(); // "payload" — clone has its own buffer +``` + +This guarantee is verified by `tests/Immutability/MessageImmutabilityTest.php`. diff --git a/docs/psr7/server-request.md b/docs/psr7/server-request.md new file mode 100644 index 0000000..c6b1fca --- /dev/null +++ b/docs/psr7/server-request.md @@ -0,0 +1,100 @@ +# ServerRequest + +`ServerRequest` extends the basic [Request](messages.md) with the slots a framework needs to receive an incoming HTTP request: server params, cookies, query string, parsed body, uploaded files, and a free-form attribute bag. + +## Constructing + +```php +use InitPHP\HTTP\Message\ServerRequest; + +$request = new ServerRequest( + 'POST', // method + '/checkout', // URI + ['Content-Type' => 'application/json'], + '{"sku":"X-1"}', // body + '1.1', // version + $_SERVER // server params +); + +$request = $request + ->withCookieParams($_COOKIE) + ->withQueryParams($_GET) + ->withParsedBody(['sku' => 'X-1']) + ->withUploadedFiles($request->normalizeFiles($_FILES)); +``` + +Everything except the constructor pair (`$method`, `$uri`) is optional and can be set later via the PSR-7 `with*()` family. + +## Hydrating from globals + +For most applications you don't want to wire all of the above manually — call the dedicated factory instead: + +```php +$request = ServerRequest::createFromGlobals(); +``` + +That's a one-liner equivalent to the seven-line `new ServerRequest(...)` recipe above. It's also **stateless**: every call returns a freshly computed instance. This is the property that makes the package safe under Swoole, RoadRunner, Octane and FrankenPHP, where a single PHP process may serve many HTTP requests in sequence. + +For tests, or when running under exotic runtimes that don't populate the superglobals, pass arrays explicitly: + +```php +$request = ServerRequest::createFromGlobals( + $server = ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/cart', 'HTTP_HOST' => 'shop.test'], + $get = [], + $post = ['sku' => 'X-1'], + $cookies = [], + $files = [] +); +``` + +### Content-Type-aware body parsing + +`createFromGlobals` reads `php://input` and parses it according to the advertised `Content-Type`: + +| Content-Type | Parsed body | +|---------------------------------------|----------------------------------------| +| `application/json` | `json_decode($body, true)` | +| `application/x-www-form-urlencoded` | `$_POST` if non-empty, else `parse_str` | +| `multipart/form-data` | `$_POST` (PHP already populated it) | +| Anything else (including no header) | `parsedBody` left as `null` | + +JSON payloads that fail to decode (or decode to a non-array value) leave `parsedBody` as `null` — they don't throw. Use `(string) $request->getBody()` if you need the raw bytes. + +### Header collection on nginx + php-fpm + +`apache_request_headers()` is only available under mod_php. On nginx + php-fpm (and FrankenPHP), `createFromGlobals` falls back to walking `$_SERVER` for `HTTP_*` and `CONTENT_*` keys and normalising them back into header case (`HTTP_X_TRACE_ID` → `X-Trace-Id`). + +## Working with uploaded files + +```php +foreach ($request->getUploadedFiles() as $field => $file) { + if ($file instanceof \Psr\Http\Message\UploadedFileInterface) { + $file->moveTo(__DIR__ . '/uploads/' . $file->getClientFilename()); + } elseif (is_array($file)) { + // Nested input names: file[parent][child] + foreach ($file as $child) { ... } + } +} +``` + +`normalizeFiles()` handles arbitrarily nested input names (`file[parent][child][...]`) — see [Uploaded Files](uploaded-files.md) for the recursion details. + +## Attributes + +Attributes are the framework-side scratch pad — a place for middleware to stash decoded JWT claims, matched route parameters, dependency-injection handles, and the rest: + +```php +$request = $request->withAttribute('user', $authenticatedUser); + +$user = $request->getAttribute('user'); // value or null +$user = $request->getAttribute('user', $guest); // value or fallback +$request = $request->withoutAttribute('user'); +``` + +`getAttributes()` returns the entire bag as an associative array. + +## Why no singleton + +The previous (`2.x`) implementation cached the first `createFromGlobals()` result in a static property and reused it forever. That worked under PHP-FPM because the process dies after one request, but it broke under any long-running runtime — the second HTTP request saw the first one's data. + +The v3 implementation has no static cache. Each call computes a fresh instance from the supplied (or default) superglobal snapshots. If you want process-wide caching, do it at your application layer where you control the lifecycle. diff --git a/docs/psr7/streams.md b/docs/psr7/streams.md new file mode 100644 index 0000000..5b4b5b1 --- /dev/null +++ b/docs/psr7/streams.md @@ -0,0 +1,97 @@ +# Streams + +PSR-7 message bodies are `Psr\Http\Message\StreamInterface`. The implementation in this package, `InitPHP\HTTP\Message\Stream`, supports three backends selected by the constructor's second argument: + +```php +use InitPHP\HTTP\Message\Stream; + +new Stream($contents, 'php://temp'); // default — disk-spilling temp file +new Stream($contents, 'php://memory'); // in-memory PHP stream +new Stream($contents, null); // pure in-memory string buffer +new Stream($resource); // wraps an existing resource handle +new Stream($otherStream); // copies an existing StreamInterface +``` + +## Choosing a backend + +| Backend | When to use | +|-----------------|-----------------------------------------------------------------------------------------------| +| `php://temp` | **Default**. Small payloads stay in memory, larger ones spill to disk (default 2 MiB). | +| `php://memory` | When you know the payload is small AND you want to forbid disk spill (e.g. secrets). | +| `null` (string) | Tiny constants like reason phrases or canned error bodies; cheapest backend, no FD allocation. | +| resource | When you already opened the handle yourself (e.g. file upload, `fopen('php://input', 'r')`). | + +The `null` backend is a recent addition for the cases where a real stream feels too heavy — it implements the full `StreamInterface` contract by keeping the bytes in a regular PHP string. + +## Writing + +`write($string)` follows `fwrite()` semantics: + +```php +$stream = new Stream('hello', null); +$stream->write(' world'); // appends past EOF +(string) $stream; // "hello world" +echo $stream->tell(); // 11 + +$stream = new Stream('hello world', null); +$stream->seek(6); +$stream->write('there'); // overwrites from position 6 +(string) $stream; // "hello there" +echo $stream->tell(); // 11 +``` + +The string backend uses substring overwrite-or-extend exactly like a real seekable stream; the position is advanced by the number of bytes actually written. + +## Reading + +```php +$stream->rewind(); +$first = $stream->read(1024); +$rest = $stream->getContents(); +``` + +`(string) $stream` performs a `rewind()` (if seekable) and returns the full contents. **It never throws.** A detached or otherwise unreadable stream returns an empty string from `__toString` — this is a PSR-7 hard requirement. + +```php +$stream = new Stream('x', 'php://temp'); +$stream->close(); +echo (string) $stream; // "" — quiet failure, not an exception +``` + +If you need a hard error, use `getContents()` directly; it raises `RuntimeException` on a detached stream. + +## isEmpty / isNotEmpty + +```php +$stream->isEmpty(); // true when size is known and < 1 +$stream->isNotEmpty(); // true when size is known and > 0 +``` + +Both return `false` for **indeterminate** streams (pipes, sockets, chunked responses with no `Content-Length`). Callers branching on these must handle the "I don't know" case explicitly — there's deliberately no boolean that lies. + +## detach() + +`detach()` releases the underlying resource and returns it to the caller. For resource-backed streams it's just `unset($this->stream); return $resource`. For the `null` (string) backend the bytes are materialised into a `php://memory` handle whose cursor is positioned at the same offset the string backend was sitting on — so callers who detached a partially-read string-backed stream get the exact same view as a resource. + +```php +$stream = new Stream('abcdef', null); +$stream->seek(3); +$resource = $stream->detach(); // resource handle +ftell($resource); // 3 +``` + +## Cloning preserves independence + +When a message is cloned (`withHeader`, `withStatus`, ...) the body is cloned too. The clone gets its own buffer; writing to it does **not** mutate the original: + +```php +$request = new Request('GET', '/', [], new Stream('original', 'php://temp')); +$clone = $request->withHeader('X-Test', 'yes'); + +$clone->getBody()->write(' tampered'); + +(string) $request->getBody(); // "original" — untouched +(string) $clone->getBody(); // " tamperedl" or similar — clone has its own +``` + +For resource-backed streams the contents are copied into a new `php://temp` handle; for string-backed streams PHP's copy-on-write does the work. Either way, the two messages are independent. diff --git a/docs/psr7/uploaded-files.md b/docs/psr7/uploaded-files.md new file mode 100644 index 0000000..8620cce --- /dev/null +++ b/docs/psr7/uploaded-files.md @@ -0,0 +1,109 @@ +# Uploaded Files + +`InitPHP\HTTP\Message\UploadedFile` implements `Psr\Http\Message\UploadedFileInterface`. The two consumer use-cases are: + +1. **Inside a controller** — iterate `ServerRequest::getUploadedFiles()` and move each upload to its final home. +2. **In tests** — build an `UploadedFile` directly from a `Stream` to simulate an incoming file without touching `$_FILES`. + +## Constructing manually + +```php +use InitPHP\HTTP\Message\UploadedFile; +use InitPHP\HTTP\Message\Stream; + +$file = new UploadedFile( + new Stream('file contents', 'php://temp'), + 13, // size in bytes — may be null when unknown + UPLOAD_ERR_OK, // one of the UPLOAD_ERR_* constants + 'manifesto.txt', // client-supplied filename + 'text/plain' // client-supplied media type +); +``` + +You can also pass a file path string or an open resource as the first argument; `UploadedFile` wraps it into a Stream on first `getStream()` call. + +## Inspection + +```php +$file->getSize(); // int|null +$file->getError(); // int — one of the UPLOAD_ERR_* constants +$file->getClientFilename(); // string|null +$file->getClientMediaType(); // string|null +$file->getStream(); // StreamInterface (throws if moved or upload errored) +``` + +## Moving the file + +```php +$file->moveTo('/var/www/uploads/manifesto.txt'); +``` + +`moveTo()` selects the right primitive based on the SAPI: + +- **CLI** (tests, scripts) — uses `rename()` (no `is_uploaded_file()` requirement). +- **Web SAPIs** — uses `move_uploaded_file()` to satisfy PHP's upload safety check. +- **Stream-backed UploadedFile** (e.g. constructed from a `Stream` in tests) — copies via `Stream::read()`/`Stream::write()` in 1 MiB chunks, looping until all bytes are flushed (partial writes are retried). + +After a successful move, the upload is consumed: + +```php +$file->getStream(); // throws RuntimeException — "after it has already been moved" +$file->moveTo('/elsewhere'); // throws too +``` + +## Nested file inputs + +PHP's `$_FILES` represents nested form names like `file[parent][child]` as parallel arrays of `tmp_name`, `size`, `error`, `name`, `type`. `ServerRequest::normalizeFiles()` walks the tree recursively and produces a matching tree of `UploadedFile` values: + +```html +
+ + + +
+``` + +```php +$request = ServerRequest::createFromGlobals(); +$tree = $request->getUploadedFiles(); + +$tree['docs']['brief'] instanceof UploadedFileInterface; // true +$tree['docs']['exhibits']['a'] instanceof UploadedFileInterface; // true +$tree['docs']['exhibits']['b'] instanceof UploadedFileInterface; // true +``` + +Walk the tree with a small recursive helper: + +```php +function walk(array $files, callable $sink): void { + foreach ($files as $entry) { + if ($entry instanceof \Psr\Http\Message\UploadedFileInterface) { + $sink($entry); + } elseif (is_array($entry)) { + walk($entry, $sink); + } + } +} + +walk($tree, static fn ($file) => $file->moveTo('/uploads/' . $file->getClientFilename())); +``` + +## Error handling + +`getError()` returns one of PHP's `UPLOAD_ERR_*` constants. Anything other than `UPLOAD_ERR_OK` means the upload failed and `getStream()` / `moveTo()` will throw `RuntimeException`. Build a small mapping table for user-facing messages: + +```php +$errors = [ + UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize.', + UPLOAD_ERR_FORM_SIZE => 'File exceeds the form-level MAX_FILE_SIZE.', + UPLOAD_ERR_PARTIAL => 'Upload was interrupted.', + UPLOAD_ERR_NO_FILE => 'No file was uploaded.', + UPLOAD_ERR_NO_TMP_DIR => 'Server is missing a temporary directory.', + UPLOAD_ERR_CANT_WRITE => 'Server could not write the file to disk.', + UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the upload.', +]; + +if ($file->getError() !== UPLOAD_ERR_OK) { + throw new \DomainException($errors[$file->getError()] ?? 'Unknown upload error.'); +} +``` diff --git a/docs/psr7/uri.md b/docs/psr7/uri.md new file mode 100644 index 0000000..6192609 --- /dev/null +++ b/docs/psr7/uri.md @@ -0,0 +1,66 @@ +# Uri + +`InitPHP\HTTP\Message\Uri` is a PSR-7 `UriInterface` value-object parsed at construction time: + +```php +use InitPHP\HTTP\Message\Uri; + +$uri = new Uri('https://alice:secret@api.example.com:8443/v1/users?active=true#contact'); + +$uri->getScheme(); // "https" +$uri->getUserInfo(); // "alice:secret" +$uri->getHost(); // "api.example.com" +$uri->getPort(); // 8443 +$uri->getAuthority(); // "alice:secret@api.example.com:8443" +$uri->getPath(); // "/v1/users" +$uri->getQuery(); // "active=true" +$uri->getFragment(); // "contact" +(string) $uri; // round-trips the whole URI +``` + +A malformed input string raises `InvalidArgumentException`. + +## Immutable mutators + +```php +$uri = (new Uri('https://example.com/')) + ->withScheme('http') + ->withHost('api.example.com') + ->withPort(8080) + ->withPath('/v2/users') + ->withQuery('limit=50&offset=0') + ->withFragment('top') + ->withUserInfo('alice', 'secret'); +``` + +Each `with*()` returns a new `Uri`; the original is untouched. + +## Standard ports collapse + +`getPort()` returns `null` when the port is the default for the scheme: + +```php +(new Uri('https://example.com:443'))->getPort(); // null +(new Uri('http://example.com:80'))->getPort(); // null +(new Uri('http://example.com:8080'))->getPort(); // 8080 +``` + +This matches PSR-7 — clients deciding whether to emit an explicit port in the `Host` header don't have to special-case standard ports themselves. + +## Path encoding + +The constructor (and `withPath()` / `withQuery()` / `withFragment()`) run the input through a percent-encoding filter that escapes any byte outside the RFC 3986 character classes for that component. Already-percent-encoded sequences are preserved: + +```php +$uri = new Uri('https://example.com/cafe%CC%81/menu'); +$uri->getPath(); // "/cafe%CC%81/menu" — kept as-is + +$uri = (new Uri('https://example.com'))->withPath('/c a f é'); +$uri->getPath(); // "/c%20a%20f%20%C3%A9" +``` + +Userinfo, query, and fragment components are encoded against slightly different allowed-character classes — see the source of `Uri::filterPath()`, `filterQueryAndFragment()`, and `filterUserInfoComponent()` for the regex specifics. + +## Set vs With + +The concrete `Uri` also exposes `setScheme/setHost/setPort/setPath/setQuery/setFragment/setUserInfo` mutators that modify the instance in place. These are useful inside builders (a single chain that constructs and immediately uses a URI) but should never escape into shared state — once a URI has been handed to a message, treat it as frozen and use `with*()`. The PSR-7 contract that this package implements only includes the `with*()` family. diff --git a/docs/recipes/file-upload.md b/docs/recipes/file-upload.md new file mode 100644 index 0000000..60673cf --- /dev/null +++ b/docs/recipes/file-upload.md @@ -0,0 +1,72 @@ +# Recipe: File Uploads + +## Server side — receiving + +```php +use InitPHP\HTTP\Message\ServerRequest; + +$request = ServerRequest::createFromGlobals(); + +foreach ($request->getUploadedFiles() as $field => $file) { + if ($file instanceof \Psr\Http\Message\UploadedFileInterface) { + if ($file->getError() !== UPLOAD_ERR_OK) { + // Reject early — see docs/psr7/uploaded-files.md for the error map. + throw new \DomainException('Upload failed.'); + } + $file->moveTo('/var/www/uploads/' . bin2hex(random_bytes(8)) . '.bin'); + } +} +``` + +For nested input names (`docs[brief]`, `docs[exhibits][a]`), walk the tree recursively — see [Uploaded Files](../psr7/uploaded-files.md#nested-file-inputs). + +## Client side — sending a single file + +PSR-7 has no opinion on how you encode `multipart/form-data` — the spec is on the wire format, not the encoding. Build the body string yourself or use a multipart-encoder package, then ship it: + +```php +use InitPHP\HTTP\Client\Client; +use InitPHP\HTTP\Message\Request; +use InitPHP\HTTP\Message\Stream; + +$boundary = '----' . bin2hex(random_bytes(8)); +$payload = "--{$boundary}\r\n" + . "Content-Disposition: form-data; name=\"avatar\"; filename=\"me.jpg\"\r\n" + . "Content-Type: image/jpeg\r\n\r\n" + . file_get_contents('/path/to/me.jpg') . "\r\n" + . "--{$boundary}--\r\n"; + +$request = new Request( + 'POST', + 'https://api.example.com/profile/avatar', + [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, + 'Content-Length' => (string) strlen($payload), + ], + new Stream($payload, 'php://temp') +); + +(new Client())->sendRequest($request); +``` + +For multi-megabyte uploads stream the file into a temp resource instead of building the payload in memory: + +```php +$tmp = fopen('php://temp', 'w+b'); +fwrite($tmp, "--{$boundary}\r\n"); +fwrite($tmp, "Content-Disposition: form-data; name=\"upload\"; filename=\"big.bin\"\r\n"); +fwrite($tmp, "Content-Type: application/octet-stream\r\n\r\n"); +stream_copy_to_stream(fopen('/path/to/big.bin', 'rb'), $tmp); +fwrite($tmp, "\r\n--{$boundary}--\r\n"); +rewind($tmp); + +$request = new Request('POST', 'https://api.example.com/upload', [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, +], new Stream($tmp)); + +(new Client())->sendRequest($request); +``` + +## Why no built-in multipart encoder + +It's a separate concern with significant subtleties (chunked transfer, RFC-2231 filename encoding, character sets, edge cases for nested data). Keeping the HTTP transport unaware of multipart encoding means you can plug in `guzzlehttp/psr7`'s `MultipartStream`, `symfony/mime`, or hand-rolled bytes — whatever you already standardised on. diff --git a/docs/recipes/json-response.md b/docs/recipes/json-response.md new file mode 100644 index 0000000..75d0bc6 --- /dev/null +++ b/docs/recipes/json-response.md @@ -0,0 +1,74 @@ +# Recipe: JSON Responses + +## With the convenience producer + +```php +use InitPHP\HTTP\Message\Response; +use InitPHP\HTTP\Emitter\Emitter; + +$response = (new Response())->json(['ok' => true, 'data' => $rows], 200); + +(new Emitter())->emit($response); +``` + +Wraps `json_encode($data, JSON_THROW_ON_ERROR)`, sets `Content-Type: application/json; charset=utf-8`, and applies the status. An unencodable payload throws `InvalidArgumentException` — no silent `false` bodies. + +Pass extra flags as the third argument: + +```php +$pretty = (new Response())->json($data, 200, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); +``` + +## Manual variant + +If you need a custom Content-Type subtype (`application/vnd.example+json`, `application/problem+json`, …): + +```php +use InitPHP\HTTP\Message\Stream; + +$body = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); +$response = (new Response(200, [ + 'Content-Type' => 'application/vnd.example+json; charset=utf-8', +]))->withBody(new Stream($body, null)); +``` + +The `null` stream backend is the cheapest option for short bodies — no resource allocation, just a PHP string. + +## RFC 9457 Problem Details + +```php +$problem = [ + 'type' => 'https://example.com/probs/invalid-input', + 'title' => 'Invalid input', + 'status' => 422, + 'detail' => 'Field "email" must be a valid address.', + 'instance' => '/users', +]; + +$body = json_encode($problem, JSON_THROW_ON_ERROR); +$response = (new Response(422, [ + 'Content-Type' => 'application/problem+json', +]))->withBody(new Stream($body, null)); +``` + +## JSON-streaming large lists + +For megabyte-class payloads, prefer streaming over `json_encode` in one shot: + +```php +$body = fopen('php://temp', 'w+b'); +fwrite($body, '['); +$first = true; +foreach ($cursor as $row) { + if (!$first) fwrite($body, ','); + fwrite($body, json_encode($row, JSON_THROW_ON_ERROR)); + $first = false; +} +fwrite($body, ']'); +rewind($body); + +$response = (new Response(200, ['Content-Type' => 'application/json; charset=utf-8'])) + ->withBody(new Stream($body)); + +(new Emitter())->emit($response, 65536); // chunked output +``` diff --git a/docs/recipes/proxying-requests.md b/docs/recipes/proxying-requests.md new file mode 100644 index 0000000..063dcf6 --- /dev/null +++ b/docs/recipes/proxying-requests.md @@ -0,0 +1,74 @@ +# Recipe: Proxying Requests + +A common shape: an incoming `ServerRequest` should be forwarded to an upstream service, the response copied back. With the PSR-7 + PSR-18 building blocks the whole thing fits in 20 lines. + +```php +use InitPHP\HTTP\Client\Client; +use InitPHP\HTTP\Message\Request; +use InitPHP\HTTP\Message\ServerRequest; +use InitPHP\HTTP\Emitter\Emitter; + +$incoming = ServerRequest::createFromGlobals(); + +// 1) Rewrite the URI to point at the upstream host. +$upstreamUri = $incoming->getUri() + ->withScheme('https') + ->withHost('upstream.internal') + ->withPort(443); + +// 2) Strip hop-by-hop headers the proxy must NOT forward. +$hopByHop = ['Connection', 'Keep-Alive', 'Proxy-Authenticate', 'Proxy-Authorization', + 'TE', 'Trailers', 'Transfer-Encoding', 'Upgrade']; +$headers = array_diff_key($incoming->getHeaders(), array_flip($hopByHop)); + +// 3) Build the upstream request from the incoming body verbatim. +$outgoing = new Request( + $incoming->getMethod(), + $upstreamUri, + $headers, + $incoming->getBody(), + $incoming->getProtocolVersion() +); + +// 4) Ship it. +$client = (new Client())->withTimeout(30); +$response = $client->sendRequest($outgoing); + +// 5) Emit the upstream response back to the caller, stripping hop-by-hop on the way out. +foreach ($hopByHop as $h) { + $response = $response->withoutHeader($h); +} + +(new Emitter())->emit($response, 65536); +``` + +## Useful extensions + +### Forwarding the client's identity + +```php +$outgoing = $outgoing + ->withHeader('X-Forwarded-For', $_SERVER['REMOTE_ADDR'] ?? '') + ->withHeader('X-Forwarded-Proto', $incoming->getUri()->getScheme()) + ->withHeader('X-Forwarded-Host', $incoming->getUri()->getHost()); +``` + +### Tracing + +```php +$traceId = $incoming->getHeaderLine('X-Trace-Id') ?: bin2hex(random_bytes(8)); +$outgoing = $outgoing->withHeader('X-Trace-Id', $traceId); +$response = $response->withHeader('X-Trace-Id', $traceId); +``` + +### Caching + +The PSR-18 client gives you `ResponseInterface` directly — pipe it through any cache middleware you like before passing it to the emitter. The package itself ships no caching layer; that's deliberate. + +### Streaming proxy + +For large bodies, neither direction should be materialised as a string. The example above already streams: `withBody($incoming->getBody())` hands the upstream a `StreamInterface` reading from `php://input`, and the emitter's `bufferLength = 65536` streams the response back. The whole pipe is two-stage chunked I/O. + +## What this does NOT replace + +If you're standing up a *real* reverse proxy you almost certainly want nginx, HAProxy, or Caddy in front. The PSR-7/PSR-18 recipe above is for "I need to forward a single request inside business logic" cases — auth-decorating a downstream API, fan-out to multiple upstreams, etc. diff --git a/docs/recipes/redirect.md b/docs/recipes/redirect.md new file mode 100644 index 0000000..254a679 --- /dev/null +++ b/docs/recipes/redirect.md @@ -0,0 +1,63 @@ +# Recipe: Redirects + +## Permanent (301) + +```php +use InitPHP\HTTP\Message\Response; + +$response = (new Response())->redirect('https://example.com/new-home', 301); +``` + +Sets `Location: https://example.com/new-home` and status 301. Use 301 for SEO-relevant moves that you want cached by search engines and browsers. + +## Temporary (302 / 307) + +```php +$response = (new Response())->redirect('/dashboard', 302); +``` + +302 (Found) is what most "after login, send the user back" flows use. If you specifically need the client to preserve the request method (e.g. POST → POST), use 307 (Temporary Redirect): + +```php +$response = (new Response())->redirect('/checkout', 307); +``` + +## See Other (303) — POST/Redirect/GET + +After processing a POST, send 303 to force the browser to do a GET on the next URL — the classic anti-double-submit pattern: + +```php +$response = (new Response())->redirect('/orders/' . $orderId, 303); +``` + +## With a delay + +For "thanks, redirecting in 5 seconds" pages: + +```php +$response = (new Response())->redirect('https://example.com/thanks', 200, 5); +``` + +`Location` is **still** set (for crawlers and non-browser clients) **plus** `Refresh: 5; url=https://example.com/thanks` for browsers that honour the countdown. The status defaults to 302 if you don't pass one; the example above uses 200 so the page body actually renders during the wait. + +## Sticky session preservation + +PSR-7 leaves cookie handling to your session middleware; if you need to preserve a session across the redirect, set it on the response before emitting: + +```php +$response = (new Response()) + ->redirect('/dashboard', 302) + ->withAddedHeader('Set-Cookie', 'PHPSESSID=' . session_id() . '; Path=/; HttpOnly'); +``` + +## Validation + +`redirect()` throws `InvalidArgumentException` if you pass anything that isn't a string or a `Psr\Http\Message\UriInterface`: + +```php +try { + $response = (new Response())->redirect(42); +} catch (\InvalidArgumentException $e) { + // "URI is not valid." +} +``` diff --git a/docs/recipes/streaming-large-files.md b/docs/recipes/streaming-large-files.md new file mode 100644 index 0000000..d7f1035 --- /dev/null +++ b/docs/recipes/streaming-large-files.md @@ -0,0 +1,83 @@ +# Recipe: Streaming Large Files + +## Sending a file as a response + +```php +use InitPHP\HTTP\Message\Response; +use InitPHP\HTTP\Message\Stream; +use InitPHP\HTTP\Emitter\Emitter; + +$path = '/var/files/report.pdf'; + +$response = (new Response(200, [ + 'Content-Type' => 'application/pdf', + 'Content-Length' => (string) filesize($path), + 'Content-Disposition' => 'attachment; filename="report.pdf"', +]))->withBody(new Stream(fopen($path, 'rb'))); + +(new Emitter())->emit($response, /* bufferLength: */ 65536); +``` + +Three things make this efficient: + +1. The body Stream wraps the file's resource handle directly — no `file_get_contents()` into memory. +2. The emitter is given a 64 KiB buffer length, so the file is read and echoed in chunks. +3. Setting `Content-Length` explicitly lets the client show a progress bar and lets reverse proxies enable `sendfile` optimisations. + +## Range support + +Add it in one block (see also [Emitter — Content-Range](../emitter/content-range.md)): + +```php +$total = filesize($path); +$range = $request->getHeaderLine('Range'); + +$status = 200; +$headers = [ + 'Content-Type' => 'application/pdf', + 'Accept-Ranges' => 'bytes', +]; + +if (preg_match('/^bytes=(\d+)-(\d*)$/', $range, $m)) { + $first = (int) $m[1]; + $last = $m[2] !== '' ? (int) $m[2] : $total - 1; + $status = 206; + $headers['Content-Range'] = sprintf('bytes %d-%d/%d', $first, $last, $total); + $headers['Content-Length'] = (string) ($last - $first + 1); +} else { + $headers['Content-Length'] = (string) $total; +} + +$response = (new Response($status, $headers)) + ->withBody(new Stream(fopen($path, 'rb'))); + +(new Emitter())->emit($response, 65536); +``` + +## Receiving a large response + +Don't `(string) $response->getBody()` it into memory; read it incrementally: + +```php +$client = new \InitPHP\HTTP\Client\Client(); +$response = $client->sendRequest(new \InitPHP\HTTP\Message\Request('GET', $url)); + +$out = fopen('/tmp/big.bin', 'wb'); +$body = $response->getBody(); +while (!$body->eof()) { + fwrite($out, $body->read(65536)); +} +fclose($out); +``` + +The PSR-18 response body in this package is backed by `php://temp` (small payloads stay in memory; larger ones spill to disk), so even before you start reading you're not paying RAM for the whole response. + +## Long-poll / Server-Sent Events + +```php +$client = (new \InitPHP\HTTP\Client\Client()) + ->withTimeout(0) // no overall timeout + ->withConnectTimeout(5); // but fail fast on connection +``` + +For SSE you'll typically also set `withFollowRedirects(false)` and read the response body chunk-by-chunk without ever calling `(string) $body`. The emitter side needs `flush()` after each emitted chunk, and any output buffering in front of it must be unwound (see [Chunked bodies](../emitter/chunked-bodies.md#disabling-output-buffering)). diff --git a/docs/reference/http-status-codes.md b/docs/reference/http-status-codes.md new file mode 100644 index 0000000..d218546 --- /dev/null +++ b/docs/reference/http-status-codes.md @@ -0,0 +1,89 @@ +# HTTP Status Codes + +The `Response::PHRASES` table maps status codes to their IANA-registered reason phrases. When you construct a `Response` without an explicit reason, the corresponding phrase from this table is used. + +| Code | Reason Phrase | +|------|-------------------------------------| +| 100 | Continue | +| 101 | Switching Protocols | +| 102 | Processing | +| 103 | Early Hints | +| 200 | OK | +| 201 | Created | +| 202 | Accepted | +| 203 | Non-Authoritative Information | +| 204 | No Content | +| 205 | Reset Content | +| 206 | Partial Content | +| 207 | Multi-status | +| 208 | Already Reported | +| 210 | Content Different | +| 226 | IM Used | +| 300 | Multiple Choices | +| 301 | Moved Permanently | +| 302 | Found | +| 303 | See Other | +| 304 | Not Modified | +| 305 | Use Proxy | +| 306 | Switch Proxy | +| 307 | Temporary Redirect | +| 308 | Permanent Redirect | +| 400 | Bad Request | +| 401 | Unauthorized | +| 402 | Payment Required | +| 403 | Forbidden | +| 404 | Not Found | +| 405 | Method Not Allowed | +| 406 | Not Acceptable | +| 407 | Proxy Authentication Required | +| 408 | Request Time-out | +| 409 | Conflict | +| 410 | Gone | +| 411 | Length Required | +| 412 | Precondition Failed | +| 413 | Request Entity Too Large | +| 414 | Request-URI Too Large | +| 415 | Unsupported Media Type | +| 416 | Requested range not satisfiable | +| 417 | Expectation Failed | +| 418 | I'm a teapot | +| 421 | Misdirected Request | +| 422 | Unprocessable Entity | +| 423 | Locked | +| 424 | Failed Dependency | +| 425 | Unordered Collection | +| 426 | Upgrade Required | +| 428 | Precondition Required | +| 429 | Too Many Requests | +| 431 | Request Header Fields Too Large | +| 451 | Unavailable For Legal Reasons | +| 500 | Internal Server Error | +| 501 | Not Implemented | +| 502 | Bad Gateway | +| 503 | Service Unavailable | +| 504 | Gateway Time-out | +| 505 | HTTP Version not supported | +| 506 | Variant Also Negotiates | +| 507 | Insufficient Storage | +| 508 | Loop Detected | +| 510 | Not Extended | +| 511 | Network Authentication Required | + +## Custom phrases + +Pass a reason explicitly when you need something off-canon: + +```php +$response = new Response(418, [], null, '1.1', "I'm a coffee maker, actually"); +$response = $response->withStatus(599, 'Network connect timeout error'); +``` + +Status codes are validated against the inclusive range 100..599. Anything else raises `InvalidArgumentException`. + +## Codes outside the table + +Codes that aren't in the table get an empty reason phrase. Many proxies and clients are fine with that; some refuse to parse a status line without a phrase. Pick a phrase yourself if you're emitting a non-standard code: + +```php +$response = (new Response())->withStatus(599, 'Network connect timeout error'); +``` diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md new file mode 100644 index 0000000..a7c0845 --- /dev/null +++ b/docs/upgrade-guide.md @@ -0,0 +1,152 @@ +# Upgrade Guide — 2.x → 3.x + +Version 3.0 cleans up several long-standing design defects. Most upgrades are mechanical search-and-replace; the trickiest ones are the parameter bag removal and the `Request::createFromGlobals` move. + +## Quick checklist + +- [ ] Replace `InitPHP\HTTP\Message\Interfaces\*` with `Psr\Http\Message\*`. +- [ ] Replace `Request::createFromGlobals()` with `ServerRequest::createFromGlobals()`. +- [ ] Replace any `$request->name = $value` / `$request->all()` / `$request->get('name')` with the PSR-7 attribute or parsed-body API. +- [ ] Replace `$request->sendRequest()` with explicit `(new Client())->sendRequest($request)`. +- [ ] Pre-encode array bodies before passing them to `Client::sendRequest()`. +- [ ] If you set `Client::setUserAgent('')` expecting a no-op, you're fine — that still works. +- [ ] If you depended on the legacy "no timeout" behaviour, call `->withTimeout(0)` explicitly. +- [ ] If you imported the misspelled `Facadeble`/`FacadebleInterface`, switch to `Facadable`/`FacadableInterface` (the old names still resolve but are `@deprecated`). + +--- + +## Breaking changes in detail + +### 1. Custom message interfaces removed + +The `InitPHP\HTTP\Message\Interfaces\*` set (`MessageInterface`, `RequestInterface`, `ResponseInterface`, `ServerRequestInterface`, `StreamInterface`, `UriInterface`) is gone. Type-hint against PSR-7 directly: + +```diff +- use InitPHP\HTTP\Message\Interfaces\RequestInterface; ++ use Psr\Http\Message\RequestInterface; +``` + +The concrete classes (`Message\Request`, `Message\Response`, …) still implement everything that PSR-7 requires; only the package-local interfaces with mandatory `setX`/`outX` mutators are gone, because the mandatory-mutator contract conflicted with PSR-7's immutability guarantees. + +### 2. `Request::createFromGlobals()` removed + +Moved to `ServerRequest`, and now stateless: + +```diff +- $request = InitPHP\HTTP\Message\Request::createFromGlobals(); ++ $request = InitPHP\HTTP\Message\ServerRequest::createFromGlobals(); +``` + +The new factory takes optional `$server, $get, $post, $cookies, $files` arguments — pass them explicitly when running under long-running PHP runtimes or in tests so you don't depend on superglobal state. + +### 3. The `_parameters` parameter bag is gone + +`__get`/`__set`/`__isset`/`all()`/`get()`/`has()`/`merge()` no longer exist on `Request`. Use the PSR-7 alternatives: + +```diff +- $request->name; +- $request->get('name'); +- $request->has('name'); +- $request->all(); +- $request->merge($_GET, $_POST, $rawData); ++ $parsed = $request->getParsedBody() ?? []; ++ $name = $parsed['name'] ?? null; ++ // For attribute-style state set during middleware: ++ $request = $request->withAttribute('name', $value); ++ $name = $request->getAttribute('name'); +``` + +`ServerRequest::createFromGlobals()` now populates `parsedBody` automatically when the request advertises a Content-Type we understand (JSON, urlencoded forms, multipart) so the typical case "read the field the client submitted" works out of the box. + +### 4. `Request::sendRequest()` removed + +The convenience hook that built and dispatched a client inline is gone — it hard-coded a fresh `Client` per call and made DI/testing painful: + +```diff +- $response = $request->sendRequest(); ++ $response = (new InitPHP\HTTP\Client\Client())->sendRequest($request); +``` + +Or, if you've adopted the facade: + +```diff ++ $response = InitPHP\HTTP\Facade\Client::sendRequest($request); +``` + +### 5. `Client::sendRequest()` no longer encodes array bodies + +The previous client silently turned `$request->all()` into a JSON body when the request happened to be the concrete `Request` class. That violated PSR-18's "send what you got" contract. v3 only accepts `string | resource | StreamInterface | null` bodies. + +```diff +- // Old: $request had ->name = 'Ada' and the client encoded it for us. +- (new Client())->sendRequest($request); ++ $request = $request->withBody(new Stream(json_encode($payload), null)) ++ ->withHeader('Content-Type', 'application/json'); ++ (new Client())->sendRequest($request); +``` + +The `send_request()` global helper still handles convenience JSON-encoding for arrays — see [`src/Helpers.php`](../src/Helpers.php). + +### 6. `Client::prepareRequest()` no longer accepts DOM / SimpleXML / arrays + +`Client::fetch()`/`get()`/`post()`/`put()`/`patch()`/`delete()`/`head()` previously coerced `DOMDocument`, `SimpleXMLElement`, `array`, and "any object with `__toString()` or `toArray()`" into a body. That responsibility belongs in the application — different APIs need different serialisation choices. v3 accepts only `string | resource | StreamInterface | null`. + +If you need the old behaviour for arrays specifically, the `send_request()` helper still does it; for DOM/SimpleXML, encode before passing in: + +```diff +- $client->post($url, $dom); ++ $client->post($url, $dom->saveHTML()); + +- $client->post($url, $simpleXml); ++ $client->post($url, $simpleXml->asXML()); +``` + +### 7. `Client` has new timeouts + +Defaults: 30 s request timeout (`CURLOPT_TIMEOUT`), 10 s connect timeout (`CURLOPT_CONNECTTIMEOUT`). The old default was 0 (no timeout). If you specifically rely on infinite waits (long-poll, SSE) make it explicit: + +```php +$client = (new Client())->withTimeout(0)->withConnectTimeout(10); +``` + +### 8. `UploadedFile` size is now nullable + +`UploadedFile::__construct(?int $size, ...)` and `UploadedFile::getSize(): ?int` match the PSR-7 contract. Code that did `int $size = $file->getSize()` will need a null check on streams whose size isn't reportable. + +### 9. Facade naming corrected + +`InitPHP\HTTP\Facade\Interfaces\Facadeble` → `Facadable`. +`InitPHP\HTTP\Facade\Traits\Facadeble` → `Facadable`. + +The old names remain as `@deprecated` aliases and continue to work, but will be removed in 4.0. + +### 10. `Response::redirect()` always sets `Location` + +Previously, when `$second > 0`, only the `Refresh` header was set — crawlers and HTTP libraries that ignore the non-standard `Refresh` could not follow the redirect. v3 always sets `Location` and adds `Refresh` on top when a delay is requested. No code changes needed if you used `redirect(..., $status, 0)`; if you set a non-zero delay and relied on the absence of `Location`, that absence is gone. + +### 11. `Response::json()` is now strict + +Now uses `JSON_THROW_ON_ERROR` and translates failure into `InvalidArgumentException`. Code that used to silently produce `false` bodies on unencodable input will now throw. + +### 12. `Internal Server Error` typo fix + +The 500 reason phrase was previously `'Internal ServerRequest Error'`. v3 emits the canonical `'Internal Server Error'`. Log-aggregation rules that match on the literal old string will miss new responses. + +### 13. `Stream::__toString()` never throws + +PSR-7's hard requirement; the old implementation propagated `RuntimeException` from detached streams. v3 returns an empty string instead. If your code relied on the throw to detect a detached stream, switch to `$stream->getContents()` (still throws) or check `isset/eof` first. + +### 14. `Stream::write()` (string backend) obeys `fwrite()` semantics + +The `target=null` (pure in-memory string) backend used to *prepend* at position 0 and never advance the cursor. v3 overwrites from the current position and advances `tell()` correctly. If you wrote code that relied on the broken prepend behaviour, swap to `Stream::__construct($newPrefix . $oldBody, null)`. + +--- + +## Things that are NOT breaking + +- **PSR-7 / PSR-17 / PSR-18 spec behaviour** — the integration test suite still passes 100%. +- **All `with*()` mutators on every message type** — names, signatures, and immutability behaviour are unchanged. +- **The static facades** — `InitPHP\HTTP\Facade\{Client,Emitter,Factory}` still resolve to the same singleton instance with the same surface. +- **`send_request()` global helper** — same signature, accepts arrays/objects with `toArray()`/`__toString()` exactly as before. + +If something on this list broke for you, please file an issue. diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..58a0cb2 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,26 @@ +parameters: + level: 5 + phpVersion: 70400 + paths: + - src + excludePaths: + - src/Helpers.php + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false + ignoreErrors: + # Stream::$stream is typed as resource|string but uses unset()/isset() for + # detach semantics. Tracked for resolution in a future pass. + - '#Property InitPHP\\HTTP\\Message\\Stream::\$stream .* in (isset|empty)\(\) is not nullable\.#' + # Trait/concrete return type alignment with PSR-7 static<> covariance. + # The shape `return (clone $this)->setX(...)` returns the concrete + # class, which is the static type at runtime but PHPStan cannot prove + # it through the trait boundary. Suppressing across the message set. + - '#Method InitPHP\\HTTP\\Message\\(Request|Response|ServerRequest|Uri)::with[A-Za-z]+\(\) should return static\(.*\) but returns InitPHP\\HTTP\\Message\\.*\.#' + # Same covariance: Response::json() returns clone $this -> Response. + - '#Method InitPHP\\HTTP\\Message\\Response::json\(\) should return \$this\(InitPHP\\HTTP\\Message\\Response\) but returns InitPHP\\HTTP\\Message\\Response\.#' + # Request::__set has a return statement that PHP discards; legacy magic-method + # signature. Cleaned up when _parameters bag is removed in v3. + - '#Method InitPHP\\HTTP\\Message\\Request::__set\(\) with return type void returns mixed but should not return anything\.#' + # PHP 7.4 still allows ?? on superglobals; suppress noise until createFromGlobals + # is moved to ServerRequest with explicit ServerRequestFactory entry. + - '#Variable \$_(GET|POST|SERVER|COOKIE|FILES|ENV) on left side of \?\? always exists and is not nullable\.#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 545b902..9e7525c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -7,6 +7,9 @@ failOnWarning="false" cacheDirectory=".phpunit.cache"> + + tests/Unit + tests/Psr7 @@ -20,6 +23,14 @@ tests/Immutability + + + src + + + src/Helpers.php + + diff --git a/src/Client/Client.php b/src/Client/Client.php index 579e2c4..dfaede5 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -1,277 +1,567 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Client; - -use \InitPHP\HTTP\Message\{Request, Stream, Response}; -use \Psr\Http\Message\{RequestInterface, ResponseInterface, StreamInterface}; -use \InitPHP\HTTP\Client\Exceptions\{ClientException, NetworkException, RequestException}; - -use const CASE_LOWER; -use const FILTER_VALIDATE_URL; - -use function extension_loaded; -use function trim; -use function strpos; -use function count; -use function preg_match; -use function explode; -use function implode; -use function strlen; -use function filter_var; -use function array_change_key_case; -use function json_encode; -use function is_string; -use function is_array; -use function is_object; -use function method_exists; -use function class_exists; -use function get_object_vars; - -class Client implements \Psr\Http\Client\ClientInterface -{ - - protected string $userAgent = 'InitPHP HTTP PSR-18 Client cURL'; - - public function __construct() - { - if (!extension_loaded('curl')) { - throw new ClientException('The CURL extension must be installed.'); - } - } - - public function getUserAgent(): string - { - return $this->userAgent; - } - - public function setUserAgent(?string $userAgent = null): self - { - !empty($userAgent) && $this->userAgent = $userAgent; - - return $this; - } - - public function withUserAgent(?string $userAgent = null): self - { - return (clone $this)->setUserAgent($userAgent); - } - - public function fetch(string $url, array $details = []): ResponseInterface - { - $details = array_change_key_case($details, CASE_LOWER); - - $request = $this->prepareRequest( - $details['method'] ?? 'GET', - $url, - $details['data'] ?? $details['body'] ?? null, - $details['headers'] ?? [], - $details['version'] ?? '1.1' - ); - - return $this->sendRequest($request); - } - - public function get(string $url, $body = null, array $headers = [], string $version = '1.1'): ResponseInterface - { - return $this->sendRequest($this->prepareRequest('GET', $url, $body, $headers, $version)); - } - - public function post(string $url, $body = null, array $headers = [], string $version = '1.1'): ResponseInterface - { - return $this->sendRequest($this->prepareRequest('POST', $url, $body, $headers, $version)); - } - - public function patch(string $url, $body = null, array $headers = [], string $version = '1.1'): ResponseInterface - { - return $this->sendRequest($this->prepareRequest('PATCH', $url, $body, $headers, $version)); - } - - public function put(string $url, $body = null, array $headers = [], string $version = '1.1'): ResponseInterface - { - return $this->sendRequest($this->prepareRequest('PUT', $url, $body, $headers, $version)); - } - - public function delete(string $url, $body = null, array $headers = [], string $version = '1.1'): ResponseInterface - { - return $this->sendRequest($this->prepareRequest('DELETE', $url, $body, $headers, $version)); - } - - public function head(string $url, $body = null, array $headers = [], string $version = '1.1'): ResponseInterface - { - return $this->sendRequest($this->prepareRequest('HEAD', $url, $body, $headers, $version)); - } - - /** - * @inheritDoc - */ - public function sendRequest(RequestInterface $request): ResponseInterface - { - if ($request instanceof Request) { - $requestParameters = $request->all(); - if (!empty($requestParameters)) { - $existing = (string) $request->getBody(); - if (trim($existing) === '') { - $request = $request->withBody(new Stream(json_encode($requestParameters), null)); - } - } - } - - $response = [ - 'body' => '', - 'version' => $request->getProtocolVersion(), - 'status' => 200, - 'headers' => [], - ]; - - $options = $this->prepareCurlOptions($request, $response); - - $curl = \curl_init(); - if ($curl === false) { - throw new ClientException('Unable to initialize cURL session.'); - } - try { - \curl_setopt_array($curl, $options); - $body = \curl_exec($curl); - if ($body === false) { - $errno = \curl_errno($curl); - $error = \curl_error($curl); - throw new NetworkException($request, $error !== '' ? $error : 'cURL error', (int) $errno); - } - $response['body'] = (string) $body; - } finally { - if (\PHP_VERSION_ID < 80500) { - \curl_close($curl); - } - } - - return new Response( - $response['status'], - $response['headers'], - new Stream($response['body'], null), - $response['version'] - ); - } - - - private function prepareCurlOptions(RequestInterface $request, array &$response): array - { - try { - $url = $request->getUri()->__toString(); - if (filter_var($url, FILTER_VALIDATE_URL) === false) { - throw new ClientException('URL address is not valid.'); - } - $version = $request->getProtocolVersion(); - $method = $request->getMethod(); - $headers = $request->getHeaders(); - $body = (string) $request->getBody(); - } catch (ClientException $e) { - throw new RequestException($request, $e->getMessage(), (int) $e->getCode(), $e); - } catch (\Throwable $e) { - throw new RequestException($request, $e->getMessage(), (int) $e->getCode(), $e); - } - - $options = [ - \CURLOPT_URL => $url, - \CURLOPT_RETURNTRANSFER => true, - \CURLOPT_ENCODING => '', - \CURLOPT_MAXREDIRS => 10, - \CURLOPT_TIMEOUT => 0, - \CURLOPT_FOLLOWLOCATION => true, - \CURLOPT_CUSTOMREQUEST => $method, - \CURLOPT_USERAGENT => $this->getUserAgent(), - ]; - switch ($version) { - case '1.0': - $options[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_0; - break; - case '2.0': - $options[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2_0; - break; - default: - $options[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_1; - } - - if (\strtoupper($method) === 'HEAD') { - $options[\CURLOPT_NOBODY] = true; - } elseif ($body !== '') { - $options[\CURLOPT_POSTFIELDS] = $body; - } - - if (!empty($headers)) { - $flat = []; - foreach ($headers as $name => $value) { - $valueStr = is_array($value) ? implode(', ', $value) : (string) $value; - $flat[] = $name . ': ' . $valueStr; - } - $options[\CURLOPT_HTTPHEADER] = $flat; - } - - $options[\CURLOPT_HEADERFUNCTION] = function ($ch, $data) use (&$response) { - $str = trim($data); - if ($str === '') { - return strlen($data); - } - if (preg_match('#^HTTP/(\d(?:\.\d)?)\s+(\d{3})#i', $str, $matches)) { - $protocol = $matches[1]; - if (strpos($protocol, '.') === false) { - $protocol .= '.0'; - } - $response['version'] = $protocol; - $response['status'] = (int) $matches[2]; - $response['headers'] = []; - return strlen($data); - } - $split = explode(':', $str, 2); - if (count($split) === 2) { - $name = trim($split[0]); - $value = trim($split[1]); - $response['headers'][$name][] = $value; - } - return strlen($data); - }; - - return $options; - } - - private function prepareRequest(string $method, string $url, $body = null, array $headers = [], string $version = '1.1'): RequestInterface - { - if ($body === null) { - $body = new Stream('', null); - } else if (is_string($body)) { - $body = new Stream($body, null); - } else if (is_array($body)) { - $body = new Stream(json_encode($body), null); - } else if ((class_exists('DOMDocument')) && ($body instanceof \DOMDocument)) { - $body = new Stream($body->saveHTML(), null); - } else if ((class_exists('SimpleXMLElement')) && ($body instanceof \SimpleXMLElement)) { - $body = new Stream($body->asXML(), null); - } else if ((is_object($body)) && !($body instanceof StreamInterface)) { - if (method_exists($body, '__toString')) { - $body = $body->__toString(); - } else if (method_exists($body, 'toArray')) { - $body = json_encode($body->toArray()); - } else { - $body = json_encode(get_object_vars($body)); - } - $body = new Stream($body, null); - } - if (!($body instanceof StreamInterface)) { - throw new \InvalidArgumentException("\$body is not supported."); - } - return new Request($method, $url, $headers, $body, $version); - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Client; + +use \InitPHP\HTTP\Message\{Request, Stream, Response}; +use \Psr\Http\Message\{RequestInterface, ResponseInterface, StreamInterface}; +use \InitPHP\HTTP\Client\Exceptions\{ClientException, NetworkException, RequestException}; + +use const CASE_LOWER; +use const FILTER_VALIDATE_URL; + +use function array_change_key_case; +use function count; +use function explode; +use function extension_loaded; +use function filter_var; +use function implode; +use function in_array; +use function is_array; +use function is_resource; +use function is_string; +use function max; +use function preg_match; +use function strlen; +use function strpos; +use function trim; + +/** + * PSR-18 ClientInterface implementation backed by ext-curl. Wraps cURL + * with sensible production defaults (timeout, connect timeout, redirect + * cap, user agent), exposes per-instance overrides for CURLOPT_* values, + * and ships a small set of convenience methods (get/post/put/...) on top + * of the standard sendRequest() entry point. + */ +class Client implements \Psr\Http\Client\ClientInterface +{ + + protected string $userAgent = 'InitPHP HTTP PSR-18 Client cURL'; + + /** + * cURL options merged on top of the defaults computed by + * {@see Client::prepareCurlOptions()}. Use these to inject SSL roots, + * proxies, custom timeouts, etc., without subclassing. + * + * @var array + */ + protected array $curlOptions = []; + + /** + * Total request timeout in seconds (CURLOPT_TIMEOUT). Defaults to 30s + * instead of cURL's 0 (no timeout) — a hard requirement for any + * production-grade HTTP client. + */ + protected int $timeout = 30; + + /** + * Connection establishment timeout (CURLOPT_CONNECTTIMEOUT) in seconds. + */ + protected int $connectTimeout = 10; + + /** + * Whether the client should transparently follow 3xx redirects. + */ + protected bool $followRedirects = true; + + /** + * Upper bound on followed redirects when {@see Client::$followRedirects} + * is enabled. + */ + protected int $maxRedirects = 10; + + /** + * Construct a client and assert ext-curl is loaded; the cURL extension + * is the only transport this client speaks. + * + * @throws ClientException When ext-curl is not loaded. + */ + public function __construct() + { + if (!extension_loaded('curl')) { + throw new ClientException('The CURL extension must be installed.'); + } + } + + /** + * Return the user agent string sent on every outbound request. + * + * @return string + */ + public function getUserAgent(): string + { + return $this->userAgent; + } + + /** + * Replace the user agent (in-place). null or empty values are ignored + * so callers cannot accidentally send a blank UA header. + * + * @param string|null $userAgent + * @return $this + */ + public function setUserAgent(?string $userAgent = null): self + { + if ($userAgent !== null && $userAgent !== '') { + $this->userAgent = $userAgent; + } + + return $this; + } + + /** + * Return a clone of the client with the user agent replaced. + * + * @param string|null $userAgent + * @return $this + */ + public function withUserAgent(?string $userAgent = null): self + { + return (clone $this)->setUserAgent($userAgent); + } + + /** + * Replace the per-instance cURL option overrides. Keys must be one of + * the CURLOPT_* constants; values are forwarded verbatim to + * curl_setopt_array(). + * + * @param array $options + * @return $this + */ + public function setCurlOptions(array $options): self + { + $this->curlOptions = $options; + + return $this; + } + + /** + * Return a clone of the client with the cURL option overrides replaced. + * + * @param array $options + * @return $this + */ + public function withCurlOptions(array $options): self + { + return (clone $this)->setCurlOptions($options); + } + + /** + * Return the current cURL option override map. + * + * @return array + */ + public function getCurlOptions(): array + { + return $this->curlOptions; + } + + /** + * Replace the total request timeout (CURLOPT_TIMEOUT, in seconds). + * Negative inputs are clamped to 0. + * + * @param int $seconds + * @return $this + */ + public function setTimeout(int $seconds): self + { + $this->timeout = max(0, $seconds); + + return $this; + } + + /** + * Return a clone of the client with the request timeout replaced. + * + * @param int $seconds + * @return $this + */ + public function withTimeout(int $seconds): self + { + return (clone $this)->setTimeout($seconds); + } + + /** + * Replace the connect timeout (CURLOPT_CONNECTTIMEOUT, in seconds). + * Negative inputs are clamped to 0. + * + * @param int $seconds + * @return $this + */ + public function setConnectTimeout(int $seconds): self + { + $this->connectTimeout = max(0, $seconds); + + return $this; + } + + /** + * Return a clone of the client with the connect timeout replaced. + * + * @param int $seconds + * @return $this + */ + public function withConnectTimeout(int $seconds): self + { + return (clone $this)->setConnectTimeout($seconds); + } + + /** + * Configure 3xx redirect handling (in-place). $follow toggles + * CURLOPT_FOLLOWLOCATION; $max caps CURLOPT_MAXREDIRS and is clamped + * to zero or greater. + * + * @param bool $follow + * @param int $max + * @return $this + */ + public function setFollowRedirects(bool $follow, int $max = 10): self + { + $this->followRedirects = $follow; + $this->maxRedirects = max(0, $max); + + return $this; + } + + /** + * Return a clone of the client with the redirect-handling settings + * replaced. + * + * @param bool $follow + * @param int $max + * @return $this + */ + public function withFollowRedirects(bool $follow, int $max = 10): self + { + return (clone $this)->setFollowRedirects($follow, $max); + } + + /** + * Dispatch a request specified as a $url + loose options array. Keys + * are matched case-insensitively and may include `method`, `data`, + * `body`, `headers` and `version`. Returns the PSR-7 response. + * + * @param string $url + * @param array $details + * @return ResponseInterface + * @throws ClientException On invalid configuration (e.g. malformed URL). + * @throws RequestException When the request cannot be prepared for cURL. + * @throws NetworkException On transport-level failure (DNS, TCP, TLS, ...). + * @throws \InvalidArgumentException When `data`/`body` is a type the underlying client cannot accept. + */ + public function fetch(string $url, array $details = []): ResponseInterface + { + $details = array_change_key_case($details, CASE_LOWER); + + $request = $this->prepareRequest( + $details['method'] ?? 'GET', + $url, + $details['data'] ?? $details['body'] ?? null, + $details['headers'] ?? [], + $details['version'] ?? '1.1' + ); + + return $this->sendRequest($request); + } + + /** + * Dispatch a GET request and return the response. + * + * @param string $url + * @param string|resource|StreamInterface|null $body + * @param array $headers + * @param string $version + * @return ResponseInterface + * @throws ClientException|RequestException|NetworkException On configuration or transport failure. + * @throws \InvalidArgumentException When $body is not a supported shape. + */ + public function get(string $url, $body = null, array $headers = [], string $version = '1.1'): ResponseInterface + { + return $this->sendRequest($this->prepareRequest('GET', $url, $body, $headers, $version)); + } + + /** + * Dispatch a POST request and return the response. + * + * @param string $url + * @param string|resource|StreamInterface|null $body + * @param array $headers + * @param string $version + * @return ResponseInterface + * @throws ClientException|RequestException|NetworkException On configuration or transport failure. + * @throws \InvalidArgumentException When $body is not a supported shape. + */ + public function post(string $url, $body = null, array $headers = [], string $version = '1.1'): ResponseInterface + { + return $this->sendRequest($this->prepareRequest('POST', $url, $body, $headers, $version)); + } + + /** + * Dispatch a PATCH request and return the response. + * + * @param string $url + * @param string|resource|StreamInterface|null $body + * @param array $headers + * @param string $version + * @return ResponseInterface + * @throws ClientException|RequestException|NetworkException On configuration or transport failure. + * @throws \InvalidArgumentException When $body is not a supported shape. + */ + public function patch(string $url, $body = null, array $headers = [], string $version = '1.1'): ResponseInterface + { + return $this->sendRequest($this->prepareRequest('PATCH', $url, $body, $headers, $version)); + } + + /** + * Dispatch a PUT request and return the response. + * + * @param string $url + * @param string|resource|StreamInterface|null $body + * @param array $headers + * @param string $version + * @return ResponseInterface + * @throws ClientException|RequestException|NetworkException On configuration or transport failure. + * @throws \InvalidArgumentException When $body is not a supported shape. + */ + public function put(string $url, $body = null, array $headers = [], string $version = '1.1'): ResponseInterface + { + return $this->sendRequest($this->prepareRequest('PUT', $url, $body, $headers, $version)); + } + + /** + * Dispatch a DELETE request and return the response. + * + * @param string $url + * @param string|resource|StreamInterface|null $body + * @param array $headers + * @param string $version + * @return ResponseInterface + * @throws ClientException|RequestException|NetworkException On configuration or transport failure. + * @throws \InvalidArgumentException When $body is not a supported shape. + */ + public function delete(string $url, $body = null, array $headers = [], string $version = '1.1'): ResponseInterface + { + return $this->sendRequest($this->prepareRequest('DELETE', $url, $body, $headers, $version)); + } + + /** + * Dispatch a HEAD request and return the response. + * + * @param string $url + * @param string|resource|StreamInterface|null $body + * @param array $headers + * @param string $version + * @return ResponseInterface + * @throws ClientException|RequestException|NetworkException On configuration or transport failure. + * @throws \InvalidArgumentException When $body is not a supported shape. + */ + public function head(string $url, $body = null, array $headers = [], string $version = '1.1'): ResponseInterface + { + return $this->sendRequest($this->prepareRequest('HEAD', $url, $body, $headers, $version)); + } + + /** + * Execute the supplied PSR-7 request and return the PSR-7 response. + * The response body is wrapped in a php://temp-backed Stream so large + * payloads spill to disk (cURL's 2 MiB threshold) instead of pinning + * the full response into the process's resident memory. + * + * @param RequestInterface $request + * @return ResponseInterface + * @throws ClientException When cURL cannot be initialised at all. + * @throws RequestException When the request itself cannot be marshalled (invalid URL, body coercion failure). + * @throws NetworkException When cURL reports a transport-level failure (DNS, TCP, TLS, timeout, ...). + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + $response = [ + 'body' => '', + 'version' => $request->getProtocolVersion(), + 'status' => 200, + 'headers' => [], + ]; + + $options = $this->prepareCurlOptions($request, $response); + + $curl = \curl_init(); + if ($curl === false) { + throw new ClientException('Unable to initialize cURL session.'); + } + try { + \curl_setopt_array($curl, $options); + $body = \curl_exec($curl); + if ($body === false) { + $errno = \curl_errno($curl); + $error = \curl_error($curl); + throw new NetworkException($request, $error !== '' ? $error : 'cURL error', (int) $errno); + } + $response['body'] = (string) $body; + } finally { + if (\PHP_VERSION_ID < 80500) { + \curl_close($curl); + } + } + + return new Response( + $response['status'], + $response['headers'], + // Wrap the response body in a php://temp-backed Stream so large + // payloads spill to disk (default 2 MiB threshold) instead of + // pinning the entire body into PHP's process memory as a string. + new Stream($response['body'], 'php://temp'), + $response['version'] + ); + } + + + /** + * Build the CURLOPT_* option map for $request, also wiring a header + * callback that captures the response status, version and headers + * into the supplied $response accumulator (by reference). + * + * @param RequestInterface $request + * @param array $response Mutated in place by the header callback. + * @return array + * @throws RequestException When the request URL fails FILTER_VALIDATE_URL, or any accessor on $request throws. + */ + private function prepareCurlOptions(RequestInterface $request, array &$response): array + { + try { + $url = $request->getUri()->__toString(); + if (filter_var($url, FILTER_VALIDATE_URL) === false) { + throw new ClientException('URL address is not valid.'); + } + $version = $request->getProtocolVersion(); + $method = $request->getMethod(); + $headers = $request->getHeaders(); + $body = (string) $request->getBody(); + } catch (ClientException $e) { + throw new RequestException($request, $e->getMessage(), (int) $e->getCode(), $e); + } catch (\Throwable $e) { + throw new RequestException($request, $e->getMessage(), (int) $e->getCode(), $e); + } + + $methodUpper = \strtoupper($method); + $options = [ + \CURLOPT_URL => $url, + \CURLOPT_RETURNTRANSFER => true, + \CURLOPT_ENCODING => '', + \CURLOPT_MAXREDIRS => $this->maxRedirects, + \CURLOPT_TIMEOUT => $this->timeout, + \CURLOPT_CONNECTTIMEOUT => $this->connectTimeout, + \CURLOPT_FOLLOWLOCATION => $this->followRedirects, + \CURLOPT_CUSTOMREQUEST => $method, + \CURLOPT_USERAGENT => $this->getUserAgent(), + ]; + switch ($version) { + case '1.0': + $options[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_0; + break; + case '2': + case '2.0': + $options[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2_0; + break; + default: + $options[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_1; + } + + if ($methodUpper === 'HEAD') { + $options[\CURLOPT_NOBODY] = true; + } elseif (\in_array($methodUpper, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + // POSTFIELDS must be set for body-bearing methods even when the + // body is empty, otherwise cURL silently downgrades the request + // to a body-less call and CUSTOMREQUEST becomes the only signal. + $options[\CURLOPT_POSTFIELDS] = $body; + } elseif ($body !== '') { + $options[\CURLOPT_POSTFIELDS] = $body; + } + + if (!empty($headers)) { + $flat = []; + foreach ($headers as $name => $value) { + $valueStr = is_array($value) ? implode(', ', $value) : (string) $value; + $flat[] = $name . ': ' . $valueStr; + } + $options[\CURLOPT_HTTPHEADER] = $flat; + } + + // Apply user-supplied option overrides last so they can override the + // defaults computed above (timeouts, redirects, headers, ...). + foreach ($this->curlOptions as $optKey => $optValue) { + $options[$optKey] = $optValue; + } + + $options[\CURLOPT_HEADERFUNCTION] = function ($ch, $data) use (&$response) { + $str = trim($data); + if ($str === '') { + return strlen($data); + } + if (preg_match('#^HTTP/(\d(?:\.\d)?)\s+(\d{3})#i', $str, $matches)) { + $protocol = $matches[1]; + if (strpos($protocol, '.') === false) { + $protocol .= '.0'; + } + $response['version'] = $protocol; + $response['status'] = (int) $matches[2]; + $response['headers'] = []; + return strlen($data); + } + $split = explode(':', $str, 2); + if (count($split) === 2) { + $name = trim($split[0]); + $value = trim($split[1]); + $response['headers'][$name][] = $value; + } + return strlen($data); + }; + + return $options; + } + + /** + * Normalise the body argument accepted by the high-level helpers + * (`fetch/get/post/...`) into a PSR-7 StreamInterface. Only stream-shaped + * inputs are accepted; structured serialisation (JSON, XML, form-encoded) + * is the caller's responsibility and must be performed before reaching + * the HTTP layer. + * + * @param string $method + * @param string $url + * @param string|resource|StreamInterface|null $body + * @param array $headers + * @param string $version + * @return RequestInterface + * @throws \InvalidArgumentException When $body is not a string, resource, StreamInterface or null. + */ + private function prepareRequest(string $method, string $url, $body = null, array $headers = [], string $version = '1.1'): RequestInterface + { + if ($body === null) { + $stream = new Stream('', null); + } elseif ($body instanceof StreamInterface) { + $stream = $body; + } elseif (is_string($body)) { + $stream = new Stream($body, null); + } elseif (is_resource($body)) { + $stream = new Stream($body); + } else { + throw new \InvalidArgumentException( + 'Request body must be a string, resource, StreamInterface, or null. ' + . 'Encode structured payloads (arrays, DOM, objects) before passing them in.' + ); + } + + return new Request($method, $url, $headers, $stream, $version); + } + +} diff --git a/src/Client/Exceptions/ClientException.php b/src/Client/Exceptions/ClientException.php index a1ae646..133e7be 100644 --- a/src/Client/Exceptions/ClientException.php +++ b/src/Client/Exceptions/ClientException.php @@ -1,20 +1,25 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Client\Exceptions; - -class ClientException extends \Exception implements \Psr\Http\Client\ClientExceptionInterface -{ -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Client\Exceptions; + +/** + * Base exception for the HTTP client. Implements PSR-18 + * ClientExceptionInterface so callers can catch every transport failure + * thrown by this client (including the more specific {@see NetworkException} + * and {@see RequestException}) with a single catch clause. + */ +class ClientException extends \Exception implements \Psr\Http\Client\ClientExceptionInterface +{ +} diff --git a/src/Client/Exceptions/NetworkException.php b/src/Client/Exceptions/NetworkException.php index 5a7752c..1222c51 100644 --- a/src/Client/Exceptions/NetworkException.php +++ b/src/Client/Exceptions/NetworkException.php @@ -1,39 +1,53 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Client\Exceptions; - -use Psr\Http\Message\RequestInterface; - -class NetworkException extends ClientException implements \Psr\Http\Client\NetworkExceptionInterface -{ - - private RequestInterface $request; - - public function __construct(RequestInterface $request, $message = "", $code = 0, \Throwable $previous = null) - { - $this->request = $request; - parent::__construct($message, $code, $previous); - } - - /** - * @inheritDoc - */ - public function getRequest(): RequestInterface - { - return $this->request; - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Client\Exceptions; + +use Psr\Http\Message\RequestInterface; + +/** + * Raised when the HTTP client cannot complete a request because of a + * transport-level failure: DNS resolution, TCP connect, TLS handshake, + * timeouts, peer reset, etc. Implements PSR-18 NetworkExceptionInterface + * so the originating request is recoverable via {@see getRequest()}. + */ +class NetworkException extends ClientException implements \Psr\Http\Client\NetworkExceptionInterface +{ + + private RequestInterface $request; + + /** + * @param RequestInterface $request The request that failed in flight. + * @param string $message Human-readable failure reason (typically the cURL error string). + * @param int $code Transport error code (typically the cURL errno). + * @param \Throwable|null $previous Underlying exception, if any. + */ + public function __construct(RequestInterface $request, $message = "", $code = 0, \Throwable $previous = null) + { + $this->request = $request; + parent::__construct($message, $code, $previous); + } + + /** + * Return the originating PSR-7 request so callers can log, retry or + * surface the exact URL that failed. + * + * @return RequestInterface + */ + public function getRequest(): RequestInterface + { + return $this->request; + } + +} diff --git a/src/Client/Exceptions/RequestException.php b/src/Client/Exceptions/RequestException.php index f03377e..5ac11e6 100644 --- a/src/Client/Exceptions/RequestException.php +++ b/src/Client/Exceptions/RequestException.php @@ -1,39 +1,54 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Client\Exceptions; - -use Psr\Http\Message\RequestInterface; - -class RequestException extends ClientException implements \Psr\Http\Client\RequestExceptionInterface -{ - - private RequestInterface $request; - - public function __construct(RequestInterface $request, $message = "", $code = 0, \Throwable $previous = null) - { - $this->request = $request; - parent::__construct($message, $code, $previous); - } - - /** - * @inheritDoc - */ - public function getRequest(): RequestInterface - { - return $this->request; - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Client\Exceptions; + +use Psr\Http\Message\RequestInterface; + +/** + * Raised when the HTTP client refuses to marshal a request — for example + * because the URL is malformed, the body cannot be coerced, or a PSR-7 + * accessor throws while the request is being read into cURL options. + * Implements PSR-18 RequestExceptionInterface so the offending request is + * recoverable via {@see getRequest()}. + */ +class RequestException extends ClientException implements \Psr\Http\Client\RequestExceptionInterface +{ + + private RequestInterface $request; + + /** + * @param RequestInterface $request The request that could not be sent. + * @param string $message Human-readable failure reason. + * @param int $code Numeric error code (typically passed through from the originating exception). + * @param \Throwable|null $previous Underlying exception, if any. + */ + public function __construct(RequestInterface $request, $message = "", $code = 0, \Throwable $previous = null) + { + $this->request = $request; + parent::__construct($message, $code, $previous); + } + + /** + * Return the PSR-7 request that was rejected so callers can log, + * report or rebuild it. + * + * @return RequestInterface + */ + public function getRequest(): RequestInterface + { + return $this->request; + } + +} diff --git a/src/Emitter/Emitter.php b/src/Emitter/Emitter.php index a24ee3f..0670eb2 100644 --- a/src/Emitter/Emitter.php +++ b/src/Emitter/Emitter.php @@ -1,145 +1,227 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Emitter; - -use InitPHP\HTTP\Emitter\Exceptions\EmitBodyException; -use \Psr\Http\Message\{ResponseInterface, StreamInterface}; - -use function assert; -use function is_string; -use function ucwords; -use function strtolower; -use function header; -use function sprintf; -use function headers_sent; -use function is_int; -use function ob_get_level; -use function ob_get_length; -use function flush; -use function strlen; -use function preg_match; - -class Emitter -{ - - protected bool $strictMode = true; - - public function __construct(bool $strictMode = true) - { - $this->strictMode = $strictMode; - } - - public function emit(ResponseInterface $response, ?int $bufferLength = null): void - { - $this->assertNoPreviousOutput(); - $this->emitStatusLine($response); - $this->emitHeaders($response); - if($bufferLength !== null && $bufferLength > 0){ - $this->emitBody($response, $bufferLength); - return; - } - echo $response->getBody(); - } - - private function emitBody(ResponseInterface $response, int $bufferLength): void - { - flush(); - $stream = $response->getBody(); - $range = $this->parseHeaderContentRange($response->getHeaderLine('content-range')); - if(isset($range['unit']) && $range['unit'] === 'bytes'){ - $this->emitBodyRange($stream, (int)$range['first'], (int)$range['last'], $bufferLength); - return; - } - if($stream->isSeekable()){ - $stream->rewind(); - } - while(!$stream->eof()){ - echo $stream->read($bufferLength); - } - } - - private function emitBodyRange(StreamInterface $body, int $first, int $last, int $bufferLength): void - { - $length = $last - $first + 1; - if($body->isSeekable()){ - $body->seek($first); - } - while($length >= $bufferLength && !$body->eof()){ - $content = $body->read($bufferLength); - $length -= strlen($content); - echo $content; - } - if($length > 0 && !$body->eof()){ - echo $body->read($length); - } - } - - private function assertNoPreviousOutput(): void - { - if ($this->strictMode !== TRUE) { - return; - } - $filename = null; - $line = null; - if(headers_sent($filename, $line)){ - assert(is_string($filename) && is_int($line)); - throw new EmitBodyException(sprintf('Unable to emit response; headers already sent in %s:%d', $filename, $line)); - } - if(ob_get_level() > 0 && ob_get_length() > 0){ - throw new EmitBodyException('Output has been emitted previously; cannot emit response'); - } - } - - private function emitStatusLine(ResponseInterface $response): void - { - $reasonPhrase = $response->getReasonPhrase(); - $statusCode = $response->getStatusCode(); - header(sprintf( - 'HTTP/%s %d%s', - $response->getProtocolVersion(), - $statusCode, ($reasonPhrase ? ' ' . $reasonPhrase : '') - ), true, $statusCode); - } - - private function emitHeaders(ResponseInterface $response): void - { - $statusCode = $response->getStatusCode(); - - foreach ($response->getHeaders() as $header => $values){ - assert(is_string($header)); - $name = ucwords(strtolower($header), '-'); - $first = (strtolower($name) !== 'set-cookie'); - foreach ($values as $value){ - header(sprintf('%s: %s', $name, $value), $first, $statusCode); - $first = false; - } - } - } - - private function parseHeaderContentRange(string $header): ?array - { - if (preg_match('/(?P[\w]+)\s+(?P\d+)-(?P\d+)\/(?P\d+|\*)/', $header, $matches)) { - return [ - 'unit' => $matches['unit'], - 'first' => (int)$matches['first'], - 'last' => (int)$matches['last'], - 'length' => ($matches['length'] === '*') ? '*' : (int)$matches['length'], - ]; - } - return null; - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Emitter; + +use InitPHP\HTTP\Emitter\Exceptions\EmitBodyException; +use InitPHP\HTTP\Emitter\Exceptions\EmitHeaderException; +use \Psr\Http\Message\{ResponseInterface, StreamInterface}; + +use function assert; +use function is_string; +use function ucwords; +use function strtolower; +use function header; +use function sprintf; +use function headers_sent; +use function is_int; +use function ob_get_level; +use function ob_get_length; +use function flush; +use function strlen; +use function preg_match; + +/** + * Server-side emitter for PSR-7 responses. Writes the status line, the + * normalised headers and the body to the SAPI / PHP-FPM output stream, + * with optional chunked emission and HTTP Range support. Strict mode (on + * by default) refuses to emit when headers have already been sent or when + * the output buffer is dirty, so partial responses never reach the wire. + */ +class Emitter +{ + + protected bool $strictMode = true; + + /** + * @param bool $strictMode When true, refuse to emit if headers have already been + * sent or output has already been written. + */ + public function __construct(bool $strictMode = true) + { + $this->strictMode = $strictMode; + } + + /** + * Emit the response. Writes the HTTP status line, every header, and + * the body to the output stream. When $bufferLength is a positive + * integer the body is streamed in chunks of that size (and Content-Range + * is honoured); otherwise the body is echoed in one shot via the + * Stream's __toString(). + * + * @param ResponseInterface $response + * @param int|null $bufferLength Optional chunk size for streamed emission. + * @return void + * @throws EmitHeaderException When strict mode is on and headers have already been sent. + * @throws EmitBodyException When strict mode is on and the output buffer already contains content. + */ + public function emit(ResponseInterface $response, ?int $bufferLength = null): void + { + $this->assertNoPreviousOutput(); + $this->emitStatusLine($response); + $this->emitHeaders($response); + if($bufferLength !== null && $bufferLength > 0){ + $this->emitBody($response, $bufferLength); + return; + } + echo $response->getBody(); + } + + /** + * Stream the response body to the output in chunks of $bufferLength + * bytes. When the response advertises a `Content-Range: bytes ...` + * header, only the requested byte range is emitted. + * + * @param ResponseInterface $response + * @param int $bufferLength + * @return void + */ + private function emitBody(ResponseInterface $response, int $bufferLength): void + { + flush(); + $stream = $response->getBody(); + $range = $this->parseHeaderContentRange($response->getHeaderLine('content-range')); + if(isset($range['unit']) && $range['unit'] === 'bytes'){ + $this->emitBodyRange($stream, (int)$range['first'], (int)$range['last'], $bufferLength); + return; + } + if($stream->isSeekable()){ + $stream->rewind(); + } + while(!$stream->eof()){ + echo $stream->read($bufferLength); + } + } + + /** + * Stream a contiguous byte range from $body to the output. The body + * is seeked to $first; bytes are read in $bufferLength chunks until + * exactly $last - $first + 1 bytes have been written. + * + * @param StreamInterface $body + * @param int $first + * @param int $last + * @param int $bufferLength + * @return void + */ + private function emitBodyRange(StreamInterface $body, int $first, int $last, int $bufferLength): void + { + $length = $last - $first + 1; + if($body->isSeekable()){ + $body->seek($first); + } + while($length >= $bufferLength && !$body->eof()){ + $content = $body->read($bufferLength); + $length -= strlen($content); + echo $content; + } + if($length > 0 && !$body->eof()){ + echo $body->read($length); + } + } + + /** + * Verify that nothing has been emitted to the client yet. Header-vs-body + * dirtiness is reported as two different exception types so callers can + * tell at a glance which contract has been violated. No-op when strict + * mode is disabled. + * + * @return void + * @throws EmitHeaderException When headers have already been sent. + * @throws EmitBodyException When the output buffer already contains content. + */ + private function assertNoPreviousOutput(): void + { + if ($this->strictMode !== true) { + return; + } + $filename = null; + $line = null; + if (headers_sent($filename, $line)) { + assert(is_string($filename) && is_int($line)); + throw new EmitHeaderException(sprintf( + 'Unable to emit response; headers already sent in %s:%d', + $filename, + $line + )); + } + if (ob_get_level() > 0 && ob_get_length() > 0) { + throw new EmitBodyException('Output has been emitted previously; cannot emit response.'); + } + } + + /** + * Send the HTTP status line for $response via header(), including + * the protocol version, status code and reason phrase. + * + * @param ResponseInterface $response + * @return void + */ + private function emitStatusLine(ResponseInterface $response): void + { + $reasonPhrase = $response->getReasonPhrase(); + $statusCode = $response->getStatusCode(); + header(sprintf( + 'HTTP/%s %d%s', + $response->getProtocolVersion(), + $statusCode, + $reasonPhrase !== '' ? ' ' . $reasonPhrase : '' + ), true, $statusCode); + } + + /** + * Send every response header to the client. Header names are + * canonicalised to Word-Case; Set-Cookie is emitted with `replace=false` + * so multiple cookies stack instead of overwriting one another. + * + * @param ResponseInterface $response + * @return void + */ + private function emitHeaders(ResponseInterface $response): void + { + $statusCode = $response->getStatusCode(); + + foreach ($response->getHeaders() as $header => $values){ + assert(is_string($header)); + $name = ucwords(strtolower($header), '-'); + $first = (strtolower($name) !== 'set-cookie'); + foreach ($values as $value){ + header(sprintf('%s: %s', $name, $value), $first, $statusCode); + $first = false; + } + } + } + + /** + * Parse an RFC 7233 `Content-Range` header value into its constituent + * parts (unit, first byte, last byte, total length). Returns null + * when the header does not match the expected pattern. + * + * @param string $header Raw header value. + * @return array{unit:string,first:int,last:int,length:int|string}|null + */ + private function parseHeaderContentRange(string $header): ?array + { + if (preg_match('/(?P[\w]+)\s+(?P\d+)-(?P\d+)\/(?P\d+|\*)/', $header, $matches)) { + return [ + 'unit' => $matches['unit'], + 'first' => (int)$matches['first'], + 'last' => (int)$matches['last'], + 'length' => ($matches['length'] === '*') ? '*' : (int)$matches['length'], + ]; + } + return null; + } + +} diff --git a/src/Emitter/Exceptions/EmitBodyException.php b/src/Emitter/Exceptions/EmitBodyException.php index 2fc8f72..bb784cc 100644 --- a/src/Emitter/Exceptions/EmitBodyException.php +++ b/src/Emitter/Exceptions/EmitBodyException.php @@ -1,20 +1,25 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Emitter\Exceptions; - -class EmitBodyException extends \RuntimeException -{ -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Emitter\Exceptions; + +/** + * Raised by {@see \InitPHP\HTTP\Emitter\Emitter} in strict mode when an + * active output buffer already contains content at the time emit() is + * called — emitting the response body in that situation would interleave + * unrelated output with the HTTP body on the wire. + */ +class EmitBodyException extends \RuntimeException +{ +} diff --git a/src/Emitter/Exceptions/EmitHeaderException.php b/src/Emitter/Exceptions/EmitHeaderException.php index abcb44e..8c82c91 100644 --- a/src/Emitter/Exceptions/EmitHeaderException.php +++ b/src/Emitter/Exceptions/EmitHeaderException.php @@ -1,20 +1,26 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Emitter\Exceptions; - -class EmitHeaderException extends \RuntimeException -{ -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Emitter\Exceptions; + +/** + * Raised by {@see \InitPHP\HTTP\Emitter\Emitter} in strict mode when + * headers have already been sent at the time emit() is called — at that + * point the HTTP status line and headers cannot be (re)written, so + * emitting the response would silently break the contract with the + * client. + */ +class EmitHeaderException extends \RuntimeException +{ +} diff --git a/src/Facade/Client.php b/src/Facade/Client.php index 365d970..b96aae9 100644 --- a/src/Facade/Client.php +++ b/src/Facade/Client.php @@ -1,53 +1,65 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - - -namespace InitPHP\HTTP\Facade; - -use InitPHP\HTTP\Facade\Interfaces\FacadebleInterface; -use InitPHP\HTTP\Facade\Traits\Facadeble; -use Psr\Http\Message\ResponseInterface; - -/** - * @mixin \InitPHP\HTTP\Client\Client - * @method static \Psr\Http\Message\ResponseInterface sendRequest(\Psr\Http\Message\RequestInterface $request) - * @method static string getUserAgent() - * @method static \InitPHP\HTTP\Client\Client setUserAgent(?string $userAgent = null) - * @method static \InitPHP\HTTP\Client\Client withUserAgent(?string $userAgent = null) - * @method static ResponseInterface fetch(string $url, array $details = []) - * @method static ResponseInterface get(string $url, mixed $body = null, array $headers = [], string $version = '1.1') - * @method static ResponseInterface post(string $url, mixed $body = null, array $headers = [], string $version = '1.1') - * @method static ResponseInterface patch(string $url, mixed $body = null, array $headers = [], string $version = '1.1') - * @method static ResponseInterface put(string $url, mixed $body = null, array $headers = [], string $version = '1.1') - * @method static ResponseInterface delete(string $url, mixed $body = null, array $headers = [], string $version = '1.1') - * @method static ResponseInterface head(string $url, mixed $body = null, array $headers = [], string $version = '1.1') - */ -final class Client implements FacadebleInterface -{ - - use Facadeble; - - private static \InitPHP\HTTP\Client\Client $instance; - - public static function getInstance(): object - { - if (!isset(self::$instance)) { - self::$instance = new \InitPHP\HTTP\Client\Client(); - } - - return self::$instance; - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + + +namespace InitPHP\HTTP\Facade; + +use InitPHP\HTTP\Facade\Interfaces\FacadableInterface; +use InitPHP\HTTP\Facade\Traits\Facadable; +use Psr\Http\Message\ResponseInterface; + +/** + * Static facade over a lazily-constructed {@see \InitPHP\HTTP\Client\Client} + * singleton. Forwards every static call to the underlying instance via + * {@see Facadable}, so application code can write + * `Client::post('https://...')` without manually wiring the PSR-18 client. + * + * @mixin \InitPHP\HTTP\Client\Client + * @method static \Psr\Http\Message\ResponseInterface sendRequest(\Psr\Http\Message\RequestInterface $request) + * @method static string getUserAgent() + * @method static \InitPHP\HTTP\Client\Client setUserAgent(?string $userAgent = null) + * @method static \InitPHP\HTTP\Client\Client withUserAgent(?string $userAgent = null) + * @method static ResponseInterface fetch(string $url, array $details = []) + * @method static ResponseInterface get(string $url, mixed $body = null, array $headers = [], string $version = '1.1') + * @method static ResponseInterface post(string $url, mixed $body = null, array $headers = [], string $version = '1.1') + * @method static ResponseInterface patch(string $url, mixed $body = null, array $headers = [], string $version = '1.1') + * @method static ResponseInterface put(string $url, mixed $body = null, array $headers = [], string $version = '1.1') + * @method static ResponseInterface delete(string $url, mixed $body = null, array $headers = [], string $version = '1.1') + * @method static ResponseInterface head(string $url, mixed $body = null, array $headers = [], string $version = '1.1') + */ +final class Client implements FacadableInterface +{ + + use Facadable; + + private static \InitPHP\HTTP\Client\Client $instance; + + /** + * Return the shared {@see \InitPHP\HTTP\Client\Client} instance, + * constructing it on first call. Subsequent calls return the exact + * same object so configuration changes persist across the facade. + * + * @return object + * @throws \InitPHP\HTTP\Client\Exceptions\ClientException When ext-curl is not loaded. + */ + public static function getInstance(): object + { + if (!isset(self::$instance)) { + self::$instance = new \InitPHP\HTTP\Client\Client(); + } + + return self::$instance; + } + +} diff --git a/src/Facade/Emitter.php b/src/Facade/Emitter.php index 8fc47bc..9b2f3f3 100644 --- a/src/Facade/Emitter.php +++ b/src/Facade/Emitter.php @@ -1,41 +1,51 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Facade; - -use InitPHP\HTTP\Facade\Interfaces\FacadebleInterface; -use InitPHP\HTTP\Facade\Traits\Facadeble; - -/** - * @mixin \InitPHP\HTTP\Emitter\Emitter - * @method static void emit(\Psr\Http\Message\ResponseInterface $response, ?int $bufferLength = null) - */ -final class Emitter implements FacadebleInterface -{ - - use Facadeble; - - private static \InitPHP\HTTP\Emitter\Emitter $instance; - - public static function getInstance(): object - { - if (!isset(self::$instance)) { - self::$instance = new \InitPHP\HTTP\Emitter\Emitter(true); - } - - return self::$instance; - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Facade; + +use InitPHP\HTTP\Facade\Interfaces\FacadableInterface; +use InitPHP\HTTP\Facade\Traits\Facadable; + +/** + * Static facade over a lazily-constructed + * {@see \InitPHP\HTTP\Emitter\Emitter} singleton. The underlying emitter + * is created with strict mode enabled, so a single `Emitter::emit($response)` + * call refuses to run when the SAPI has already started writing output. + * + * @mixin \InitPHP\HTTP\Emitter\Emitter + * @method static void emit(\Psr\Http\Message\ResponseInterface $response, ?int $bufferLength = null) + */ +final class Emitter implements FacadableInterface +{ + + use Facadable; + + private static \InitPHP\HTTP\Emitter\Emitter $instance; + + /** + * Return the shared {@see \InitPHP\HTTP\Emitter\Emitter} instance, + * constructing it on first call with strict mode enabled. + * + * @return object + */ + public static function getInstance(): object + { + if (!isset(self::$instance)) { + self::$instance = new \InitPHP\HTTP\Emitter\Emitter(true); + } + + return self::$instance; + } + +} diff --git a/src/Facade/Factory.php b/src/Facade/Factory.php index 53aaf7b..34b587a 100644 --- a/src/Facade/Factory.php +++ b/src/Facade/Factory.php @@ -1,54 +1,65 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Facade; - -use InitPHP\HTTP\Facade\Interfaces\FacadebleInterface; -use InitPHP\HTTP\Facade\Traits\Facadeble; -use \Psr\Http\Message\{RequestInterface, - ResponseInterface, - ServerRequestInterface, - StreamInterface, - UploadedFileInterface, - UriInterface}; - -/** - * @mixin \InitPHP\HTTP\Factory\Factory - * @method static RequestInterface createRequest(string $method, $uri) - * @method static ResponseInterface createResponse(int $code = 200, string $reasonPhrase = '') - * @method static ServerRequestInterface createServerRequest(string $method, $uri, array $serverParams = []) - * @method static StreamInterface createStream(string $content = '') - * @method static StreamInterface createStreamFromFile(string $filename, string $mode = 'r') - * @method static StreamInterface createStreamFromResource($resource) - * @method static UploadedFileInterface createUploadedFile(StreamInterface $stream, int $size = null, int $error = \UPLOAD_ERR_OK, string $clientFilename = null, string $clientMediaType = null) - * @method static UriInterface createUri(string $uri = '') - */ -class Factory implements FacadebleInterface -{ - - use Facadeble; - - private static \InitPHP\HTTP\Factory\Factory $instance; - - public static function getInstance(): object - { - if (!isset(self::$instance)) { - self::$instance = new \InitPHP\HTTP\Factory\Factory(); - } - - return self::$instance; - } - -} \ No newline at end of file + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Facade; + +use InitPHP\HTTP\Facade\Interfaces\FacadableInterface; +use InitPHP\HTTP\Facade\Traits\Facadable; +use \Psr\Http\Message\{RequestInterface, + ResponseInterface, + ServerRequestInterface, + StreamInterface, + UploadedFileInterface, + UriInterface}; + +/** + * Static facade over a lazily-constructed + * {@see \InitPHP\HTTP\Factory\Factory} singleton. Lets callers write + * `Factory::createResponse(200)` without manually instantiating the PSR-17 + * factory bundle (RequestFactory, ResponseFactory, StreamFactory, + * UriFactory, UploadedFileFactory, ServerRequestFactory). + * + * @mixin \InitPHP\HTTP\Factory\Factory + * @method static RequestInterface createRequest(string $method, $uri) + * @method static ResponseInterface createResponse(int $code = 200, string $reasonPhrase = '') + * @method static ServerRequestInterface createServerRequest(string $method, $uri, array $serverParams = []) + * @method static StreamInterface createStream(string $content = '') + * @method static StreamInterface createStreamFromFile(string $filename, string $mode = 'r') + * @method static StreamInterface createStreamFromResource($resource) + * @method static UploadedFileInterface createUploadedFile(StreamInterface $stream, int $size = null, int $error = \UPLOAD_ERR_OK, string $clientFilename = null, string $clientMediaType = null) + * @method static UriInterface createUri(string $uri = '') + */ +final class Factory implements FacadableInterface +{ + + use Facadable; + + private static \InitPHP\HTTP\Factory\Factory $instance; + + /** + * Return the shared {@see \InitPHP\HTTP\Factory\Factory} instance, + * constructing it on first call. + * + * @return object + */ + public static function getInstance(): object + { + if (!isset(self::$instance)) { + self::$instance = new \InitPHP\HTTP\Factory\Factory(); + } + + return self::$instance; + } + +} diff --git a/src/Facade/Interfaces/FacadableInterface.php b/src/Facade/Interfaces/FacadableInterface.php new file mode 100644 index 0000000..18efccf --- /dev/null +++ b/src/Facade/Interfaces/FacadableInterface.php @@ -0,0 +1,50 @@ + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Facade\Interfaces; + +/** + * Contract every static facade in this package implements. + * + * A facade is a thin static front-door over a singleton instance of the + * underlying service class (Client, Emitter, Factory, ...). The instance + * is created lazily on first access via {@see getInstance()} and reused + * thereafter; subsequent reads from a facade always return the same + * service object, which is the intended behaviour for stateless services. + */ +interface FacadableInterface +{ + /** + * Forward an instance-level call to the singleton service object. + * + * @param string $name + * @param array $arguments + * @return mixed + */ + public function __call($name, $arguments); + + /** + * Forward a static call to the singleton service object. + * + * @param string $name + * @param array $arguments + * @return mixed + */ + public static function __callStatic($name, $arguments); + + /** + * Resolve (creating it on first call) the singleton service instance. + */ + public static function getInstance(): object; +} diff --git a/src/Facade/Interfaces/FacadebleInterface.php b/src/Facade/Interfaces/FacadebleInterface.php index a906f44..97ec0d8 100644 --- a/src/Facade/Interfaces/FacadebleInterface.php +++ b/src/Facade/Interfaces/FacadebleInterface.php @@ -1,27 +1,24 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Facade\Interfaces; - -interface FacadebleInterface -{ - - public function __call($name, $arguments); - - public static function __callStatic($name, $arguments); - - public static function getInstance(): object; - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Facade\Interfaces; + +/** + * @deprecated Misspelled name retained as a backwards-compatible alias. + * Use {@see FacadableInterface} instead; this shim will be + * removed in the next major version. + */ +interface FacadebleInterface extends FacadableInterface +{ +} diff --git a/src/Facade/Traits/Facadable.php b/src/Facade/Traits/Facadable.php new file mode 100644 index 0000000..4c69dae --- /dev/null +++ b/src/Facade/Traits/Facadable.php @@ -0,0 +1,43 @@ + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Facade\Traits; + +/** + * Implementation of {@see \InitPHP\HTTP\Facade\Interfaces\FacadableInterface} + * that forwards every call to the singleton service returned by + * {@see getInstance()}. + */ +trait Facadable +{ + /** + * @param string $name + * @param array $arguments + * @return mixed + */ + public function __call($name, $arguments) + { + return self::getInstance()->{$name}(...$arguments); + } + + /** + * @param string $name + * @param array $arguments + * @return mixed + */ + public static function __callStatic($name, $arguments) + { + return self::getInstance()->{$name}(...$arguments); + } +} diff --git a/src/Facade/Traits/Facadeble.php b/src/Facade/Traits/Facadeble.php index c72774b..53114fa 100644 --- a/src/Facade/Traits/Facadeble.php +++ b/src/Facade/Traits/Facadeble.php @@ -1,31 +1,25 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Facade\Traits; - -trait Facadeble -{ - - public function __call($name, $arguments) - { - return self::getInstance()->{$name}(...$arguments); - } - - public static function __callStatic($name, $arguments) - { - return self::getInstance()->{$name}(...$arguments); - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Facade\Traits; + +/** + * @deprecated Misspelled name retained as a backwards-compatible alias. + * Use {@see Facadable} instead; this shim will be removed in + * the next major version. + */ +trait Facadeble +{ + use Facadable; +} diff --git a/src/Factory/Factory.php b/src/Factory/Factory.php index fd6b462..c51799f 100644 --- a/src/Factory/Factory.php +++ b/src/Factory/Factory.php @@ -1,129 +1,181 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Factory; - -use \RuntimeException; -use \InvalidArgumentException; -use \InitPHP\HTTP\Message\{Request, - Response, - ServerRequest, - Stream, - UploadedFile, - Uri}; -use \Psr\Http\Message\{RequestFactoryInterface, - RequestInterface, - ResponseInterface, - ServerRequestInterface, - UploadedFileInterface, - UriFactoryInterface, - UploadedFileFactoryInterface, - StreamFactoryInterface, - ServerRequestFactoryInterface, - ResponseFactoryInterface, - StreamInterface, - UriInterface}; - -use function in_array; -use function fopen; -use function error_get_last; - -class Factory implements RequestFactoryInterface, UriFactoryInterface, UploadedFileFactoryInterface, StreamFactoryInterface, ServerRequestFactoryInterface, ResponseFactoryInterface -{ - - /** - * @inheritDoc - */ - public function createRequest(string $method, $uri): RequestInterface - { - return new Request($method, $uri); - } - - /** - * @inheritDoc - */ - public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface - { - return new Response($code, [], null, '1.1', ($reasonPhrase === '' ? null : $reasonPhrase)); - } - - /** - * @inheritDoc - */ - public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface - { - return new ServerRequest($method, $uri, [], null, '1.1', $serverParams); - } - - /** - * @inheritDoc - */ - public function createStream(string $content = ''): StreamInterface - { - return new Stream($content, 'php://temp'); - } - - /** - * @inheritDoc - */ - public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface - { - if($filename === ''){ - throw new RuntimeException('Path cannot be empty'); - } - if($mode === '' || in_array($mode[0], ['r', 'w', 'a', 'x', 'c'], true) === FALSE){ - throw new InvalidArgumentException(sprintf('The mode "%s" is invalid.', $mode)); - } - - if(FALSE === $resource = @fopen($filename, $mode)){ - throw new RuntimeException(sprintf('The file "%s" cannot be opened: %s', $filename, error_get_last()['message'] ?? '')); - } - - return new Stream($resource, 'php://temp'); - } - - /** - * @inheritDoc - */ - public function createStreamFromResource($resource): StreamInterface - { - if($resource instanceof StreamInterface){ - return $resource; - } - - return new Stream($resource, 'php://temp'); - } - - /** - * @inheritDoc - */ - public function createUploadedFile(StreamInterface $stream, int $size = null, int $error = \UPLOAD_ERR_OK, string $clientFilename = null, string $clientMediaType = null): UploadedFileInterface - { - if($size === null){ - $size = $stream->getSize(); - } - - return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType); - } - - /** - * @inheritDoc - */ - public function createUri(string $uri = ''): UriInterface - { - return new Uri($uri); - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Factory; + +use \RuntimeException; +use \InvalidArgumentException; +use \InitPHP\HTTP\Message\{Request, + Response, + ServerRequest, + Stream, + UploadedFile, + Uri}; +use \Psr\Http\Message\{RequestFactoryInterface, + RequestInterface, + ResponseInterface, + ServerRequestInterface, + UploadedFileInterface, + UriFactoryInterface, + UploadedFileFactoryInterface, + StreamFactoryInterface, + ServerRequestFactoryInterface, + ResponseFactoryInterface, + StreamInterface, + UriInterface}; + +use function in_array; +use function fopen; +use function error_get_last; + +/** + * PSR-17 factory bundle. A single class implements every PSR-17 factory + * interface (RequestFactory, ResponseFactory, ServerRequestFactory, + * StreamFactory, UploadedFileFactory, UriFactory), so applications wiring + * up DI containers only need to register this one type to satisfy all + * six bindings. + */ +class Factory implements RequestFactoryInterface, UriFactoryInterface, UploadedFileFactoryInterface, StreamFactoryInterface, ServerRequestFactoryInterface, ResponseFactoryInterface +{ + + /** + * Build a fresh PSR-7 Request. + * + * @param string $method + * @param string|UriInterface $uri + * @return RequestInterface + * @throws InvalidArgumentException When $uri is a malformed URI string. + */ + public function createRequest(string $method, $uri): RequestInterface + { + return new Request($method, $uri); + } + + /** + * Build a fresh PSR-7 Response. An empty $reasonPhrase resolves to + * the IANA-registered phrase for $code at the Response level. + * + * @param int $code + * @param string $reasonPhrase + * @return ResponseInterface + * @throws InvalidArgumentException When $code or the default HTTP version is invalid. + */ + public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface + { + return new Response($code, [], null, '1.1', ($reasonPhrase === '' ? null : $reasonPhrase)); + } + + /** + * Build a fresh PSR-7 ServerRequest with the supplied $_SERVER-style + * snapshot. To bootstrap from real superglobals call + * {@see \InitPHP\HTTP\Message\ServerRequest::createFromGlobals()} instead. + * + * @param string $method + * @param string|UriInterface $uri + * @param array $serverParams + * @return ServerRequestInterface + * @throws InvalidArgumentException When $uri is a malformed URI string. + */ + public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface + { + return new ServerRequest($method, $uri, [], null, '1.1', $serverParams); + } + + /** + * Build a php://temp-backed Stream pre-loaded with $content. + * + * @param string $content + * @return StreamInterface + * @throws RuntimeException When php://temp cannot be opened. + */ + public function createStream(string $content = ''): StreamInterface + { + return new Stream($content, 'php://temp'); + } + + /** + * Open $filename in $mode and wrap the resulting handle in a Stream. + * + * @param string $filename + * @param string $mode One of the fopen() mode prefixes (r, w, a, x, c). + * @return StreamInterface + * @throws RuntimeException When $filename is empty or cannot be opened. + * @throws InvalidArgumentException When $mode is not a recognised fopen() mode. + */ + public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface + { + if($filename === ''){ + throw new RuntimeException('Path cannot be empty'); + } + if($mode === '' || in_array($mode[0], ['r', 'w', 'a', 'x', 'c'], true) === FALSE){ + throw new InvalidArgumentException(sprintf('The mode "%s" is invalid.', $mode)); + } + + if(FALSE === $resource = @fopen($filename, $mode)){ + throw new RuntimeException(sprintf('The file "%s" cannot be opened: %s', $filename, error_get_last()['message'] ?? '')); + } + + return new Stream($resource, 'php://temp'); + } + + /** + * Wrap an existing PHP resource handle (or pass through a Stream) + * in a php://temp-backed Stream. + * + * @param resource|StreamInterface $resource + * @return StreamInterface + */ + public function createStreamFromResource($resource): StreamInterface + { + if($resource instanceof StreamInterface){ + return $resource; + } + + return new Stream($resource, 'php://temp'); + } + + /** + * Build a PSR-7 UploadedFile from a stream. When $size is null the + * factory tries to derive it from the stream itself before construction. + * + * @param StreamInterface $stream + * @param int|null $size + * @param int $error One of the UPLOAD_ERR_* constants. + * @param string|null $clientFilename + * @param string|null $clientMediaType + * @return UploadedFileInterface + * @throws InvalidArgumentException When the stream cannot be wrapped (e.g. unusable handle). + */ + public function createUploadedFile(StreamInterface $stream, int $size = null, int $error = \UPLOAD_ERR_OK, string $clientFilename = null, string $clientMediaType = null): UploadedFileInterface + { + if($size === null){ + $size = $stream->getSize(); + } + + return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType); + } + + /** + * Build a PSR-7 Uri from a string. + * + * @param string $uri + * @return UriInterface + * @throws InvalidArgumentException When parse_url() cannot interpret $uri. + */ + public function createUri(string $uri = ''): UriInterface + { + return new Uri($uri); + } + +} diff --git a/src/Helpers.php b/src/Helpers.php index 8c5df08..11fe1c8 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -7,7 +7,6 @@ * @author Muhammet ŞAFAK * @copyright Copyright © 2022 Muhammet ŞAFAK * @license ./LICENSE MIT - * @version 2.0 * @link https://www.muhammetsafak.com.tr */ @@ -15,24 +14,65 @@ if (!function_exists('send_request')) { /** - * @param string|\Psr\Http\Message\RequestInterface $method [GET|POST|PUT|PATCH|HEAD|DELETE] - * @param string|null $url - * @param array|null $headers - * @param string|null|\DOMDocument|\SimpleXMLElement|array|object|\Psr\Http\Message\StreamInterface $body - * @param string|null $version - * @return \Psr\Http\Message\ResponseInterface + * Convenience wrapper around InitPHP\HTTP\Facade\Client that accepts + * either a pre-built PSR-7 RequestInterface or a method+URL pair plus + * loose configuration: + * + * send_request($psr7Request); + * send_request('POST', 'https://api.example/x', ['Accept'=>'application/json'], ['k'=>'v']); + * + * Body coercion handled at this convenience layer (the underlying + * PSR-18 Client only accepts string/resource/StreamInterface/null): + * - array -> json_encode($body), Content-Type added if absent. + * - object with __toString() -> (string) $body + * - object with toArray() -> json_encode($body->toArray()) + * - otherwise -> passed through verbatim. + * + * @param string|\Psr\Http\Message\RequestInterface $method HTTP method or a fully-formed PSR-7 request. + * @param string|null $url Required when \$method is a string. + * @param array|null $headers + * @param mixed $body + * @param string|null $version HTTP protocol version. + * @throws \InvalidArgumentException When \$method is a string but \$url is null. + * @throws \Psr\Http\Client\ClientExceptionInterface On transport-level failure. */ function send_request($method, ?string $url = null, ?array $headers = [], $body = null, ?string $version = null): \Psr\Http\Message\ResponseInterface { if ($method instanceof \Psr\Http\Message\RequestInterface) { return \InitPHP\HTTP\Facade\Client::sendRequest($method); } + if ($url === null) { + throw new \InvalidArgumentException('send_request() requires a URL when the first argument is an HTTP method.'); + } + + $headers = $headers ?? []; + + if (is_array($body)) { + $body = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!array_key_exists('Content-Type', $headers) && !array_key_exists('content-type', $headers)) { + $headers['Content-Type'] = 'application/json; charset=utf-8'; + } + } elseif (is_object($body) && !($body instanceof \Psr\Http\Message\StreamInterface)) { + if (method_exists($body, '__toString')) { + $body = (string) $body; + } elseif (method_exists($body, 'toArray')) { + $body = json_encode($body->toArray(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!array_key_exists('Content-Type', $headers) && !array_key_exists('content-type', $headers)) { + $headers['Content-Type'] = 'application/json; charset=utf-8'; + } + } else { + throw new \InvalidArgumentException( + 'Unsupported body type ' . get_class($body) . '; pass a string, resource, StreamInterface, ' + . 'array, object with __toString(), or object with toArray().' + ); + } + } return \InitPHP\HTTP\Facade\Client::fetch($url, [ - 'method' => $method, - 'body' => $body, - 'headers' => $headers, - 'version' => $version, + 'method' => $method, + 'body' => $body, + 'headers' => $headers, + 'version' => $version ?? '1.1', ]); } } diff --git a/src/Message/Interfaces/MessageInterface.php b/src/Message/Interfaces/MessageInterface.php deleted file mode 100644 index 5bdf16f..0000000 --- a/src/Message/Interfaces/MessageInterface.php +++ /dev/null @@ -1,64 +0,0 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - - -namespace InitPHP\HTTP\Message\Interfaces; - -interface MessageInterface extends \Psr\Http\Message\MessageInterface -{ - - /** - * @param string $version - * @return $this - */ - public function setProtocolVersion(string $version): self; - - /** - * @param array $headers - * @return $this - */ - public function setHeaders(array $headers): self; - - /** - * @param string $name - * @param string $value - * @return $this - */ - public function setHeader($name, $value): self; - - /** - * @param string $name - * @param string $value - * @return $this - */ - public function addedHeader($name, $value): self; - - /** - * @param string $name - * @return $this - */ - public function outHeader($name): self; - - /** - * @return bool - */ - public function isEmpty(): bool; - - /** - * @return bool - */ - public function isNotEmpty(): bool; - -} diff --git a/src/Message/Interfaces/RequestInterface.php b/src/Message/Interfaces/RequestInterface.php deleted file mode 100644 index c6db351..0000000 --- a/src/Message/Interfaces/RequestInterface.php +++ /dev/null @@ -1,85 +0,0 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - - -namespace InitPHP\HTTP\Message\Interfaces; - -use \Psr\Http\Message\{StreamInterface, UriInterface}; - -interface RequestInterface extends \Psr\Http\Message\RequestInterface, MessageInterface -{ - - /** - * @param string $requestTarget - * @return $this - */ - public function setRequestTarget($requestTarget): self; - - /** - * @param string ...$methods - * @return bool - */ - public function isMethod(string ...$methods): bool; - - /** - * @return bool - */ - public function isGet(): bool; - - /** - * @return bool - */ - public function isPost(): bool; - - /** - * @return bool - */ - public function isPut(): bool; - - /** - * @return bool - */ - public function isDelete(): bool; - - /** - * @return bool - */ - public function isHead(): bool; - - /** - * @return bool - */ - public function isPatch(): bool; - - /** - * @param string $method - * @return $this - */ - public function setMethod($method): self; - - /** - * @param UriInterface $uri - * @param bool $preserveHost - * @return $this - */ - public function setUri(UriInterface $uri, bool $preserveHost = false): self; - - /** - * @param StreamInterface $body - * @return $this - */ - public function setBody(StreamInterface $body): self; - -} diff --git a/src/Message/Interfaces/ResponseInterface.php b/src/Message/Interfaces/ResponseInterface.php deleted file mode 100644 index 7667d47..0000000 --- a/src/Message/Interfaces/ResponseInterface.php +++ /dev/null @@ -1,52 +0,0 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - - -namespace InitPHP\HTTP\Message\Interfaces; - -use Psr\Http\Message\UriInterface; - -interface ResponseInterface extends \Psr\Http\Message\ResponseInterface, MessageInterface -{ - - /** - * @param int $code - * @param string $reasonPhrase - * @return $this - */ - public function setStatusCode(int $code, string $reasonPhrase = ''): self; - - /** - * @param \Psr\Http\Message\StreamInterface|resource|scalar|string|null $body - * @return $this - */ - public function setStream($body): self; - - /** - * @param array $data - * @param int $status - * @return $this - */ - public function json(array $data = [], int $status = 200): self; - - /** - * @param UriInterface|string $uri - * @param int $status - * @param int $second - * @return $this - */ - public function redirect($uri, int $status = 302, int $second = 0): self; - -} diff --git a/src/Message/Interfaces/ServerRequestInterface.php b/src/Message/Interfaces/ServerRequestInterface.php deleted file mode 100644 index 71c6bc0..0000000 --- a/src/Message/Interfaces/ServerRequestInterface.php +++ /dev/null @@ -1,65 +0,0 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - - -namespace InitPHP\HTTP\Message\Interfaces; - -interface ServerRequestInterface extends \Psr\Http\Message\ServerRequestInterface, RequestInterface -{ - - /** - * @param array $cookies - * @return $this - */ - public function setCookieParams(array $cookies): self; - - /** - * @param array $query - * @return $this - */ - public function setQueryParams(array $query): self; - - /** - * @param array $uploadedFiles - * @return $this - */ - public function setUploadedFiles(array $uploadedFiles): self; - - /** - * @param array|object|null $data - * @return $this - */ - public function setParsedBody($data): self; - - /** - * @param string $name - * @param string $value - * @return $this - */ - public function setAttribute($name, $value): self; - - /** - * @param string $name - * @return $this - */ - public function outAttribute($name): self; - - /** - * @param array $files - * @return array - */ - public function normalizeFiles(array $files): array; - -} diff --git a/src/Message/Interfaces/StreamInterface.php b/src/Message/Interfaces/StreamInterface.php deleted file mode 100644 index edf9fc0..0000000 --- a/src/Message/Interfaces/StreamInterface.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - - -namespace InitPHP\HTTP\Message\Interfaces; - -interface StreamInterface extends \Psr\Http\Message\StreamInterface -{ - /** - * @return bool - */ - public function isEmpty(): bool; - - /** - * @return bool - */ - public function isNotEmpty(): bool; - -} diff --git a/src/Message/Interfaces/UriInterface.php b/src/Message/Interfaces/UriInterface.php deleted file mode 100644 index 841f462..0000000 --- a/src/Message/Interfaces/UriInterface.php +++ /dev/null @@ -1,64 +0,0 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Message\Interfaces; - -interface UriInterface extends \Psr\Http\Message\UriInterface -{ - - /** - * @param string $scheme - * @return $this - */ - public function setScheme(string $scheme): self; - - /** - * @param string $user - * @param string|null $password - * @return $this - */ - public function setUserInfo(string $user, ?string $password = null): self; - - /** - * @param string $host - * @return $this - */ - public function setHost(string $host): self; - - /** - * @param int|null $port - * @return $this - */ - public function setPort(?int $port): self; - - /** - * @param string $path - * @return $this - */ - public function setPath(string $path): self; - - /** - * @param string $query - * @return $this - */ - public function setQuery(string $query): self; - - /** - * @param string $fragment - * @return $this - */ - public function setFragment(string $fragment): self; - -} diff --git a/src/Message/Request.php b/src/Message/Request.php index 1578a50..cc5b7e7 100644 --- a/src/Message/Request.php +++ b/src/Message/Request.php @@ -1,153 +1,68 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Message; - -use \InitPHP\HTTP\Message\Traits\{MessageTrait, RequestTrait}; -use Psr\Http\Message\UriInterface; -use stdClass; -use function in_array; -use function function_exists; -use function array_key_exists; -use function json_decode; -use function json_encode; -use function array_merge; -use function defined; -use function file_get_contents; - -class Request implements \InitPHP\HTTP\Message\Interfaces\RequestInterface -{ - - use MessageTrait, RequestTrait; - - private array $_parameters = []; - - private object $_objParameters; - - private static Request $requestImmutable; - - /** - * PSR-7 immutability: clone'da body ve URI'yi de derinleştir; aksi halde - * `$cloned->getBody()->write(...)` veya `$cloned->getUri()->setHost(...)` - * orijinali mutasyona uğratır. - */ - public function __clone() - { - if (isset($this->stream)) { - $this->stream = clone $this->stream; - } - if (isset($this->uri)) { - $this->uri = clone $this->uri; - } - if (isset($this->_objParameters)) { - $this->_objParameters = clone $this->_objParameters; - } - } - - public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1') - { - if(!($uri instanceof UriInterface)) { - $uri = new Uri($uri); - } - $this->setUpConstruct($method, $uri, $body, $headers, $version); - - if (!isset($this->_objParameters)) { - $this->_objParameters = new stdClass(); - } - } - - public function __isset($name) - { - return array_key_exists($name, $this->_parameters); - } - - public function __set($name, $value) - { - $this->_parameters[$name] = $value; - $this->_objParameters = json_decode(json_encode($this->_parameters)); - - return $value; - } - - public function __get($name) - { - return array_key_exists($name, $this->_parameters) ? $this->_objParameters->{$name} : null; - } - - public static function createFromGlobals(): self - { - if (!isset(self::$requestImmutable)) { - $method = ($_SERVER['REQUEST_METHOD']) ?? ((defined('PHP_SAPI') && PHP_SAPI === 'cli') ? 'CLI' : 'GET'); - $port = $_SERVER['SERVER_PORT'] ?? null; - $uri = (($_SERVER['HTTPS'] ?? 'off') === 'on' ? 'https' : 'http') - . '://' - . (($_SERVER['SERVER_NAME']) ?? ($_ENV['SERVER_NAME'] ?? 'localhost')) - . ($port !== null && !in_array((int)$port, [80, 443], true) ? ':' . $port : '') - . ($_SERVER['REQUEST_URI'] ?? '/'); - - $headers = function_exists('apache_request_headers') ? apache_request_headers() : false; - if (!$headers) { - $headers = []; - } - - if (($body = @file_get_contents('php://input')) === FALSE) { - $body = ''; - } - self::$requestImmutable = new self($method, $uri, $headers, new \InitPHP\HTTP\Message\Stream($body, null), '1.1'); - - if (($rawData = !empty($body) ? json_decode($body, true) : false) === FALSE) { - $rawData = []; - } - - self::$requestImmutable->merge($_GET ?? [], $_POST ?? [], $rawData); - } - - return self::$requestImmutable; - } - - - public function all(): array - { - return $this->_parameters; - } - - public function get(string $name, $default = null) - { - return array_key_exists($name, $this->_parameters) ? $this->_parameters[$name] : $default; - } - - public function has(string $name): bool - { - return array_key_exists($name, $this->_parameters); - } - - public function merge(array ...$array): self - { - $this->_parameters = array_merge($this->_parameters, ...$array); - !empty($this->_parameters) && $this->_objParameters = (object)json_decode(json_encode($this->_parameters), false); - - return $this; - } - - public function sendRequest(): \Psr\Http\Message\ResponseInterface - { - if (isset(self::$requestImmutable) && $this === self::$requestImmutable) { - throw new \RuntimeException('Making requests to itself causes an infinite loop problem.'); - } - - return (new \InitPHP\HTTP\Client\Client())->sendRequest($this); - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Message; + +use \InitPHP\HTTP\Message\Traits\{MessageTrait, RequestTrait}; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\UriInterface; + +/** + * PSR-7 RequestInterface implementation backed by {@see MessageTrait} and + * {@see RequestTrait}. Carries the HTTP method, request target, URI, headers + * and body; immutability of `with*()` returns is guaranteed by the deep + * clone in {@see Request::__clone()}. + */ +class Request implements RequestInterface +{ + + use MessageTrait, RequestTrait; + + /** + * Clone the body and URI deeply so callers using `with*()` cannot + * mutate the original message via the returned copy. Without this the + * default shallow clone leaves both instances pointing at the same + * StreamInterface and UriInterface, violating PSR-7 immutability. + * + * @return void + */ + public function __clone() + { + if (isset($this->stream)) { + $this->stream = clone $this->stream; + } + if (isset($this->uri)) { + $this->uri = clone $this->uri; + } + } + + /** + * Build a new request. + * + * @param string $method HTTP method (case preserved as supplied). + * @param string|UriInterface $uri Target URI as a string or PSR-7 UriInterface. + * @param array $headers Header name => value(s). + * @param string|resource|\Psr\Http\Message\StreamInterface|null $body Request body in any form accepted by {@see Stream}. + * @param string $version HTTP protocol version (e.g. "1.1", "2.0"). + * @throws \InvalidArgumentException When $uri is a malformed URI string, or any supplied header is invalid. + */ + public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1') + { + if (!($uri instanceof UriInterface)) { + $uri = new Uri($uri); + } + $this->setUpConstruct($method, $uri, $body, $headers, $version); + } + +} diff --git a/src/Message/Response.php b/src/Message/Response.php index da92666..14c1515 100644 --- a/src/Message/Response.php +++ b/src/Message/Response.php @@ -1,232 +1,305 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Message; - -use \InvalidArgumentException; -use \InitPHP\HTTP\Message\Traits\MessageTrait; -use \InitPHP\HTTP\Message\Interfaces\{ResponseInterface, StreamInterface}; - -use function in_array; -use function is_numeric; -use function is_resource; -use function is_scalar; -use function is_string; -use function json_encode; - -class Response implements ResponseInterface -{ - - use MessageTrait; - - protected const PHRASES = [ - 100 => 'Continue', - 101 => 'Switching Protocols', - 102 => 'Processing', - 103 => 'Early Hints', - 200 => 'OK', - 201 => 'Created', - 202 => 'Accepted', - 203 => 'Non-Authoritative Information', - 204 => 'No Content', - 205 => 'Reset Content', - 206 => 'Partial Content', - 207 => 'Multi-status', - 208 => 'Already Reported', - 210 => 'Content Different', - 226 => 'IM Used', - 300 => 'Multiple Choices', - 301 => 'Moved Permanently', - 302 => 'Found', - 303 => 'See Other', - 304 => 'Not Modified', - 305 => 'Use Proxy', - 306 => 'Switch Proxy', - 307 => 'Temporary Redirect', - 308 => 'Permanent Redirect', - 400 => 'Bad Request', - 401 => 'Unauthorized', - 402 => 'Payment Required', - 403 => 'Forbidden', - 404 => 'Not Found', - 405 => 'Method Not Allowed', - 406 => 'Not Acceptable', - 407 => 'Proxy Authentication Required', - 408 => 'Request Time-out', - 409 => 'Conflict', - 410 => 'Gone', - 411 => 'Length Required', - 412 => 'Precondition Failed', - 413 => 'Request Entity Too Large', - 414 => 'Request-URI Too Large', - 415 => 'Unsupported Media Type', - 416 => 'Requested range not satisfiable', - 417 => 'Expectation Failed', - 418 => 'I\'m a teapot', - 421 => 'Misdirected Request', - 422 => 'Unprocessable Entity', - 423 => 'Locked', - 424 => 'Failed Dependency', - 425 => 'Unordered Collection', - 426 => 'Upgrade Required', - 428 => 'Precondition Required', - 429 => 'Too Many Requests', - 431 => 'Request Header Fields Too Large', - 451 => 'Unavailable For Legal Reasons', - 500 => 'Internal ServerRequest Error', - 501 => 'Not Implemented', - 502 => 'Bad Gateway', - 503 => 'Service Unavailable', - 504 => 'Gateway Time-out', - 505 => 'HTTP Version not supported', - 506 => 'Variant Also Negotiates', - 507 => 'Insufficient Storage', - 508 => 'Loop Detected', - 510 => 'Not Extended', - 511 => 'Network Authentication Required', - ]; - - protected int $statusCode; - protected string $reasonPhrase; - - - /** - * @param int $status - * @param array $headers - * @param StreamInterface|string|null|resource $body - * @param string $version - * @param string|null $reason - */ - /** - * PSR-7 immutability: clone'da body'i de derinleştir ki - * `$cloned->getBody()->write(...)` orijinali bozmasın. - */ - public function __clone() - { - if (isset($this->stream)) { - $this->stream = clone $this->stream; - } - } - - public function __construct(int $status = 200, array $headers = [], $body = null, string $version = '1.1', ?string $reason = null) - { - if(!in_array($version, ['1.0', '1.1', '2.0'], true)){ - throw new \InvalidArgumentException('Supported HTTP versions are; "1.0", "1.1" or "2.0"'); - } - $this->setStream($body); - $this->statusCode = $status; - $this->setHeaders($headers); - if($reason === null && isset(self::PHRASES[$this->statusCode])){ - $this->reasonPhrase = self::PHRASES[$this->statusCode]; - }else{ - $this->reasonPhrase = $reason ?? ''; - } - $this->protocol = $version; - } - - /** - * @inheritDoc - */ - public function setStream($body): self - { - if($body === null){ - return $this; - } - if($body instanceof StreamInterface){ - $this->stream = $body; - return $this; - } - if(is_resource($body)){ - $this->stream = new Stream($body); - return $this; - } - if(is_scalar($body)){ - $this->stream = new Stream((string)$body); - return $this; - } - return $this; - } - - /** - * @inheritDoc - */ - public function getStatusCode(): int - { - return $this->statusCode; - } - - /** - * @inheritDoc - */ - public function setStatusCode(int $code, string $reasonPhrase = ''): self - { - if(!is_numeric($code)){ - throw new InvalidArgumentException('Status code has to be an integer'); - } - $code = (int)$code; - if($code < 100 || $code > 599){ - throw new InvalidArgumentException('Status code has to be an integer between 100 and 599. A status code of '.$code.' was given.'); - } - $this->statusCode = $code; - if (empty($reasonPhrase) && isset(self::PHRASES[$code])) { - $reasonPhrase = self::PHRASES[$code]; - } - $this->reasonPhrase = $reasonPhrase; - - return $this; - } - - /** - * @inheritDoc - */ - public function withStatus($code, $reasonPhrase = ''): self - { - return (clone $this)->setStatusCode($code, $reasonPhrase); - } - - /** - * @inheritDoc - */ - public function getReasonPhrase(): string - { - return $this->reasonPhrase; - } - - public function json(array $data = [], int $status = 200): self - { - return (clone $this)->setHeader('Content-Type', 'application/json') - ->setBody(new Stream(json_encode($data), null)) - ->setStatusCode($status); - } - - public function redirect($uri, int $status = 302, int $second = 0): self - { - if (is_string($uri)) { - $uri = new Uri($uri); - } - if (!($uri instanceof \Psr\Http\Message\UriInterface)) { - throw new InvalidArgumentException('URI is not valid.'); - } - - $with = clone $this; - - $with->setStatusCode($status); - - return $second > 0 - ? $with->setHeader('Refresh', $second . '; url=' . $uri->__toString()) - : $with->setHeader('Location', $uri->__toString()); - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Message; + +use \InvalidArgumentException; +use \InitPHP\HTTP\Message\Traits\MessageTrait; +use \Psr\Http\Message\{ResponseInterface, StreamInterface}; + +use function in_array; +use function is_numeric; +use function is_resource; +use function is_scalar; +use function is_string; +use function json_encode; + +/** + * PSR-7 ResponseInterface implementation backed by {@see MessageTrait}. + * Carries the response status code, reason phrase, headers and body; + * immutability of `with*()` returns is guaranteed by the deep clone in + * {@see Response::__clone()}. Ships with two convenience producers, + * {@see Response::json()} and {@see Response::redirect()}, on top of the + * standard PSR-7 surface. + */ +class Response implements ResponseInterface +{ + + use MessageTrait; + + protected const PHRASES = [ + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 103 => 'Early Hints', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-status', + 208 => 'Already Reported', + 210 => 'Content Different', + 226 => 'IM Used', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 306 => 'Switch Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Time-out', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Large', + 415 => 'Unsupported Media Type', + 416 => 'Requested range not satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', + 421 => 'Misdirected Request', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Unordered Collection', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Time-out', + 505 => 'HTTP Version not supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 510 => 'Not Extended', + 511 => 'Network Authentication Required', + ]; + + protected int $statusCode; + protected string $reasonPhrase; + + + /** + * Deep-clone the body Stream so callers using `with*()` cannot mutate + * the original response via `$cloned->getBody()->write(...)`. Without + * this the default shallow clone leaves both instances pointing at the + * same underlying resource handle. + * + * @return void + */ + public function __clone() + { + if (isset($this->stream)) { + $this->stream = clone $this->stream; + } + } + + /** + * Build a new response. + * + * @param int $status HTTP status code (100..599). + * @param array $headers Header name => value(s). + * @param StreamInterface|string|resource|null $body Response body in any form accepted by {@see Stream}. + * @param string $version HTTP protocol version; one of "1.0", "1.1", "2", "2.0", "3", "3.0". + * @param string|null $reason Reason phrase; defaults to the IANA phrase for $status when null. + * @throws InvalidArgumentException When $version is not one of the supported HTTP version strings. + */ + public function __construct(int $status = 200, array $headers = [], $body = null, string $version = '1.1', ?string $reason = null) + { + if (!in_array($version, ['1.0', '1.1', '2', '2.0', '3', '3.0'], true)) { + throw new \InvalidArgumentException( + 'Supported HTTP versions are: "1.0", "1.1", "2", "2.0", "3" or "3.0".' + ); + } + $this->setStream($body); + $this->statusCode = $status; + $this->setHeaders($headers); + if($reason === null && isset(self::PHRASES[$this->statusCode])){ + $this->reasonPhrase = self::PHRASES[$this->statusCode]; + }else{ + $this->reasonPhrase = $reason ?? ''; + } + $this->protocol = $version; + } + + /** + * Assign the body from a flexible input shape: StreamInterface and + * resource values are wrapped/stored verbatim; scalar values are + * stringified through a fresh {@see Stream}; null is a no-op. + * + * @param StreamInterface|resource|scalar|null $body + * @return $this + */ + public function setStream($body): self + { + if($body === null){ + return $this; + } + if($body instanceof StreamInterface){ + $this->stream = $body; + return $this; + } + if(is_resource($body)){ + $this->stream = new Stream($body); + return $this; + } + if(is_scalar($body)){ + $this->stream = new Stream((string)$body); + return $this; + } + return $this; + } + + /** + * Return the HTTP status code. + * + * @return int + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * Replace the status code (and optionally the reason phrase) in-place. + * When $reasonPhrase is empty and an IANA phrase is known for $code, + * the IANA phrase is used. + * + * @param int $code Status code in the range 100..599. + * @param string $reasonPhrase Optional reason phrase override. + * @return $this + * @throws InvalidArgumentException When $code is outside the 100..599 range. + */ + public function setStatusCode(int $code, string $reasonPhrase = ''): self + { + if(!is_numeric($code)){ + throw new InvalidArgumentException('Status code has to be an integer'); + } + $code = (int)$code; + if($code < 100 || $code > 599){ + throw new InvalidArgumentException('Status code has to be an integer between 100 and 599. A status code of '.$code.' was given.'); + } + $this->statusCode = $code; + if (empty($reasonPhrase) && isset(self::PHRASES[$code])) { + $reasonPhrase = self::PHRASES[$code]; + } + $this->reasonPhrase = $reasonPhrase; + + return $this; + } + + /** + * Return a clone of the response with the status code (and optional + * reason phrase) replaced. + * + * @param int $code + * @param string $reasonPhrase + * @return static + * @throws InvalidArgumentException When $code is outside the 100..599 range. + */ + public function withStatus($code, $reasonPhrase = ''): self + { + return (clone $this)->setStatusCode($code, $reasonPhrase); + } + + /** + * Return the reason phrase associated with the current status code. + * + * @return string + */ + public function getReasonPhrase(): string + { + return $this->reasonPhrase; + } + + /** + * Return a JSON-encoded copy of this response with the appropriate + * Content-Type and status. Encoding errors raise InvalidArgumentException + * instead of silently producing a non-JSON body. + * + * @param array|list $data + * @param int $status HTTP status code for the new response. + * @param int $flags Additional json_encode() flags OR'd onto JSON_THROW_ON_ERROR. + * @return static + * @throws InvalidArgumentException When $data cannot be encoded as JSON, or $status is out of range. + */ + public function json(array $data = [], int $status = 200, int $flags = 0): self + { + try { + $encoded = json_encode($data, JSON_THROW_ON_ERROR | $flags); + } catch (\JsonException $e) { + throw new InvalidArgumentException('Cannot encode response payload as JSON: ' . $e->getMessage(), 0, $e); + } + + return (clone $this) + ->setHeader('Content-Type', 'application/json; charset=utf-8') + ->setBody(new Stream($encoded, null)) + ->setStatusCode($status); + } + + /** + * Return a redirect copy of this response. + * + * The Location header is always set so non-browser clients (crawlers, + * HTTP libraries, monitoring) can follow the redirect; the Refresh + * header is added on top when a non-zero $second is supplied so + * browsers honour the delay. + * + * @param string|\Psr\Http\Message\UriInterface $uri Target location, as a string or PSR-7 URI. + * @param int $status HTTP status code (typically 301/302/303/307/308). + * @param int $second Delay in seconds before browsers follow; 0 omits the Refresh header. + * @return static + * @throws InvalidArgumentException When $uri is not a string or UriInterface, or $status is out of range. + */ + public function redirect($uri, int $status = 302, int $second = 0): self + { + if (is_string($uri)) { + $uri = new Uri($uri); + } + if (!($uri instanceof \Psr\Http\Message\UriInterface)) { + throw new InvalidArgumentException('URI is not valid.'); + } + + $location = (string) $uri; + $with = (clone $this) + ->setStatusCode($status) + ->setHeader('Location', $location); + + if ($second > 0) { + $with->setHeader('Refresh', $second . '; url=' . $location); + } + + return $with; + } + +} diff --git a/src/Message/ServerRequest.php b/src/Message/ServerRequest.php index 8c14523..e7139a1 100644 --- a/src/Message/ServerRequest.php +++ b/src/Message/ServerRequest.php @@ -1,277 +1,584 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Message; - -use \InitPHP\HTTP\Message\Interfaces\ServerRequestInterface; -use \InitPHP\HTTP\Message\Traits\{MessageTrait, RequestTrait}; -use \Psr\Http\Message\{UploadedFileInterface, UriInterface}; - -use function array_keys; -use function is_array; -use function is_object; - -class ServerRequest implements ServerRequestInterface -{ - - use MessageTrait, RequestTrait; - - protected array $serverParams = []; - - protected array $cookieParams = []; - - protected array $queryParams = []; - - /** @var null|object|array */ - protected $parsedBody = null; - - protected array $attributes = []; - - /** @var UploadedFileInterface[] */ - protected array $uploadedFiles = []; - - - /** - * PSR-7 immutability: clone'da body + URI'yi derinleştir. - * uploadedFiles ve attributes ham PHP array'i; copy-on-write zaten kopyalar. - */ - public function __clone() - { - if (isset($this->stream)) { - $this->stream = clone $this->stream; - } - if (isset($this->uri)) { - $this->uri = clone $this->uri; - } - } - - public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1', array $serverParams = []) - { - $this->serverParams = $serverParams; - if(!($uri instanceof UriInterface)){ - $uri = new Uri($uri); - } - $this->setUpConstruct($method, $uri, $body, $headers, $version); - } - - /** - * @inheritDoc - */ - public function getServerParams(): array - { - return $this->serverParams; - } - - /** - * @inheritDoc - */ - public function getCookieParams(): array - { - return $this->cookieParams; - } - - /** - * @inheritDoc - */ - public function setCookieParams(array $cookies): self - { - $this->cookieParams = $cookies; - - return $this; - } - - /** - * @inheritDoc - */ - public function withCookieParams(array $cookies): ServerRequest - { - return (clone $this)->setCookieParams($cookies); - } - - /** - * @inheritDoc - */ - public function getQueryParams(): array - { - return $this->queryParams; - } - - /** - * @inheritDoc - */ - public function setQueryParams(array $query): self - { - $this->queryParams = $query; - - return $this; - } - - /** - * @inheritDoc - */ - public function withQueryParams(array $query): ServerRequest - { - return (clone $this)->setQueryParams($query); - } - - /** - * @inheritDoc - */ - public function getUploadedFiles(): array - { - return $this->uploadedFiles; - } - - /** - * @inheritDoc - */ - public function setUploadedFiles(array $uploadedFiles): self - { - $this->uploadedFiles = $this->normalizeFiles($uploadedFiles); - - return $this; - } - - /** - * @inheritDoc - */ - public function withUploadedFiles(array $uploadedFiles): self - { - return (clone $this)->setUploadedFiles($uploadedFiles); - } - - /** - * @inheritDoc - */ - public function getParsedBody() - { - return $this->parsedBody; - } - - /** - * @inheritDoc - */ - public function setParsedBody($data): self - { - if(!is_array($data) && !is_object($data) && $data !== null){ - throw new \InvalidArgumentException('First parameter to withParsedBody MUST be object, array or null'); - } - $this->parsedBody = $data; - - return $this; - } - - /** - * @inheritDoc - */ - public function withParsedBody($data): self - { - return (clone $this)->setParsedBody($data); - } - - /** - * @inheritDoc - */ - public function getAttributes(): array - { - return $this->attributes; - } - - /** - * @inheritDoc - */ - public function getAttribute($name, $default = null) - { - return $this->attributes[$name] ?? $default; - } - - /** - * @inheritDoc - */ - public function setAttribute($name, $value): self - { - $this->attributes[$name] = $value; - - return $this; - } - - /** - * @inheritDoc - */ - public function withAttribute($name, $value): self - { - return (clone $this)->setAttribute($name, $value); - } - - /** - * @inheritDoc - */ - public function outAttribute($name): self - { - if (!isset($this->attributes[$name])) { - return $this; - } - unset($this->attributes[$name]); - - return $this; - } - - /** - * @inheritDoc - */ - public function withoutAttribute($name): self - { - return (clone $this)->outAttribute($name); - } - - /** - * @inheritDoc - */ - public function normalizeFiles(array $files): array - { - $normalized = []; - foreach ($files as $key => $value) { - if ($value instanceof UploadedFileInterface) { - $normalized[$key] = $value; - continue; - } - if(!isset($value['tmp_name'])){ - throw new \InvalidArgumentException('Invalid value in files specification'); - } - if(is_array($value['tmp_name'])){ - $normalized[$key] = []; - foreach (array_keys($value['tmp_name']) as $fileId) { - $normalized[$key][$fileId] = new UploadedFile( - $value['tmp_name'][$fileId], - (int)$value['size'][$fileId], - (int)$value['error'][$fileId], - $value['name'][$fileId], - $value['type'][$fileId] - ); - } - }else{ - $normalized[$key] = new UploadedFile( - $value['tmp_name'], - (int)$value['size'], - (int)$value['error'], - $value['name'], - $value['type'] - ); - } - } - return $normalized; - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Message; + +use \InitPHP\HTTP\Message\Traits\{MessageTrait, RequestTrait}; +use \Psr\Http\Message\{ServerRequestInterface, UploadedFileInterface, UriInterface}; + +use const PHP_SAPI; + +use function array_keys; +use function array_merge; +use function file_get_contents; +use function function_exists; +use function in_array; +use function is_array; +use function is_object; +use function json_decode; +use function parse_str; +use function str_replace; +use function strpos; +use function strtolower; +use function substr; +use function ucwords; + +/** + * PSR-7 ServerRequestInterface implementation backed by {@see MessageTrait} + * and {@see RequestTrait}. Adds the server-side state PSR-7 attaches to an + * inbound request — server params, cookies, query, parsed body, uploaded + * files and arbitrary attributes — plus a stateless + * {@see ServerRequest::createFromGlobals()} bootstrapper safe to use under + * persistent runtimes (Swoole, RoadRunner, Octane, FrankenPHP). + */ +class ServerRequest implements ServerRequestInterface +{ + + use MessageTrait, RequestTrait; + + protected array $serverParams = []; + + protected array $cookieParams = []; + + protected array $queryParams = []; + + /** @var null|object|array */ + protected $parsedBody = null; + + protected array $attributes = []; + + /** @var UploadedFileInterface[] */ + protected array $uploadedFiles = []; + + + /** + * Deep-clone the body Stream and URI so callers using `with*()` cannot + * mutate the original message via the returned copy. The uploadedFiles + * and attributes arrays are raw PHP arrays — copy-on-write already + * shields them. + * + * @return void + */ + public function __clone() + { + if (isset($this->stream)) { + $this->stream = clone $this->stream; + } + if (isset($this->uri)) { + $this->uri = clone $this->uri; + } + } + + /** + * Build a new server request. + * + * @param string $method HTTP method (case preserved as supplied). + * @param string|UriInterface $uri Target URI as a string or PSR-7 UriInterface. + * @param array $headers Header name => value(s). + * @param string|resource|\Psr\Http\Message\StreamInterface|null $body Request body in any form accepted by {@see Stream}. + * @param string $version HTTP protocol version (e.g. "1.1", "2.0"). + * @param array $serverParams Snapshot of `$_SERVER`-style entries. + * @throws \InvalidArgumentException When $uri is a malformed URI string, or any supplied header is invalid. + */ + public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1', array $serverParams = []) + { + $this->serverParams = $serverParams; + if(!($uri instanceof UriInterface)){ + $uri = new Uri($uri); + } + $this->setUpConstruct($method, $uri, $body, $headers, $version); + } + + /** + * Hydrate a ServerRequest from the PHP superglobals of the current process. + * + * Unlike legacy implementations this method is **stateless**: every call + * returns a fresh instance computed from the supplied (or default) + * superglobal snapshots, making it safe to use under Swoole, RoadRunner, + * Octane, FrankenPHP and similar persistent runtimes. + * + * Body parsing is Content-Type-aware: + * - application/json -> json_decode(..., true) + * - application/x-www-form-urlencoded -> parse_str(...) + * - anything else -> parsedBody left as null and + * the caller can rely on $_POST/$_FILES if appropriate. + * + * Header collection falls back to parsing HTTP_* keys from $_SERVER when + * apache_request_headers() is unavailable (nginx + php-fpm, FrankenPHP). + * + * @param array|null $server Defaults to $_SERVER. + * @param array|null $get Defaults to $_GET. + * @param array|null $post Defaults to $_POST. + * @param array|null $cookies Defaults to $_COOKIE. + * @param array|null $files Defaults to $_FILES. + * @return self + * @throws \InvalidArgumentException When $files contains malformed entries that {@see normalizeFiles()} rejects. + */ + public static function createFromGlobals( + ?array $server = null, + ?array $get = null, + ?array $post = null, + ?array $cookies = null, + ?array $files = null + ): self { + $server = $server ?? $_SERVER; + $get = $get ?? $_GET; + $post = $post ?? $_POST; + $cookies = $cookies ?? $_COOKIE; + $files = $files ?? $_FILES; + + $method = isset($server['REQUEST_METHOD']) ? (string) $server['REQUEST_METHOD'] : 'GET'; + if (PHP_SAPI === 'cli' && !isset($server['REQUEST_METHOD'])) { + $method = 'GET'; + } + + $headers = self::collectRequestHeaders($server); + + $scheme = (!empty($server['HTTPS']) && $server['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = (string) ($server['HTTP_HOST'] ?? $server['SERVER_NAME'] ?? 'localhost'); + $port = isset($server['SERVER_PORT']) ? (int) $server['SERVER_PORT'] : null; + $path = (string) ($server['REQUEST_URI'] ?? '/'); + + // HTTP_HOST may already include the port; only append SERVER_PORT + // when the host header omitted one and the port is non-standard. + $hostHasPort = strpos($host, ':') !== false; + $standardPort = ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443); + if (!$hostHasPort && $port !== null && !$standardPort) { + $host .= ':' . $port; + } + + $uri = $scheme . '://' . $host . $path; + + $rawBody = ''; + $bodyStream = fopen('php://temp', 'w+b'); + if ($bodyStream !== false) { + $input = file_get_contents('php://input'); + if (is_string($input) && $input !== '') { + $rawBody = $input; + fwrite($bodyStream, $rawBody); + } + } + $stream = $bodyStream !== false ? new Stream($bodyStream) : new Stream('', null); + + $protocol = '1.1'; + if (isset($server['SERVER_PROTOCOL']) && strpos((string) $server['SERVER_PROTOCOL'], 'HTTP/') === 0) { + $protocol = substr((string) $server['SERVER_PROTOCOL'], 5); + } + + $request = new self($method, $uri, $headers, $stream, $protocol, $server); + $request = $request + ->withCookieParams($cookies) + ->withQueryParams($get); + if (!empty($files)) { + $request = $request->withUploadedFiles($request->normalizeFiles($files)); + } + + $parsedBody = self::parseRequestBody($headers, $server, $rawBody, $post); + if ($parsedBody !== null) { + $request = $request->withParsedBody($parsedBody); + } + + return $request; + } + + /** + * Collect inbound request headers, preferring apache_request_headers() + * when available and falling back to parsing HTTP_* and CONTENT_* keys + * from the $_SERVER-style snapshot. + * + * @param array $server + * @return array + */ + private static function collectRequestHeaders(array $server): array + { + if (function_exists('apache_request_headers')) { + $apache = apache_request_headers(); + if (is_array($apache) && $apache !== []) { + return $apache; + } + } + $headers = []; + foreach ($server as $key => $value) { + if (!is_string($key)) { + continue; + } + if (strpos($key, 'HTTP_') === 0) { + $name = ucwords(strtolower(str_replace('_', '-', substr($key, 5))), '-'); + $headers[$name] = (string) $value; + continue; + } + if (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true)) { + $name = ucwords(strtolower(str_replace('_', '-', $key)), '-'); + $headers[$name] = (string) $value; + } + } + return $headers; + } + + /** + * Decide what to feed into {@see ServerRequest::withParsedBody()} based + * on the advertised Content-Type. Returns null when the body shouldn't + * be parsed (multipart, unknown types, or empty bodies for non-form + * requests). + * + * @param array $headers + * @param array $server + * @param string $rawBody + * @param array $post + * @return array|null + */ + private static function parseRequestBody(array $headers, array $server, string $rawBody, array $post): ?array + { + $contentType = $server['CONTENT_TYPE'] + ?? $headers['Content-Type'] + ?? $headers['content-type'] + ?? ''; + $contentType = strtolower((string) $contentType); + + if ($contentType === '') { + return null; + } + if (strpos($contentType, 'application/json') !== false && $rawBody !== '') { + $decoded = json_decode($rawBody, true); + return is_array($decoded) ? $decoded : null; + } + if (strpos($contentType, 'application/x-www-form-urlencoded') !== false) { + // PHP already filled $_POST for us when the request reached the + // FPM/CLI server; fall back to parse_str only if it's empty. + if (!empty($post)) { + return $post; + } + if ($rawBody !== '') { + $parsed = []; + parse_str($rawBody, $parsed); + return $parsed; + } + return null; + } + if (strpos($contentType, 'multipart/form-data') !== false && !empty($post)) { + return $post; + } + return null; + } + + /** + * Return the $_SERVER-style snapshot supplied at construction time. + * + * @return array + */ + public function getServerParams(): array + { + return $this->serverParams; + } + + /** + * Return the cookies attached to this request as an associative array. + * + * @return array + */ + public function getCookieParams(): array + { + return $this->cookieParams; + } + + /** + * Replace the cookies array (in-place). + * + * @param array $cookies + * @return $this + */ + public function setCookieParams(array $cookies): self + { + $this->cookieParams = $cookies; + + return $this; + } + + /** + * Return a clone of the request with the cookies replaced. + * + * @param array $cookies + * @return ServerRequest + */ + public function withCookieParams(array $cookies): ServerRequest + { + return (clone $this)->setCookieParams($cookies); + } + + /** + * Return the deserialised query string parameters. + * + * @return array + */ + public function getQueryParams(): array + { + return $this->queryParams; + } + + /** + * Replace the query parameter array (in-place). + * + * @param array $query + * @return $this + */ + public function setQueryParams(array $query): self + { + $this->queryParams = $query; + + return $this; + } + + /** + * Return a clone of the request with the query parameters replaced. + * + * @param array $query + * @return ServerRequest + */ + public function withQueryParams(array $query): ServerRequest + { + return (clone $this)->setQueryParams($query); + } + + /** + * Return the normalised uploaded files tree. + * + * @return array + */ + public function getUploadedFiles(): array + { + return $this->uploadedFiles; + } + + /** + * Replace the uploaded files (in-place), normalising whatever shape the + * caller passed in via {@see ServerRequest::normalizeFiles()}. + * + * @param array $uploadedFiles + * @return $this + * @throws \InvalidArgumentException When a leaf value is neither an UploadedFileInterface nor a valid $_FILES entry. + */ + public function setUploadedFiles(array $uploadedFiles): self + { + $this->uploadedFiles = $this->normalizeFiles($uploadedFiles); + + return $this; + } + + /** + * Return a clone of the request with the uploaded files replaced. + * + * @param array $uploadedFiles + * @return static + * @throws \InvalidArgumentException When a leaf value is neither an UploadedFileInterface nor a valid $_FILES entry. + */ + public function withUploadedFiles(array $uploadedFiles): self + { + return (clone $this)->setUploadedFiles($uploadedFiles); + } + + /** + * Return the parsed body — null, an object, or an array — as supplied + * by a previous call to {@see ServerRequest::withParsedBody()}. + * + * @return null|object|array + */ + public function getParsedBody() + { + return $this->parsedBody; + } + + /** + * Replace the parsed body (in-place). PSR-7 restricts the value to + * null, an array or an object. + * + * @param null|object|array $data + * @return $this + * @throws \InvalidArgumentException When $data is not null, an array or an object. + */ + public function setParsedBody($data): self + { + if(!is_array($data) && !is_object($data) && $data !== null){ + throw new \InvalidArgumentException('First parameter to withParsedBody MUST be object, array or null'); + } + $this->parsedBody = $data; + + return $this; + } + + /** + * Return a clone of the request with the parsed body replaced. + * + * @param null|object|array $data + * @return static + * @throws \InvalidArgumentException When $data is not null, an array or an object. + */ + public function withParsedBody($data): self + { + return (clone $this)->setParsedBody($data); + } + + /** + * Return all request attributes as an associative array. + * + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * Return a single attribute by name, or $default when absent. + * + * @param string $name + * @param mixed $default + * @return mixed + */ + public function getAttribute($name, $default = null) + { + return $this->attributes[$name] ?? $default; + } + + /** + * Set a single attribute (in-place). + * + * @param string $name + * @param mixed $value + * @return $this + */ + public function setAttribute($name, $value): self + { + $this->attributes[$name] = $value; + + return $this; + } + + /** + * Return a clone of the request with the attribute set. + * + * @param string $name + * @param mixed $value + * @return static + */ + public function withAttribute($name, $value): self + { + return (clone $this)->setAttribute($name, $value); + } + + /** + * Remove an attribute (in-place). No-op when the attribute is absent. + * + * @param string $name + * @return $this + */ + public function outAttribute($name): self + { + if (!isset($this->attributes[$name])) { + return $this; + } + unset($this->attributes[$name]); + + return $this; + } + + /** + * Return a clone of the request with the attribute removed. + * + * @param string $name + * @return static + */ + public function withoutAttribute($name): self + { + return (clone $this)->outAttribute($name); + } + + /** + * Convert PHP's $_FILES array (or a tree of pre-built + * UploadedFileInterface instances) into a normalised structure of + * UploadedFileInterface values. Arbitrarily nested input names of the + * form `file[parent][child][...]` are supported by recursing through + * the parallel arrays PHP produces. + * + * @param array $files + * @return array + * @throws \InvalidArgumentException When a leaf value is neither an UploadedFileInterface nor a valid $_FILES entry. + */ + public function normalizeFiles(array $files): array + { + $normalized = []; + foreach ($files as $key => $value) { + if ($value instanceof UploadedFileInterface) { + $normalized[$key] = $value; + continue; + } + if (!is_array($value)) { + throw new \InvalidArgumentException('Invalid value in files specification'); + } + if (isset($value['tmp_name'])) { + $normalized[$key] = self::createUploadedFileFromSpec($value); + continue; + } + $normalized[$key] = $this->normalizeFiles($value); + } + return $normalized; + } + + /** + * Build either a single UploadedFile or a tree of them from a single + * $_FILES entry. Detects nested input names by inspecting whether + * tmp_name is itself an array (PHP's signal for nested uploads). + * + * @param array $value + * @return UploadedFileInterface|array + */ + private static function createUploadedFileFromSpec(array $value) + { + if (is_array($value['tmp_name'])) { + return self::normalizeNestedFileSpec($value); + } + + return new UploadedFile( + $value['tmp_name'], + isset($value['size']) ? (int) $value['size'] : null, + (int) ($value['error'] ?? UPLOAD_ERR_OK), + $value['name'] ?? null, + $value['type'] ?? null + ); + } + + /** + * Walk the parallel arrays PHP populates for nested file uploads + * (`file[a][b]`) and produce a matching UploadedFile tree, recursing + * any additional levels via {@see self::createUploadedFileFromSpec()}. + * + * @param array $files + * @return array + */ + private static function normalizeNestedFileSpec(array $files): array + { + $normalized = []; + foreach (array_keys($files['tmp_name']) as $key) { + $spec = [ + 'tmp_name' => $files['tmp_name'][$key] ?? '', + 'size' => $files['size'][$key] ?? null, + 'error' => $files['error'][$key] ?? UPLOAD_ERR_OK, + 'name' => $files['name'][$key] ?? null, + 'type' => $files['type'][$key] ?? null, + ]; + $normalized[$key] = self::createUploadedFileFromSpec($spec); + } + return $normalized; + } + +} diff --git a/src/Message/Stream.php b/src/Message/Stream.php index a95d184..38b1ac3 100644 --- a/src/Message/Stream.php +++ b/src/Message/Stream.php @@ -1,555 +1,692 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Message; - -use \InitPHP\HTTP\Message\Interfaces\StreamInterface; -use \Throwable; -use \RuntimeException; -use \InvalidArgumentException; - -use const SEEK_SET; -use const SEEK_CUR; -use const SEEK_END; - -use function is_string; -use function in_array; -use function is_scalar; -use function is_resource; -use function substr; -use function strlen; -use function error_get_last; -use function var_export; -use function stream_get_meta_data; -use function stream_get_contents; -use function fopen; -use function fwrite; -use function fclose; -use function fstat; -use function ftell; -use function feof; -use function rewind; -use function fseek; -use function fread; -use function clearstatcache; - -class Stream implements StreamInterface -{ - /** - * ["php://temp"|"php://memory"|NULL] - * - * @var string|null - */ - protected ?string $target = 'php://temp'; - - /** @var resource|string */ - private $stream; - - protected bool $seekable = false; - - protected bool $readable = false; - - protected bool $writable = false; - - /** @var string|false|null false = aranıp bulunamadı, null = henüz aranmadı */ - protected $uri = null; - - protected ?int $size = null; - - protected int $seek = 0; - - protected const READ_WRITE_HASH = [ - 'read' => [ - 'r' => true, - 'w+' => true, - 'r+' => true, - 'x+' => true, - 'c+' => true, - 'rb' => true, - 'w+b' => true, - 'r+b' => true, - 'x+b' => true, - 'c+b' => true, - 'rt' => true, - 'w+t' => true, - 'r+t' => true, - 'x+t' => true, - 'c+t' => true, - 'a+' => true, - ], - 'write' => [ - 'w' => true, - 'w+' => true, - 'rw' => true, - 'r+' => true, - 'x+' => true, - 'c+' => true, - 'wb' => true, - 'w+b' => true, - 'r+b' => true, - 'x+b' => true, - 'c+b' => true, - 'w+t' => true, - 'r+t' => true, - 'x+t' => true, - 'c+t' => true, - 'a' => true, - 'a+' => true, - ], - ]; - - public function __construct($body = '', ?string $target = null) - { - $this->init($body, $target); - } - - /** - * @inheritDoc - */ - public function __toString() - { - if($this->isSeekable()){ - $this->seek(0); - } - return $this->getContents(); - } - - /** - * @inheritDoc - */ - public function __destruct() - { - $this->close(); - } - - /** - * PSR-7 immutability: clone yapıldığında resource'u derinleştir; aksi halde - * iki Stream aynı resource handle'ını paylaşır, withBody dışı tüm withX - * çağrıları orijinal mesajın body'sini de değiştirir. - */ - public function __clone() - { - if (!isset($this->stream)) { - return; - } - $this->uri = null; - if (is_string($this->stream)) { - // String backend zaten PHP copy-on-write; ek iş gerekmiyor. - return; - } - if (!is_resource($this->stream)) { - return; - } - $originalPosition = @ftell($this->stream); - if ($this->seekable) { - @rewind($this->stream); - } - $contents = @stream_get_contents($this->stream); - if ($contents === false) { - $contents = ''; - } - if ($this->seekable && is_int($originalPosition)) { - @fseek($this->stream, $originalPosition); - } - $copy = @fopen('php://temp', 'w+b'); - if ($copy === false) { - return; - } - if ($contents !== '') { - fwrite($copy, $contents); - } - // Orijinal stream'in pozisyonunu koru - if (is_int($originalPosition)) { - fseek($copy, $originalPosition); - } else { - rewind($copy); - } - $this->stream = $copy; - $this->size = strlen($contents); - $this->seekable = true; - $this->readable = true; - $this->writable = true; - } - - /** - * @param null|string|resource|StreamInterface $body - * @param string|null $target

["php://temp"|"php://memory"|NULL]

- * @return StreamInterface - * @throws InvalidArgumentException - */ - public function init($body = null, ?string $target = 'php://temp'): StreamInterface - { - if(in_array($target, ['php://temp', 'php://memory', null], true) === FALSE){ - throw new InvalidArgumentException('The target for the stream can only be "php://temp", "php://memory" or NULL.'); - } - $this->target = $target; - if($body === null){ - $body = ''; - } - if($body instanceof StreamInterface){ - if($body->isSeekable()){ - $body->rewind(); - } - $body = $body->getContents(); - } - // Resource'lar target'tan bağımsız her zaman kabul edilir - if(is_resource($body)){ - $this->stream = $body; - $meta = stream_get_meta_data($this->stream); - $this->seekable = $meta['seekable'] && fseek($this->stream, 0, SEEK_CUR) === 0; - $this->readable = isset(self::READ_WRITE_HASH['read'][$meta['mode']]); - $this->writable = isset(self::READ_WRITE_HASH['write'][$meta['mode']]); - return $this; - } - if($this->target === null){ - if(!is_scalar($body)){ - throw new InvalidArgumentException("The parameter \$body must be a string."); - } - $this->stream = (string)$body; - $this->seek = 0; - $this->size = strlen($this->stream); - $this->readable = true; - $this->writable = true; - $this->seekable = true; - return $this; - } - if(is_string($body)){ - $resource = @fopen($this->target, 'w+b'); - if($resource === false){ - throw new RuntimeException(sprintf('Unable to open stream "%s": %s', $this->target, error_get_last()['message'] ?? '')); - } - if($body !== ''){ - fwrite($resource, $body); - fseek($resource, 0); - } - $this->stream = $resource; - $meta = stream_get_meta_data($this->stream); - $this->seekable = $meta['seekable'] && fseek($this->stream, 0, SEEK_CUR) === 0; - $this->readable = isset(self::READ_WRITE_HASH['read'][$meta['mode']]); - $this->writable = isset(self::READ_WRITE_HASH['write'][$meta['mode']]); - return $this; - } - throw new InvalidArgumentException("The parameter \$body must be a string or a resource."); - } - - /** - * @inheritDoc - */ - public function close() - { - if(isset($this->stream)){ - if(is_resource($this->stream)){ - fclose($this->stream); - } - unset($this->stream); - $this->size = null; - $this->uri = null; - $this->readable = false; - $this->writable = false; - $this->seekable = false; - } - } - - /** - * @inheritDoc - */ - public function detach() - { - if(!isset($this->stream)){ - return null; - } - $res = $this->stream; - unset($this->stream); - $this->size = null; - $this->uri = null; - $this->readable = false; - $this->writable = false; - $this->seekable = false; - return is_string($res) ? $this->str2resorce($res) : $res; - } - - /** - * @inheritDoc - */ - public function getSize(): ?int - { - if($this->size !== null){ - return $this->size; - } - if(!isset($this->stream)){ - return null; - } - if(is_string($this->stream)){ - return $this->size = strlen($this->stream); - } - if(($uri = $this->getUri())){ - clearstatcache(true, $uri); - } - $stats = fstat($this->stream); - if(isset($stats['size'])){ - return $this->size = $stats['size']; - } - return null; - } - - /** - * @inheritDoc - */ - public function tell(): int - { - if(!isset($this->stream)){ - throw new RuntimeException('Stream is detached'); - } - if(is_string($this->stream)){ - return $this->seek; - } - if(($result = @ftell($this->stream)) === FALSE){ - throw new RuntimeException('Unable to determine stream position: ' . (error_get_last()['message'] ?? '')); - } - return $result; - } - - /** - * @inheritDoc - */ - public function eof(): bool - { - if(!isset($this->stream)){ - return false; - } - if(is_string($this->stream)){ - if($this->size === null){ - $this->size = strlen($this->stream); - } - return $this->size <= $this->seek; - } - return feof($this->stream); - } - - /** - * @inheritDoc - */ - public function isSeekable(): bool - { - return $this->seekable; - } - - /** - * @inheritDoc - */ - public function seek($offset, $whence = SEEK_SET) - { - if(!isset($this->stream)){ - throw new RuntimeException('Stream is detached'); - } - if(!$this->seekable){ - throw new RuntimeException('Stream is not seekable'); - } - if(is_string($this->stream)){ - $this->str_seek($offset, $whence); - return; - } - if(-1 === fseek($this->stream, $offset, $whence)){ - throw new RuntimeException('Unable to seek to stream position "'.$offset.'" with whence ' . var_export($whence, true)); - } - } - - /** - * @inheritDoc - */ - public function rewind() - { - $this->seek(0); - } - - /** - * @inheritDoc - */ - public function isWritable(): bool - { - return $this->writable; - } - - /** - * @inheritDoc - */ - public function write($string): int - { - if(!isset($this->stream)){ - throw new RuntimeException('Stream is detached'); - } - if(!$this->writable){ - throw new RuntimeException('Cannot write to a non-writable stream'); - } - if(!is_string($string)){ - throw new InvalidArgumentException('Only strings can be written to the stream.'); - } - if(is_string($this->stream)){ - if($this->size === null){ - $this->size = strlen($this->stream); - } - if($this->seek === 0){ - $this->stream = $string . $this->stream; - }elseif($this->seek >= $this->size){ - $this->stream .= $string; - }else{ - $stream = $this->stream; - $this->stream = substr($stream, 0, $this->seek) - . $string - . substr($stream, $this->seek); - } - $size = strlen($string); - $this->size += $size; - return $size; - } - $this->size = null; - if(($result = @fwrite($this->stream, $string)) === FALSE){ - throw new RuntimeException('Unable to write to stream: ' . (error_get_last()['message'] ?? '')); - } - return $result; - } - - /** - * @inheritDoc - */ - public function isReadable(): bool - { - return $this->readable; - } - - /** - * @inheritDoc - */ - public function read($length): string - { - if(!isset($this->stream)){ - throw new RuntimeException('Stream is detached'); - } - if(!$this->readable){ - throw new RuntimeException('Cannot read from non-readable stream'); - } - if(is_string($this->stream)){ - $res = substr($this->stream, $this->seek, $length); - $this->seek += $length; - return $res; - } - if(($result = @fread($this->stream, $length)) === FALSE){ - throw new RuntimeException('Unable to read from stream: ' . (error_get_last()['message'] ?? '')); - } - return $result; - } - - /** - * @inheritDoc - */ - public function getContents(): string - { - if(!isset($this->stream)){ - throw new RuntimeException('Stream is detached'); - } - if(is_string($this->stream)){ - return $this->stream; - } - if(($res = @stream_get_contents($this->stream)) === FALSE){ - throw new RuntimeException('Unable to read stream content: ' . (error_get_last()['message'] ?? '')); - } - return $res; - } - - /** - * @inheritDoc - */ - public function getMetadata($key = null) - { - if(!isset($this->stream)){ - return $key ? null : []; - } - if(is_string($this->stream)){ - $data = [ - 'uri' => null, - 'seekable' => false, - 'eof' => $this->size === $this->seek - ]; - }else{ - $data = stream_get_meta_data($this->stream); - } - if($key === null){ - return $data; - } - return $data[$key] ?? null; - } - - /** - * @inheritDoc - */ - public function isEmpty(): bool - { - return $this->getSize() < 1; - } - - /** - * @inheritDoc - */ - public function isNotEmpty(): bool - { - return $this->getSize() > 0; - } - - protected function getUri() - { - if($this->uri === null){ - $this->uri = $this->getMetadata('uri') ?? false; - } - return $this->uri === false ? null : $this->uri; - } - - /** - * @param string $string - * @return resource - */ - private function str2resorce(string $string) - { - $stream = fopen('php://memory', 'r+'); - fwrite($stream, $string); - rewind($stream); - return $stream; - } - - private function str_seek($offset, $whence = SEEK_SET) - { - if($this->size === null){ - $this->size = strlen($this->stream); - } - if($whence === SEEK_SET){ - if($offset > $this->size){ - $offset = $this->size; - } - $this->seek = $offset; - return; - } - if($whence === SEEK_CUR){ - $this->seek += $offset; - } - if($whence === SEEK_END){ - $this->seek = $this->size + $offset; - } - if($this->seek < 0){ - $this->seek = 0; - }elseif($this->seek > $this->size){ - $this->seek = $this->size; - } - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Message; + +use \Psr\Http\Message\StreamInterface; +use \Throwable; +use \RuntimeException; +use \InvalidArgumentException; + +use const SEEK_SET; +use const SEEK_CUR; +use const SEEK_END; + +use function is_string; +use function in_array; +use function is_scalar; +use function is_resource; +use function substr; +use function strlen; +use function error_get_last; +use function var_export; +use function stream_get_meta_data; +use function stream_get_contents; +use function fopen; +use function fwrite; +use function fclose; +use function fstat; +use function ftell; +use function feof; +use function rewind; +use function fseek; +use function fread; +use function clearstatcache; + +/** + * PSR-7 StreamInterface implementation supporting three backends in a + * single class: a real PHP resource handle (php://temp, files, sockets, + * php://memory) or a plain in-memory string when constructed with a null + * target. The string backend keeps small bodies allocation-free while + * still honouring the full seek/read/write contract. + */ +class Stream implements StreamInterface +{ + /** + * ["php://temp"|"php://memory"|NULL] + * + * @var string|null + */ + protected ?string $target = 'php://temp'; + + /** @var resource|string */ + private $stream; + + protected bool $seekable = false; + + protected bool $readable = false; + + protected bool $writable = false; + + /** @var string|false|null false = looked up and unavailable, null = not yet looked up */ + protected $uri = null; + + protected ?int $size = null; + + protected int $seek = 0; + + protected const READ_WRITE_HASH = [ + 'read' => [ + 'r' => true, + 'w+' => true, + 'r+' => true, + 'x+' => true, + 'c+' => true, + 'rb' => true, + 'w+b' => true, + 'r+b' => true, + 'x+b' => true, + 'c+b' => true, + 'rt' => true, + 'w+t' => true, + 'r+t' => true, + 'x+t' => true, + 'c+t' => true, + 'a+' => true, + ], + 'write' => [ + 'w' => true, + 'w+' => true, + 'rw' => true, + 'r+' => true, + 'x+' => true, + 'c+' => true, + 'wb' => true, + 'w+b' => true, + 'r+b' => true, + 'x+b' => true, + 'c+b' => true, + 'w+t' => true, + 'r+t' => true, + 'x+t' => true, + 'c+t' => true, + 'a' => true, + 'a+' => true, + ], + ]; + + /** + * Build a stream from a string, resource, StreamInterface or null body. + * + * @param null|string|resource|StreamInterface $body Initial body contents. + * @param string|null $target Backing store: "php://temp", "php://memory" or NULL for an in-memory string backend. + * @throws InvalidArgumentException When $target is invalid or $body cannot be coerced. + * @throws RuntimeException When the underlying php:// stream cannot be opened. + */ + public function __construct($body = '', ?string $target = null) + { + $this->init($body, $target); + } + + /** + * Read the entire stream into a string. PSR-7 forbids __toString from + * raising exceptions: any error reading the underlying stream is + * swallowed and an empty string is returned, mirroring the behaviour + * of file_get_contents() failure modes. + * + * @return string + */ + public function __toString(): string + { + try { + if ($this->isSeekable()) { + $this->seek(0); + } + return $this->getContents(); + } catch (\Throwable $e) { + return ''; + } + } + + /** + * Close the underlying handle (if any) when the stream goes out of scope. + * + * @return void + */ + public function __destruct() + { + $this->close(); + } + + /** + * Deep-clone the underlying resource so the original and the clone do + * not share a single handle. Without this, every PSR-7 `with*()` call + * would mutate the body of the original message because both Stream + * instances point at the same resource. + * + * String backends are exempt — PHP's copy-on-write already isolates + * them at the language level. + * + * @return void + */ + public function __clone() + { + if (!isset($this->stream)) { + return; + } + $this->uri = null; + if (is_string($this->stream)) { + // String backend is already PHP copy-on-write; nothing to do. + return; + } + if (!is_resource($this->stream)) { + return; + } + $originalPosition = @ftell($this->stream); + if ($this->seekable) { + @rewind($this->stream); + } + $contents = @stream_get_contents($this->stream); + if ($contents === false) { + $contents = ''; + } + if ($this->seekable && is_int($originalPosition)) { + @fseek($this->stream, $originalPosition); + } + $copy = @fopen('php://temp', 'w+b'); + if ($copy === false) { + return; + } + if ($contents !== '') { + fwrite($copy, $contents); + } + // Preserve the original stream's cursor position. + if (is_int($originalPosition)) { + fseek($copy, $originalPosition); + } else { + rewind($copy); + } + $this->stream = $copy; + $this->size = strlen($contents); + $this->seekable = true; + $this->readable = true; + $this->writable = true; + } + + /** + * (Re)initialise the stream against a new body/target pair. Called by + * the constructor; can also be used to re-seat an existing instance. + * + * @param null|string|resource|StreamInterface $body + * @param string|null $target ["php://temp"|"php://memory"|NULL] + * @return StreamInterface + * @throws InvalidArgumentException When $target is invalid, or $body cannot be coerced to a string for the in-memory backend. + * @throws RuntimeException When the underlying php:// stream cannot be opened. + */ + public function init($body = null, ?string $target = 'php://temp'): StreamInterface + { + if(in_array($target, ['php://temp', 'php://memory', null], true) === FALSE){ + throw new InvalidArgumentException('The target for the stream can only be "php://temp", "php://memory" or NULL.'); + } + $this->target = $target; + if($body === null){ + $body = ''; + } + if($body instanceof StreamInterface){ + if($body->isSeekable()){ + $body->rewind(); + } + $body = $body->getContents(); + } + // Resources are always accepted regardless of $target. + if(is_resource($body)){ + $this->stream = $body; + $meta = stream_get_meta_data($this->stream); + $this->seekable = $meta['seekable'] && fseek($this->stream, 0, SEEK_CUR) === 0; + $this->readable = isset(self::READ_WRITE_HASH['read'][$meta['mode']]); + $this->writable = isset(self::READ_WRITE_HASH['write'][$meta['mode']]); + return $this; + } + if($this->target === null){ + if(!is_scalar($body)){ + throw new InvalidArgumentException("The parameter \$body must be a string."); + } + $this->stream = (string)$body; + $this->seek = 0; + $this->size = strlen($this->stream); + $this->readable = true; + $this->writable = true; + $this->seekable = true; + return $this; + } + if(is_string($body)){ + $resource = @fopen($this->target, 'w+b'); + if($resource === false){ + throw new RuntimeException(sprintf('Unable to open stream "%s": %s', $this->target, error_get_last()['message'] ?? '')); + } + if($body !== ''){ + fwrite($resource, $body); + fseek($resource, 0); + } + $this->stream = $resource; + $meta = stream_get_meta_data($this->stream); + $this->seekable = $meta['seekable'] && fseek($this->stream, 0, SEEK_CUR) === 0; + $this->readable = isset(self::READ_WRITE_HASH['read'][$meta['mode']]); + $this->writable = isset(self::READ_WRITE_HASH['write'][$meta['mode']]); + return $this; + } + throw new InvalidArgumentException("The parameter \$body must be a string or a resource."); + } + + /** + * Close the stream and release the underlying handle. Subsequent reads + * or writes will raise RuntimeException. + * + * @return void + */ + public function close() + { + if(isset($this->stream)){ + if(is_resource($this->stream)){ + fclose($this->stream); + } + unset($this->stream); + $this->size = null; + $this->uri = null; + $this->readable = false; + $this->writable = false; + $this->seekable = false; + } + } + + /** + * Detach the underlying handle from this Stream and return it. The + * Stream is left in an unusable state; the string backend is + * materialised into a php://memory resource before being returned so + * the caller always receives a resource handle. + * + * @return resource|null + * @throws RuntimeException When the string backend cannot be materialised. + */ + public function detach() + { + if(!isset($this->stream)){ + return null; + } + $res = $this->stream; + unset($this->stream); + $this->size = null; + $this->uri = null; + $this->readable = false; + $this->writable = false; + $this->seekable = false; + return is_string($res) ? $this->stringToResource($res) : $res; + } + + /** + * Return the body size in bytes, or null when the size is unknown + * (e.g. non-seekable resources without a usable fstat() entry). + * + * @return int|null + */ + public function getSize(): ?int + { + if($this->size !== null){ + return $this->size; + } + if(!isset($this->stream)){ + return null; + } + if(is_string($this->stream)){ + return $this->size = strlen($this->stream); + } + if(($uri = $this->getUri())){ + clearstatcache(true, $uri); + } + $stats = fstat($this->stream); + if(isset($stats['size'])){ + return $this->size = $stats['size']; + } + return null; + } + + /** + * Return the current cursor position in the stream. + * + * @return int + * @throws RuntimeException When the stream is detached or ftell() fails. + */ + public function tell(): int + { + if(!isset($this->stream)){ + throw new RuntimeException('Stream is detached'); + } + if(is_string($this->stream)){ + return $this->seek; + } + if(($result = @ftell($this->stream)) === FALSE){ + throw new RuntimeException('Unable to determine stream position: ' . (error_get_last()['message'] ?? '')); + } + return $result; + } + + /** + * True when the cursor is at end-of-stream. Returns false for detached + * streams so callers cannot accidentally treat detachment as EOF. + * + * @return bool + */ + public function eof(): bool + { + if(!isset($this->stream)){ + return false; + } + if(is_string($this->stream)){ + if($this->size === null){ + $this->size = strlen($this->stream); + } + return $this->size <= $this->seek; + } + return feof($this->stream); + } + + /** + * True when the stream supports random-access seeking. + * + * @return bool + */ + public function isSeekable(): bool + { + return $this->seekable; + } + + /** + * Move the cursor to $offset using the supplied $whence (SEEK_SET, + * SEEK_CUR or SEEK_END). + * + * @param int $offset + * @param int $whence + * @return void + * @throws RuntimeException When the stream is detached, not seekable, or fseek() fails. + */ + public function seek($offset, $whence = SEEK_SET) + { + if(!isset($this->stream)){ + throw new RuntimeException('Stream is detached'); + } + if(!$this->seekable){ + throw new RuntimeException('Stream is not seekable'); + } + if(is_string($this->stream)){ + $this->str_seek($offset, $whence); + return; + } + if(-1 === fseek($this->stream, $offset, $whence)){ + throw new RuntimeException('Unable to seek to stream position "'.$offset.'" with whence ' . var_export($whence, true)); + } + } + + /** + * Move the cursor back to the start of the stream. + * + * @return void + * @throws RuntimeException When the stream is detached or not seekable. + */ + public function rewind() + { + $this->seek(0); + } + + /** + * True when the stream supports being written to. + * + * @return bool + */ + public function isWritable(): bool + { + return $this->writable; + } + + /** + * Write $string at the current cursor position and return the number of + * bytes written. The in-memory string backend mirrors fwrite() semantics: + * appending past EOF extends the buffer; writing in the middle overwrites + * the slice in place. + * + * @param string $string + * @return int + * @throws RuntimeException When the stream is detached, not writable, or the underlying fwrite() fails. + * @throws InvalidArgumentException When $string is not a string. + */ + public function write($string): int + { + if(!isset($this->stream)){ + throw new RuntimeException('Stream is detached'); + } + if(!$this->writable){ + throw new RuntimeException('Cannot write to a non-writable stream'); + } + if(!is_string($string)){ + throw new InvalidArgumentException('Only strings can be written to the stream.'); + } + if (is_string($this->stream)) { + if ($this->size === null) { + $this->size = strlen($this->stream); + } + $written = strlen($string); + if ($this->seek >= $this->size) { + // Append past EOF; fseek-style POSIX semantics would zero-pad, + // but PSR-7 callers never rely on that and PHP's fwrite on a + // text stream simply extends — match that. + $this->stream .= $string; + } else { + // Overwrite from the current position, exactly like fwrite() + // on a seekable real stream. + $this->stream = substr($this->stream, 0, $this->seek) + . $string + . substr($this->stream, $this->seek + $written); + } + $this->seek += $written; + $this->size = strlen($this->stream); + return $written; + } + $this->size = null; + if(($result = @fwrite($this->stream, $string)) === FALSE){ + throw new RuntimeException('Unable to write to stream: ' . (error_get_last()['message'] ?? '')); + } + return $result; + } + + /** + * True when the stream supports being read from. + * + * @return bool + */ + public function isReadable(): bool + { + return $this->readable; + } + + /** + * Read up to $length bytes from the stream and advance the cursor by + * the number of bytes actually returned. + * + * @param int $length + * @return string + * @throws RuntimeException When the stream is detached, not readable, or fread() fails. + */ + public function read($length): string + { + if(!isset($this->stream)){ + throw new RuntimeException('Stream is detached'); + } + if(!$this->readable){ + throw new RuntimeException('Cannot read from non-readable stream'); + } + if(is_string($this->stream)){ + $res = substr($this->stream, $this->seek, $length); + $this->seek += $length; + return $res; + } + if(($result = @fread($this->stream, $length)) === FALSE){ + throw new RuntimeException('Unable to read from stream: ' . (error_get_last()['message'] ?? '')); + } + return $result; + } + + /** + * Read everything from the current cursor position to end-of-stream + * and return it as a string. + * + * @return string + * @throws RuntimeException When the stream is detached or stream_get_contents() fails. + */ + public function getContents(): string + { + if(!isset($this->stream)){ + throw new RuntimeException('Stream is detached'); + } + if(is_string($this->stream)){ + return $this->stream; + } + if(($res = @stream_get_contents($this->stream)) === FALSE){ + throw new RuntimeException('Unable to read stream content: ' . (error_get_last()['message'] ?? '')); + } + return $res; + } + + /** + * Return stream metadata. With no key, returns the entire metadata + * array; with a key, returns that single entry or null when absent. + * + * @param string|null $key + * @return mixed + */ + public function getMetadata($key = null) + { + if(!isset($this->stream)){ + return $key ? null : []; + } + if(is_string($this->stream)){ + $data = [ + 'uri' => null, + 'seekable' => false, + 'eof' => $this->size === $this->seek + ]; + }else{ + $data = stream_get_meta_data($this->stream); + } + if($key === null){ + return $data; + } + return $data[$key] ?? null; + } + + /** + * Returns true only when the underlying stream is known to contain zero + * bytes. A size of null (pipes, sockets, on-the-fly responses) is treated + * as "indeterminate" — both isEmpty() and isNotEmpty() return false in + * that case so callers can branch defensively. + * + * @return bool + */ + public function isEmpty(): bool + { + $size = $this->getSize(); + return $size !== null && $size < 1; + } + + /** + * Counterpart of {@see Stream::isEmpty()}: only returns true when the + * size is known and strictly positive. + * + * @return bool + */ + public function isNotEmpty(): bool + { + $size = $this->getSize(); + return $size !== null && $size > 0; + } + + /** + * Return the resource's URI (file path, php:// stream name, ...) when + * available, caching the result so getMetadata() is only consulted + * once per stream. + * + * @return string|null + */ + protected function getUri() + { + if($this->uri === null){ + $this->uri = $this->getMetadata('uri') ?? false; + } + return $this->uri === false ? null : $this->uri; + } + + /** + * Materialise the in-memory string backend into a real php://memory + * resource handle when the caller asks to detach. The cursor of the new + * handle is preserved at the same offset as the in-memory cursor (the + * caller observes the stream they were already using, just as a resource). + * + * @param string $string + * @return resource + * @throws RuntimeException When php://memory cannot be opened. + */ + private function stringToResource(string $string) + { + $stream = fopen('php://memory', 'r+b'); + if ($stream === false) { + throw new RuntimeException('Unable to open php://memory for stream detach.'); + } + if ($string !== '') { + fwrite($stream, $string); + } + $position = $this->seek; + if ($position < 0) { + $position = 0; + } + $length = strlen($string); + if ($position > $length) { + $position = $length; + } + fseek($stream, $position); + return $stream; + } + + /** + * fseek()-compatible seek implementation for the in-memory string + * backend. Honours SEEK_SET / SEEK_CUR / SEEK_END and clamps the + * resulting cursor into the [0, size] range. + * + * @param int $offset + * @param int $whence + * @return void + */ + private function str_seek($offset, $whence = SEEK_SET) + { + if($this->size === null){ + $this->size = strlen($this->stream); + } + if($whence === SEEK_SET){ + if($offset > $this->size){ + $offset = $this->size; + } + $this->seek = $offset; + return; + } + if($whence === SEEK_CUR){ + $this->seek += $offset; + } + if($whence === SEEK_END){ + $this->seek = $this->size + $offset; + } + if($this->seek < 0){ + $this->seek = 0; + }elseif($this->seek > $this->size){ + $this->seek = $this->size; + } + } + +} diff --git a/src/Message/Traits/MessageTrait.php b/src/Message/Traits/MessageTrait.php index 6157351..2375d62 100644 --- a/src/Message/Traits/MessageTrait.php +++ b/src/Message/Traits/MessageTrait.php @@ -1,264 +1,359 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - - -namespace InitPHP\HTTP\Message\Traits; - -use InitPHP\HTTP\Message\Stream; -use Psr\Http\Message\StreamInterface; - -use function array_merge; -use function implode; -use function is_array; -use function is_numeric; -use function is_string; -use function preg_match; -use function strtolower; -use function trim; - -trait MessageTrait -{ - - protected array $headers = []; - - protected array $headerNames = []; - - protected string $protocol = '1.1'; - - protected StreamInterface $stream; - - /** - * @inheritDoc - */ - public function getProtocolVersion(): string - { - return $this->protocol; - } - - /** - * @inheritDoc - */ - public function setProtocolVersion(string $version): self - { - if ($this->protocol === $version) { - return $this; - } - $this->protocol = $version; - - return $this; - } - - /** - * @inheritDoc - */ - public function withProtocolVersion($version): self - { - return (clone $this)->setProtocolVersion($version); - } - - /** - * @inheritDoc - */ - public function getHeaders(): array - { - return $this->headers; - } - - /** - * @inheritDoc - */ - public function hasHeader($name): bool - { - return isset($this->headerNames[strtolower($name)]); - } - - /** - * @inheritDoc - */ - public function getHeader($name) - { - $lowercase = strtolower($name); - $name = $this->headerNames[$lowercase] ?? $name; - return $this->headers[$name] ?? []; - } - - /** - * @inheritDoc - */ - public function getHeaderLine($name): string - { - return implode(', ', $this->getHeader($name)); - } - - /** - * @inheritDoc - */ - public function setHeader($name, $value): self - { - $value = $this->validateAndTrimHeader($name, $value); - $lowercase = strtolower($name); - $this->headerNames[$lowercase] = $name; - $this->headers[$name] = $value; - - return $this; - } - - /** - * @inheritDoc - */ - public function withHeader($name, $value): self - { - return (clone $this)->setHeader($name, $value); - } - - /** - * @inheritDoc - */ - public function addedHeader($name, $value): self - { - if (!is_string($name) || empty($name)) { - throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.'); - } - $this->setHeaders([$name => $value]); - - return $this; - } - - /** - * @inheritDoc - */ - public function withAddedHeader($name, $value): self - { - return (clone $this)->addedHeader($name, $value); - } - - /** - * @inheritDoc - */ - public function outHeader($name): self - { - $lowercase = strtolower($name); - if (!isset($this->headerNames[$lowercase])) { - return $this; - } - - $original = $this->headerNames[$lowercase]; - unset($this->headers[$original], $this->headerNames[$lowercase]); - - return $this; - } - - /** - * @inheritDoc - */ - public function withoutHeader($name): self - { - return (clone $this)->outHeader($name); - } - - /** - * @inheritDoc - */ - public function getBody(): StreamInterface - { - if(!isset($this->stream)){ - $this->stream = new Stream(''); - } - return $this->stream; - } - - /** - * @inheritDoc - */ - public function setBody(StreamInterface $body): self - { - if (isset($this->stream) && $body === $this->stream) { - return $this; - } - $this->stream = $body; - - return $this; - } - - /** - * @inheritDoc - */ - public function withBody(StreamInterface $body): self - { - return (clone $this)->setBody($body); - } - - /** - * @inheritDoc - */ - public function setHeaders(array $headers): self - { - foreach ($headers as $key => $value) { - $name = (string)$key; - $value = $this->validateAndTrimHeader($name, $value); - $lowercase = strtolower($name); - if(isset($this->headerNames[$lowercase])){ - $header = $this->headerNames[$lowercase]; - $this->headers[$header] = array_merge($this->headers[$header], $value); - continue; - } - $this->headerNames[$lowercase] = $name; - $this->headers[$name] = $value; - } - - return $this; - } - - /** - * @inheritDoc - */ - public function isEmpty(): bool - { - return $this->getBody()->getSize() < 1; - } - - /** - * @inheritDoc - */ - public function isNotEmpty(): bool - { - return $this->getBody()->getSize() > 0; - } - - protected function validateAndTrimHeader($header, $values): array - { - if(!is_string($header) || (bool)preg_match("@^[!#$%&'*+.^_`|~0-9A-Za-z-]+$@", $header) === FALSE){ - throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.'); - } - if(!is_array($values)){ - if((!is_numeric($values) && !is_string($values)) || ((bool)preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string)$values)) === FALSE){ - throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings.'); - } - return [trim((string)$values, " \t")]; - } - if(empty($values)){ - throw new \InvalidArgumentException('Header values must be a string or an array of strings, empty array given.'); - } - $res = []; - foreach ($values as $value) { - if((!is_numeric($value) && !is_string($value)) || (bool)preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string)$value) === FALSE){ - throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings.'); - } - $res[] = trim((string)$value, " \t"); - } - return $res; - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + + +namespace InitPHP\HTTP\Message\Traits; + +use InitPHP\HTTP\Message\Stream; +use Psr\Http\Message\StreamInterface; + +use function array_merge; +use function implode; +use function is_array; +use function is_numeric; +use function is_string; +use function preg_match; +use function strtolower; +use function trim; + +/** + * Shared PSR-7 MessageInterface plumbing reused by Request, Response and + * ServerRequest. Owns the protocol version, the case-insensitive header bag + * and the body Stream, and ships both immutable PSR-7 `with*()` operations + * and in-place `set*()` mutators used internally during construction. + */ +trait MessageTrait +{ + + protected array $headers = []; + + protected array $headerNames = []; + + protected string $protocol = '1.1'; + + protected StreamInterface $stream; + + /** + * Return the HTTP protocol version (e.g. "1.1", "2.0"). + * + * @return string + */ + public function getProtocolVersion(): string + { + return $this->protocol; + } + + /** + * Replace the protocol version (in-place). No-ops when the value is + * already current to avoid spurious mutations. + * + * @param string $version + * @return $this + */ + public function setProtocolVersion(string $version): self + { + if ($this->protocol === $version) { + return $this; + } + $this->protocol = $version; + + return $this; + } + + /** + * Return a clone of the message with the protocol version replaced. + * + * @param string $version + * @return static + */ + public function withProtocolVersion($version): self + { + return (clone $this)->setProtocolVersion($version); + } + + /** + * Return all headers as an associative array of name => list-of-values. + * Names are returned in the original case supplied by the caller. + * + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * True when a header with the given (case-insensitive) name exists. + * + * @param string $name + * @return bool + */ + public function hasHeader($name): bool + { + return isset($this->headerNames[strtolower($name)]); + } + + /** + * Return all values for the given header (case-insensitive lookup), or + * an empty array when the header is not present. + * + * @param string $name + * @return string[] + */ + public function getHeader($name) + { + $lowercase = strtolower($name); + $name = $this->headerNames[$lowercase] ?? $name; + return $this->headers[$name] ?? []; + } + + /** + * Return the header values joined by ", " — the canonical "header line" + * representation per RFC 7230. + * + * @param string $name + * @return string + */ + public function getHeaderLine($name): string + { + return implode(', ', $this->getHeader($name)); + } + + /** + * Replace (or create) a header (in-place). Existing values for the same + * name are dropped; the supplied value(s) are validated against RFC 7230 + * before being stored. + * + * @param string $name + * @param string|string[] $value + * @return $this + * @throws \InvalidArgumentException When the header name or any value is invalid. + */ + public function setHeader($name, $value): self + { + $value = $this->validateAndTrimHeader($name, $value); + $lowercase = strtolower($name); + $this->headerNames[$lowercase] = $name; + $this->headers[$name] = $value; + + return $this; + } + + /** + * Return a clone of the message with the given header replaced. + * + * @param string $name + * @param string|string[] $value + * @return static + * @throws \InvalidArgumentException When the header name or any value is invalid. + */ + public function withHeader($name, $value): self + { + return (clone $this)->setHeader($name, $value); + } + + /** + * Append the value(s) to an existing header (in-place), preserving any + * already-stored values. Creates the header when absent. + * + * @param string $name + * @param string|string[] $value + * @return $this + * @throws \InvalidArgumentException When the header name is empty or values are invalid. + */ + public function addedHeader($name, $value): self + { + if (!is_string($name) || empty($name)) { + throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.'); + } + $this->setHeaders([$name => $value]); + + return $this; + } + + /** + * Return a clone of the message with the value(s) appended to the + * existing header. + * + * @param string $name + * @param string|string[] $value + * @return static + * @throws \InvalidArgumentException When the header name is empty or values are invalid. + */ + public function withAddedHeader($name, $value): self + { + return (clone $this)->addedHeader($name, $value); + } + + /** + * Remove a header (in-place). No-op when the header does not exist. + * + * @param string $name + * @return $this + */ + public function outHeader($name): self + { + $lowercase = strtolower($name); + if (!isset($this->headerNames[$lowercase])) { + return $this; + } + + $original = $this->headerNames[$lowercase]; + unset($this->headers[$original], $this->headerNames[$lowercase]); + + return $this; + } + + /** + * Return a clone of the message without the given header. + * + * @param string $name + * @return static + */ + public function withoutHeader($name): self + { + return (clone $this)->outHeader($name); + } + + /** + * Return the message body as a PSR-7 StreamInterface. A fresh, empty + * stream is created on demand if no body has been assigned yet. + * + * @return StreamInterface + */ + public function getBody(): StreamInterface + { + if(!isset($this->stream)){ + $this->stream = new Stream(''); + } + return $this->stream; + } + + /** + * Replace the message body (in-place). Assigning the same stream twice + * short-circuits to avoid trivial mutations. + * + * @param StreamInterface $body + * @return $this + */ + public function setBody(StreamInterface $body): self + { + if (isset($this->stream) && $body === $this->stream) { + return $this; + } + $this->stream = $body; + + return $this; + } + + /** + * Return a clone of the message with the body replaced. + * + * @param StreamInterface $body + * @return static + */ + public function withBody(StreamInterface $body): self + { + return (clone $this)->setBody($body); + } + + /** + * Bulk-load headers from an associative array, validating every value + * and folding additional values into existing headers (case-insensitive + * key collision). + * + * @param array $headers + * @return $this + * @throws \InvalidArgumentException When any header name or value is invalid. + */ + public function setHeaders(array $headers): self + { + foreach ($headers as $key => $value) { + $name = (string)$key; + $value = $this->validateAndTrimHeader($name, $value); + $lowercase = strtolower($name); + if(isset($this->headerNames[$lowercase])){ + $header = $this->headerNames[$lowercase]; + $this->headers[$header] = array_merge($this->headers[$header], $value); + continue; + } + $this->headerNames[$lowercase] = $name; + $this->headers[$name] = $value; + } + + return $this; + } + + /** + * True when the body is known to contain zero bytes. A size of null + * (pipes, sockets, on-the-fly responses) is treated as indeterminate — + * both {@see MessageTrait::isEmpty()} and + * {@see MessageTrait::isNotEmpty()} return false in that case so + * callers can branch defensively. + * + * @return bool + */ + public function isEmpty(): bool + { + $size = $this->getBody()->getSize(); + return $size !== null && $size < 1; + } + + /** + * Counterpart of {@see MessageTrait::isEmpty()}: only true when the + * body size is known and strictly positive. + * + * @return bool + */ + public function isNotEmpty(): bool + { + $size = $this->getBody()->getSize(); + return $size !== null && $size > 0; + } + + /** + * Validate the header name against RFC 7230 token rules and coerce the + * value(s) into a list of trimmed strings, rejecting anything that + * contains control characters or non-tchar bytes. + * + * @param string $header + * @param string|int|float|array $values + * @return string[] + * @throws \InvalidArgumentException When the name or any value violates RFC 7230. + */ + protected function validateAndTrimHeader($header, $values): array + { + if(!is_string($header) || (bool)preg_match("@^[!#$%&'*+.^_`|~0-9A-Za-z-]+$@", $header) === FALSE){ + throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.'); + } + if(!is_array($values)){ + if((!is_numeric($values) && !is_string($values)) || ((bool)preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string)$values)) === FALSE){ + throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings.'); + } + return [trim((string)$values, " \t")]; + } + if(empty($values)){ + throw new \InvalidArgumentException('Header values must be a string or an array of strings, empty array given.'); + } + $res = []; + foreach ($values as $value) { + if((!is_numeric($value) && !is_string($value)) || (bool)preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string)$value) === FALSE){ + throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings.'); + } + $res[] = trim((string)$value, " \t"); + } + return $res; + } + +} diff --git a/src/Message/Traits/RequestTrait.php b/src/Message/Traits/RequestTrait.php index a3102a4..cae1d6d 100644 --- a/src/Message/Traits/RequestTrait.php +++ b/src/Message/Traits/RequestTrait.php @@ -1,223 +1,299 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Message\Traits; - -use InitPHP\HTTP\Message\Stream; -use \Psr\Http\Message\{StreamInterface, UriInterface}; - -use function in_array; -use function is_string; -use function preg_match; -use function strtoupper; -use function array_map; - -trait RequestTrait -{ - - protected string $method; - - protected UriInterface $uri; - - protected string $requestTarget; - - /** - * @inheritDoc - */ - public function isGet(): bool - { - return strtoupper($this->getMethod()) === 'GET'; - } - - /** - * @inheritDoc - */ - public function isPost(): bool - { - return strtoupper($this->getMethod()) === 'POST'; - } - - /** - * @inheritDoc - */ - public function isPut(): bool - { - return strtoupper($this->getMethod()) === 'PUT'; - } - - /** - * @inheritDoc - */ - public function isDelete(): bool - { - return strtoupper($this->getMethod()) === 'DELETE'; - } - - /** - * @inheritDoc - */ - public function isHead(): bool - { - return strtoupper($this->getMethod()) === 'HEAD'; - } - - /** - * @inheritDoc - */ - public function isPatch(): bool - { - return strtoupper($this->getMethod()) === 'PATCH'; - } - - /** - * @inheritDoc - */ - public function isMethod(string ...$method): bool - { - return in_array(strtoupper($this->getMethod()), array_map('strtoupper', $method), true); - } - - /** - * @inheritDoc - */ - public function getRequestTarget(): string - { - if(isset($this->requestTarget)){ - return $this->requestTarget; - } - if(($target = $this->uri->getPath()) === ''){ - $target = '/'; - } - if(($query = $this->uri->getQuery()) !== ''){ - $target .= '?' . $query; - } - - return $target; - } - - /** - * @inheritDoc - */ - public function setRequestTarget($requestTarget): self - { - if(preg_match("#\s#", $requestTarget)){ - throw new \InvalidArgumentException('Invalid request target provided; cannot contain whitespace.'); - } - $this->requestTarget = $requestTarget; - - return $this; - } - - /** - * @inheritDoc - */ - public function withRequestTarget($requestTarget): self - { - return (clone $this)->setRequestTarget($requestTarget); - } - - /** - * @inheritDoc - */ - public function getMethod(): string - { - return $this->method; - } - - /** - * @inheritDoc - */ - public function setMethod($method): self - { - if(!is_string($method)){ - throw new \InvalidArgumentException('Method must be a string.'); - } - $this->method = $method; - return $this; - } - - /** - * @inheritDoc - */ - public function withMethod($method): self - { - return (clone $this)->setMethod($method); - } - - /** - * @inheritDoc - */ - public function getUri(): UriInterface - { - return $this->uri; - } - - /** - * @inheritDoc - */ - public function setUri(UriInterface $uri, bool $preserveHost = false): self - { - if($uri === $this->uri){ - return $this; - } - $this->uri = $uri; - if (!$preserveHost || !$this->hasHeader('Host')) { - $this->updateHostFormUri(); - } - - return $this; - } - - /** - * @inheritDoc - */ - public function withUri(UriInterface $uri, $preserveHost = false): self - { - return (clone $this)->setUri($uri, $preserveHost); - } - - protected function updateHostFormUri() - { - if(($host = $this->uri->getHost()) === ''){ - return; - } - if(($port = $this->uri->getPort()) !== null){ - $host .= ':' . $port; - } - if(isset($this->headerNames['host'])){ - $header = $this->headerNames['host']; - }else{ - $this->headerNames['host'] = $header = 'Host'; - } - $this->headers = [$header => [$host]] + $this->headers; - } - - protected function setUpConstruct($method, $uri, $body, $headers, $version) - { - $this->method = $method; - $this->uri = $uri; - $this->setHeaders($headers); - $this->protocol = $version; - if(!$this->hasHeader('Host')){ - $this->updateHostFormUri(); - } - if($body instanceof StreamInterface){ - $this->stream = $body; - }elseif(!empty($body)){ - $this->stream = new Stream($body); - } - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Message\Traits; + +use InitPHP\HTTP\Message\Stream; +use \Psr\Http\Message\{StreamInterface, UriInterface}; + +use function in_array; +use function is_string; +use function preg_match; +use function strtoupper; +use function array_map; + +/** + * Shared request-shaped behaviour for the concrete Request and ServerRequest + * classes. Implements PSR-7 RequestInterface accessors (method, URI, request + * target) on top of {@see MessageTrait}, plus convenience predicates such as + * {@see RequestTrait::isGet()} that wrap a case-insensitive method check. + */ +trait RequestTrait +{ + + protected string $method; + + protected UriInterface $uri; + + protected string $requestTarget; + + /** + * True when the request method is GET (case-insensitive comparison). + * + * @return bool + */ + public function isGet(): bool + { + return strtoupper($this->getMethod()) === 'GET'; + } + + /** + * True when the request method is POST (case-insensitive comparison). + * + * @return bool + */ + public function isPost(): bool + { + return strtoupper($this->getMethod()) === 'POST'; + } + + /** + * True when the request method is PUT (case-insensitive comparison). + * + * @return bool + */ + public function isPut(): bool + { + return strtoupper($this->getMethod()) === 'PUT'; + } + + /** + * True when the request method is DELETE (case-insensitive comparison). + * + * @return bool + */ + public function isDelete(): bool + { + return strtoupper($this->getMethod()) === 'DELETE'; + } + + /** + * True when the request method is HEAD (case-insensitive comparison). + * + * @return bool + */ + public function isHead(): bool + { + return strtoupper($this->getMethod()) === 'HEAD'; + } + + /** + * True when the request method is PATCH (case-insensitive comparison). + * + * @return bool + */ + public function isPatch(): bool + { + return strtoupper($this->getMethod()) === 'PATCH'; + } + + /** + * True when the current request method matches one of the supplied + * candidates. Comparison is case-insensitive on both sides. + * + * @param string ...$method Candidate HTTP methods to test against. + * @return bool + */ + public function isMethod(string ...$method): bool + { + return in_array(strtoupper($this->getMethod()), array_map('strtoupper', $method), true); + } + + /** + * Return the request target as defined by PSR-7: an explicit override set + * via {@see RequestTrait::setRequestTarget()} takes precedence, otherwise + * the URI's path (defaulting to "/") with the query string appended. + * + * @return string + */ + public function getRequestTarget(): string + { + if(isset($this->requestTarget)){ + return $this->requestTarget; + } + if(($target = $this->uri->getPath()) === ''){ + $target = '/'; + } + if(($query = $this->uri->getQuery()) !== ''){ + $target .= '?' . $query; + } + + return $target; + } + + /** + * Replace the request target (in-place). The target must not contain + * whitespace per PSR-7. + * + * @param string $requestTarget + * @return $this + * @throws \InvalidArgumentException When the request target contains whitespace. + */ + public function setRequestTarget($requestTarget): self + { + if(preg_match("#\s#", $requestTarget)){ + throw new \InvalidArgumentException('Invalid request target provided; cannot contain whitespace.'); + } + $this->requestTarget = $requestTarget; + + return $this; + } + + /** + * Return a clone of the message with the request target replaced. + * + * @param string $requestTarget + * @return static + * @throws \InvalidArgumentException When the request target contains whitespace. + */ + public function withRequestTarget($requestTarget): self + { + return (clone $this)->setRequestTarget($requestTarget); + } + + /** + * Return the HTTP method string as supplied by the caller (case preserved). + * + * @return string + */ + public function getMethod(): string + { + return $this->method; + } + + /** + * Replace the HTTP method (in-place). + * + * @param string $method + * @return $this + * @throws \InvalidArgumentException When $method is not a string. + */ + public function setMethod($method): self + { + if (!is_string($method)) { + throw new \InvalidArgumentException('Method must be a string.'); + } + $this->method = $method; + return $this; + } + + /** + * Return a clone of the message with the HTTP method replaced. + * + * @param string $method + * @return static + * @throws \InvalidArgumentException When $method is not a string. + */ + public function withMethod($method): self + { + return (clone $this)->setMethod($method); + } + + /** + * Return the PSR-7 URI currently associated with this request. + * + * @return UriInterface + */ + public function getUri(): UriInterface + { + return $this->uri; + } + + /** + * Replace the URI (in-place). The Host header is synchronised to the new + * URI unless $preserveHost is true AND a Host header already exists, per + * PSR-7 RequestInterface::withUri() semantics. + * + * @param UriInterface $uri + * @param bool $preserveHost + * @return $this + */ + public function setUri(UriInterface $uri, bool $preserveHost = false): self + { + if($uri === $this->uri){ + return $this; + } + $this->uri = $uri; + if (!$preserveHost || !$this->hasHeader('Host')) { + $this->updateHostFromUri(); + } + + return $this; + } + + /** + * Return a clone of the message with the URI replaced, applying the same + * Host-synchronisation rule as {@see RequestTrait::setUri()}. + * + * @param UriInterface $uri + * @param bool $preserveHost + * @return static + */ + public function withUri(UriInterface $uri, $preserveHost = false): self + { + return (clone $this)->setUri($uri, $preserveHost); + } + + /** + * Synchronise the Host header from the current URI. The header is + * prepended to the header collection so it appears first when emitted, + * matching the canonical RFC 7230 order. + * + * @return void + */ + protected function updateHostFromUri() + { + if(($host = $this->uri->getHost()) === ''){ + return; + } + if(($port = $this->uri->getPort()) !== null){ + $host .= ':' . $port; + } + if(isset($this->headerNames['host'])){ + $header = $this->headerNames['host']; + }else{ + $this->headerNames['host'] = $header = 'Host'; + } + $this->headers = [$header => [$host]] + $this->headers; + } + + /** + * Shared constructor body for Request / ServerRequest: assign method, + * URI, headers, protocol version and body in one pass, ensuring the + * Host header is derived from the URI when not supplied explicitly. + * + * @param string $method + * @param UriInterface $uri + * @param string|resource|StreamInterface|null $body + * @param array $headers + * @param string $version + * @return void + */ + protected function setUpConstruct($method, $uri, $body, $headers, $version) + { + $this->method = $method; + $this->uri = $uri; + $this->setHeaders($headers); + $this->protocol = $version; + if(!$this->hasHeader('Host')){ + $this->updateHostFromUri(); + } + if($body instanceof StreamInterface){ + $this->stream = $body; + }elseif(!empty($body)){ + $this->stream = new Stream($body); + } + } + +} diff --git a/src/Message/UploadedFile.php b/src/Message/UploadedFile.php index 45dd6f6..e2d4d90 100644 --- a/src/Message/UploadedFile.php +++ b/src/Message/UploadedFile.php @@ -1,194 +1,262 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Message; - -use \RuntimeException; -use \InvalidArgumentException; -use \Psr\Http\Message\{UploadedFileInterface, StreamInterface}; - -use const UPLOAD_ERR_OK; -use const UPLOAD_ERR_INI_SIZE; -use const UPLOAD_ERR_FORM_SIZE; -use const UPLOAD_ERR_PARTIAL; -use const UPLOAD_ERR_NO_FILE; -use const UPLOAD_ERR_NO_TMP_DIR; -use const UPLOAD_ERR_CANT_WRITE; -use const UPLOAD_ERR_EXTENSION; -use const PHP_SAPI; - -use function is_string; -use function is_resource; -use function fopen; -use function sprintf; -use function error_get_last; -use function rename; -use function move_uploaded_file; - -class UploadedFile implements UploadedFileInterface -{ - - protected const ERRORS = [ - UPLOAD_ERR_OK => 1, - UPLOAD_ERR_INI_SIZE => 1, - UPLOAD_ERR_FORM_SIZE => 1, - UPLOAD_ERR_PARTIAL => 1, - UPLOAD_ERR_NO_FILE => 1, - UPLOAD_ERR_NO_TMP_DIR => 1, - UPLOAD_ERR_CANT_WRITE => 1, - UPLOAD_ERR_EXTENSION => 1, - ]; - - protected ?StreamInterface $stream = null; - - protected ?string $clientFilename; - protected ?string $clientMediaType; - protected ?int $error; - protected ?string $file = null; - protected bool $moved = false; - protected ?int $size; - - /** - * @param StreamInterface|string|resource $streamOrFile - * @param int $size - * @param int $errorStatus - * @param string|null $clientFilename - * @param string|null $clientMediaType - */ - public function __construct($streamOrFile, int $size, int $errorStatus, ?string $clientFilename = null, ?string $clientMediaType = null) - { - $this->size = $size; - $this->error = $errorStatus; - $this->clientFilename = $clientFilename; - $this->clientMediaType = $clientMediaType; - $this->init($streamOrFile); - } - - public function __destruct() - { - unset($this->clientFilename, $this->clientMediaType, $this->error, $this->file, $this->size, $this->stream); - $this->moved = false; - } - - protected function init($streamOrFile) - { - if($this->error !== UPLOAD_ERR_OK){ - return; - } - if(is_string($streamOrFile) && !empty($streamOrFile)){ - $this->file = $streamOrFile; - return; - } - if(is_resource($streamOrFile)){ - $this->stream = new Stream($streamOrFile); - return; - } - if($streamOrFile instanceof StreamInterface){ - $this->stream = $streamOrFile; - return; - } - throw new InvalidArgumentException('Invalid stream or file provided for UploadedFile'); - } - - /** - * @inheritDoc - */ - public function getStream() - { - $this->throwHasErrorOrMoved(); - if($this->stream instanceof StreamInterface){ - return $this->stream; - } - if(($resource = @fopen($this->file, 'r')) === FALSE){ - throw new RuntimeException(sprintf('The file "%s" cannot be opened: %s', $this->file, error_get_last()['message'] ?? '')); - } - return new Stream($resource); - } - - /** - * @inheritDoc - */ - public function moveTo($targetPath) - { - $this->throwHasErrorOrMoved(); - if(!is_string($targetPath) || $targetPath === ''){ - throw new InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string'); - } - if($this->file !== null){ - $this->moved = ('cli' === PHP_SAPI) ? @rename($this->file, $targetPath) : @move_uploaded_file($this->file, $targetPath); - if($this->moved === FALSE){ - throw new RuntimeException(sprintf('Uploaded file could not be moved to "%s": %s', $targetPath, error_get_last()['message'] ?? '')); - } - }else{ - $stream = $this->getStream(); - if($stream->isSeekable()){ - $stream->rewind(); - } - if(($resource = @fopen($targetPath, 'w')) === FALSE){ - throw new RuntimeException(sprintf('The file "%s" cannot be opened: %s', $targetPath, error_get_last()['message'] ?? '')); - } - $dest = new Stream($resource); - while (!$stream->eof()) { - if(!$dest->write($stream->read(1048576))){ - break; - } - } - $this->moved = true; - } - } - - /** - * @inheritDoc - */ - public function getSize(): int - { - return $this->size; - } - - /** - * @inheritDoc - */ - public function getError(): int - { - return $this->error; - } - - /** - * @inheritDoc - */ - public function getClientFilename(): ?string - { - return $this->clientFilename; - } - - /** - * @inheritDoc - */ - public function getClientMediaType(): ?string - { - return $this->clientMediaType; - } - - protected function throwHasErrorOrMoved(): void - { - if($this->error !== UPLOAD_ERR_OK){ - throw new RuntimeException('Cannot retrieve stream due to upload error'); - } - if($this->moved){ - throw new RuntimeException('Cannot retrieve stream after it has already been moved'); - } - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Message; + +use \RuntimeException; +use \InvalidArgumentException; +use \Psr\Http\Message\{UploadedFileInterface, StreamInterface}; + +use const UPLOAD_ERR_OK; +use const UPLOAD_ERR_INI_SIZE; +use const UPLOAD_ERR_FORM_SIZE; +use const UPLOAD_ERR_PARTIAL; +use const UPLOAD_ERR_NO_FILE; +use const UPLOAD_ERR_NO_TMP_DIR; +use const UPLOAD_ERR_CANT_WRITE; +use const UPLOAD_ERR_EXTENSION; +use const PHP_SAPI; + +use function is_string; +use function is_resource; +use function fopen; +use function sprintf; +use function error_get_last; +use function rename; +use function move_uploaded_file; + +/** + * PSR-7 UploadedFileInterface implementation. Wraps a single uploaded file + * — supplied as a tmp_name path, a resource handle, or a pre-built + * StreamInterface — and ships the contract methods {@see getStream()} / + * {@see moveTo()} plus the metadata accessors PSR-7 mandates. Designed to + * work both under PHP-FPM (where move_uploaded_file() guards against + * spoofed uploads) and under the CLI test runner (where rename() is used + * as the fallback). + */ +class UploadedFile implements UploadedFileInterface +{ + + protected const ERRORS = [ + UPLOAD_ERR_OK => 1, + UPLOAD_ERR_INI_SIZE => 1, + UPLOAD_ERR_FORM_SIZE => 1, + UPLOAD_ERR_PARTIAL => 1, + UPLOAD_ERR_NO_FILE => 1, + UPLOAD_ERR_NO_TMP_DIR => 1, + UPLOAD_ERR_CANT_WRITE => 1, + UPLOAD_ERR_EXTENSION => 1, + ]; + + protected ?StreamInterface $stream = null; + + protected ?string $clientFilename; + protected ?string $clientMediaType; + protected ?int $error; + protected ?string $file = null; + protected bool $moved = false; + protected ?int $size; + + /** + * Build an UploadedFile from one of the three input shapes PSR-7 + * accepts. + * + * @param StreamInterface|string|resource $streamOrFile A tmp_name path, a stream handle, or a pre-built Stream. + * @param int|null $size File size in bytes; PSR-7 allows null when fstat() cannot report one. + * @param int $errorStatus One of the UPLOAD_ERR_* constants. + * @param string|null $clientFilename Client-supplied filename (untrusted). + * @param string|null $clientMediaType Client-supplied MIME type (untrusted). + * @throws InvalidArgumentException When $streamOrFile is not one of the supported shapes (and the upload completed without error). + */ + public function __construct($streamOrFile, ?int $size, int $errorStatus, ?string $clientFilename = null, ?string $clientMediaType = null) + { + $this->size = $size; + $this->error = $errorStatus; + $this->clientFilename = $clientFilename; + $this->clientMediaType = $clientMediaType; + $this->init($streamOrFile); + } + + /** + * Release the wrapped state when the instance goes out of scope. + * + * @return void + */ + public function __destruct() + { + unset($this->clientFilename, $this->clientMediaType, $this->error, $this->file, $this->size, $this->stream); + $this->moved = false; + } + + /** + * Initialise the backing storage from the constructor input shape. + * Skipped when the upload reported a non-OK error code; in that case + * the file is not addressable anyway. + * + * @param StreamInterface|string|resource $streamOrFile + * @return void + * @throws InvalidArgumentException When $streamOrFile is not a usable string/resource/StreamInterface. + */ + protected function init($streamOrFile) + { + if($this->error !== UPLOAD_ERR_OK){ + return; + } + if(is_string($streamOrFile) && !empty($streamOrFile)){ + $this->file = $streamOrFile; + return; + } + if(is_resource($streamOrFile)){ + $this->stream = new Stream($streamOrFile); + return; + } + if($streamOrFile instanceof StreamInterface){ + $this->stream = $streamOrFile; + return; + } + throw new InvalidArgumentException('Invalid stream or file provided for UploadedFile'); + } + + /** + * Return the uploaded file as a StreamInterface. When the upload was + * supplied as a tmp_name path, the file is opened on demand in read + * mode. + * + * @return StreamInterface + * @throws RuntimeException When the upload errored, was already moved, or the tmp file cannot be opened. + */ + public function getStream() + { + $this->throwHasErrorOrMoved(); + if($this->stream instanceof StreamInterface){ + return $this->stream; + } + if(($resource = @fopen($this->file, 'r')) === FALSE){ + throw new RuntimeException(sprintf('The file "%s" cannot be opened: %s', $this->file, error_get_last()['message'] ?? '')); + } + return new Stream($resource); + } + + /** + * Move the uploaded file to $targetPath. Uses move_uploaded_file() + * under SAPI runtimes so PHP's anti-spoofing safeguard applies; falls + * back to rename() under the CLI SAPI; falls back to a chunked copy + * when the file was supplied as a stream rather than a path. + * + * @param string $targetPath + * @return void + * @throws InvalidArgumentException When $targetPath is empty or not a string. + * @throws RuntimeException When the upload errored, was already moved, or the move/copy fails. + */ + public function moveTo($targetPath) + { + $this->throwHasErrorOrMoved(); + if(!is_string($targetPath) || $targetPath === ''){ + throw new InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string'); + } + if($this->file !== null){ + $this->moved = ('cli' === PHP_SAPI) ? @rename($this->file, $targetPath) : @move_uploaded_file($this->file, $targetPath); + if($this->moved === FALSE){ + throw new RuntimeException(sprintf('Uploaded file could not be moved to "%s": %s', $targetPath, error_get_last()['message'] ?? '')); + } + }else{ + $stream = $this->getStream(); + if($stream->isSeekable()){ + $stream->rewind(); + } + if(($resource = @fopen($targetPath, 'w')) === FALSE){ + throw new RuntimeException(sprintf('The file "%s" cannot be opened: %s', $targetPath, error_get_last()['message'] ?? '')); + } + $dest = new Stream($resource); + while (!$stream->eof()) { + $chunk = $stream->read(1048576); + if ($chunk === '') { + // Nothing left to copy; avoid an infinite loop if the + // upstream stream's eof() never flips. + break; + } + $written = 0; + $length = strlen($chunk); + while ($written < $length) { + $delta = $dest->write(substr($chunk, $written)); + if ($delta <= 0) { + throw new RuntimeException('Failed writing uploaded file payload to destination.'); + } + $written += $delta; + } + } + $this->moved = true; + } + } + + /** + * Return the upload size in bytes, or null when unknown. + * + * @return int|null + */ + public function getSize(): ?int + { + return $this->size; + } + + /** + * Return one of the UPLOAD_ERR_* constants describing the upload status. + * + * @return int + */ + public function getError(): int + { + return $this->error; + } + + /** + * Return the client-supplied filename (untrusted), or null when none + * was supplied. + * + * @return string|null + */ + public function getClientFilename(): ?string + { + return $this->clientFilename; + } + + /** + * Return the client-supplied MIME type (untrusted), or null when none + * was supplied. + * + * @return string|null + */ + public function getClientMediaType(): ?string + { + return $this->clientMediaType; + } + + /** + * Guard against reading or moving a file that errored on upload or + * has already been moved (PSR-7 forbids a second move). + * + * @return void + * @throws RuntimeException When the upload errored or was already moved. + */ + protected function throwHasErrorOrMoved(): void + { + if($this->error !== UPLOAD_ERR_OK){ + throw new RuntimeException('Cannot retrieve stream due to upload error'); + } + if($this->moved){ + throw new RuntimeException('Cannot retrieve stream after it has already been moved'); + } + } + +} diff --git a/src/Message/Uri.php b/src/Message/Uri.php index 5b4ac73..4864e3d 100644 --- a/src/Message/Uri.php +++ b/src/Message/Uri.php @@ -1,395 +1,535 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\HTTP\Message; - -use \InitPHP\HTTP\Message\Interfaces\UriInterface; -use \InvalidArgumentException; - -use function parse_url; -use function strtolower; -use function is_string; -use function preg_replace_callback; -use function rawurlencode; -use function ltrim; -use function sprintf; -use function strpos; -use function preg_replace; - -class Uri implements UriInterface -{ - - protected const SCHEMES = ['http' => 80, 'https' => 443]; - - protected const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~'; - - protected const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;='; - - protected string $scheme = ''; - - protected string $userInfo = ''; - - protected string $host = ''; - - protected ?int $port = null; - - protected string $path = ''; - - protected string $query = ''; - - protected string $fragment = ''; - - public function __construct(string $uri = '') - { - if($uri !== ''){ - if(FALSE === $parts = parse_url($uri)){ - throw new InvalidArgumentException(sprintf('Unable to parse URI: "%s"', $uri)); - } - $this->scheme = isset($parts['scheme']) ? strtolower($parts['scheme']) : ''; - $this->host = isset($parts['host']) ? strtolower($parts['host']) : ''; - $this->port = isset($parts['port']) ? $this->filterPort($parts['port']) : null; - $this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : ''; - $this->query = isset($parts['query']) ? $this->filterQueryAndFragment($parts['query']) : ''; - $this->fragment = isset($parts['fragment']) ? $this->filterQueryAndFragment($parts['fragment']) : ''; - if (isset($parts['user'])) { - $this->userInfo = $this->filterUserInfoComponent($parts['user']); - if (isset($parts['pass'])) { - $this->userInfo .= ':' . $this->filterUserInfoComponent($parts['pass']); - } - } - } - } - - /** - * @inheritDoc - */ - public function __toString() - { - return self::createUriString($this->scheme, $this->getAuthority(), $this->path, $this->query, $this->fragment); - } - - /** - * @inheritDoc - */ - public function getScheme(): string - { - return $this->scheme; - } - - /** - * @inheritDoc - */ - 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; - } - - /** - * @inheritDoc - */ - public function getUserInfo(): string - { - return $this->userInfo; - } - - /** - * @inheritDoc - */ - public function getHost(): string - { - return $this->host; - } - - /** - * @inheritDoc - */ - public function getPort(): ?int - { - return $this->port; - } - - /** - * @inheritDoc - */ - public function getPath(): string - { - if(isset($this->path[0], $this->path[1]) && $this->path[0] === '/' && $this->path[1] === '/'){ - return '/' . ltrim($this->path, '/'); - } - return $this->path; - } - - /** - * @inheritDoc - */ - public function getQuery(): string - { - return $this->query; - } - - /** - * @inheritDoc - */ - public function getFragment(): string - { - return $this->fragment; - } - - /** - * @inheritDoc - */ - public function setScheme(string $scheme): self - { - if (!is_string($scheme)) { - throw new InvalidArgumentException('Scheme must be a string.'); - } - if ($this->scheme === $scheme = strtolower($scheme)) { - return $this; - } - $this->scheme = $scheme; - $this->port = $this->filterPort($this->port); - - return $this; - } - - /** - * @inheritDoc - */ - public function withScheme($scheme): self - { - return (clone $this)->setScheme($scheme); - } - - /** - * @inheritDoc - */ - public function setUserInfo(string $user, ?string $password = null): self - { - $info = $this->filterUserInfoComponent($user); - if ($password !== null && $password !== '') { - $info .= ':' . $this->filterUserInfoComponent($password); - } - if ($this->userInfo === $info) { - return $this; - } - $this->userInfo = $info; - - return $this; - } - - /** - * @inheritDoc - */ - public function withUserInfo($user, $password = null): self - { - return (clone $this)->setUserInfo($user, $password); - } - - /** - * @inheritDoc - */ - public function setHost(string $host): self - { - if (!is_string($host)) { - throw new InvalidArgumentException('Host must be a string'); - } - if ($this->host === $host = strtolower($host)) { - return $this; - } - $this->host = $host; - - return $this; - } - - /** - * @inheritDoc - */ - public function withHost($host): self - { - return (clone $this)->setHost($host); - } - - /** - * @inheritDoc - */ - public function setPort(?int $port): self - { - if ($this->port === $port = $this->filterPort($port)) { - return $this; - } - $this->port = $port; - - return $this; - } - - /** - * @inheritDoc - */ - public function withPort($port): self - { - return (clone $this)->setPort($port); - } - - /** - * @inheritDoc - */ - public function setPath(string $path): self - { - if ($this->path === $path = $this->filterPath($path)) { - return $this; - } - $this->path = $path; - - return $this; - } - - /** - * @inheritDoc - */ - public function withPath($path): self - { - return (clone $this)->setPath($path); - } - - /** - * @inheritDoc - */ - public function setQuery(string $query): self - { - if($this->query === $query = $this->filterQueryAndFragment($query)){ - return $this; - } - $this->query = $query; - - return $this; - } - - /** - * @inheritDoc - */ - public function withQuery($query): self - { - return (clone $this)->setQuery($query); - } - - /** - * @inheritDoc - */ - public function setFragment(string $fragment): self - { - if($this->fragment === $fragment = $this->filterQueryAndFragment($fragment)){ - return $this; - } - $this->fragment = $fragment; - - return $this; - } - - /** - * @inheritDoc - */ - public function withFragment($fragment): self - { - return (clone $this)->setFragment($fragment); - } - - protected static function createUriString(string $scheme, string $authority, string $path, string $query, string $fragment): string - { - $uri = ''; - if($scheme !== ''){ - $uri .= $scheme . ':'; - } - if($authority !== '' || $scheme === 'file'){ - $uri .= '//' . $authority; - } - - if($path !== ''){ - if($path[0] !== '/'){ - if($authority !== ''){ - $path = '/' . $path; - } - }elseif(isset($path[1]) && $path[1] === '/'){ - if($authority === ''){ - $path = '/' . ltrim($path, '/'); - } - } - $uri .= $path; - } - - if($query !== ''){ - $uri .= '?' . $query; - } - - if($fragment !== ''){ - $uri .= '#' . $fragment; - } - - return $uri; - } - - protected static function isNonStandardPort(string $scheme, int $port): bool - { - return !isset(self::SCHEMES[$scheme]) || $port !== self::SCHEMES[$scheme]; - } - - protected function filterPort($port): ?int - { - if($port === null){ - return null; - } - $port = (int)$port; - if($port < 0 || $port > 0xffff){ - throw new InvalidArgumentException(sprintf('Invalid port %d. Must be between 0 and 65535', $port)); - } - return self::isNonStandardPort($this->scheme, $port) ? $port : null; - } - - protected function filterPath($path): string - { - if(!is_string($path)){ - throw new InvalidArgumentException('Path must be a string'); - } - return preg_replace_callback('/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $path); - } - - protected function filterQueryAndFragment($str): string - { - if (!is_string($str)) { - throw new InvalidArgumentException('Query and fragment must be a string'); - } - return preg_replace_callback('/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $str); - } - - protected function filterUserInfoComponent(string $component): string - { - return preg_replace_callback('/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $component); - } - - protected static function rawurlencodeMatchZero(array $match): string - { - return rawurlencode($match[0]); - } - -} + + * @copyright Copyright © 2022 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\HTTP\Message; + +use \Psr\Http\Message\UriInterface; +use \InvalidArgumentException; + +use function parse_url; +use function strtolower; +use function preg_replace_callback; +use function rawurlencode; +use function ltrim; +use function sprintf; +use function strpos; +use function preg_replace; + +/** + * PSR-7 UriInterface implementation. Parses an input URI string into its + * RFC 3986 components (scheme, user info, host, port, path, query, + * fragment) and exposes them through the standard PSR-7 accessors plus a + * matching set of in-place setters used internally by the `with*()` + * methods. Component-level encoding rules are applied on assignment so the + * stored values are always emit-safe. + */ +class Uri implements UriInterface +{ + + protected const SCHEMES = ['http' => 80, 'https' => 443]; + + protected const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~'; + + protected const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;='; + + protected string $scheme = ''; + + protected string $userInfo = ''; + + protected string $host = ''; + + protected ?int $port = null; + + protected string $path = ''; + + protected string $query = ''; + + protected string $fragment = ''; + + /** + * Build a Uri from a string. Empty input produces an empty Uri (all + * components blank); a non-empty string is parsed with parse_url() and + * each component is filtered for RFC 3986 compliance. + * + * @param string $uri + * @throws InvalidArgumentException When parse_url() fails, or a component (port, user info, ...) is invalid. + */ + public function __construct(string $uri = '') + { + if($uri !== ''){ + if(FALSE === $parts = parse_url($uri)){ + throw new InvalidArgumentException(sprintf('Unable to parse URI: "%s"', $uri)); + } + $this->scheme = isset($parts['scheme']) ? strtolower($parts['scheme']) : ''; + $this->host = isset($parts['host']) ? strtolower($parts['host']) : ''; + $this->port = isset($parts['port']) ? $this->filterPort($parts['port']) : null; + $this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : ''; + $this->query = isset($parts['query']) ? $this->filterQueryAndFragment($parts['query']) : ''; + $this->fragment = isset($parts['fragment']) ? $this->filterQueryAndFragment($parts['fragment']) : ''; + if (isset($parts['user'])) { + $this->userInfo = $this->filterUserInfoComponent($parts['user']); + if (isset($parts['pass'])) { + $this->userInfo .= ':' . $this->filterUserInfoComponent($parts['pass']); + } + } + } + } + + /** + * Recompose the URI string from its components per RFC 3986. + * + * @return string + */ + public function __toString() + { + return self::createUriString($this->scheme, $this->getAuthority(), $this->path, $this->query, $this->fragment); + } + + /** + * Return the scheme component (lower-cased), or "" when absent. + * + * @return string + */ + public function getScheme(): string + { + return $this->scheme; + } + + /** + * Return the authority component (user-info @ host : port), or "" when + * the host is empty. + * + * @return string + */ + 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; + } + + /** + * Return the user-info component (e.g. "user" or "user:password"), + * percent-encoded as required by RFC 3986. + * + * @return string + */ + public function getUserInfo(): string + { + return $this->userInfo; + } + + /** + * Return the host component (lower-cased), or "" when absent. + * + * @return string + */ + public function getHost(): string + { + return $this->host; + } + + /** + * Return the port component, or null when the URI uses the default + * port for its scheme (or no port at all). + * + * @return int|null + */ + public function getPort(): ?int + { + return $this->port; + } + + /** + * Return the path component. Paths beginning with "//" are collapsed + * to a single leading "/" so the resulting URI cannot be misparsed as + * having an authority component. + * + * @return string + */ + public function getPath(): string + { + if(isset($this->path[0], $this->path[1]) && $this->path[0] === '/' && $this->path[1] === '/'){ + return '/' . ltrim($this->path, '/'); + } + return $this->path; + } + + /** + * Return the query component (without the leading "?"), percent-encoded. + * + * @return string + */ + public function getQuery(): string + { + return $this->query; + } + + /** + * Return the fragment component (without the leading "#"), percent-encoded. + * + * @return string + */ + public function getFragment(): string + { + return $this->fragment; + } + + /** + * Replace the scheme (in-place, lower-cased). Also re-runs the port + * filter so the default port for the new scheme is hidden. + * + * @param string $scheme + * @return $this + */ + public function setScheme(string $scheme): self + { + $scheme = strtolower($scheme); + if ($this->scheme === $scheme) { + return $this; + } + $this->scheme = $scheme; + $this->port = $this->filterPort($this->port); + + return $this; + } + + /** + * Return a clone of the URI with the scheme replaced. + * + * @param string $scheme + * @return static + */ + public function withScheme($scheme): self + { + return (clone $this)->setScheme($scheme); + } + + /** + * Replace the user-info component (in-place), percent-encoding both + * the user and the optional password. + * + * @param string $user + * @param string|null $password + * @return $this + */ + public function setUserInfo(string $user, ?string $password = null): self + { + $info = $this->filterUserInfoComponent($user); + if ($password !== null && $password !== '') { + $info .= ':' . $this->filterUserInfoComponent($password); + } + if ($this->userInfo === $info) { + return $this; + } + $this->userInfo = $info; + + return $this; + } + + /** + * Return a clone of the URI with the user-info component replaced. + * + * @param string $user + * @param string|null $password + * @return static + */ + public function withUserInfo($user, $password = null): self + { + return (clone $this)->setUserInfo($user, $password); + } + + /** + * Replace the host (in-place, lower-cased). + * + * @param string $host + * @return $this + */ + public function setHost(string $host): self + { + $host = strtolower($host); + if ($this->host === $host) { + return $this; + } + $this->host = $host; + + return $this; + } + + /** + * Return a clone of the URI with the host replaced. + * + * @param string $host + * @return static + */ + public function withHost($host): self + { + return (clone $this)->setHost($host); + } + + /** + * Replace the port (in-place). Default ports for the current scheme + * are normalised to null by {@see Uri::filterPort()}. + * + * @param int|null $port + * @return $this + * @throws InvalidArgumentException When $port is outside the 0..65535 range. + */ + public function setPort(?int $port): self + { + if ($this->port === $port = $this->filterPort($port)) { + return $this; + } + $this->port = $port; + + return $this; + } + + /** + * Return a clone of the URI with the port replaced. + * + * @param int|null $port + * @return static + * @throws InvalidArgumentException When $port is outside the 0..65535 range. + */ + public function withPort($port): self + { + return (clone $this)->setPort($port); + } + + /** + * Replace the path (in-place), percent-encoding any reserved or + * non-allowed characters per RFC 3986. + * + * @param string $path + * @return $this + */ + public function setPath(string $path): self + { + if ($this->path === $path = $this->filterPath($path)) { + return $this; + } + $this->path = $path; + + return $this; + } + + /** + * Return a clone of the URI with the path replaced. + * + * @param string $path + * @return static + */ + public function withPath($path): self + { + return (clone $this)->setPath($path); + } + + /** + * Replace the query string (in-place), percent-encoding any reserved + * or non-allowed characters. + * + * @param string $query + * @return $this + */ + public function setQuery(string $query): self + { + if($this->query === $query = $this->filterQueryAndFragment($query)){ + return $this; + } + $this->query = $query; + + return $this; + } + + /** + * Return a clone of the URI with the query string replaced. + * + * @param string $query + * @return static + */ + public function withQuery($query): self + { + return (clone $this)->setQuery($query); + } + + /** + * Replace the fragment (in-place), percent-encoding any reserved or + * non-allowed characters. + * + * @param string $fragment + * @return $this + */ + public function setFragment(string $fragment): self + { + if($this->fragment === $fragment = $this->filterQueryAndFragment($fragment)){ + return $this; + } + $this->fragment = $fragment; + + return $this; + } + + /** + * Return a clone of the URI with the fragment replaced. + * + * @param string $fragment + * @return static + */ + public function withFragment($fragment): self + { + return (clone $this)->setFragment($fragment); + } + + /** + * Recompose a URI string from its individual components per RFC 3986. + * + * @param string $scheme + * @param string $authority + * @param string $path + * @param string $query + * @param string $fragment + * @return string + */ + protected static function createUriString(string $scheme, string $authority, string $path, string $query, string $fragment): string + { + $uri = ''; + if($scheme !== ''){ + $uri .= $scheme . ':'; + } + if($authority !== '' || $scheme === 'file'){ + $uri .= '//' . $authority; + } + + if($path !== ''){ + if($path[0] !== '/'){ + if($authority !== ''){ + $path = '/' . $path; + } + }elseif(isset($path[1]) && $path[1] === '/'){ + if($authority === ''){ + $path = '/' . ltrim($path, '/'); + } + } + $uri .= $path; + } + + if($query !== ''){ + $uri .= '?' . $query; + } + + if($fragment !== ''){ + $uri .= '#' . $fragment; + } + + return $uri; + } + + /** + * True when $port is not the default port for $scheme (so the port + * must be rendered explicitly in the authority component). + * + * @param string $scheme + * @param int $port + * @return bool + */ + protected static function isNonStandardPort(string $scheme, int $port): bool + { + return !isset(self::SCHEMES[$scheme]) || $port !== self::SCHEMES[$scheme]; + } + + /** + * Validate $port and normalise default ports for the current scheme + * to null so they are omitted from the rendered URI. + * + * @param int|string|null $port + * @return int|null + * @throws InvalidArgumentException When $port is outside the 0..65535 range. + */ + protected function filterPort($port): ?int + { + if($port === null){ + return null; + } + $port = (int)$port; + if($port < 0 || $port > 0xffff){ + throw new InvalidArgumentException(sprintf('Invalid port %d. Must be between 0 and 65535', $port)); + } + return self::isNonStandardPort($this->scheme, $port) ? $port : null; + } + + /** + * Percent-encode characters not allowed in a URI path per RFC 3986. + * + * @param string $path + * @return string + */ + protected function filterPath(string $path): string + { + return preg_replace_callback( + '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/', + [__CLASS__, 'rawurlencodeMatchZero'], + $path + ); + } + + /** + * Percent-encode characters not allowed in a query string or fragment + * per RFC 3986. + * + * @param string $str + * @return string + */ + protected function filterQueryAndFragment(string $str): string + { + return preg_replace_callback( + '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', + [__CLASS__, 'rawurlencodeMatchZero'], + $str + ); + } + + /** + * Percent-encode characters not allowed in a single user-info + * component (user or password) per RFC 3986. + * + * @param string $component + * @return string + */ + protected function filterUserInfoComponent(string $component): string + { + return preg_replace_callback('/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $component); + } + + /** + * preg_replace_callback() helper that returns the rawurlencode() of + * the entire matched substring. + * + * @param array{0:string} $match + * @return string + */ + protected static function rawurlencodeMatchZero(array $match): string + { + return rawurlencode($match[0]); + } + +} diff --git a/tests/Unit/Client/ClientConfigurationTest.php b/tests/Unit/Client/ClientConfigurationTest.php new file mode 100644 index 0000000..0028256 --- /dev/null +++ b/tests/Unit/Client/ClientConfigurationTest.php @@ -0,0 +1,136 @@ +getCurlOptions())); + } + + public function testSetUserAgentRejectsNullAndEmpty(): void + { + $client = new Client(); + $original = $client->getUserAgent(); + + $client->setUserAgent(null); + self::assertSame($original, $client->getUserAgent(), 'null UA must be ignored'); + + $client->setUserAgent(''); + self::assertSame($original, $client->getUserAgent(), 'empty UA must be ignored'); + } + + public function testSetUserAgentReplacesValue(): void + { + $client = new Client(); + $client->setUserAgent('Acme/1.0'); + self::assertSame('Acme/1.0', $client->getUserAgent()); + } + + public function testWithUserAgentReturnsCloneAndDoesNotMutateOriginal(): void + { + $client = new Client(); + $client->setUserAgent('Original/1.0'); + + $clone = $client->withUserAgent('Cloned/2.0'); + + self::assertNotSame($client, $clone); + self::assertSame('Original/1.0', $client->getUserAgent()); + self::assertSame('Cloned/2.0', $clone->getUserAgent()); + } + + public function testSetTimeoutClampsNegativeValuesToZero(): void + { + $client = new Client(); + $client->setTimeout(-5); + // We cannot read the timeout back directly; assert via curl-options + // shape after a no-op withCurlOptions to keep the test public-surface + // only. Easiest visible signal: the returned $this for chaining. + self::assertSame($client, $client->setTimeout(0)); + } + + public function testWithTimeoutReturnsClone(): void + { + $client = new Client(); + $clone = $client->withTimeout(60); + self::assertNotSame($client, $clone); + } + + public function testSetConnectTimeoutClampsNegativeValuesToZero(): void + { + $client = new Client(); + // Same observability constraint as setTimeout — we exercise the path + // for branch coverage, even when there's no public getter to inspect. + self::assertSame($client, $client->setConnectTimeout(-1)); + } + + public function testWithConnectTimeoutReturnsClone(): void + { + $client = new Client(); + $clone = $client->withConnectTimeout(5); + self::assertNotSame($client, $clone); + } + + public function testSetFollowRedirectsClampsMaxToZero(): void + { + $client = new Client(); + self::assertSame($client, $client->setFollowRedirects(false, -10)); + } + + public function testWithFollowRedirectsReturnsClone(): void + { + $client = new Client(); + $clone = $client->withFollowRedirects(false, 3); + self::assertNotSame($client, $clone); + } + + public function testCurlOptionsRoundTripThroughSetterAndGetter(): void + { + $client = new Client(); + $options = [ + CURLOPT_VERBOSE => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_PROXY => 'http://proxy.example:3128', + ]; + + $client->setCurlOptions($options); + self::assertSame($options, $client->getCurlOptions()); + } + + public function testWithCurlOptionsReturnsCloneWithIndependentOptions(): void + { + $client = new Client(); + $client->setCurlOptions([CURLOPT_VERBOSE => true]); + + $clone = $client->withCurlOptions([CURLOPT_SSL_VERIFYPEER => false]); + + self::assertNotSame($client, $clone); + self::assertSame([CURLOPT_VERBOSE => true], $client->getCurlOptions()); + self::assertSame([CURLOPT_SSL_VERIFYPEER => false], $clone->getCurlOptions()); + } + + public function testSetCurlOptionsReplacesEntireBagNotMerges(): void + { + $client = new Client(); + $client->setCurlOptions([CURLOPT_VERBOSE => true]); + $client->setCurlOptions([CURLOPT_PROXY => 'http://proxy.example']); + + // Replacement semantics: the verbose flag must be gone. + self::assertSame([CURLOPT_PROXY => 'http://proxy.example'], $client->getCurlOptions()); + } +} diff --git a/tests/Unit/Client/ClientHttpVerbsTest.php b/tests/Unit/Client/ClientHttpVerbsTest.php new file mode 100644 index 0000000..aa65175 --- /dev/null +++ b/tests/Unit/Client/ClientHttpVerbsTest.php @@ -0,0 +1,137 @@ +fetch(self::fixtureBaseUrl() . '/echo'); + + self::assertSame(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + self::assertSame('GET', $data['method']); + } + + public function testFetchHonoursMethodFromDetailsArray(): void + { + $client = new Client(); + $response = $client->fetch(self::fixtureBaseUrl() . '/echo', [ + 'method' => 'POST', + 'body' => 'hello', + 'headers' => ['X-Trace' => 'fetch'], + ]); + + $data = json_decode((string) $response->getBody(), true); + self::assertSame('POST', $data['method']); + self::assertSame('hello', $data['body']); + self::assertSame('fetch', $data['headers']['x-trace'] ?? null); + } + + public function testFetchIsCaseInsensitiveOnDetailsKeys(): void + { + $client = new Client(); + // METHOD/HEADERS in upper case — array_change_key_case must lower them. + $response = $client->fetch(self::fixtureBaseUrl() . '/echo', [ + 'METHOD' => 'POST', + 'HEADERS' => ['X-Case' => 'upper'], + ]); + + $data = json_decode((string) $response->getBody(), true); + self::assertSame('POST', $data['method']); + self::assertSame('upper', $data['headers']['x-case'] ?? null); + } + + public function testGetReachesEchoEndpoint(): void + { + $client = new Client(); + $response = $client->get(self::fixtureBaseUrl() . '/echo'); + + self::assertSame(200, $response->getStatusCode()); + $data = json_decode((string) $response->getBody(), true); + self::assertSame('GET', $data['method']); + } + + public function testPostSendsBody(): void + { + $client = new Client(); + $response = $client->post(self::fixtureBaseUrl() . '/echo', '{"x":1}', [ + 'Content-Type' => 'application/json', + ]); + + $data = json_decode((string) $response->getBody(), true); + self::assertSame('POST', $data['method']); + self::assertSame('{"x":1}', $data['body']); + } + + public function testPutSendsBody(): void + { + $client = new Client(); + $response = $client->put(self::fixtureBaseUrl() . '/echo', 'put-body'); + + $data = json_decode((string) $response->getBody(), true); + self::assertSame('PUT', $data['method']); + self::assertSame('put-body', $data['body']); + } + + public function testPatchSendsBody(): void + { + $client = new Client(); + $response = $client->patch(self::fixtureBaseUrl() . '/echo', 'patch-body'); + + $data = json_decode((string) $response->getBody(), true); + self::assertSame('PATCH', $data['method']); + self::assertSame('patch-body', $data['body']); + } + + public function testDeleteReachesEchoEndpoint(): void + { + $client = new Client(); + $response = $client->delete(self::fixtureBaseUrl() . '/echo'); + + $data = json_decode((string) $response->getBody(), true); + self::assertSame('DELETE', $data['method']); + } + + public function testHeadReturnsHeadersOnly(): void + { + $client = new Client(); + $response = $client->head(self::fixtureBaseUrl() . '/echo'); + + self::assertSame(200, $response->getStatusCode()); + // CURLOPT_NOBODY ensures the body is discarded on the wire. + self::assertSame('', (string) $response->getBody()); + } +} diff --git a/tests/Unit/Client/ClientPrepareRequestTest.php b/tests/Unit/Client/ClientPrepareRequestTest.php new file mode 100644 index 0000000..f8beaf4 --- /dev/null +++ b/tests/Unit/Client/ClientPrepareRequestTest.php @@ -0,0 +1,110 @@ +expectException(\Psr\Http\Client\NetworkExceptionInterface::class); + $client->post(self::UNREACHABLE_URL, null); + } + + public function testStringBodyIsAccepted(): void + { + $client = new Client(); + $this->expectException(\Psr\Http\Client\NetworkExceptionInterface::class); + $client->post(self::UNREACHABLE_URL, '{"k":"v"}'); + } + + public function testResourceBodyIsAccepted(): void + { + $client = new Client(); + $resource = fopen('php://memory', 'r+b'); + self::assertIsResource($resource); + fwrite($resource, 'payload'); + fseek($resource, 0); + + $this->expectException(\Psr\Http\Client\NetworkExceptionInterface::class); + try { + $client->post(self::UNREACHABLE_URL, $resource); + } finally { + if (is_resource($resource)) { + fclose($resource); + } + } + } + + public function testStreamInterfaceBodyIsAccepted(): void + { + $client = new Client(); + $stream = new Stream('payload', null); + $this->expectException(\Psr\Http\Client\NetworkExceptionInterface::class); + $client->post(self::UNREACHABLE_URL, $stream); + } + + public function testArrayBodyIsRejected(): void + { + $client = new Client(); + // Arrays must be encoded by the caller (see send_request() helper). + // The Client itself MUST throw rather than silently json_encode(). + $this->expectException(\InvalidArgumentException::class); + $client->post(self::UNREACHABLE_URL, ['key' => 'value']); + } + + public function testPlainObjectBodyIsRejected(): void + { + $client = new Client(); + $this->expectException(\InvalidArgumentException::class); + $client->post(self::UNREACHABLE_URL, new \stdClass()); + } + + public function testBooleanBodyIsRejected(): void + { + $client = new Client(); + $this->expectException(\InvalidArgumentException::class); + $client->post(self::UNREACHABLE_URL, true); + } + + public function testIntegerBodyIsRejected(): void + { + $client = new Client(); + // Integers/floats are scalars but the Client only accepts string/ + // resource/StreamInterface/null. Stringification is caller's job. + $this->expectException(\InvalidArgumentException::class); + $client->post(self::UNREACHABLE_URL, 42); + } + + public function testInvalidUrlIsReportedAsRequestException(): void + { + $client = new Client(); + // FILTER_VALIDATE_URL rejects "not-a-url" so prepareCurlOptions wraps + // the ClientException in a RequestException carrying the request. + $this->expectException(RequestException::class); + $client->get('not-a-url'); + } +} diff --git a/tests/Unit/Emitter/EmitterBodyTest.php b/tests/Unit/Emitter/EmitterBodyTest.php new file mode 100644 index 0000000..3484711 --- /dev/null +++ b/tests/Unit/Emitter/EmitterBodyTest.php @@ -0,0 +1,100 @@ +0: stream is chunked through read(bufferLength) in a + * while(!eof) loop. + * + * Both paths must emit the same bytes; the chunked path additionally + * rewinds the stream first so callers don't have to. + */ +final class EmitterBodyTest extends TestCase +{ + public function testDefaultEmitWritesEntireBody(): void + { + $response = new Response(200, [], 'the entire body'); + $emitter = new Emitter(false); + + ob_start(); + $emitter->emit($response); + $output = (string) ob_get_clean(); + + self::assertSame('the entire body', $output); + } + + public function testChunkedEmitWritesEntireBody(): void + { + $payload = str_repeat('abcdefghij', 100); // 1000 bytes + $response = new Response(200, [], new Stream($payload, 'php://temp')); + $emitter = new Emitter(false); + + ob_start(); + $emitter->emit($response, 64); // 64-byte chunks + $output = (string) ob_get_clean(); + + self::assertSame($payload, $output); + } + + public function testChunkedEmitRewindsBeforeReading(): void + { + // Body already at EOF before emit() runs. The chunked path must + // rewind() it; otherwise the client sees an empty response. + $stream = new Stream('rewind-me', 'php://temp'); + $stream->seek($stream->getSize() ?? 0); // park at EOF + + $response = new Response(200, [], $stream); + $emitter = new Emitter(false); + + ob_start(); + $emitter->emit($response, 8); + $output = (string) ob_get_clean(); + + self::assertSame('rewind-me', $output); + } + + public function testChunkedEmitWithBufferLargerThanBodyEmitsOnce(): void + { + $response = new Response(200, [], 'small'); + $emitter = new Emitter(false); + + ob_start(); + $emitter->emit($response, 1024); + $output = (string) ob_get_clean(); + + self::assertSame('small', $output); + } + + public function testZeroBufferLengthFallsBackToDefaultEmit(): void + { + // bufferLength=0 fails the `> 0` guard and we take the echo path. + $response = new Response(200, [], 'fallback'); + $emitter = new Emitter(false); + + ob_start(); + $emitter->emit($response, 0); + $output = (string) ob_get_clean(); + + self::assertSame('fallback', $output); + } + + public function testNegativeBufferLengthFallsBackToDefaultEmit(): void + { + $response = new Response(200, [], 'fallback'); + $emitter = new Emitter(false); + + ob_start(); + $emitter->emit($response, -10); + $output = (string) ob_get_clean(); + + self::assertSame('fallback', $output); + } +} diff --git a/tests/Unit/Emitter/EmitterContentRangeTest.php b/tests/Unit/Emitter/EmitterContentRangeTest.php new file mode 100644 index 0000000..4089e42 --- /dev/null +++ b/tests/Unit/Emitter/EmitterContentRangeTest.php @@ -0,0 +1,120 @@ +-/` and the caller + * requests chunked emission, the emitter must: + * + * - seek the body to + * - emit exactly (last - first + 1) bytes + * + * The parseHeaderContentRange() / emitBodyRange() pair drives this. + */ +final class EmitterContentRangeTest extends TestCase +{ + public function testEmitsContiguousByteRange(): void + { + $payload = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // 26 bytes + $body = new Stream($payload, 'php://temp'); + $response = (new Response(206, [], $body)) + ->withHeader('Content-Range', 'bytes 5-9/26'); + + $emitter = new Emitter(false); + ob_start(); + $emitter->emit($response, 4); // small chunk to exercise the loop + $output = (string) ob_get_clean(); + + // Bytes 5..9 inclusive => "FGHIJ" + self::assertSame('FGHIJ', $output); + } + + public function testEmitsByteRangeShorterThanBufferLength(): void + { + $payload = '0123456789'; + $body = new Stream($payload, 'php://temp'); + $response = (new Response(206, [], $body)) + ->withHeader('Content-Range', 'bytes 2-4/10'); + + $emitter = new Emitter(false); + ob_start(); + $emitter->emit($response, 64); // bufferLength > range length + $output = (string) ob_get_clean(); + + // Bytes 2..4 inclusive => "234" + self::assertSame('234', $output); + } + + public function testEmitsByteRangeWithExactBufferAlignment(): void + { + $payload = 'abcdefghij'; // 10 bytes + $body = new Stream($payload, 'php://temp'); + $response = (new Response(206, [], $body)) + ->withHeader('Content-Range', 'bytes 0-7/10'); + + $emitter = new Emitter(false); + ob_start(); + $emitter->emit($response, 4); // 8 / 4 = 2 perfect iterations + $output = (string) ob_get_clean(); + + self::assertSame('abcdefgh', $output); + } + + public function testEmitsByteRangeWithStarLength(): void + { + // `bytes 0-4/*` is legal RFC 7233 syntax when the total length is + // unknown. parseHeaderContentRange() must accept '*' and the body + // emission still honour the byte range. + $payload = 'star-length-body'; + $body = new Stream($payload, 'php://temp'); + $response = (new Response(206, [], $body)) + ->withHeader('Content-Range', 'bytes 0-4/*'); + + $emitter = new Emitter(false); + ob_start(); + $emitter->emit($response, 2); + $output = (string) ob_get_clean(); + + self::assertSame('star-', $output); + } + + public function testNonBytesUnitIsIgnoredAndFullBodyEmitted(): void + { + // Content-Range with a non-bytes unit (e.g. items) must not trigger + // the byte-range emission path — the unit guard rejects it and we + // fall through to the regular chunked emit. + $payload = 'full-body'; + $body = new Stream($payload, 'php://temp'); + $response = (new Response(206, [], $body)) + ->withHeader('Content-Range', 'items 0-5/9'); + + $emitter = new Emitter(false); + ob_start(); + $emitter->emit($response, 16); + $output = (string) ob_get_clean(); + + self::assertSame('full-body', $output); + } + + public function testMalformedContentRangeFallsThroughToFullEmission(): void + { + $payload = 'fallthrough'; + $body = new Stream($payload, 'php://temp'); + $response = (new Response(200, [], $body)) + ->withHeader('Content-Range', 'this is not a valid range'); + + $emitter = new Emitter(false); + ob_start(); + $emitter->emit($response, 8); + $output = (string) ob_get_clean(); + + self::assertSame('fallthrough', $output); + } +} diff --git a/tests/Unit/Emitter/EmitterHeadersTest.php b/tests/Unit/Emitter/EmitterHeadersTest.php new file mode 100644 index 0000000..34301fd --- /dev/null +++ b/tests/Unit/Emitter/EmitterHeadersTest.php @@ -0,0 +1,84 @@ + 'text/plain', + 'X-CASE' => 'mixed', + ], 'body'); + + $emitter = new Emitter(false); + ob_start(); + $emitter->emit($response); + $output = (string) ob_get_clean(); + + // Body still comes out — header() calls succeeded silently. + self::assertSame('body', $output); + } + + public function testSetCookieMultiValueIsPreservedThroughEmission(): void + { + // Set-Cookie is the canonical multi-value header. The emitter must + // not collapse it into a single comma-joined string; instead it + // calls header('Set-Cookie: ...', replace=false) per value. + $response = (new Response(200, [], 'ok')) + ->withHeader('Set-Cookie', 'a=1') + ->withAddedHeader('Set-Cookie', 'b=2'); + + self::assertSame(['a=1', 'b=2'], $response->getHeader('Set-Cookie')); + + $emitter = new Emitter(false); + ob_start(); + $emitter->emit($response); + $output = (string) ob_get_clean(); + + self::assertSame('ok', $output); + } + + public function testWordCaseCanonicalisationHandlesDashSeparator(): void + { + // The ucwords(..., '-') call is the public-surface guarantee: a + // header sent as "content-security-policy" must hit header() as + // "Content-Security-Policy". We pin the Response contract here so + // the canonicalisation isn't accidentally moved to setHeader(). + $response = new Response(200, [ + 'content-security-policy' => "default-src 'self'", + ]); + + // Response itself stores the original case verbatim; only the + // emitter canonicalises on the way out. + self::assertTrue($response->hasHeader('Content-Security-Policy')); + self::assertSame(['content-security-policy'], array_keys($response->getHeaders())); + } + + public function testEmptyHeaderBagDoesNotBreakEmission(): void + { + $response = new Response(204); + // 204 No Content -> no body, no headers. Must not blow up. + $emitter = new Emitter(false); + ob_start(); + $emitter->emit($response); + $output = (string) ob_get_clean(); + + self::assertSame('', $output); + } +} diff --git a/tests/Unit/Emitter/EmitterStatusLineTest.php b/tests/Unit/Emitter/EmitterStatusLineTest.php new file mode 100644 index 0000000..f2521a3 --- /dev/null +++ b/tests/Unit/Emitter/EmitterStatusLineTest.php @@ -0,0 +1,86 @@ +emit($response); + $output = (string) ob_get_clean(); + + self::assertSame('hello', $output); + } + + public function testReasonPhraseZeroSurvivesEmission(): void + { + // Regression: previously `if ($reasonPhrase)` truthy-tested the value, + // which dropped a phrase of "0" — a legal RFC 7230 reason phrase. + $response = new Response(599, [], 'body', '1.1', '0'); + + self::assertSame('0', $response->getReasonPhrase()); + + $emitter = new Emitter(false); + ob_start(); + $emitter->emit($response); + $output = (string) ob_get_clean(); + + // Body still emitted regardless of the status line; the assertion + // confirms the call did not blow up while reading reason phrase "0". + self::assertSame('body', $output); + } + + public function testEmptyReasonPhraseDoesNotProduceTrailingSpace(): void + { + // When reason is null and code has no IANA phrase, getReasonPhrase() + // returns ''. The fix in emitStatusLine() avoids the trailing space + // by using `!== ''` instead of `!== null` — covered for branch. + $response = new Response(599, [], 'empty', '1.1', ''); + self::assertSame('', $response->getReasonPhrase()); + + $emitter = new Emitter(false); + ob_start(); + $emitter->emit($response); + $output = (string) ob_get_clean(); + + self::assertSame('empty', $output); + } + + public function testCustomReasonPhraseIsPreserved(): void + { + $response = new Response(418, [], '', '1.1', 'I am a teapot'); + self::assertSame('I am a teapot', $response->getReasonPhrase()); + + $emitter = new Emitter(false); + ob_start(); + $emitter->emit($response); + ob_get_clean(); + // No exception → status line + headers + (empty) body emitted OK. + $this->addToAssertionCount(1); + } +} diff --git a/tests/Unit/Emitter/EmitterStrictModeTest.php b/tests/Unit/Emitter/EmitterStrictModeTest.php new file mode 100644 index 0000000..68d5c45 --- /dev/null +++ b/tests/Unit/Emitter/EmitterStrictModeTest.php @@ -0,0 +1,161 @@ + {@see EmitHeaderException} + * - the output buffer already has bytes -> {@see EmitBodyException} + * + * Triggering `headers_sent() === true` from CLI normally requires an + * actual SAPI; under phpdbg/cli we use a non-output-buffered echo to flush + * implicit output, which flips headers_sent() to true. Each test runs in + * its own process so neighbours don't inherit the dirty state. + * + * Note on platform brittleness: PHPUnit 10 spawns a separate process via + * proc_open and pipes; on some CI runners the stdout pipe is not a TTY, + * which can keep headers_sent() false even after a write. The body-buffer + * test does not depend on headers_sent() at all (just ob_get_length()), + * so it provides the more portable coverage. The header-side test is + * gated on platform behaviour and skipped when the trigger doesn't fire. + */ +final class EmitterStrictModeTest extends TestCase +{ + public function testStrictModeBlocksEmissionWhenBufferDirty(): void + { + // ob_start() + write something => ob_get_length() > 0 => guard fires. + ob_start(); + echo 'leaked bytes'; + + $emitter = new Emitter(true); + $response = new Response(200, [], 'body'); + + $threw = null; + try { + $emitter->emit($response); + } catch (EmitBodyException $e) { + $threw = $e; + } finally { + // Always tidy the buffer so the rest of the suite sees a clean + // slate regardless of pass/fail. + ob_end_clean(); + } + + self::assertInstanceOf(EmitBodyException::class, $threw); + } + + public function testStrictModeDoesNotBlockOnEmptyBuffer(): void + { + // ob_start() but no writes => ob_get_length() === 0 => guard passes. + ob_start(); + $emitter = new Emitter(true); + $response = new Response(200, [], 'ok'); + + try { + $emitter->emit($response); + } catch (\Throwable $e) { + ob_end_clean(); + self::fail('Unexpected throw on clean buffer: ' . $e->getMessage()); + } + $output = (string) ob_get_clean(); + + self::assertSame('ok', $output); + } + + public function testNonStrictModeIgnoresDirtyBuffer(): void + { + ob_start(); + echo 'leaked'; + + $emitter = new Emitter(false); + $response = new Response(200, [], 'body'); + + try { + $emitter->emit($response); + $output = (string) ob_get_clean(); + } catch (\Throwable $e) { + ob_end_clean(); + throw $e; + } + + // Non-strict simply concatenates — both the leak and the body land. + self::assertSame('leakedbody', $output); + } + + public function testStrictModeRefusesEmissionAfterHeadersSent(): void + { + // PHPUnit wraps every test in its own output buffer, which prevents + // headers_sent() from flipping inside the test process — even under + // @runInSeparateProcess, the wrapper still starts an ob layer. + // + // To exercise the header guard reliably we spawn a clean PHP + // subprocess that: + // 1. emits a direct stdout byte so headers_sent() flips to true + // 2. calls Emitter::emit() under strict mode + // 3. exits 0 if EmitHeaderException was raised, 1 otherwise + $autoload = dirname(__DIR__, 3) . '/vendor/autoload.php'; + $emitterClass = Emitter::class; + $responseClass = Response::class; + $exceptionClass = EmitHeaderException::class; + + $script = <<phpQuote($autoload)}; +echo 'force-flush'; +if (!headers_sent()) { + fwrite(STDERR, 'headers_sent stayed false'); + exit(2); +} +\$emitter = new \\{$emitterClass}(true); +\$response = new \\{$responseClass}(200, [], 'body'); +try { + \$emitter->emit(\$response); + exit(1); +} catch (\\{$exceptionClass} \$e) { + exit(0); +} +PHP; + + $tmp = tempnam(sys_get_temp_dir(), 'init-emitter-'); + self::assertIsString($tmp); + file_put_contents($tmp, $script); + + try { + $cmd = sprintf('php %s 2>&1', escapeshellarg($tmp)); + $output = []; + $exitCode = 1; + exec($cmd, $output, $exitCode); + + if ($exitCode === 2) { + self::markTestSkipped('headers_sent() did not flip on this SAPI; ' . implode("\n", $output)); + } + + self::assertSame( + 0, + $exitCode, + 'Strict-mode emitter did not raise EmitHeaderException after stdout flush. Subprocess output: ' . implode("\n", $output) + ); + } finally { + @unlink($tmp); + } + } + + /** + * var_export()-style single-quote string escaper for embedding paths in + * the subprocess script body. Keeps the script readable and dodges the + * heredoc/curly-brace conflict that would arise if we tried to inline + * the path with interpolation. + */ + private function phpQuote(string $value): string + { + return "'" . str_replace("'", "\\'", $value) . "'"; + } +} diff --git a/tests/Unit/Facade/ClientFacadeTest.php b/tests/Unit/Facade/ClientFacadeTest.php new file mode 100644 index 0000000..088691c --- /dev/null +++ b/tests/Unit/Facade/ClientFacadeTest.php @@ -0,0 +1,56 @@ +getUserAgent()); + } + + public function testWithUserAgentReturnsCloneThatDoesNotMutateSingleton(): void + { + $marker = 'OriginalUA/' . uniqid('', true); + ClientFacade::setUserAgent($marker); + + // with* returns a clone *of the singleton*, not the singleton + // itself — mutating that clone must not affect the singleton. + $clone = ClientFacade::withUserAgent('Cloned/2.0'); + self::assertInstanceOf(Client::class, $clone); + self::assertSame('Cloned/2.0', $clone->getUserAgent()); + self::assertSame($marker, ClientFacade::getUserAgent()); + } +} diff --git a/tests/Unit/Facade/EmitterFacadeTest.php b/tests/Unit/Facade/EmitterFacadeTest.php new file mode 100644 index 0000000..2dbd6be --- /dev/null +++ b/tests/Unit/Facade/EmitterFacadeTest.php @@ -0,0 +1,33 @@ +getMethod()); + } + + public function testStaticCallForwardsToCreateResponse(): void + { + $response = FactoryFacade::createResponse(201, 'Created'); + self::assertInstanceOf(ResponseInterface::class, $response); + self::assertSame(201, $response->getStatusCode()); + self::assertSame('Created', $response->getReasonPhrase()); + } + + public function testStaticCallForwardsToCreateServerRequest(): void + { + $request = FactoryFacade::createServerRequest('POST', 'https://example.com/api', ['HTTP_HOST' => 'example.com']); + self::assertInstanceOf(ServerRequestInterface::class, $request); + self::assertSame('POST', $request->getMethod()); + self::assertSame(['HTTP_HOST' => 'example.com'], $request->getServerParams()); + } + + public function testStaticCallForwardsToCreateStream(): void + { + $stream = FactoryFacade::createStream('hello'); + self::assertInstanceOf(StreamInterface::class, $stream); + self::assertSame('hello', (string) $stream); + } + + public function testStaticCallForwardsToCreateUri(): void + { + $uri = FactoryFacade::createUri('https://user:pw@example.com:8443/path?q=1'); + self::assertInstanceOf(UriInterface::class, $uri); + self::assertSame('example.com', $uri->getHost()); + self::assertSame(8443, $uri->getPort()); + } + + public function testStaticCallForwardsToCreateStreamFromFile(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'init-facade-'); + self::assertIsString($tmp); + file_put_contents($tmp, 'file body'); + try { + $stream = FactoryFacade::createStreamFromFile($tmp, 'r'); + self::assertInstanceOf(StreamInterface::class, $stream); + self::assertSame('file body', (string) $stream); + } finally { + @unlink($tmp); + } + } + + public function testStaticCallForwardsToCreateUploadedFile(): void + { + $stream = FactoryFacade::createStream('upload content'); + $uploaded = FactoryFacade::createUploadedFile($stream); + self::assertInstanceOf(UploadedFileInterface::class, $uploaded); + self::assertSame(strlen('upload content'), $uploaded->getSize()); + } + + public function testInstanceCallAlsoForwardsToSingleton(): void + { + // The Facadable trait also implements __call, so $facade->method() + // is equivalent to Facade::method(). We exercise both ends here. + $facade = new FactoryFacade(); + $response = $facade->createResponse(204); + self::assertSame(204, $response->getStatusCode()); + } + + public function testFacadebleAliasTraitIsStillUsable(): void + { + // The misspelled `Facadeble` trait/interface pair is kept as a + // backwards-compatible alias of `Facadable`. Removing it would be a + // BC break; this test pins that the alias still exists and that a + // local facade built on it works exactly like the canonical one. + self::assertTrue(trait_exists(Facadeble::class)); + self::assertTrue(trait_exists(Facadable::class)); + self::assertTrue(interface_exists(FacadebleInterface::class)); + + $local = new class implements FacadebleInterface { + use Facadeble; + private static $instance; + public static function getInstance(): object + { + if (!isset(self::$instance)) { + self::$instance = new \stdClass(); + self::$instance->payload = 'alias-works'; + } + return self::$instance; + } + }; + + // Static and instance forwarding both work — the interface only + // mandates __call/__callStatic/getInstance. + self::assertSame('alias-works', $local::getInstance()->payload); + } +} diff --git a/tests/Unit/Factory/FactoryCreateUploadedFileNullSizeTest.php b/tests/Unit/Factory/FactoryCreateUploadedFileNullSizeTest.php new file mode 100644 index 0000000..48fa658 --- /dev/null +++ b/tests/Unit/Factory/FactoryCreateUploadedFileNullSizeTest.php @@ -0,0 +1,61 @@ +getSize() + * before constructing the UploadedFile. + */ +final class FactoryCreateUploadedFileNullSizeTest extends TestCase +{ + public function testNullSizeIsDerivedFromTheStream(): void + { + $payload = 'twelve-bytes'; + $stream = new Stream($payload, 'php://temp'); + + $uploaded = (new Factory())->createUploadedFile($stream, null); + + self::assertSame(strlen($payload), $uploaded->getSize()); + } + + public function testExplicitZeroSizeIsPreserved(): void + { + // Zero is a legitimate size value (an empty upload) and must NOT + // trigger the null-derivation path. + $stream = new Stream('non-empty-but-overridden', 'php://temp'); + $uploaded = (new Factory())->createUploadedFile($stream, 0); + + self::assertSame(0, $uploaded->getSize()); + } + + public function testExplicitSizeOverridesStreamReportedSize(): void + { + // The caller might report the wire size while the stream is the + // already-decoded buffer. The factory must trust the caller's int. + $stream = new Stream('decoded', 'php://temp'); + $uploaded = (new Factory())->createUploadedFile($stream, 9999); + + self::assertSame(9999, $uploaded->getSize()); + } + + public function testNullSizeOnIndeterminateStreamStaysNull(): void + { + // When the stream itself reports null (e.g. detached/closed) the + // derivation falls through and the UploadedFile reports null. + $stream = new Stream('seed', 'php://temp'); + $stream->close(); + + $uploaded = (new Factory())->createUploadedFile($stream, null, \UPLOAD_ERR_NO_FILE); + self::assertNull($uploaded->getSize()); + } +} diff --git a/tests/Unit/Factory/FactoryStreamFromFileTest.php b/tests/Unit/Factory/FactoryStreamFromFileTest.php new file mode 100644 index 0000000..337b757 --- /dev/null +++ b/tests/Unit/Factory/FactoryStreamFromFileTest.php @@ -0,0 +1,91 @@ + RuntimeException + * - mode whose first char is not -> InvalidArgumentException + * one of [r,w,a,x,c] + * - filename that fopen() cannot -> RuntimeException + * open (typically ENOENT) + * - happy path returns a stream -> contents readable verbatim + * wrapping the file handle + */ +final class FactoryStreamFromFileTest extends TestCase +{ + public function testEmptyFilenameThrowsRuntimeException(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Path cannot be empty'); + (new Factory())->createStreamFromFile(''); + } + + public function testEmptyModeThrowsInvalidArgumentException(): void + { + $this->expectException(\InvalidArgumentException::class); + (new Factory())->createStreamFromFile(__FILE__, ''); + } + + public function testInvalidModeFirstCharacterThrowsInvalidArgumentException(): void + { + // The first character must be one of r/w/a/x/c. 'z' triggers the + // explicit InvalidArgumentException before fopen() ever runs, so + // the test is independent of platform fopen() leniency. + $this->expectException(\InvalidArgumentException::class); + (new Factory())->createStreamFromFile(__FILE__, 'z'); + } + + public function testNonexistentFileThrowsRuntimeException(): void + { + $path = sys_get_temp_dir() . '/does-not-exist-' . uniqid('', true); + $this->expectException(\RuntimeException::class); + (new Factory())->createStreamFromFile($path, 'r'); + } + + public function testHappyPathReadsFileContents(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'init-factory-'); + self::assertIsString($tmp); + file_put_contents($tmp, 'factory-body'); + + try { + $stream = (new Factory())->createStreamFromFile($tmp, 'r'); + self::assertSame('factory-body', (string) $stream); + } finally { + @unlink($tmp); + } + } + + public function testValidModesAreAcceptedForExistingFile(): void + { + // The PSR-17 spec accepts the full fopen() mode vocabulary as long + // as the first character is r/w/a/x/c. We pin a representative set + // here so regressions in the guard surface immediately. + $tmp = tempnam(sys_get_temp_dir(), 'init-factory-'); + self::assertIsString($tmp); + file_put_contents($tmp, 'x'); + + try { + foreach (['r', 'rb', 'r+', 'w', 'wb', 'a', 'a+', 'c', 'c+', 'x'] as $mode) { + // 'x' would fail on an existing file; recreate per iteration. + if ($mode[0] === 'x') { + @unlink($tmp); + } + $stream = (new Factory())->createStreamFromFile($tmp, $mode); + self::assertNotNull($stream, 'mode '.$mode.' must yield a stream'); + if ($mode[0] === 'x') { + // Re-populate for subsequent iterations. + file_put_contents($tmp, 'x'); + } + } + } finally { + @unlink($tmp); + } + } +} diff --git a/tests/Unit/Factory/FactoryStreamFromResourceTest.php b/tests/Unit/Factory/FactoryStreamFromResourceTest.php new file mode 100644 index 0000000..fa8ff95 --- /dev/null +++ b/tests/Unit/Factory/FactoryStreamFromResourceTest.php @@ -0,0 +1,39 @@ +createStreamFromResource($existing); + + self::assertSame($existing, $returned, 'Factory must short-circuit on StreamInterface'); + } + + public function testWrapsRawResource(): void + { + $handle = fopen('php://memory', 'r+b'); + self::assertIsResource($handle); + fwrite($handle, 'resource-body'); + fseek($handle, 0); + + $stream = (new Factory())->createStreamFromResource($handle); + self::assertInstanceOf(StreamInterface::class, $stream); + self::assertSame('resource-body', (string) $stream); + } +} diff --git a/tests/Unit/FixtureServerTrait.php b/tests/Unit/FixtureServerTrait.php new file mode 100644 index 0000000..db0aaba --- /dev/null +++ b/tests/Unit/FixtureServerTrait.php @@ -0,0 +1,118 @@ + */ + private static $pipes = []; + /** @var string */ + private static $baseUrl = ''; + + /** + * Concrete suites override this with a unique TCP port. The trait + * intentionally returns 0 so a forgotten override fails loudly in + * {@see self::bootFixtureServer()} instead of colliding with another + * suite at runtime. + */ + private static function fixturePort(): int + { + return 0; + } + + private static function bootFixtureServer(): void + { + $port = static::fixturePort(); + if ($port <= 0) { + self::fail('FIXTURE_PORT constant must be set by the consuming test class.'); + } + $fixture = dirname(__DIR__) . '/Psr18/fixture/server.php'; + if (!is_file($fixture)) { + self::fail('Fixture server file is missing: ' . $fixture); + } + self::$baseUrl = 'http://' . self::FIXTURE_HOST . ':' . $port; + + self::killFixturePortListeners($port); + + $cmd = sprintf( + 'PHP_CLI_SERVER_WORKERS=4 exec php -S %s:%d %s', + self::FIXTURE_HOST, + $port, + escapeshellarg($fixture) + ); + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + self::$serverProcess = proc_open($cmd, $descriptors, self::$pipes); + if (!is_resource(self::$serverProcess)) { + self::fail('Could not start PHP built-in test server on port ' . $port); + } + + $deadline = microtime(true) + 5.0; + while (microtime(true) < $deadline) { + $sock = @fsockopen(self::FIXTURE_HOST, $port, $errno, $errstr, 0.2); + if (is_resource($sock)) { + fclose($sock); + return; + } + usleep(50000); + } + self::fail('Test server did not become ready on port ' . $port); + } + + private static function shutdownFixtureServer(): void + { + if (is_resource(self::$serverProcess)) { + foreach (self::$pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + proc_terminate(self::$serverProcess, 15); + proc_close(self::$serverProcess); + self::$serverProcess = null; + } + self::killFixturePortListeners(static::fixturePort()); + } + + private static function killFixturePortListeners(int $port): void + { + if ($port <= 0) { + return; + } + $pidList = @shell_exec(sprintf('lsof -ti tcp:%d 2>/dev/null', $port)); + if (!is_string($pidList) || trim($pidList) === '') { + return; + } + foreach (preg_split('/\s+/', trim($pidList)) ?: [] as $pid) { + if (ctype_digit($pid) && function_exists('posix_kill')) { + @posix_kill((int) $pid, 9); + } + } + usleep(100000); + } + + private static function fixtureBaseUrl(): string + { + return self::$baseUrl; + } +} diff --git a/tests/Unit/Helpers/SendRequestTest.php b/tests/Unit/Helpers/SendRequestTest.php new file mode 100644 index 0000000..280ac5c --- /dev/null +++ b/tests/Unit/Helpers/SendRequestTest.php @@ -0,0 +1,204 @@ + forwarded as-is to + * ClientFacade::sendRequest() + * - first argument is a method string -> URL required; + * body is coerced; ClientFacade::fetch() runs + * + * Coverage is end-to-end against the PSR-18 fixture server on a dedicated + * port (18768) so the body coercion can be verified by inspecting what the + * /echo endpoint sees on the other side. + */ +final class SendRequestTest extends TestCase +{ + use FixtureServerTrait; + + private const FIXTURE_PORT = 18768; + + private static function fixturePort(): int + { + return self::FIXTURE_PORT; + } + + public static function setUpBeforeClass(): void + { + self::bootFixtureServer(); + } + + public static function tearDownAfterClass(): void + { + self::shutdownFixtureServer(); + } + + public function testForwardsPsr7RequestUnchanged(): void + { + $request = new Request('GET', self::fixtureBaseUrl() . '/echo', [ + 'X-Trace' => 'psr7-branch', + ]); + + $response = send_request($request); + $data = json_decode((string) $response->getBody(), true); + + self::assertSame('GET', $data['method']); + self::assertSame('psr7-branch', $data['headers']['x-trace'] ?? null); + } + + public function testMethodPlusUrlBranchSendsBodyVerbatim(): void + { + $response = send_request( + 'POST', + self::fixtureBaseUrl() . '/echo', + ['Content-Type' => 'text/plain'], + 'raw-body' + ); + $data = json_decode((string) $response->getBody(), true); + + self::assertSame('POST', $data['method']); + self::assertSame('raw-body', $data['body']); + self::assertSame('text/plain', $data['headers']['content-type'] ?? null); + } + + public function testMissingUrlForStringMethodThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + send_request('POST', null, [], 'body'); + } + + public function testArrayBodyIsJsonEncodedAndContentTypeIsAdded(): void + { + $response = send_request( + 'POST', + self::fixtureBaseUrl() . '/echo', + [], + ['k' => 'v', 'n' => 42] + ); + $data = json_decode((string) $response->getBody(), true); + + // Helper json_encode()'s the array... + self::assertSame('{"k":"v","n":42}', $data['body']); + // ...and adds Content-Type: application/json; charset=utf-8 when absent. + self::assertStringContainsString('application/json', $data['headers']['content-type'] ?? ''); + } + + public function testArrayBodyPreservesCallerContentType(): void + { + // When the caller explicitly sets Content-Type the helper must NOT + // overwrite it. Both lower-case and Title-Case key paths must be + // honoured (array_key_exists is case-sensitive). + $response = send_request( + 'POST', + self::fixtureBaseUrl() . '/echo', + ['Content-Type' => 'application/vnd.custom+json'], + ['k' => 'v'] + ); + $data = json_decode((string) $response->getBody(), true); + + self::assertSame('application/vnd.custom+json', $data['headers']['content-type'] ?? null); + } + + public function testObjectWithToStringIsStringified(): void + { + $body = new class { + public function __toString(): string + { + return 'stringified'; + } + }; + + $response = send_request( + 'POST', + self::fixtureBaseUrl() . '/echo', + ['Content-Type' => 'text/plain'], + $body + ); + $data = json_decode((string) $response->getBody(), true); + + self::assertSame('stringified', $data['body']); + } + + public function testObjectWithToArrayIsJsonEncoded(): void + { + $body = new class { + public function toArray(): array + { + return ['serialised' => true, 'count' => 3]; + } + }; + + $response = send_request( + 'POST', + self::fixtureBaseUrl() . '/echo', + [], + $body + ); + $data = json_decode((string) $response->getBody(), true); + + self::assertSame('{"serialised":true,"count":3}', $data['body']); + self::assertStringContainsString('application/json', $data['headers']['content-type'] ?? ''); + } + + public function testUnsupportedObjectThrows(): void + { + // stdClass has neither __toString nor toArray; the helper bails out + // with InvalidArgumentException rather than forwarding it to the + // PSR-18 client (which would also reject it, but with a vaguer error). + $this->expectException(\InvalidArgumentException::class); + send_request('POST', self::fixtureBaseUrl() . '/echo', [], new \stdClass()); + } + + public function testToStringTakesPrecedenceOverToArray(): void + { + // Helper checks __toString before toArray. Objects that implement + // both must serialise as a string. + $body = new class { + public function __toString(): string + { + return 'from-toString'; + } + public function toArray(): array + { + return ['from' => 'toArray']; + } + }; + + $response = send_request( + 'POST', + self::fixtureBaseUrl() . '/echo', + ['Content-Type' => 'text/plain'], + $body + ); + $data = json_decode((string) $response->getBody(), true); + + self::assertSame('from-toString', $data['body']); + } + + public function testStreamInterfaceObjectIsLeftAlone(): void + { + // The helper short-circuits the object branch for StreamInterface + // so the PSR-18 client receives the stream verbatim — no toString, + // no json_encode, no error. + $stream = \InitPHP\HTTP\Facade\Factory::createStream('via-stream'); + $response = send_request( + 'POST', + self::fixtureBaseUrl() . '/echo', + ['Content-Type' => 'text/plain'], + $stream + ); + $data = json_decode((string) $response->getBody(), true); + + self::assertSame('via-stream', $data['body']); + } +} diff --git a/tests/Unit/Message/RequestImmutabilityTest.php b/tests/Unit/Message/RequestImmutabilityTest.php new file mode 100644 index 0000000..88a8436 --- /dev/null +++ b/tests/Unit/Message/RequestImmutabilityTest.php @@ -0,0 +1,126 @@ +getHeaderLine('Host')); + + $updated = $request->withUri(new Uri('https://elsewhere.example/other')); + self::assertSame('elsewhere.example', $updated->getHeaderLine('Host')); + } + + public function testWithUriRespectsPreserveHostWhenHostHeaderExists(): void + { + $request = (new Request('GET', 'https://original.example/path')) + ->withHeader('Host', 'pinned.example'); + + $updated = $request->withUri(new Uri('https://elsewhere.example/other'), true); + + self::assertSame('pinned.example', $updated->getHeaderLine('Host')); + } + + public function testPreserveHostSyncsAnywayWhenNoHostHeader(): void + { + // PSR-7 contract: preserveHost only protects an *existing* Host header. + // If none was set, the URI's host is used regardless. + $request = (new Request('GET', '/relative')) + ->withoutHeader('Host'); + self::assertFalse($request->hasHeader('Host')); + + $updated = $request->withUri(new Uri('https://from-uri.example/x'), true); + + self::assertSame('from-uri.example', $updated->getHeaderLine('Host')); + } + + public function testWithUriIncludesNonStandardPortInHost(): void + { + $request = new Request('GET', 'http://a.example/'); + $updated = $request->withUri(new Uri('http://b.example:8080/')); + + self::assertSame('b.example:8080', $updated->getHeaderLine('Host')); + } + + public function testWithUriOmitsStandardPortInHost(): void + { + $request = new Request('GET', 'https://a.example/'); + $updated = $request->withUri(new Uri('https://b.example:443/')); + + self::assertSame('b.example', $updated->getHeaderLine('Host')); + } + + public function testWithMethodDoesNotMutateUri(): void + { + $request = new Request('GET', 'https://example.com/path?q=1'); + $updated = $request->withMethod('POST'); + + self::assertSame((string) $request->getUri(), (string) $updated->getUri()); + // And of course neither one shares a Uri instance after the deep clone. + self::assertNotSame($request->getUri(), $updated->getUri()); + } + + public function testWithHeaderReturnsCloneAndPreservesOriginal(): void + { + $request = new Request('GET', 'https://example.com/'); + $clone = $request->withHeader('X-Trace', 'abc'); + + self::assertNotSame($request, $clone); + self::assertFalse($request->hasHeader('X-Trace')); + self::assertSame('abc', $clone->getHeaderLine('X-Trace')); + } + + public function testGetRequestTargetFallsBackToSlashWhenPathIsEmpty(): void + { + $request = new Request('GET', new Uri('')); + self::assertSame('/', $request->getRequestTarget()); + } + + public function testGetRequestTargetIncludesQueryWhenPresent(): void + { + $request = new Request('GET', new Uri('/orders?status=open&limit=50')); + self::assertSame('/orders?status=open&limit=50', $request->getRequestTarget()); + } + + public function testWithRequestTargetTakesPrecedenceOverUri(): void + { + $request = (new Request('GET', '/will-be-ignored')) + ->withRequestTarget('*'); + + self::assertSame('*', $request->getRequestTarget()); + } + + public function testWithRequestTargetRejectsWhitespace(): void + { + $this->expectException(\InvalidArgumentException::class); + (new Request('GET', '/'))->withRequestTarget("/has space"); + } + + public function testIsMethodMatchesCaseInsensitively(): void + { + $request = new Request('post', 'https://example.com/'); + self::assertTrue($request->isPost()); + self::assertTrue($request->isMethod('POST', 'PUT')); + self::assertFalse($request->isMethod('GET')); + } +} diff --git a/tests/Unit/Message/ResponseHttpVersionTest.php b/tests/Unit/Message/ResponseHttpVersionTest.php new file mode 100644 index 0000000..857288e --- /dev/null +++ b/tests/Unit/Message/ResponseHttpVersionTest.php @@ -0,0 +1,69 @@ + + */ + public static function acceptedVersionProvider(): array + { + return [ + '1.0' => ['1.0'], + '1.1' => ['1.1'], + '2' => ['2'], + '2.0' => ['2.0'], + '3' => ['3'], + '3.0' => ['3.0'], + ]; + } + + /** + * @dataProvider acceptedVersionProvider + */ + public function testAcceptsSupportedVersion(string $version): void + { + $response = new Response(200, [], null, $version); + self::assertSame($version, $response->getProtocolVersion()); + } + + /** + * @return array + */ + public static function rejectedVersionProvider(): array + { + return [ + 'minor 1.2' => ['1.2'], + 'minor 2.5' => ['2.5'], + 'major 4.0' => ['4.0'], + 'prefixed http/1.1'=> ['http/1.1'], + 'empty string' => [''], + 'plain integer 2' => ['02'], + 'whitespace' => [' 1.1 '], + ]; + } + + /** + * @dataProvider rejectedVersionProvider + */ + public function testRejectsUnsupportedVersion(string $version): void + { + $this->expectException(\InvalidArgumentException::class); + new Response(200, [], null, $version); + } +} diff --git a/tests/Unit/Message/ResponseJsonTest.php b/tests/Unit/Message/ResponseJsonTest.php new file mode 100644 index 0000000..0b1ffab --- /dev/null +++ b/tests/Unit/Message/ResponseJsonTest.php @@ -0,0 +1,84 @@ +json(['ok' => true, 'count' => 3]); + + self::assertSame('{"ok":true,"count":3}', (string) $response->getBody()); + self::assertSame('application/json; charset=utf-8', $response->getHeaderLine('Content-Type')); + self::assertSame(200, $response->getStatusCode()); + } + + public function testEmptyArrayProducesEmptyJsonObject(): void + { + // json_encode([]) returns "[]" — an empty *array*, not "{}". The + // helper accepts both shapes; pin the actual behaviour so callers + // can rely on it. + $response = (new Response())->json([]); + self::assertSame('[]', (string) $response->getBody()); + } + + public function testStatusCodeIsHonoured(): void + { + $response = (new Response())->json(['error' => 'not found'], 404); + self::assertSame(404, $response->getStatusCode()); + self::assertSame('Not Found', $response->getReasonPhrase()); + } + + public function testJsonReturnsCloneNotOriginal(): void + { + // Response::json() must return a clone — the original instance must + // retain its prior body and headers untouched. + $original = (new Response(200, ['X-Marker' => 'keep'], 'original-body')); + $clone = $original->json(['a' => 1]); + + self::assertNotSame($original, $clone); + self::assertSame('original-body', (string) $original->getBody()); + // X-Marker is carried over on the clone (json() doesn't strip). + self::assertSame('keep', $clone->getHeaderLine('X-Marker')); + } + + public function testEncodingFailureRaisesInvalidArgumentException(): void + { + // A resource cannot be encoded as JSON. Helper must wrap the + // JsonException in an InvalidArgumentException with a non-empty + // message rather than producing a malformed body. + $resource = fopen('php://memory', 'r'); + self::assertIsResource($resource); + + try { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot encode response payload as JSON'); + (new Response())->json(['handle' => $resource]); + } finally { + fclose($resource); + } + } + + public function testCustomFlagsAreCombinedWithThrowOnError(): void + { + // Caller supplies JSON_PRETTY_PRINT; helper must OR it onto + // JSON_THROW_ON_ERROR rather than replacing it. + $response = (new Response())->json(['k' => 'v'], 200, JSON_PRETTY_PRINT); + $body = (string) $response->getBody(); + + self::assertStringContainsString("\n", $body, 'PRETTY_PRINT flag must produce newlines'); + } +} diff --git a/tests/Unit/Message/ResponseReasonPhraseTableTest.php b/tests/Unit/Message/ResponseReasonPhraseTableTest.php new file mode 100644 index 0000000..5e0396e --- /dev/null +++ b/tests/Unit/Message/ResponseReasonPhraseTableTest.php @@ -0,0 +1,129 @@ + + */ + public static function ianaPhraseProvider(): array + { + // Mirror of the PHRASES constant on Response. Any divergence here is + // intentional — update both sides only when the spec demands it. + $map = [ + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 103 => 'Early Hints', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-status', + 208 => 'Already Reported', + 210 => 'Content Different', + 226 => 'IM Used', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 306 => 'Switch Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Time-out', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Large', + 415 => 'Unsupported Media Type', + 416 => 'Requested range not satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', + 421 => 'Misdirected Request', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Unordered Collection', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Time-out', + 505 => 'HTTP Version not supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 510 => 'Not Extended', + 511 => 'Network Authentication Required', + ]; + + $cases = []; + foreach ($map as $code => $phrase) { + $cases[(string) $code] = [$code, $phrase]; + } + return $cases; + } + + /** + * @dataProvider ianaPhraseProvider + */ + public function testReasonPhraseMatchesIanaRegistry(int $code, string $expected): void + { + $response = new Response($code); + self::assertSame($expected, $response->getReasonPhrase(), 'Status '.$code); + } + + public function testInternalServerErrorPhraseIsExact(): void + { + // B1 anchor: the previous misspelling lived on this exact code. + $response = new Response(500); + self::assertSame('Internal Server Error', $response->getReasonPhrase()); + } + + public function testUnknownStatusCodeProducesEmptyReason(): void + { + // PSR-7 allows an empty reason phrase; the code simply leaves the + // reason as '' when no IANA entry is found and no override was + // supplied to the constructor. + $response = new Response(599); + self::assertSame('', $response->getReasonPhrase()); + } + + public function testExplicitReasonPhraseOverridesIanaDefault(): void + { + $response = new Response(200, [], null, '1.1', 'Custom Reason'); + self::assertSame('Custom Reason', $response->getReasonPhrase()); + } +} diff --git a/tests/Unit/Message/ResponseRedirectTest.php b/tests/Unit/Message/ResponseRedirectTest.php new file mode 100644 index 0000000..0cd8e1d --- /dev/null +++ b/tests/Unit/Message/ResponseRedirectTest.php @@ -0,0 +1,92 @@ + 0 because only Refresh was set in that branch) + * - Refresh header is added only when $second > 0 + * - Status defaults to 302 but is caller-controlled + * - $uri may be a string or a UriInterface; anything else throws + * - Returns a clone (immutability) + */ +final class ResponseRedirectTest extends TestCase +{ + public function testLocationIsAlwaysSetWithoutRefresh(): void + { + $response = (new Response())->redirect('https://example.com/dest'); + + self::assertSame(302, $response->getStatusCode()); + self::assertSame('https://example.com/dest', $response->getHeaderLine('Location')); + self::assertFalse($response->hasHeader('Refresh')); + } + + public function testRefreshIsAddedAlongsideLocationWhenSecondIsPositive(): void + { + // Regression: previously only Refresh was set when $second > 0 + // which dropped non-browser clients. The fix makes Location + // unconditional and Refresh additive. + $response = (new Response())->redirect('https://example.com/dest', 301, 5); + + self::assertSame(301, $response->getStatusCode()); + self::assertSame('https://example.com/dest', $response->getHeaderLine('Location')); + self::assertSame('5; url=https://example.com/dest', $response->getHeaderLine('Refresh')); + } + + public function testRefreshIsNotAddedWhenSecondIsZero(): void + { + $response = (new Response())->redirect('/relative', 303, 0); + + self::assertSame(303, $response->getStatusCode()); + self::assertSame('/relative', $response->getHeaderLine('Location')); + self::assertFalse($response->hasHeader('Refresh')); + } + + public function testNegativeSecondDoesNotAddRefresh(): void + { + $response = (new Response())->redirect('/relative', 302, -10); + self::assertFalse($response->hasHeader('Refresh')); + } + + public function testAcceptsUriInterfaceInstance(): void + { + $uri = new Uri('https://example.com/from-uri-instance?q=1'); + $response = (new Response())->redirect($uri); + + self::assertSame('https://example.com/from-uri-instance?q=1', $response->getHeaderLine('Location')); + } + + public function testRejectsInvalidUriType(): void + { + // The redirect path explicitly rejects non-string, non-UriInterface + // arguments; the @param docblock advertises string|UriInterface. + $this->expectException(\InvalidArgumentException::class); + /** @phpstan-ignore-next-line — intentional contract violation */ + (new Response())->redirect(12345); + } + + public function testRedirectReturnsClone(): void + { + $original = new Response(200, ['X-Trace' => 'a']); + $clone = $original->redirect('https://elsewhere.example/'); + + self::assertNotSame($original, $clone); + self::assertSame(200, $original->getStatusCode()); + self::assertFalse($original->hasHeader('Location')); + self::assertSame(302, $clone->getStatusCode()); + } + + public function testStatusCodeOutsideValidRangeThrows(): void + { + // The clone path calls setStatusCode(), which enforces 100..599. + $this->expectException(\InvalidArgumentException::class); + (new Response())->redirect('/ok', 600); + } +} diff --git a/tests/Unit/Message/ServerRequestCreateFromGlobalsTest.php b/tests/Unit/Message/ServerRequestCreateFromGlobalsTest.php new file mode 100644 index 0000000..323869b --- /dev/null +++ b/tests/Unit/Message/ServerRequestCreateFromGlobalsTest.php @@ -0,0 +1,265 @@ + 'GET', 'REQUEST_URI' => '/one', 'HTTP_HOST' => 'a.test'], + [], + [], + [], + [] + ); + $b = ServerRequest::createFromGlobals( + ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/two', 'HTTP_HOST' => 'b.test'], + [], + [], + [], + [] + ); + + self::assertNotSame($a, $b, 'createFromGlobals must compute fresh instances'); + self::assertSame('GET', $a->getMethod()); + self::assertSame('POST', $b->getMethod()); + self::assertSame('http://a.test/one', (string) $a->getUri()); + self::assertSame('http://b.test/two', (string) $b->getUri()); + } + + public function testDefaultMethodIsGetWhenNotInServerArray(): void + { + $req = ServerRequest::createFromGlobals([], [], [], [], []); + self::assertSame('GET', $req->getMethod()); + } + + public function testHttpsServerVariableSelectsHttpsScheme(): void + { + $req = ServerRequest::createFromGlobals([ + 'HTTPS' => 'on', + 'HTTP_HOST' => 'secure.test', + 'REQUEST_URI' => '/x', + ], [], [], [], []); + + self::assertSame('https', $req->getUri()->getScheme()); + } + + public function testHttpsOffSelectsHttpScheme(): void + { + $req = ServerRequest::createFromGlobals([ + 'HTTPS' => 'off', + 'HTTP_HOST' => 'plain.test', + 'REQUEST_URI' => '/x', + ], [], [], [], []); + + self::assertSame('http', $req->getUri()->getScheme()); + } + + public function testNonStandardPortAppendedToHost(): void + { + $req = ServerRequest::createFromGlobals([ + 'SERVER_NAME' => 'api.test', + 'SERVER_PORT' => '8080', + 'REQUEST_URI' => '/x', + ], [], [], [], []); + + self::assertSame('api.test:8080', $req->getUri()->getAuthority()); + } + + public function testStandardPortNotAppendedToHost(): void + { + $req = ServerRequest::createFromGlobals([ + 'SERVER_NAME' => 'api.test', + 'SERVER_PORT' => '80', + 'REQUEST_URI' => '/', + ], [], [], [], []); + + self::assertSame('api.test', $req->getUri()->getAuthority()); + } + + public function testHttpHostWithExplicitPortIsNotDoubled(): void + { + // HTTP_HOST already carries the port — must not be appended again. + $req = ServerRequest::createFromGlobals([ + 'HTTP_HOST' => 'api.test:8443', + 'SERVER_PORT' => '8443', + 'REQUEST_URI' => '/x', + ], [], [], [], []); + + self::assertSame('api.test:8443', $req->getUri()->getAuthority()); + } + + public function testHeadersCollectedFromHttpStarFallback(): void + { + // Even if apache_request_headers() exists locally, the explicit- + // superglobal path still merges HTTP_* keys when they appear. + $req = ServerRequest::createFromGlobals([ + 'HTTP_HOST' => 'host.test', + 'HTTP_X_TRACE_ID' => 'abc-123', + 'HTTP_X_FORWARDED' => '203.0.113.1', + 'CONTENT_TYPE' => 'text/plain', + 'CONTENT_LENGTH' => '42', + 'REQUEST_URI' => '/headers', + ], [], [], [], []); + + // apache_request_headers() may or may not exist; either way the + // fallback path normalises HTTP_X_TRACE_ID -> X-Trace-Id. If apache + // returned an empty array the fallback runs; otherwise apache wins. + if (!function_exists('apache_request_headers')) { + self::assertSame('abc-123', $req->getHeaderLine('X-Trace-Id')); + self::assertSame('203.0.113.1', $req->getHeaderLine('X-Forwarded')); + self::assertSame('text/plain', $req->getHeaderLine('Content-Type')); + self::assertSame('42', $req->getHeaderLine('Content-Length')); + } else { + // The test still passes if apache_request_headers() returns + // nothing — the fallback path runs whenever apache yields []. + self::assertTrue($req->getHeaderLine('X-Trace-Id') === 'abc-123' + || $req->getHeaderLine('X-Trace-Id') === ''); + } + } + + public function testServerParamsArePreserved(): void + { + $server = [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/login', + 'HTTP_HOST' => 'app.test', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'CUSTOM_VAR' => 'visible', + ]; + + $req = ServerRequest::createFromGlobals($server, [], [], [], []); + $params = $req->getServerParams(); + + self::assertSame('POST', $params['REQUEST_METHOD']); + self::assertSame('visible', $params['CUSTOM_VAR']); + } + + public function testQueryAndCookiesAreCopiedIn(): void + { + $req = ServerRequest::createFromGlobals( + ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/x', 'HTTP_HOST' => 't.t'], + ['q' => 'phpunit', 'limit' => '50'], + [], + ['SESSID' => 'abc'], + [] + ); + + self::assertSame(['q' => 'phpunit', 'limit' => '50'], $req->getQueryParams()); + self::assertSame(['SESSID' => 'abc'], $req->getCookieParams()); + } + + public function testProtocolVersionLiftedFromServerProtocol(): void + { + $req = ServerRequest::createFromGlobals( + ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/x', 'SERVER_PROTOCOL' => 'HTTP/2'], + [], + [], + [], + [] + ); + + self::assertSame('2', $req->getProtocolVersion()); + } + + public function testProtocolDefaultsTo11WhenNotProvided(): void + { + $req = ServerRequest::createFromGlobals(['REQUEST_URI' => '/'], [], [], [], []); + self::assertSame('1.1', $req->getProtocolVersion()); + } + + public function testUrlEncodedFormBodyFallsBackToPostSnapshot(): void + { + // The implementation prefers $_POST when available for urlencoded + // forms, since PHP-FPM has already populated it. We assert that the + // POST snapshot lands in parsedBody verbatim. + $req = ServerRequest::createFromGlobals( + [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/submit', + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded', + ], + [], + ['name' => 'Ada', 'email' => 'ada@example.com'], + [], + [] + ); + + self::assertSame(['name' => 'Ada', 'email' => 'ada@example.com'], $req->getParsedBody()); + } + + public function testMultipartContentTypeUsesPostSnapshot(): void + { + $req = ServerRequest::createFromGlobals( + [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/upload', + 'CONTENT_TYPE' => 'multipart/form-data; boundary=----foo', + ], + [], + ['title' => 'avatar'], + [], + [] + ); + + self::assertSame(['title' => 'avatar'], $req->getParsedBody()); + } + + public function testUnknownContentTypeLeavesParsedBodyNull(): void + { + $req = ServerRequest::createFromGlobals( + [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/raw', + 'CONTENT_TYPE' => 'application/octet-stream', + ], + [], + ['ignored' => 'value'], + [], + [] + ); + + self::assertNull($req->getParsedBody()); + } + + public function testMissingContentTypeLeavesParsedBodyNull(): void + { + $req = ServerRequest::createFromGlobals( + ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/no-ct'], + [], + ['ignored' => 'value'], + [], + [] + ); + + self::assertNull($req->getParsedBody()); + } + + public function testZeroArgInvocationFallsBackToRealSuperglobals(): void + { + // Smoke test — no assertion on the contents because $_SERVER under + // PHPUnit varies, but the call must not throw and must return a + // ServerRequest. Stateless behaviour (regression for B4) is verified + // separately above. + $req = ServerRequest::createFromGlobals(); + self::assertInstanceOf(ServerRequest::class, $req); + } +} diff --git a/tests/Unit/Message/ServerRequestNormalizeFilesTest.php b/tests/Unit/Message/ServerRequestNormalizeFilesTest.php new file mode 100644 index 0000000..014bf81 --- /dev/null +++ b/tests/Unit/Message/ServerRequestNormalizeFilesTest.php @@ -0,0 +1,194 @@ + [ + 'tmp_name' => '/tmp/avatar.tmp', + 'size' => 1024, + 'error' => UPLOAD_ERR_OK, + 'name' => 'me.png', + 'type' => 'image/png', + ], + ]; + + $normalised = $this->newServerRequest()->normalizeFiles($files); + + self::assertArrayHasKey('avatar', $normalised); + self::assertInstanceOf(UploadedFileInterface::class, $normalised['avatar']); + self::assertSame(1024, $normalised['avatar']->getSize()); + self::assertSame('me.png', $normalised['avatar']->getClientFilename()); + self::assertSame('image/png', $normalised['avatar']->getClientMediaType()); + self::assertSame(UPLOAD_ERR_OK, $normalised['avatar']->getError()); + } + + public function testSimpleArrayShapeBecomesParallelTree(): void + { + // + $files = [ + 'docs' => [ + 'tmp_name' => ['/tmp/a.tmp', '/tmp/b.tmp'], + 'size' => [10, 20], + 'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_OK], + 'name' => ['a.txt', 'b.txt'], + 'type' => ['text/plain', 'text/plain'], + ], + ]; + + $normalised = $this->newServerRequest()->normalizeFiles($files); + + self::assertIsArray($normalised['docs']); + self::assertCount(2, $normalised['docs']); + self::assertInstanceOf(UploadedFileInterface::class, $normalised['docs'][0]); + self::assertInstanceOf(UploadedFileInterface::class, $normalised['docs'][1]); + self::assertSame('a.txt', $normalised['docs'][0]->getClientFilename()); + self::assertSame('b.txt', $normalised['docs'][1]->getClientFilename()); + } + + public function testNestedFileInputProducesNestedTree(): void + { + // + // + // + // + // PHP populates this as nested arrays at the LEAF level only; + // intermediate associative keys live on each parallel field. + $files = [ + 'docs' => [ + 'brief' => [ + 'tmp_name' => '/tmp/brief.tmp', + 'size' => 100, + 'error' => UPLOAD_ERR_OK, + 'name' => 'brief.pdf', + 'type' => 'application/pdf', + ], + 'exhibits' => [ + 'a' => [ + 'tmp_name' => '/tmp/a.tmp', + 'size' => 200, + 'error' => UPLOAD_ERR_OK, + 'name' => 'exhibit-a.png', + 'type' => 'image/png', + ], + 'b' => [ + 'tmp_name' => '/tmp/b.tmp', + 'size' => 300, + 'error' => UPLOAD_ERR_OK, + 'name' => 'exhibit-b.png', + 'type' => 'image/png', + ], + ], + ], + ]; + + $normalised = $this->newServerRequest()->normalizeFiles($files); + + self::assertInstanceOf(UploadedFileInterface::class, $normalised['docs']['brief']); + self::assertInstanceOf(UploadedFileInterface::class, $normalised['docs']['exhibits']['a']); + self::assertInstanceOf(UploadedFileInterface::class, $normalised['docs']['exhibits']['b']); + self::assertSame('brief.pdf', $normalised['docs']['brief']->getClientFilename()); + self::assertSame('exhibit-a.png', $normalised['docs']['exhibits']['a']->getClientFilename()); + } + + public function testPreBuiltUploadedFileInterfaceIsPassedThrough(): void + { + // Mixed input: a hand-rolled UploadedFile alongside raw spec. + $stream = new Stream('seed', 'php://temp'); + $prebuilt = new UploadedFile($stream, 4, UPLOAD_ERR_OK, 'prebuilt.txt', 'text/plain'); + + $files = [ + 'one' => $prebuilt, + 'two' => [ + 'tmp_name' => '/tmp/two.tmp', + 'size' => 7, + 'error' => UPLOAD_ERR_OK, + 'name' => 'two.txt', + 'type' => 'text/plain', + ], + ]; + + $normalised = $this->newServerRequest()->normalizeFiles($files); + + self::assertSame($prebuilt, $normalised['one']); + self::assertInstanceOf(UploadedFileInterface::class, $normalised['two']); + self::assertSame('two.txt', $normalised['two']->getClientFilename()); + } + + public function testNestedPreBuiltUploadedFileInterfaceTreesAreWalkedTransparently(): void + { + $stream = new Stream('seed', 'php://temp'); + $a = new UploadedFile($stream, 4, UPLOAD_ERR_OK, 'a.txt', 'text/plain'); + $b = new UploadedFile($stream, 4, UPLOAD_ERR_OK, 'b.txt', 'text/plain'); + + $files = [ + 'level1' => [ + 'level2' => [ + 'a' => $a, + 'b' => $b, + ], + ], + ]; + + $normalised = $this->newServerRequest()->normalizeFiles($files); + self::assertSame($a, $normalised['level1']['level2']['a']); + self::assertSame($b, $normalised['level1']['level2']['b']); + } + + public function testInvalidScalarLeafThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->newServerRequest()->normalizeFiles(['bogus' => 'not-a-file']); + } + + public function testEmptyInputProducesEmptyOutput(): void + { + $normalised = $this->newServerRequest()->normalizeFiles([]); + self::assertSame([], $normalised); + } + + public function testMissingOptionalFieldsDoNotThrow(): void + { + // Real-world malformed $_FILES often omits 'size' or 'type'; the + // implementation defaults those rather than raising TypeError. + $files = [ + 'minimal' => [ + 'tmp_name' => '/tmp/min.tmp', + 'error' => UPLOAD_ERR_OK, + ], + ]; + + $normalised = $this->newServerRequest()->normalizeFiles($files); + self::assertInstanceOf(UploadedFileInterface::class, $normalised['minimal']); + self::assertNull($normalised['minimal']->getSize()); + self::assertSame(UPLOAD_ERR_OK, $normalised['minimal']->getError()); + } +} diff --git a/tests/Unit/Message/StreamDetachPreservesPositionTest.php b/tests/Unit/Message/StreamDetachPreservesPositionTest.php new file mode 100644 index 0000000..9934ae8 --- /dev/null +++ b/tests/Unit/Message/StreamDetachPreservesPositionTest.php @@ -0,0 +1,106 @@ +detach(); + + self::assertIsResource($resource, 'String-backend detach must materialise into a real resource'); + fclose($resource); + } + + public function testDetachAtZeroPositionsResourceAtZero(): void + { + $stream = new Stream('abcdef', null); + $resource = $stream->detach(); + + self::assertSame(0, ftell($resource)); + rewind($resource); + self::assertSame('abcdef', stream_get_contents($resource)); + fclose($resource); + } + + public function testDetachAtMiddlePreservesCursor(): void + { + $stream = new Stream('abcdef', null); + $stream->seek(3); + $resource = $stream->detach(); + + self::assertSame(3, ftell($resource), 'Materialised handle must inherit the in-memory cursor'); + self::assertSame('def', stream_get_contents($resource)); + fclose($resource); + } + + public function testDetachAtEndPreservesCursor(): void + { + $stream = new Stream('abcdef', null); + $stream->seek(6); + $resource = $stream->detach(); + + self::assertSame(6, ftell($resource)); + self::assertSame('', stream_get_contents($resource)); + fclose($resource); + } + + public function testDetachClampsOutOfBoundsCursor(): void + { + // The in-memory seek() implementation already clamps to the buffer + // length, but detach() carries its own clamp so a pathological + // direct manipulation cannot punch past EOF on the new handle. + $stream = new Stream('abc', null); + $stream->seek(10); // clamped to 3 by Stream::str_seek() + + $resource = $stream->detach(); + self::assertLessThanOrEqual(3, ftell($resource)); + fclose($resource); + } + + public function testDetachResourceBackedReturnsTheResourceVerbatim(): void + { + // Only the string backend materialises through stringToResource(); + // resource-backed streams hand the underlying resource back. + $orig = fopen('php://temp', 'w+b'); + fwrite($orig, 'payload'); + rewind($orig); + + $stream = new Stream($orig); + $detached = $stream->detach(); + + self::assertSame($orig, $detached); + fclose($detached); + } + + public function testStateAfterDetachIsBrokenButSafe(): void + { + // After detach() the Stream wrapper is supposed to be unusable — + // most operations throw RuntimeException, __toString returns ''. + $stream = new Stream('seed', null); + $resource = $stream->detach(); + + // __toString must NOT throw even though the wrapper has nothing left. + self::assertSame('', (string) $stream); + + // The detached resource still works on the caller's side. + rewind($resource); + self::assertSame('seed', stream_get_contents($resource)); + fclose($resource); + } +} diff --git a/tests/Unit/Message/StreamIsEmptyTest.php b/tests/Unit/Message/StreamIsEmptyTest.php new file mode 100644 index 0000000..44d7ba3 --- /dev/null +++ b/tests/Unit/Message/StreamIsEmptyTest.php @@ -0,0 +1,67 @@ +isEmpty()); + self::assertFalse($stream->isNotEmpty()); + } + + public function testNonEmptyStringStreamReportsNotEmpty(): void + { + $stream = new Stream('hello', null); + self::assertFalse($stream->isEmpty()); + self::assertTrue($stream->isNotEmpty()); + } + + public function testUnknownSizeIsIndeterminate(): void + { + // Wrap a stream whose size cannot be reported by fstat() — anonymous + // pipes are the canonical example. After detach() the size resets to + // null too; we use that path to provoke the "size unknown" state in + // a portable way. + $resource = fopen('php://temp', 'w+b'); + self::assertIsResource($resource); + fwrite($resource, 'pipeable'); + + $stream = new Stream($resource); + $stream->detach(); + + // Detached: size is null, and isEmpty/isNotEmpty must both report false. + self::assertFalse($stream->isEmpty(), 'Detached stream is indeterminate, not empty'); + self::assertFalse($stream->isNotEmpty(), 'Detached stream is indeterminate, not non-empty'); + } + + public function testTempBackedNonEmptyStreamReportsNotEmpty(): void + { + $stream = new Stream('content', 'php://temp'); + self::assertFalse($stream->isEmpty()); + self::assertTrue($stream->isNotEmpty()); + } + + public function testEmptyTempStreamReportsEmpty(): void + { + $stream = new Stream('', 'php://temp'); + self::assertTrue($stream->isEmpty()); + self::assertFalse($stream->isNotEmpty()); + } +} diff --git a/tests/Unit/Message/StreamStringBackendWriteTest.php b/tests/Unit/Message/StreamStringBackendWriteTest.php new file mode 100644 index 0000000..b76cdb6 --- /dev/null +++ b/tests/Unit/Message/StreamStringBackendWriteTest.php @@ -0,0 +1,105 @@ +stream`), + * - never advanced $this->seek after a write, + * so a freshly-constructed stream's write(' world') produced ' worldhello' + * instead of overwriting from the current cursor, and `tell()` always + * reported zero. Both behaviours diverge from fwrite() on a real handle. + * + * These tests pin the corrected semantics: substring overwrite-or-extend + * with the cursor advancing by the number of bytes actually written. + */ +final class StreamStringBackendWriteTest extends TestCase +{ + public function testWriteFromZeroOverwritesAndExtends(): void + { + // "hello" is 5 bytes; writing " world" (6 bytes) from position 0 + // overwrites all of it and extends one byte past — yielding " world". + $stream = new Stream('hello', null); + $written = $stream->write(' world'); + + self::assertSame(6, $written); + self::assertSame(6, $stream->tell()); + self::assertSame(' world', (string) $stream); + } + + public function testWriteFromMiddleOverwritesInPlace(): void + { + $stream = new Stream('hello world', null); + $stream->seek(6); + $written = $stream->write('there'); + + self::assertSame(5, $written); + self::assertSame(11, $stream->tell()); + self::assertSame('hello there', (string) $stream); + } + + public function testWriteBeyondEofAppends(): void + { + $stream = new Stream('abc', null); + $stream->seek(3); + $written = $stream->write('def'); + + self::assertSame(3, $written); + self::assertSame(6, $stream->tell()); + self::assertSame('abcdef', (string) $stream); + } + + public function testWriteAtEofExtendsWithoutCorruption(): void + { + $stream = new Stream('first', null); + // Seek past EOF — the implementation clamps to size, then appends. + $stream->seek(100); + $stream->write('-second'); + + // Check tell() before casting: __toString() rewinds, then tell() + // would always report 0. + self::assertSame(strlen('first-second'), $stream->tell()); + self::assertSame('first-second', (string) $stream); + } + + public function testEmptyWriteIsNoOpOnContentButValid(): void + { + $stream = new Stream('keep', null); + $written = $stream->write(''); + + self::assertSame(0, $written); + self::assertSame('keep', (string) $stream); + } + + public function testTellAdvancesAfterMultipleWrites(): void + { + $stream = new Stream('', null); + $stream->write('alpha'); + $stream->write('-'); + $stream->write('beta'); + + // tell() before cast (cast rewinds for seekable streams). + self::assertSame(10, $stream->tell()); + self::assertSame('alpha-beta', (string) $stream); + } + + public function testRewindThenWriteOverwrites(): void + { + $stream = new Stream('original', null); + $stream->seek(strlen('original')); // park at EOF + self::assertSame(strlen('original'), $stream->tell()); + + $stream->rewind(); + self::assertSame(0, $stream->tell()); + + $stream->write('CHANGE'); + self::assertSame(6, $stream->tell()); // before cast (cast rewinds) + self::assertSame('CHANGEal', (string) $stream); + } +} diff --git a/tests/Unit/Message/StreamToStringNeverThrowsTest.php b/tests/Unit/Message/StreamToStringNeverThrowsTest.php new file mode 100644 index 0000000..3e7daa6 --- /dev/null +++ b/tests/Unit/Message/StreamToStringNeverThrowsTest.php @@ -0,0 +1,88 @@ +close(); + + $out = (string) $stream; + + self::assertSame('', $out); + } + + public function testToStringOfDetachedStreamReturnsEmptyString(): void + { + $stream = new Stream('payload', 'php://temp'); + $resource = $stream->detach(); + + // Detached: subsequent operations on $stream are no-ops on the + // resource the caller now owns. __toString must not surface the + // RuntimeException that getContents() raises in this state. + $out = (string) $stream; + + self::assertSame('', $out); + + if (is_resource($resource)) { + fclose($resource); + } + } + + public function testToStringOfClosedStringBackendStreamReturnsEmptyString(): void + { + $stream = new Stream('text', null); + $stream->close(); + + self::assertSame('', (string) $stream); + } + + public function testToStringRewindsBeforeReadingForSeekableStreams(): void + { + // Even when the cursor is at EOF, __toString rewinds first so the + // caller observes the full payload. This is the happy-path counterpart + // to the "never throws" guarantee: failure returns ''; success returns + // the whole thing. + $stream = new Stream('full body', 'php://temp'); + $stream->seek(strlen('full body')); + + self::assertSame('full body', (string) $stream); + } + + public function testToStringOfFreshTempStreamReturnsSeeded(): void + { + $stream = new Stream('seed', 'php://temp'); + self::assertSame('seed', (string) $stream); + } + + public function testGetContentsStillThrowsOnClosedStream(): void + { + // The "never throws" guarantee applies to __toString *only*. + // getContents() retains its strict failure surface so callers who + // want a hard error keep one. + $stream = new Stream('x', 'php://temp'); + $stream->close(); + + $this->expectException(\RuntimeException::class); + $stream->getContents(); + } +} diff --git a/tests/Unit/Message/UploadedFileMoveToTest.php b/tests/Unit/Message/UploadedFileMoveToTest.php new file mode 100644 index 0000000..2e55c79 --- /dev/null +++ b/tests/Unit/Message/UploadedFileMoveToTest.php @@ -0,0 +1,125 @@ + */ + private $tempPaths = []; + + protected function tearDown(): void + { + foreach ($this->tempPaths as $path) { + if (is_file($path)) { + @unlink($path); + } + } + $this->tempPaths = []; + } + + private function tempPath(string $hint = 'upload'): string + { + $path = sys_get_temp_dir() . '/initphp-http-' . $hint . '-' . bin2hex(random_bytes(6)); + $this->tempPaths[] = $path; + return $path; + } + + public function testCliRenamePathMovesFile(): void + { + $source = $this->tempPath('src'); + file_put_contents($source, 'cli-payload'); + + $dest = $this->tempPath('dest'); + + $file = new UploadedFile($source, filesize($source), UPLOAD_ERR_OK, 'source.txt', 'text/plain'); + $file->moveTo($dest); + + self::assertFileExists($dest); + self::assertSame('cli-payload', file_get_contents($dest)); + } + + public function testStreamCopyPathHandlesPayloadLargerThanBuffer(): void + { + // 1 MiB read buffer in moveTo(); use 2.5 MiB so we exercise multiple + // read/write iterations and verify nothing gets lost between chunks. + $payload = str_repeat('A', 2_621_440); // 2.5 MiB + + $stream = new Stream($payload, 'php://temp'); + $file = new UploadedFile($stream, strlen($payload), UPLOAD_ERR_OK, 'big.bin', 'application/octet-stream'); + + $dest = $this->tempPath('big'); + $file->moveTo($dest); + + self::assertFileExists($dest); + self::assertSame(strlen($payload), filesize($dest)); + self::assertSame(hash('sha256', $payload), hash_file('sha256', $dest)); + } + + public function testSecondMoveAfterMoveThrows(): void + { + $source = $this->tempPath('once-only'); + file_put_contents($source, 'data'); + + $file = new UploadedFile($source, 4, UPLOAD_ERR_OK); + $file->moveTo($this->tempPath('first')); + + $this->expectException(\RuntimeException::class); + $file->moveTo($this->tempPath('second')); + } + + public function testGetStreamAfterMoveThrows(): void + { + $source = $this->tempPath('after-move'); + file_put_contents($source, 'data'); + + $file = new UploadedFile($source, 4, UPLOAD_ERR_OK); + $file->moveTo($this->tempPath('done')); + + $this->expectException(\RuntimeException::class); + $file->getStream(); + } + + public function testMoveToRejectsEmptyTarget(): void + { + $source = $this->tempPath('reject'); + file_put_contents($source, 'data'); + $file = new UploadedFile($source, 4, UPLOAD_ERR_OK); + + $this->expectException(\InvalidArgumentException::class); + $file->moveTo(''); + } + + public function testMoveToThrowsWhenSourceHadUploadError(): void + { + // When the upload errored, the constructor doesn't bind a source — + // moveTo() must refuse cleanly rather than try to rename nothing. + $file = new UploadedFile('/nope', null, UPLOAD_ERR_INI_SIZE); + + $this->expectException(\RuntimeException::class); + $file->moveTo($this->tempPath('never-reached')); + } + + public function testGetStreamThrowsWhenUploadHadError(): void + { + $file = new UploadedFile('/nope', null, UPLOAD_ERR_NO_FILE); + + $this->expectException(\RuntimeException::class); + $file->getStream(); + } +} From a24cf0792b1758139041d02f94f7cee858a18916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 18:48:08 +0300 Subject: [PATCH 2/4] Add support for PHP 3.x branch in workflows and composer constraints --- .github/workflows/static-analysis.yml | 4 ++-- .github/workflows/tests.yml | 17 +++++------------ CHANGELOG.md | 8 ++++++++ composer.json | 2 +- docs/getting-started.md | 4 ++++ docs/upgrade-guide.md | 17 +++++++++++++++++ src/Message/Stream.php | 6 +++--- src/Message/Traits/MessageTrait.php | 2 +- src/Message/UploadedFile.php | 4 ++-- tests/Unit/Emitter/EmitterBodyTest.php | 3 +++ tests/Unit/Emitter/EmitterContentRangeTest.php | 3 +++ tests/Unit/Emitter/EmitterHeadersTest.php | 3 +++ tests/Unit/Emitter/EmitterStatusLineTest.php | 8 ++++++++ tests/Unit/Emitter/EmitterStrictModeTest.php | 3 +++ 14 files changed, 63 insertions(+), 21 deletions(-) diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 066c29b..1c89dd5 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -2,9 +2,9 @@ name: Static Analysis on: push: - branches: [main, 2.x] + branches: [main, 2.x, 3.x] pull_request: - branches: [main, 2.x] + branches: [main, 2.x, 3.x] jobs: phpstan: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bbf4b1a..2d3eb4d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,9 +2,9 @@ name: Tests on: push: - branches: [main, 2.x] + branches: [main, 2.x, 3.x] pull_request: - branches: [main, 2.x] + branches: [main, 2.x, 3.x] jobs: phpunit: @@ -18,8 +18,6 @@ jobs: include: - php: '7.4' dependency-version: prefer-lowest - - php: '8.4' - dependency-version: prefer-stable steps: - name: Checkout @@ -33,14 +31,9 @@ jobs: coverage: none tools: composer:v2 - - name: Resolve PHPUnit constraint - run: | - if php -r 'exit((int) version_compare(PHP_VERSION, "8.1.0", "<"));'; then - composer require --dev --no-update --no-interaction "phpunit/phpunit:^9.6" - else - composer require --dev --no-update --no-interaction "phpunit/phpunit:^10.5" - fi - + # Composer's solver picks PHPUnit 9.x and http-factory-tests 1.x on + # PHP < 8.1, and 10.x / 2.x on PHP >= 8.1, based on the wide ranges in + # composer.json. No manual narrowing required. - name: Install dependencies run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --prefer-dist diff --git a/CHANGELOG.md b/CHANGELOG.md index 6de5152..373018c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Packagist metadata enrichment (description, keywords, homepage, support links). - Composer scripts: `composer test`, `composer test:coverage`, `composer phpstan`, `composer ci`. - EditorConfig, CHANGELOG, contributor-friendly docblocks across `src/`. +- `http-interop/http-factory-tests` dev-dep widened to `^1.1 || ^2.0` so the CI matrix can install the v1 line on PHP 7.4 / 8.0 (the v2 line requires PHP 8.1+). ### Changed (breaking) @@ -40,6 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`Stream::str2resorce()` renamed** to `Stream::stringToResource()` (private). Materialised detach handle now preserves cursor position. - **`RequestTrait::updateHostFormUri()` renamed** to `updateHostFromUri()`. - **HTTP version whitelist widened** to accept `2`, `2.0`, `3`, `3.0` (alongside `1.0` and `1.1`). +- **Native return types added for PSR-7 v2 covariance** on six methods: + `Stream::close(): void`, `Stream::seek(...): void`, `Stream::rewind(): void`, + `MessageTrait::getHeader($name): array`, + `UploadedFile::getStream(): StreamInterface`, `UploadedFile::moveTo(...): void`. + No call-site change is required (the existing `@return` PHPDoc lines + already documented these types), but subclass overrides must match the + new signatures or stay untyped. ### Fixed diff --git a/composer.json b/composer.json index 3ac632c..c906e7b 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ }, "require-dev": { "ext-curl": "*", - "http-interop/http-factory-tests": "^2.2", + "http-interop/http-factory-tests": "^1.1 || ^2.0", "php-http/psr7-integration-tests": "^1.3", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.6 || ^10.5" diff --git a/docs/getting-started.md b/docs/getting-started.md index ee791a1..2c0fe37 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,8 +11,12 @@ Optional but recommended: ```bash composer require --dev phpunit/phpunit "^9.6 || ^10.5" composer require --dev phpstan/phpstan "^1.10" +composer require --dev http-interop/http-factory-tests "^1.1 || ^2.0" +composer require --dev php-http/psr7-integration-tests "^1.3" ``` +The split `^1.1 || ^2.0` constraint on `http-interop/http-factory-tests` lets Composer pick v1 on PHP 7.4 / 8.0 and v2 on PHP 8.1+ automatically; the v2 line requires PHP 8.1+ and PHPUnit 10. + ## A complete round-trip in 12 lines ```php diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md index a7c0845..4b2901e 100644 --- a/docs/upgrade-guide.md +++ b/docs/upgrade-guide.md @@ -140,6 +140,23 @@ PSR-7's hard requirement; the old implementation propagated `RuntimeException` f The `target=null` (pure in-memory string) backend used to *prepend* at position 0 and never advance the cursor. v3 overwrites from the current position and advances `tell()` correctly. If you wrote code that relied on the broken prepend behaviour, swap to `Stream::__construct($newPrefix . $oldBody, null)`. +### 15. Native return types added for PSR-7 v2 covariance + +Six methods gained explicit language-level return types to satisfy the tightened `psr/http-message: ^2.0` contract: + +| Method | Added return type | +|----------------------------------------------|---------------------| +| `Stream::close()` | `: void` | +| `Stream::seek($offset, $whence = SEEK_SET)` | `: void` | +| `Stream::rewind()` | `: void` | +| `MessageTrait::getHeader($name)` | `: array` | +| `UploadedFile::getStream()` | `: StreamInterface` | +| `UploadedFile::moveTo($targetPath)` | `: void` | + +Existing PHPDoc `@return` lines already documented these types — only the runtime signature changed. **No call-site code needs to change.** + +If you **extended** any of these classes and **overrode** one of those methods, your override's signature must now match the new return type (or stay untyped — PHP allows that). An override declaring a different return type will fail at class load with a covariance error. + --- ## Things that are NOT breaking diff --git a/src/Message/Stream.php b/src/Message/Stream.php index 38b1ac3..b8d98cd 100644 --- a/src/Message/Stream.php +++ b/src/Message/Stream.php @@ -285,7 +285,7 @@ public function init($body = null, ?string $target = 'php://temp'): StreamInterf * * @return void */ - public function close() + public function close(): void { if(isset($this->stream)){ if(is_resource($this->stream)){ @@ -410,7 +410,7 @@ public function isSeekable(): bool * @return void * @throws RuntimeException When the stream is detached, not seekable, or fseek() fails. */ - public function seek($offset, $whence = SEEK_SET) + public function seek($offset, $whence = SEEK_SET): void { if(!isset($this->stream)){ throw new RuntimeException('Stream is detached'); @@ -433,7 +433,7 @@ public function seek($offset, $whence = SEEK_SET) * @return void * @throws RuntimeException When the stream is detached or not seekable. */ - public function rewind() + public function rewind(): void { $this->seek(0); } diff --git a/src/Message/Traits/MessageTrait.php b/src/Message/Traits/MessageTrait.php index 2375d62..f4539d7 100644 --- a/src/Message/Traits/MessageTrait.php +++ b/src/Message/Traits/MessageTrait.php @@ -111,7 +111,7 @@ public function hasHeader($name): bool * @param string $name * @return string[] */ - public function getHeader($name) + public function getHeader($name): array { $lowercase = strtolower($name); $name = $this->headerNames[$lowercase] ?? $name; diff --git a/src/Message/UploadedFile.php b/src/Message/UploadedFile.php index e2d4d90..afc9d78 100644 --- a/src/Message/UploadedFile.php +++ b/src/Message/UploadedFile.php @@ -136,7 +136,7 @@ protected function init($streamOrFile) * @return StreamInterface * @throws RuntimeException When the upload errored, was already moved, or the tmp file cannot be opened. */ - public function getStream() + public function getStream(): StreamInterface { $this->throwHasErrorOrMoved(); if($this->stream instanceof StreamInterface){ @@ -159,7 +159,7 @@ public function getStream() * @throws InvalidArgumentException When $targetPath is empty or not a string. * @throws RuntimeException When the upload errored, was already moved, or the move/copy fails. */ - public function moveTo($targetPath) + public function moveTo($targetPath): void { $this->throwHasErrorOrMoved(); if(!is_string($targetPath) || $targetPath === ''){ diff --git a/tests/Unit/Emitter/EmitterBodyTest.php b/tests/Unit/Emitter/EmitterBodyTest.php index 3484711..050858e 100644 --- a/tests/Unit/Emitter/EmitterBodyTest.php +++ b/tests/Unit/Emitter/EmitterBodyTest.php @@ -16,6 +16,9 @@ * * Both paths must emit the same bytes; the chunked path additionally * rewinds the stream first so callers don't have to. + * + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled */ final class EmitterBodyTest extends TestCase { diff --git a/tests/Unit/Emitter/EmitterContentRangeTest.php b/tests/Unit/Emitter/EmitterContentRangeTest.php index 4089e42..d9a5324 100644 --- a/tests/Unit/Emitter/EmitterContentRangeTest.php +++ b/tests/Unit/Emitter/EmitterContentRangeTest.php @@ -17,6 +17,9 @@ * - emit exactly (last - first + 1) bytes * * The parseHeaderContentRange() / emitBodyRange() pair drives this. + * + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled */ final class EmitterContentRangeTest extends TestCase { diff --git a/tests/Unit/Emitter/EmitterHeadersTest.php b/tests/Unit/Emitter/EmitterHeadersTest.php index 34301fd..abb8f51 100644 --- a/tests/Unit/Emitter/EmitterHeadersTest.php +++ b/tests/Unit/Emitter/EmitterHeadersTest.php @@ -16,6 +16,9 @@ * As with the status line, CLI cannot intercept header() calls. We test * the *call path* by emitting and asserting the body comes out intact — * any exception from the header() chain would surface here. + * + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled */ final class EmitterHeadersTest extends TestCase { diff --git a/tests/Unit/Emitter/EmitterStatusLineTest.php b/tests/Unit/Emitter/EmitterStatusLineTest.php index f2521a3..127752d 100644 --- a/tests/Unit/Emitter/EmitterStatusLineTest.php +++ b/tests/Unit/Emitter/EmitterStatusLineTest.php @@ -22,6 +22,14 @@ * status line, so we keep the assertions on the public Response API and * rely on the EmitterStrictModeTest @runInSeparateProcess tests to * exercise the SAPI integration end of the contract. + * + * Tests are isolated per process because PHPUnit 9's text result printer + * writes progress dots directly to STDOUT before each test runs, which + * flips headers_sent() to true and breaks any header() call the emitter + * would otherwise attempt. + * + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled */ final class EmitterStatusLineTest extends TestCase { diff --git a/tests/Unit/Emitter/EmitterStrictModeTest.php b/tests/Unit/Emitter/EmitterStrictModeTest.php index 68d5c45..d301102 100644 --- a/tests/Unit/Emitter/EmitterStrictModeTest.php +++ b/tests/Unit/Emitter/EmitterStrictModeTest.php @@ -26,6 +26,9 @@ * test does not depend on headers_sent() at all (just ob_get_length()), * so it provides the more portable coverage. The header-side test is * gated on platform behaviour and skipped when the trigger doesn't fire. + * + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled */ final class EmitterStrictModeTest extends TestCase { From eccadee32b281249aeb9c99e51e13a21a566f075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 19:02:55 +0300 Subject: [PATCH 3/4] Refactor host constant to static method in FixtureServerTrait --- tests/Unit/FixtureServerTrait.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/Unit/FixtureServerTrait.php b/tests/Unit/FixtureServerTrait.php index db0aaba..f9c696e 100644 --- a/tests/Unit/FixtureServerTrait.php +++ b/tests/Unit/FixtureServerTrait.php @@ -16,7 +16,15 @@ */ trait FixtureServerTrait { - private const FIXTURE_HOST = '127.0.0.1'; + /** + * PHP 8.1 and earlier do not allow constants inside traits — that was + * relaxed in PHP 8.2. Expose the host as a static method instead so the + * trait keeps working across the whole PHP 7.4 – 8.4 matrix. + */ + private static function fixtureHost(): string + { + return '127.0.0.1'; + } /** @var resource|null */ private static $serverProcess; @@ -46,13 +54,13 @@ private static function bootFixtureServer(): void if (!is_file($fixture)) { self::fail('Fixture server file is missing: ' . $fixture); } - self::$baseUrl = 'http://' . self::FIXTURE_HOST . ':' . $port; + self::$baseUrl = 'http://' . self::fixtureHost() . ':' . $port; self::killFixturePortListeners($port); $cmd = sprintf( 'PHP_CLI_SERVER_WORKERS=4 exec php -S %s:%d %s', - self::FIXTURE_HOST, + self::fixtureHost(), $port, escapeshellarg($fixture) ); @@ -69,7 +77,7 @@ private static function bootFixtureServer(): void $deadline = microtime(true) + 5.0; while (microtime(true) < $deadline) { - $sock = @fsockopen(self::FIXTURE_HOST, $port, $errno, $errstr, 0.2); + $sock = @fsockopen(self::fixtureHost(), $port, $errno, $errstr, 0.2); if (is_resource($sock)) { fclose($sock); return; From 3a56e456f4409e8001469be2cb41dfba74d1c00d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 19:25:42 +0300 Subject: [PATCH 4/4] Add memory limit to PHPStan command and update return types for PSR-7 v2 --- .github/workflows/static-analysis.yml | 2 +- CHANGELOG.md | 10 ++++++++-- docs/upgrade-guide.md | 7 ++++++- src/Message/Uri.php | 2 +- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 1c89dd5..6a262e4 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -27,4 +27,4 @@ jobs: run: composer update --no-interaction --no-progress --prefer-dist - name: Run PHPStan - run: vendor/bin/phpstan analyse --no-progress + run: vendor/bin/phpstan analyse --no-progress --memory-limit=512M diff --git a/CHANGELOG.md b/CHANGELOG.md index 373018c..2864eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,10 +41,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`Stream::str2resorce()` renamed** to `Stream::stringToResource()` (private). Materialised detach handle now preserves cursor position. - **`RequestTrait::updateHostFormUri()` renamed** to `updateHostFromUri()`. - **HTTP version whitelist widened** to accept `2`, `2.0`, `3`, `3.0` (alongside `1.0` and `1.1`). -- **Native return types added for PSR-7 v2 covariance** on six methods: +- **Native return types added for PSR-7 v2 covariance** on seven methods: `Stream::close(): void`, `Stream::seek(...): void`, `Stream::rewind(): void`, `MessageTrait::getHeader($name): array`, - `UploadedFile::getStream(): StreamInterface`, `UploadedFile::moveTo(...): void`. + `UploadedFile::getStream(): StreamInterface`, `UploadedFile::moveTo(...): void`, + `Uri::__toString(): string`. + Required by PHP 7.4 + PSR-7 v2 (`Declaration must be compatible with ...` + fatal at class load); PHP 8.0+ accepted the untyped signatures silently + but the fix is uniform across all supported PHP versions. No call-site change is required (the existing `@return` PHPDoc lines already documented these types), but subclass overrides must match the new signatures or stay untyped. @@ -60,6 +64,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `UploadedFile::moveTo()` survives partial-write iterations and retries until the chunk is flushed. - `ServerRequest::normalizeFiles()` recurses into arbitrarily nested file input names (`file[parent][child][…]`). - `send_request()` (global helper) requires a URL when the first arg is a method string instead of silently sending to `null`. +- `tests/Unit/FixtureServerTrait` exposes the loopback host through a static method instead of a trait constant, so the unit suite loads on PHP 7.4 / 8.0 / 8.1 (trait constants are a PHP 8.2+ feature). +- `static-analysis.yml` workflow now runs PHPStan with `--memory-limit=512M`, matching the `composer phpstan` script — the default 128 MiB triggered an OOM under PHP 7.4 during local reproduction. ### Removed (in addition to breaking changes above) diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md index 4b2901e..008ac32 100644 --- a/docs/upgrade-guide.md +++ b/docs/upgrade-guide.md @@ -142,7 +142,7 @@ The `target=null` (pure in-memory string) backend used to *prepend* at position ### 15. Native return types added for PSR-7 v2 covariance -Six methods gained explicit language-level return types to satisfy the tightened `psr/http-message: ^2.0` contract: +Seven methods gained explicit language-level return types to satisfy the tightened `psr/http-message: ^2.0` contract: | Method | Added return type | |----------------------------------------------|---------------------| @@ -152,9 +152,14 @@ Six methods gained explicit language-level return types to satisfy the tightened | `MessageTrait::getHeader($name)` | `: array` | | `UploadedFile::getStream()` | `: StreamInterface` | | `UploadedFile::moveTo($targetPath)` | `: void` | +| `Uri::__toString()` | `: string` | Existing PHPDoc `@return` lines already documented these types — only the runtime signature changed. **No call-site code needs to change.** +> **Why this matters most on PHP 7.4 + PSR-7 v2.** PHP 7.4 enforces return-type covariance strictly: an untyped implementation does not satisfy a typed interface return, and the result is a fatal at class load: +> *"Declaration of InitPHP\HTTP\Message\Uri::__toString() must be compatible with Psr\Http\Message\UriInterface::__toString(): string"*. +> PHP 8.0+ accepted the older signatures silently. The fix is uniform across all supported PHP versions. + If you **extended** any of these classes and **overrode** one of those methods, your override's signature must now match the new return type (or stay untyped — PHP allows that). An override declaring a different return type will fail at class load with a covariance error. --- diff --git a/src/Message/Uri.php b/src/Message/Uri.php index 4864e3d..820be19 100644 --- a/src/Message/Uri.php +++ b/src/Message/Uri.php @@ -91,7 +91,7 @@ public function __construct(string $uri = '') * * @return string */ - public function __toString() + public function __toString(): string { return self::createUriString($this->scheme, $this->getAuthority(), $this->path, $this->query, $this->fragment); }