From 3392fd185b4e66e28e8c2b8e529fd9b57a717abd Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 14 May 2026 22:52:09 -0300 Subject: [PATCH 01/27] refactor: rename Headers::fromHeaderables to Headers::from Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/php-library-documentation.md | 2 +- README.md | 425 ++++++++++++++---- composer.json | 6 +- src/CacheControl.php | 7 +- src/Client/Request.php | 70 +++ src/Client/Response.php | 82 ++++ src/Client/Transport.php | 23 + src/Client/Transports/InMemoryTransport.php | 36 ++ src/Client/Transports/NetworkTransport.php | 83 ++++ src/Code.php | 10 + src/ContentType.php | 8 +- src/Cookie.php | 16 +- src/Exceptions/HttpConfigurationInvalid.php | 23 + src/Exceptions/HttpException.php | 26 ++ src/Exceptions/HttpNetworkFailed.php | 28 ++ src/Exceptions/HttpRequestFailed.php | 66 +++ src/Exceptions/HttpRequestInvalid.php | 39 ++ src/Exceptions/MalformedPath.php | 21 + src/Exceptions/NoMoreResponses.php | 17 + .../SynthesizedResponseHasNoRaw.php | 11 + src/Headerable.php | 19 + src/Headers.php | 84 +++- src/Http.php | 38 ++ src/HttpBuilder.php | 38 ++ src/Internal/Client/Cursor.php | 18 + src/Internal/Client/RequestResolver.php | 44 ++ src/Internal/Client/Url.php | 45 ++ src/Internal/Request/Body.php | 45 -- .../CacheControl/CacheControlDirective.php | 2 +- .../{ => Server}/CacheControl/Directives.php | 2 +- .../{ => Server}/Cookies/CookieName.php | 4 +- .../{ => Server}/Cookies/CookieValue.php | 4 +- .../ConflictingLifetimeAttributes.php | 2 +- .../Exceptions/CookieNameIsInvalid.php | 2 +- .../Exceptions/CookieValueIsInvalid.php | 2 +- .../Exceptions/InvalidResource.php | 2 +- .../Exceptions/MissingResourceStream.php | 2 +- .../Exceptions/NonReadableStream.php | 2 +- .../Exceptions/NonSeekableStream.php | 2 +- .../Exceptions/NonWritableStream.php | 2 +- .../Exceptions/SameSiteNoneRequiresSecure.php | 2 +- .../{ => Server}/Request/DecodedRequest.php | 4 +- src/Internal/{ => Server}/Request/Decoder.php | 5 +- .../{ => Server}/Request/QueryParameters.php | 3 +- .../Request/RouteParameterResolver.php | 2 +- src/Internal/{ => Server}/Request/Uri.php | 3 +- .../Response/InternalResponse.php | 10 +- .../{ => Server}/Response/ProtocolVersion.php | 2 +- .../{ => Server}/Response/ResponseHeaders.php | 8 +- src/Internal/{ => Server}/Stream/Stream.php | 12 +- .../{ => Server}/Stream/StreamFactory.php | 20 +- .../{ => Server}/Stream/StreamMetaData.php | 2 +- .../{Request => Shared}/Attribute.php | 2 +- src/Internal/Shared/Body.php | 79 ++++ src/ResponseCacheDirectives.php | 4 +- src/{ => Server}/Request.php | 7 +- src/{ => Server}/Response.php | 30 +- src/{ => Server}/Responses.php | 52 +-- tests/Drivers/Laminas/LaminasTest.php | 2 +- tests/Drivers/Slim/SlimTest.php | 2 +- tests/Unit/Client/RequestTest.php | 170 +++++++ tests/Unit/Client/ResponseTest.php | 201 +++++++++ .../Transports/InMemoryTransportTest.php | 70 +++ .../Transports/NetworkTransportTest.php | 263 +++++++++++ tests/{ => Unit}/CodeTest.php | 20 +- tests/{ => Unit}/CookieTest.php | 26 +- .../HttpConfigurationInvalidTest.php | 32 ++ .../Unit/Exceptions/HttpNetworkFailedTest.php | 87 ++++ .../Unit/Exceptions/HttpRequestFailedTest.php | 110 +++++ .../Exceptions/HttpRequestInvalidTest.php | 87 ++++ tests/Unit/Exceptions/MalformedPathTest.php | 54 +++ tests/Unit/Exceptions/NoMoreResponsesTest.php | 32 ++ tests/Unit/HeadersTest.php | 149 ++++++ tests/Unit/HttpBuilderTest.php | 118 +++++ tests/Unit/HttpTest.php | 305 +++++++++++++ tests/Unit/Internal/Client/CursorTest.php | 50 +++ .../Internal/Client/RequestResolverTest.php | 88 ++++ tests/Unit/Internal/Client/UrlTest.php | 125 ++++++ .../Request/RouteParameterResolverTest.php | 4 +- .../Server}/Stream/StreamFactoryTest.php | 4 +- .../Internal/Server}/Stream/StreamTest.php | 16 +- tests/{ => Unit}/SameSiteTest.php | 2 +- tests/{ => Unit/Server}/HeadersTest.php | 4 +- .../{ => Unit/Server}/ProtocolVersionTest.php | 4 +- tests/{ => Unit/Server}/RequestTest.php | 82 +++- tests/{ => Unit/Server}/ResponseTest.php | 6 +- .../Server}/ResponseWithCookiesTest.php | 4 +- 87 files changed, 3439 insertions(+), 283 deletions(-) create mode 100644 src/Client/Request.php create mode 100644 src/Client/Response.php create mode 100644 src/Client/Transport.php create mode 100644 src/Client/Transports/InMemoryTransport.php create mode 100644 src/Client/Transports/NetworkTransport.php create mode 100644 src/Exceptions/HttpConfigurationInvalid.php create mode 100644 src/Exceptions/HttpException.php create mode 100644 src/Exceptions/HttpNetworkFailed.php create mode 100644 src/Exceptions/HttpRequestFailed.php create mode 100644 src/Exceptions/HttpRequestInvalid.php create mode 100644 src/Exceptions/MalformedPath.php create mode 100644 src/Exceptions/NoMoreResponses.php create mode 100644 src/Exceptions/SynthesizedResponseHasNoRaw.php create mode 100644 src/Headerable.php create mode 100644 src/Http.php create mode 100644 src/HttpBuilder.php create mode 100644 src/Internal/Client/Cursor.php create mode 100644 src/Internal/Client/RequestResolver.php create mode 100644 src/Internal/Client/Url.php delete mode 100644 src/Internal/Request/Body.php rename src/Internal/{ => Server}/CacheControl/CacheControlDirective.php (94%) rename src/Internal/{ => Server}/CacheControl/Directives.php (92%) rename src/Internal/{ => Server}/Cookies/CookieName.php (84%) rename src/Internal/{ => Server}/Cookies/CookieValue.php (84%) rename src/Internal/{ => Server}/Exceptions/ConflictingLifetimeAttributes.php (89%) rename src/Internal/{ => Server}/Exceptions/CookieNameIsInvalid.php (90%) rename src/Internal/{ => Server}/Exceptions/CookieValueIsInvalid.php (91%) rename src/Internal/{ => Server}/Exceptions/InvalidResource.php (81%) rename src/Internal/{ => Server}/Exceptions/MissingResourceStream.php (80%) rename src/Internal/{ => Server}/Exceptions/NonReadableStream.php (80%) rename src/Internal/{ => Server}/Exceptions/NonSeekableStream.php (80%) rename src/Internal/{ => Server}/Exceptions/NonWritableStream.php (80%) rename src/Internal/{ => Server}/Exceptions/SameSiteNoneRequiresSecure.php (89%) rename src/Internal/{ => Server}/Request/DecodedRequest.php (82%) rename src/Internal/{ => Server}/Request/Decoder.php (76%) rename src/Internal/{ => Server}/Request/QueryParameters.php (85%) rename src/Internal/{ => Server}/Request/RouteParameterResolver.php (98%) rename src/Internal/{ => Server}/Request/Uri.php (97%) rename src/Internal/{ => Server}/Response/InternalResponse.php (92%) rename src/Internal/{ => Server}/Response/ProtocolVersion.php (90%) rename src/Internal/{ => Server}/Response/ResponseHeaders.php (91%) rename src/Internal/{ => Server}/Stream/Stream.php (90%) rename src/Internal/{ => Server}/Stream/StreamFactory.php (95%) rename src/Internal/{ => Server}/Stream/StreamMetaData.php (94%) rename src/Internal/{Request => Shared}/Attribute.php (96%) create mode 100644 src/Internal/Shared/Body.php rename src/{ => Server}/Request.php (76%) rename src/{ => Server}/Response.php (57%) rename src/{ => Server}/Responses.php (59%) create mode 100644 tests/Unit/Client/RequestTest.php create mode 100644 tests/Unit/Client/ResponseTest.php create mode 100644 tests/Unit/Client/Transports/InMemoryTransportTest.php create mode 100644 tests/Unit/Client/Transports/NetworkTransportTest.php rename tests/{ => Unit}/CodeTest.php (88%) rename tests/{ => Unit}/CookieTest.php (87%) create mode 100644 tests/Unit/Exceptions/HttpConfigurationInvalidTest.php create mode 100644 tests/Unit/Exceptions/HttpNetworkFailedTest.php create mode 100644 tests/Unit/Exceptions/HttpRequestFailedTest.php create mode 100644 tests/Unit/Exceptions/HttpRequestInvalidTest.php create mode 100644 tests/Unit/Exceptions/MalformedPathTest.php create mode 100644 tests/Unit/Exceptions/NoMoreResponsesTest.php create mode 100644 tests/Unit/HeadersTest.php create mode 100644 tests/Unit/HttpBuilderTest.php create mode 100644 tests/Unit/HttpTest.php create mode 100644 tests/Unit/Internal/Client/CursorTest.php create mode 100644 tests/Unit/Internal/Client/RequestResolverTest.php create mode 100644 tests/Unit/Internal/Client/UrlTest.php rename tests/{Internal => Unit/Internal/Server}/Request/RouteParameterResolverTest.php (98%) rename tests/{Internal => Unit/Internal/Server}/Stream/StreamFactoryTest.php (95%) rename tests/{Internal => Unit/Internal/Server}/Stream/StreamTest.php (95%) rename tests/{ => Unit}/SameSiteTest.php (95%) rename tests/{ => Unit/Server}/HeadersTest.php (99%) rename tests/{ => Unit/Server}/ProtocolVersionTest.php (89%) rename tests/{ => Unit/Server}/RequestTest.php (87%) rename tests/{ => Unit/Server}/ResponseTest.php (99%) rename tests/{ => Unit/Server}/ResponseWithCookiesTest.php (97%) diff --git a/.claude/rules/php-library-documentation.md b/.claude/rules/php-library-documentation.md index d7ac6da..4791cb9 100644 --- a/.claude/rules/php-library-documentation.md +++ b/.claude/rules/php-library-documentation.md @@ -10,7 +10,7 @@ paths: 1. Include an anchor-linked table of contents. 2. Start with a concise one-line description of what the library does. -3. Include a **badges** section (license, build status, coverage, latest version, PHP version). +3. Include a **license** badge. Do not include any other badges. 4. Provide an **Overview** section explaining the problem the library solves and its design philosophy. 5. **Installation** section: Composer command (`composer require vendor/package`). 6. **How to use** section: complete, runnable code examples covering the primary use cases. Each example diff --git a/README.md b/README.md index 5916f07..764f53e 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,24 @@ * [Overview](#overview) * [Installation](#installation) +* [Upgrading from 1.x to 2.x](#upgrading-from-1x-to-2x) * [How to use](#how-to-use) - * [Request](#request) - * [Response](#response) + * [Server](#server) + * [Request](#request) + * [Response](#response) + * [Status codes](#status-codes) + * [Client](#client) + * [Configuring Http](#configuring-http) + * [Making a request](#making-a-request) + * [Reading the response](#reading-the-response) + * [Query parameters](#query-parameters) + * [Custom headers and content type](#custom-headers-and-content-type) + * [Error handling](#error-handling) + * [Configuring timeouts](#configuring-timeouts) * [License](#license) * [Contributing](#contributing) -
+
## Overview @@ -19,6 +30,18 @@ for PHP, covering requests, responses, streams, cookies, headers, methods, statu Ships with a fluent response builder that maps common outcomes to the correct HTTP semantics out of the box. Interoperable with Slim, Laminas, and any PSR-compliant framework. +In addition, the library provides a thin [PSR-18](https://www.php-fig.org/psr/psr-18) outbound HTTP client façade +(`Http`) that composes any PSR-18 client with PSR-17 factories, handles URL construction, serializes JSON bodies, +and maps transport exceptions to typed exceptions — without bundling any HTTP client implementation. + +The public API is organized by role: + +| Namespace | Purpose | +|---------------------------|-----------------------------------------------------------------------------------------------| +| `TinyBlocks\Http\` | Shared primitives: `Method`, `Code`, `Headers`, `ContentType`, `CacheControl`, `Cookie`, etc. | +| `TinyBlocks\Http\Server\` | PSR-7 / PSR-15 server-side request decoding and response building | +| `TinyBlocks\Http\Client\` | Outbound HTTP: `Request`, `Response`, and typed exceptions | +
## Installation @@ -27,18 +50,50 @@ Interoperable with Slim, Laminas, and any PSR-compliant framework. composer require tiny-blocks/http ``` +To make outbound HTTP requests, also require a [PSR-18](https://www.php-fig.org/psr/psr-18) client and +[PSR-17](https://www.php-fig.org/psr/psr-17) factories. For example, using Guzzle and Nyholm: + +```bash +composer require guzzlehttp/guzzle nyholm/psr7 +``` + +
+ +## Upgrading from 1.x to 2.x + +Version 2.x moves the server-side `Request` and `Response` classes into the `Server\` sub-namespace. The shared +primitives (`Method`, `Code`, `ContentType`, `CacheControl`, `Cookie`, `Headers`, etc.) stay at the root and are +unchanged. + +Run the following find/replace commands in your project: + +```bash +# Request +grep -rn 'TinyBlocks\\Http\\Request' . +sed -i 's/use TinyBlocks\\Http\\Request;/use TinyBlocks\\Http\\Server\\Request;/g' $(grep -rl 'use TinyBlocks\\Http\\Request;' .) + +# Response +grep -rn 'TinyBlocks\\Http\\Response' . +sed -i 's/use TinyBlocks\\Http\\Response;/use TinyBlocks\\Http\\Server\\Response;/g' $(grep -rl 'use TinyBlocks\\Http\\Response;' .) + +# Responses interface +grep -rn 'TinyBlocks\\Http\\Responses' . +sed -i 's/use TinyBlocks\\Http\\Responses;/use TinyBlocks\\Http\\Server\\Responses;/g' $(grep -rl 'use TinyBlocks\\Http\\Responses;' .) +``` +
## How to use -The library exposes interfaces like `Headers` and concrete implementations like `Request`, `Response`, `ContentType`, -and others, which facilitate construction. +
+ +### Server
-### Request +#### Request -#### Decoding a request +##### Decoding a request The library provides a small public API to decode a PSR-7 `ServerRequestInterface` into a typed structure, allowing you to access route parameters and JSON body fields consistently. @@ -48,7 +103,7 @@ to access route parameters and JSON body fields consistently. ```php use Psr\Http\Message\ServerRequestInterface; - use TinyBlocks\Http\Request; + use TinyBlocks\Http\Server\Request; /** @var ServerRequestInterface $psrRequest */ $decoded = Request::from(request: $psrRequest)->decode(); @@ -64,7 +119,7 @@ to access route parameters and JSON body fields consistently. ```php use Psr\Http\Message\ServerRequestInterface; - use TinyBlocks\Http\Request; + use TinyBlocks\Http\Server\Request; /** @var ServerRequestInterface $psrRequest */ $request = Request::from(request: $psrRequest); @@ -76,7 +131,7 @@ to access route parameters and JSON body fields consistently. - **Access the full URI**: Use `toString()` on the decoded `uri()` to retrieve the complete request URI as a string. ```php - use TinyBlocks\Http\Request; + use TinyBlocks\Http\Server\Request; $decoded = Request::from(request: $psrRequest)->decode(); @@ -84,10 +139,10 @@ to access route parameters and JSON body fields consistently. ``` - **Access query parameters**: Use `queryParameters()` on the decoded `uri()` to retrieve typed access to query string - values. Each value is returned as an `Attribute`, providing the same safe conversions and defaults as body fields. + values. Each value is returned as an `Attribute`, providing safe conversions and defaults. ```php - use TinyBlocks\Http\Request; + use TinyBlocks\Http\Server\Request; $decoded = Request::from(request: $psrRequest)->decode(); @@ -97,40 +152,40 @@ to access route parameters and JSON body fields consistently. $active = $decoded->uri()->queryParameters()->get(key: 'active')->toBoolean(); # default: false ``` -- **Typed access with defaults**: Each value is returned as an Attribute, which provides safe conversions and default +- **Typed access with defaults**: Each value is returned as an `Attribute`, which provides safe conversions and default values when the underlying value is missing or not compatible. ```php - use TinyBlocks\Http\Request; - + use TinyBlocks\Http\Server\Request; + $request = Request::from(request: $psrRequest); $decoded = $request->decode(); - - $method = $request->method(); # default: Method enum - + + $method = $request->method(); # Method enum + $id = $decoded->uri()->route()->get(key: 'id')->toInteger(); # default: 0 $uri = $decoded->uri()->toString(); # default: "" $sort = $decoded->uri()->queryParameters()->get(key: 'sort')->toString(); # default: "" $limit = $decoded->uri()->queryParameters()->get(key: 'limit')->toInteger(); # default: 0 - + $note = $decoded->body()->get(key: 'note')->toString(); # default: "" $tags = $decoded->body()->get(key: 'tags')->toArray(); # default: [] $price = $decoded->body()->get(key: 'price')->toFloat(); # default: 0.00 $active = $decoded->body()->get(key: 'active')->toBoolean(); # default: false ``` -- **Custom route attribute name**: If your framework stores route params in a different request attribute, you can - specify it via `route()`. +- **Custom route attribute name**: If your framework stores route params in a different request attribute, specify it + via `route()`. ```php - use TinyBlocks\Http\Request; - + use TinyBlocks\Http\Server\Request; + $decoded = Request::from(request: $psrRequest)->decode(); $id = $decoded->uri()->route(name: '_route_params')->get(key: 'id')->toInteger(); ``` -#### How route parameters are resolved +##### How route parameters are resolved The library resolves route parameters from the PSR-7 `ServerRequestInterface` using a **multistep fallback strategy**, designed to work across different frameworks without importing any framework-specific code. @@ -146,8 +201,7 @@ designed to work across different frameworks without importing any framework-spe - If the value is a **scalar** (e.g., a string), it is returned as-is. 2. **Known attribute scan** (only when using the default `__route__` name) — Scans all commonly used attribute keys - across frameworks: - - `__route__`, `_route_params`, `route`, `routing`, `routeResult`, `routeInfo` + across frameworks: `__route__`, `_route_params`, `route`, `routing`, `routeResult`, `routeInfo`. 3. **Direct attribute fallback** — As a last resort, tries `$request->getAttribute($key)` directly, which supports frameworks like Laravel that store route params as individual request attributes. @@ -166,13 +220,13 @@ designed to work across different frameworks without importing any framework-spe | **FastRoute (generic)** | `routeInfo` | Array with route parameters | | **Manual injection** | Any custom key | `$request->withAttribute('__route__', [...])` | -#### Manually injecting route parameters +##### Manually injecting route parameters -If your framework or middleware does not automatically populate route attributes, you can inject them manually using +If your framework or middleware does not automatically populate route attributes, inject them manually using PSR-7's `withAttribute()`: ```php -use TinyBlocks\Http\Request; +use TinyBlocks\Http\Server\Request; $psrRequest = $psrRequest->withAttribute('__route__', [ 'id' => '42', @@ -193,9 +247,9 @@ $slug = Request::from(request: $psrRequest)
-### Response +#### Response -#### Creating a response +##### Creating a response The library provides an easy and flexible way to create HTTP responses, allowing you to specify the status code, headers, and body. You can use the `Response` class to generate responses, and the result will always be a @@ -207,8 +261,8 @@ to the [PSR-7](https://www.php-fig.org/psr/psr-7) standard. `application/json` content type. ```php - use TinyBlocks\Http\Response; - + use TinyBlocks\Http\Server\Response; + Response::ok(body: ['message' => 'Resource created successfully.']); ``` @@ -216,26 +270,24 @@ to the [PSR-7](https://www.php-fig.org/psr/psr-7) standard. if you want to specify a custom content type or any other header, you can pass the headers as additional arguments. ```php - use TinyBlocks\Http\Response; - use TinyBlocks\Http\ContentType; use TinyBlocks\Http\CacheControl; + use TinyBlocks\Http\ContentType; use TinyBlocks\Http\ResponseCacheDirectives; - - $body = 'This is a plain text response'; - + use TinyBlocks\Http\Server\Response; + $contentType = ContentType::textPlain(); - + $cacheControl = CacheControl::fromResponseDirectives( maxAge: ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000), staleIfError: ResponseCacheDirectives::staleIfError() ); - - Response::ok($body, $contentType, $cacheControl) + + Response::ok('This is a plain text response', $contentType, $cacheControl) ->withHeader(name: 'X-ID', value: 100) ->withHeader(name: 'X-NAME', value: 'Xpto'); ``` -#### Setting cookies +##### Setting cookies The library models the `Set-Cookie` HTTP response header through the `Cookie` value object, covering the full [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265) attribute set plus modern additions such as `SameSite` and @@ -246,84 +298,81 @@ The library models the `Set-Cookie` HTTP response header through the `Cookie` va ```php use TinyBlocks\Http\Cookie; - use TinyBlocks\Http\Response; use TinyBlocks\Http\SameSite; - + use TinyBlocks\Http\Server\Response; + $cookie = Cookie::create(name: 'refresh_token', value: $opaqueToken) ->httpOnly() ->secure() ->withSameSite(sameSite: SameSite::STRICT) ->withPath(path: '/v1/sessions') ->withMaxAge(seconds: 604800); - + Response::ok(body: ['ok' => true], $cookie); ``` - **Setting multiple cookies**: Pass each `Cookie` as an additional header argument. The response emits one - `Set-Cookie` header per cookie, preserving all of them (this follows the PSR-7 multi-value header model). + `Set-Cookie` header per cookie, preserving all of them. ```php use TinyBlocks\Http\Cookie; - use TinyBlocks\Http\Response; use TinyBlocks\Http\SameSite; - + use TinyBlocks\Http\Server\Response; + $accessCookie = Cookie::create(name: 'access_token', value: $accessToken) ->httpOnly() ->secure() ->withPath(path: '/'); - + $refreshCookie = Cookie::create(name: 'refresh_token', value: $refreshToken) ->httpOnly() ->secure() ->withSameSite(sameSite: SameSite::STRICT) ->withPath(path: '/v1/sessions') ->withMaxAge(seconds: 604800); - + Response::ok(body: ['ok' => true], $accessCookie, $refreshCookie); ``` - **Expiring a cookie**: Use `Cookie::expire()` to instruct the browser to delete a previously set cookie. Chain the - same `Path` (and `Domain`, if applicable) used when the cookie was issued; otherwise the browser will not match the - cookie and the deletion will have no effect. + same `Path` (and `Domain`, if applicable) used when the cookie was issued. ```php use TinyBlocks\Http\Cookie; - use TinyBlocks\Http\Response; use TinyBlocks\Http\SameSite; - + use TinyBlocks\Http\Server\Response; + $expired = Cookie::expire(name: 'refresh_token') ->httpOnly() ->secure() ->withSameSite(sameSite: SameSite::STRICT) ->withPath(path: '/v1/sessions'); - + Response::noContent($expired); ``` - **Using an absolute expiration date**: When an explicit deletion moment is preferable over `Max-Age`, use - `withExpires()`. The date is converted to UTC and rendered in - the [RFC 7231](https://datatracker.ietf.org/doc/html/rfc7231) - date format required by the `Expires` attribute. `Max-Age` and `Expires` are mutually exclusive — setting both - throws `ConflictingLifetimeAttributes` when the response is serialized. + `withExpires()`. `Max-Age` and `Expires` are mutually exclusive — setting both throws + `ConflictingLifetimeAttributes` when the response is serialized. ```php use DateTimeImmutable; use DateTimeZone; use TinyBlocks\Http\Cookie; - + Cookie::create(name: 'preference', value: 'dark-mode')->withExpires( expires: new DateTimeImmutable(datetime: '2030-01-15 12:00:00', timezone: new DateTimeZone(timezone: 'UTC')) ); ``` -- **Cross-site cookies**: `SameSite::NONE` requires the `Secure` flag — modern browsers reject `SameSite=None` cookies - sent over insecure connections. The library enforces this invariant at serialization time and throws +- **Cross-site cookies**: `SameSite::NONE` requires the `Secure` flag — modern browsers reject `SameSite=None` + cookies sent over insecure connections. The library enforces this invariant at serialization time and throws `SameSiteNoneRequiresSecure` when the combination is incomplete. ```php use TinyBlocks\Http\Cookie; use TinyBlocks\Http\SameSite; - + Cookie::create(name: 'embed_session', value: $token) ->withSameSite(sameSite: SameSite::NONE) ->secure(); @@ -331,18 +380,19 @@ The library models the `Set-Cookie` HTTP response header through the `Cookie` va - **Validation at construction time**: Cookie names and values are validated against [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265). Names cannot be empty nor contain control characters, - whitespace, or token separators (``( ) < > @ , ; : \ " / [ ] ? = { }``). Values cannot contain control characters, - whitespace, double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or Base64) before - passing it when it may contain arbitrary text. + whitespace, or token separators. Values cannot contain control characters, whitespace, double quotes, commas, + semicolons, or backslashes. Encode the value before passing it when it may contain arbitrary text. ```php use TinyBlocks\Http\Cookie; - + Cookie::create(name: 'user_id', value: (string)$userId); # valid Cookie::create(name: 'payload', value: base64_encode($jsonBody)); # encode arbitrary values first ``` -#### Using the status code +
+ +#### Status codes The library exposes a concrete implementation through the `Code` enum. You can retrieve the status codes, their corresponding messages, and check for various status code ranges using the methods provided. @@ -352,7 +402,7 @@ corresponding messages, and check for various status code ranges using the metho ```php use TinyBlocks\Http\Code; - + Code::OK->value; # 200 Code::OK->message(); # OK Code::IM_A_TEAPOT->message(); # I'm a teapot @@ -363,7 +413,7 @@ corresponding messages, and check for various status code ranges using the metho ```php use TinyBlocks\Http\Code; - + Code::isValidCode(code: 200); # true Code::isValidCode(code: 999); # false ``` @@ -372,7 +422,7 @@ corresponding messages, and check for various status code ranges using the metho ```php use TinyBlocks\Http\Code; - + Code::isErrorCode(code: 500); # true Code::isErrorCode(code: 200); # false ``` @@ -381,19 +431,244 @@ corresponding messages, and check for various status code ranges using the metho ```php use TinyBlocks\Http\Code; - + Code::isSuccessCode(code: 500); # false Code::isSuccessCode(code: 200); # true ``` -
+
+ +### Client + +
+ +#### Configuring Http + +`Http` is a thin, immutable façade over any [PSR-18](https://www.php-fig.org/psr/psr-18) HTTP client. It does not +bundle any transport implementation — bring your own. + +```php +use GuzzleHttp\Client as GuzzleClient; +use Nyholm\Psr7\Factory\Psr17Factory; +use TinyBlocks\Http\Http; + +$factory = new Psr17Factory(); + +$http = Http::from( + client: new GuzzleClient(), + requestFactory: $factory, + streamFactory: $factory +); +``` + +Pass an optional `baseUrl` to avoid repeating the host on every request: + +```php +$http = Http::from( + client: new GuzzleClient(), + requestFactory: $factory, + streamFactory: $factory, + baseUrl: 'https://api.example.com' +); +``` + +
+ +#### Making a request + +Build a `Client\Request` with `Request::create()` and pass it to `Http::send()`. + +```php +use TinyBlocks\Http\Client\Request; +use TinyBlocks\Http\Http; +use TinyBlocks\Http\Method; + +$response = $http->send( + request: Request::create( + url: '/dragons', + method: Method::GET + ) +); +``` + +For requests with a JSON body: + +```php +use TinyBlocks\Http\Client\Request; +use TinyBlocks\Http\Http; +use TinyBlocks\Http\Method; + +$response = $http->send( + request: Request::create( + url: '/dragons', + method: Method::POST, + body: ['name' => 'Hydra', 'type' => 'water'] + ) +); +``` + +
+ +#### Reading the response + +`Http::send()` returns an immutable `Client\Response`. It provides typed access to the status code, headers, and +JSON body. + +```php +use TinyBlocks\Http\Client\Request; +use TinyBlocks\Http\Method; + +$response = $http->send( + request: Request::create(url: '/dragons/42', method: Method::GET) +); + +$response->statusCode(); # 200 +$response->code(); # Code::OK (null if not in the Code enum) +$response->isSuccess(); # true +$response->isError(); # false + +$response->body()->get(key: 'id')->toInteger(); # 42 +$response->body()->get(key: 'name')->toString(); # "Hydra" +$response->body()->toArray(); # ['id' => 42, 'name' => 'Hydra'] + +$response->headers(); # ['Content-Type' => 'application/json', ...] +$response->raw(); # the underlying PSR-7 ResponseInterface +``` + +
+ +#### Query parameters + +Pass an associative array to `query`. Values are encoded +using [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). + +```php +use TinyBlocks\Http\Client\Request; +use TinyBlocks\Http\Method; + +$http->send( + request: Request::create( + url: '/dragons', + method: Method::GET, + query: ['sort' => 'name', 'order' => 'asc', 'limit' => 50] + ) +); +# Sends: GET /dragons?sort=name&order=asc&limit=50 +``` + +
+ +#### Custom headers and content type + +By default, requests with a body are sent with `Content-Type: application/json` and `Accept: application/json`. +Pass one or more `Headers` instances to override or extend the defaults. + +```php +use TinyBlocks\Http\Charset; +use TinyBlocks\Http\Client\Request; +use TinyBlocks\Http\ContentType; +use TinyBlocks\Http\Method; + +$http->send( + request: Request::create( + url: '/dragons', + method: Method::POST, + body: ['name' => 'Hydra'], + headers: ContentType::applicationJson(charset: Charset::UTF_8) + ) +); +# Sends: Content-Type: application/json; charset=utf-8 +``` + +
+ +#### Error handling + +`Http::send()` maps PSR-18 transport exceptions to three typed exceptions. All three extend `HttpRequestFailed`, +which carries the target `$url`, the `Method`, and the `$reason` string. + +| Exception | Cause | +|----------------------|------------------------------------------------------------------------| +| `HttpNetworkFailed` | `NetworkExceptionInterface` — connection refused, timeout, DNS failure | +| `HttpRequestInvalid` | `RequestExceptionInterface` — malformed request object | +| `HttpRequestFailed` | Any other `ClientExceptionInterface` | + +```php +use TinyBlocks\Http\Client\Exceptions\HttpNetworkFailed; +use TinyBlocks\Http\Client\Exceptions\HttpRequestFailed; +use TinyBlocks\Http\Client\Exceptions\HttpRequestInvalid; +use TinyBlocks\Http\Client\Request; +use TinyBlocks\Http\Method; + +try { + $response = $http->send( + request: Request::create(url: '/dragons', method: Method::GET) + ); +} catch (HttpNetworkFailed $exception) { + # connection-level failure — retry is often appropriate + echo $exception->url; # "https://api.example.com/dragons" + echo $exception->method; # Method::GET + echo $exception->reason; # "connection refused" +} catch (HttpRequestInvalid $exception) { + # malformed request — do not retry +} catch (HttpRequestFailed $exception) { + # catch-all for any other PSR-18 client exception +} +``` + +The original PSR-18 exception is always preserved as the previous exception (`$exception->getPrevious()`). + +
+ +#### Configuring timeouts + +Timeouts are not part of this library's public API. Configure them directly on the PSR-18 client you inject. + +**Guzzle:** + +```php +use GuzzleHttp\Client as GuzzleClient; +use Nyholm\Psr7\Factory\Psr17Factory; +use TinyBlocks\Http\Http; + +$factory = new Psr17Factory(); + +$http = Http::from( + client: new GuzzleClient([ + 'timeout' => 5.0, + 'connect_timeout' => 2.0 + ]), + requestFactory: $factory, + streamFactory: $factory, + baseUrl: 'https://api.example.com' +); +``` + +**Symfony HttpClient:** + +```php +use Nyholm\Psr7\Factory\Psr17Factory; +use Symfony\Component\HttpClient\Psr18Client; +use TinyBlocks\Http\Http; + +$factory = new Psr17Factory(); + +$http = Http::from( + client: new Psr18Client( + \Symfony\Component\HttpClient\HttpClient::create([ + 'timeout' => 5.0 + ]) + ), + requestFactory: $factory, + streamFactory: $factory, + baseUrl: 'https://api.example.com' +); +``` ## License Http is licensed under [MIT](LICENSE). -
- ## Contributing Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to diff --git a/composer.json b/composer.json index a410ac1..29f9d7d 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "tiny-blocks/http", - "description": "Implements PSR-7 and PSR-15 HTTP primitives for PHP, with a fluent response builder, cookies, and cache control.\n\nThe package is designed to be used in any PHP application, and can be used as a standalone library or as part of a larger framework.", + "description": "Implements PSR-7, PSR-15, and PSR-18 HTTP primitives for PHP, with a fluent response builder, cookies, cache control, and a PSR-18 client façade.\n\nThe package is designed to be used in any PHP application, and can be used as a standalone library or as part of a larger framework.", "license": "MIT", "type": "library", "authors": [ @@ -16,13 +16,17 @@ }, "require": { "php": "^8.5", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1", "psr/http-message": "^2.0", "tiny-blocks/mapper": "^2.0" }, "require-dev": { "ergebnis/composer-normalize": "^2.51", + "guzzlehttp/guzzle": "^7.9", "infection/infection": "^0.32", "laminas/laminas-httphandlerrunner": "^2.13", + "nyholm/psr7": "^1.8", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^13.1", "slim/psr7": "^1.8", diff --git a/src/CacheControl.php b/src/CacheControl.php index bb8e53c..bac9dbc 100644 --- a/src/CacheControl.php +++ b/src/CacheControl.php @@ -4,12 +4,7 @@ namespace TinyBlocks\Http; -/** - * Defines HTTP Cache-Control headers and their directives. - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - */ -final readonly class CacheControl implements Headers +final readonly class CacheControl implements Headerable { private function __construct(private array $directives) { diff --git a/src/Client/Request.php b/src/Client/Request.php new file mode 100644 index 0000000..e2e75d8 --- /dev/null +++ b/src/Client/Request.php @@ -0,0 +1,70 @@ +body, + query: $this->query, + method: $this->method, + headers: $this->headers + ); + } + + public function withQuery(array $query): Request + { + return new Request( + url: $this->url, + body: $this->body, + query: $query, + method: $this->method, + headers: $this->headers + ); + } + + public function withMergedHeaders(array $defaults): Request + { + return new Request( + url: $this->url, + body: $this->body, + query: $this->query, + method: $this->method, + headers: $this->headers->mergedWith(defaults: $defaults) + ); + } +} diff --git a/src/Client/Response.php b/src/Client/Response.php new file mode 100644 index 0000000..34d6a10 --- /dev/null +++ b/src/Client/Response.php @@ -0,0 +1,82 @@ +getHeaders() as $name => $values) { + $entries[$name] = implode(', ', $values); + } + + return new Response( + psr: $response, + body: Body::fromResponse(response: $response), + code: Code::from($response->getStatusCode()), + headers: Headers::fromArray(entries: $entries) + ); + } + + public static function with(Code $code, ?array $body = null, array $headers = []): Response + { + return new Response( + psr: null, + body: Body::fromArray(data: $body ?? []), + code: $code, + headers: Headers::fromArray(entries: $headers) + ); + } + + public function code(): Code + { + return $this->code; + } + + public function body(): Body + { + return $this->body; + } + + public function isError(): bool + { + return $this->code->isError(); + } + + public function headers(): Headers + { + return $this->headers; + } + + public function isSuccess(): bool + { + return $this->code->isSuccess(); + } + + public function raw(): ResponseInterface + { + if (is_null($this->psr)) { + throw new SynthesizedResponseHasNoRaw(); + } + + return $this->psr; + } +} diff --git a/src/Client/Transport.php b/src/Client/Transport.php new file mode 100644 index 0000000..e54b599 --- /dev/null +++ b/src/Client/Transport.php @@ -0,0 +1,23 @@ + $responses */ + private function __construct(private Cursor $cursor, private array $responses) + { + } + + /** @param list $responses */ + public static function with(array $responses): InMemoryTransport + { + return new InMemoryTransport(cursor: new Cursor(), responses: $responses); + } + + public function send(Request $request): Response + { + $index = $this->cursor->advance(); + + if (!isset($this->responses[$index])) { + throw NoMoreResponses::atIndex(index: $index); + } + + return $this->responses[$index]; + } +} diff --git a/src/Client/Transports/NetworkTransport.php b/src/Client/Transports/NetworkTransport.php new file mode 100644 index 0000000..023cf46 --- /dev/null +++ b/src/Client/Transports/NetworkTransport.php @@ -0,0 +1,83 @@ +requestFactory->createRequest( + method: $request->method->value, + uri: $request->url + ); + + foreach ($request->headers->toArray() as $name => $value) { + $psrRequest = $psrRequest->withHeader($name, $value); + } + + if (!is_null($request->body)) { + try { + $encoded = json_encode( + $request->body, + self::JSON_FLAGS, + self::MAX_JSON_DEPTH + ); + } catch (JsonException $exception) { + throw HttpRequestInvalid::fromJsonError(request: $request, exception: $exception); + } + + $stream = $this->streamFactory->createStream(content: $encoded); + $psrRequest = $psrRequest->withBody($stream); + } + + try { + $psrResponse = $this->client->sendRequest($psrRequest); + } catch (NetworkExceptionInterface $exception) { + throw HttpNetworkFailed::fromClientException(request: $request, exception: $exception); + } catch (RequestExceptionInterface $exception) { + throw HttpRequestInvalid::fromClientException(request: $request, exception: $exception); + } catch (ClientExceptionInterface $exception) { + throw HttpRequestFailed::fromClientException(request: $request, exception: $exception); + } + + return Response::from(response: $psrResponse); + } +} diff --git a/src/Code.php b/src/Code.php index 424fb82..43b665a 100644 --- a/src/Code.php +++ b/src/Code.php @@ -89,6 +89,16 @@ enum Code: int case NOT_EXTENDED = 510; case NETWORK_AUTHENTICATION_REQUIRED = 511; + public function isError(): bool + { + return self::isErrorCode(code: $this->value); + } + + public function isSuccess(): bool + { + return self::isSuccessCode(code: $this->value); + } + /** * Returns the HTTP status message associated with the enum's code. * diff --git a/src/ContentType.php b/src/ContentType.php index 221ed9f..d3f5565 100644 --- a/src/ContentType.php +++ b/src/ContentType.php @@ -4,13 +4,7 @@ namespace TinyBlocks\Http; -/** - * The Content-Type representation header is used to indicate the original media type - * of the resource (prior to any content encoding applied for sending). - * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type - */ -final readonly class ContentType implements Headers +final readonly class ContentType implements Headerable { private function __construct(private MimeType $mimeType, private ?Charset $charset) { diff --git a/src/Cookie.php b/src/Cookie.php index b0156a9..14130a2 100644 --- a/src/Cookie.php +++ b/src/Cookie.php @@ -7,12 +7,12 @@ use DateTimeImmutable; use DateTimeInterface; use DateTimeZone; -use TinyBlocks\Http\Internal\Cookies\CookieName; -use TinyBlocks\Http\Internal\Cookies\CookieValue; -use TinyBlocks\Http\Internal\Exceptions\ConflictingLifetimeAttributes; -use TinyBlocks\Http\Internal\Exceptions\SameSiteNoneRequiresSecure; +use TinyBlocks\Http\Internal\Server\Cookies\CookieName; +use TinyBlocks\Http\Internal\Server\Cookies\CookieValue; +use TinyBlocks\Http\Internal\Server\Exceptions\ConflictingLifetimeAttributes; +use TinyBlocks\Http\Internal\Server\Exceptions\SameSiteNoneRequiresSecure; -final readonly class Cookie implements Headers +final readonly class Cookie implements Headerable { private const string EXPIRES_FORMAT = 'D, d M Y H:i:s \G\M\T'; @@ -209,9 +209,9 @@ public function withSameSite(SameSite $sameSite): Cookie public function toArray(): array { $invariantViolation = match (true) { - $this->sameSite === SameSite::NONE && !$this->secure => new SameSiteNoneRequiresSecure(), - !is_null($this->maxAge) && !is_null($this->expires) => new ConflictingLifetimeAttributes(), - default => null, + $this->sameSite === SameSite::NONE && !$this->secure => new SameSiteNoneRequiresSecure(), + !is_null($this->maxAge) && !is_null($this->expires) => new ConflictingLifetimeAttributes(), + default => null, }; if (!is_null($invariantViolation)) { diff --git a/src/Exceptions/HttpConfigurationInvalid.php b/src/Exceptions/HttpConfigurationInvalid.php new file mode 100644 index 0000000..387cdc3 --- /dev/null +++ b/src/Exceptions/HttpConfigurationInvalid.php @@ -0,0 +1,23 @@ +url, + method: $request->method, + reason: $exception->getMessage(), + previous: $exception + ); + } +} diff --git a/src/Exceptions/HttpRequestFailed.php b/src/Exceptions/HttpRequestFailed.php new file mode 100644 index 0000000..4ae2947 --- /dev/null +++ b/src/Exceptions/HttpRequestFailed.php @@ -0,0 +1,66 @@ +url, + method: $request->method, + reason: $exception->getMessage(), + previous: $exception + ); + } + + public static function fromJsonError(Request $request, JsonException $exception): self + { + return new self( + url: $request->url, + method: $request->method, + reason: sprintf(self::JSON_ERROR_REASON_TEMPLATE, $exception->getMessage()), + previous: $exception + ); + } + + public function method(): Method + { + return $this->method; + } + + public function reason(): string + { + return $this->reason; + } + + public function url(): string + { + return $this->url; + } +} diff --git a/src/Exceptions/HttpRequestInvalid.php b/src/Exceptions/HttpRequestInvalid.php new file mode 100644 index 0000000..f5fb1ac --- /dev/null +++ b/src/Exceptions/HttpRequestInvalid.php @@ -0,0 +1,39 @@ +url, + method: $request->method, + reason: $exception->getMessage(), + previous: $exception + ); + } + + public static function fromJsonError(Request $request, JsonException $exception): static + { + return new self( + url: $request->url, + method: $request->method, + reason: sprintf(self::JSON_ERROR_REASON_TEMPLATE, $exception->getMessage()), + previous: $exception + ); + } +} diff --git a/src/Exceptions/MalformedPath.php b/src/Exceptions/MalformedPath.php new file mode 100644 index 0000000..5970d1f --- /dev/null +++ b/src/Exceptions/MalformedPath.php @@ -0,0 +1,21 @@ +url, + method: $request->method, + reason: sprintf(self::REASON_TEMPLATE, $request->url) + ); + } +} diff --git a/src/Exceptions/NoMoreResponses.php b/src/Exceptions/NoMoreResponses.php new file mode 100644 index 0000000..5ab6093 --- /dev/null +++ b/src/Exceptions/NoMoreResponses.php @@ -0,0 +1,17 @@ + $value) { + $lowerIndex[strtolower($name)] = $name; + } + + $this->entries = $entries; + $this->lowerIndex = $lowerIndex; + } + + public static function fromArray(array $entries): Headers + { + return new Headers($entries); + } + + public static function from(Headerable ...$headerables): Headers + { + $entries = []; + + foreach ($headerables as $headerable) { + foreach ($headerable->toArray() as $name => $value) { + $entries[$name] = is_array($value) ? implode(', ', $value) : $value; + } + } + + return new Headers($entries); + } + + public function get(string $name): ?string + { + $key = strtolower($name); + + if (!isset($this->lowerIndex[$key])) { + return null; + } + + return $this->entries[$this->lowerIndex[$key]]; + } + + public function has(string $name): bool + { + return isset($this->lowerIndex[strtolower($name)]); + } + + public function mergedWith(array $defaults): Headers + { + $merged = []; + + foreach ($defaults as $name => $value) { + if (isset($this->lowerIndex[strtolower($name)])) { + continue; + } + + $merged[$name] = $value; + } + + foreach ($this->entries as $name => $value) { + $merged[$name] = $value; + } + + return new Headers($merged); + } + + public function toArray(): array + { + return $this->entries; + } } diff --git a/src/Http.php b/src/Http.php new file mode 100644 index 0000000..445a2ad --- /dev/null +++ b/src/Http.php @@ -0,0 +1,38 @@ +resolver = RequestResolver::withBaseUrl(baseUrl: $baseUrl); + } + + public static function create(): HttpBuilder + { + return new HttpBuilder(baseUrl: null, transport: null); + } + + /** + * Sends a request through the configured transport and returns the response. + * + * @param Request $request The outbound request to send. + * @return Response The response returned by the transport. + * @throws HttpException When resolution or the transport fails. + */ + public function send(Request $request): Response + { + return $this->transport->send(request: $this->resolver->resolve(request: $request)); + } +} diff --git a/src/HttpBuilder.php b/src/HttpBuilder.php new file mode 100644 index 0000000..37aac7e --- /dev/null +++ b/src/HttpBuilder.php @@ -0,0 +1,38 @@ +transport); + } + + public function withTransport(Transport $transport): HttpBuilder + { + return new HttpBuilder(baseUrl: $this->baseUrl, transport: $transport); + } + + public function build(): Http + { + if (is_null($this->transport)) { + throw HttpConfigurationInvalid::missingTransport(); + } + + if (is_null($this->baseUrl)) { + throw HttpConfigurationInvalid::missingBaseUrl(); + } + + return new Http(baseUrl: $this->baseUrl, transport: $this->transport); + } +} diff --git a/src/Internal/Client/Cursor.php b/src/Internal/Client/Cursor.php new file mode 100644 index 0000000..db3bdf9 --- /dev/null +++ b/src/Internal/Client/Cursor.php @@ -0,0 +1,18 @@ +position; + $this->position++; + + return $current; + } +} diff --git a/src/Internal/Client/RequestResolver.php b/src/Internal/Client/RequestResolver.php new file mode 100644 index 0000000..7a2dbcb --- /dev/null +++ b/src/Internal/Client/RequestResolver.php @@ -0,0 +1,44 @@ + 'application/json', + 'Content-Type' => 'application/json' + ]; + + private function __construct(private string $baseUrl) + { + } + + public static function withBaseUrl(string $baseUrl): RequestResolver + { + return new RequestResolver(baseUrl: $baseUrl); + } + + public function resolve(Request $request): Request + { + try { + $url = Url::compose( + path: $request->url, + query: $request->query, + baseUrl: $this->baseUrl + ); + } catch (InvalidArgumentException) { + throw MalformedPath::fromRequest(request: $request); + } + + return $request + ->withUrl(url: $url->toString()) + ->withQuery(query: []) + ->withMergedHeaders(defaults: self::JSON_DEFAULTS); + } +} diff --git a/src/Internal/Client/Url.php b/src/Internal/Client/Url.php new file mode 100644 index 0000000..33e70b8 --- /dev/null +++ b/src/Internal/Client/Url.php @@ -0,0 +1,45 @@ +value; + } +} diff --git a/src/Internal/Request/Body.php b/src/Internal/Request/Body.php deleted file mode 100644 index dfdd68d..0000000 --- a/src/Internal/Request/Body.php +++ /dev/null @@ -1,45 +0,0 @@ -getBody(); - $streamFactory = StreamFactory::fromStream(stream: $body); - - if (!$streamFactory->isEmptyContent()) { - return new Body(data: json_decode($streamFactory->content(), true)); - } - - $parsedBody = $request->getParsedBody(); - - if (is_array($parsedBody)) { - return new Body(data: $parsedBody); - } - - return new Body(data: []); - } - - public function get(string $key): Attribute - { - $value = ($this->data[$key] ?? null); - - return Attribute::from(value: $value); - } - - public function toArray(): array - { - return $this->data; - } -} diff --git a/src/Internal/CacheControl/CacheControlDirective.php b/src/Internal/Server/CacheControl/CacheControlDirective.php similarity index 94% rename from src/Internal/CacheControl/CacheControlDirective.php rename to src/Internal/Server/CacheControl/CacheControlDirective.php index aa86459..55215c1 100644 --- a/src/Internal/CacheControl/CacheControlDirective.php +++ b/src/Internal/Server/CacheControl/CacheControlDirective.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\CacheControl; +namespace TinyBlocks\Http\Internal\Server\CacheControl; trait CacheControlDirective { diff --git a/src/Internal/CacheControl/Directives.php b/src/Internal/Server/CacheControl/Directives.php similarity index 92% rename from src/Internal/CacheControl/Directives.php rename to src/Internal/Server/CacheControl/Directives.php index c30eb90..8bd8536 100644 --- a/src/Internal/CacheControl/Directives.php +++ b/src/Internal/Server/CacheControl/Directives.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\CacheControl; +namespace TinyBlocks\Http\Internal\Server\CacheControl; /** * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#cache_directives diff --git a/src/Internal/Cookies/CookieName.php b/src/Internal/Server/Cookies/CookieName.php similarity index 84% rename from src/Internal/Cookies/CookieName.php rename to src/Internal/Server/Cookies/CookieName.php index dd0e375..577e821 100644 --- a/src/Internal/Cookies/CookieName.php +++ b/src/Internal/Server/Cookies/CookieName.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Cookies; +namespace TinyBlocks\Http\Internal\Server\Cookies; -use TinyBlocks\Http\Internal\Exceptions\CookieNameIsInvalid; +use TinyBlocks\Http\Internal\Server\Exceptions\CookieNameIsInvalid; final readonly class CookieName { diff --git a/src/Internal/Cookies/CookieValue.php b/src/Internal/Server/Cookies/CookieValue.php similarity index 84% rename from src/Internal/Cookies/CookieValue.php rename to src/Internal/Server/Cookies/CookieValue.php index a1dc59d..393910d 100644 --- a/src/Internal/Cookies/CookieValue.php +++ b/src/Internal/Server/Cookies/CookieValue.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Cookies; +namespace TinyBlocks\Http\Internal\Server\Cookies; -use TinyBlocks\Http\Internal\Exceptions\CookieValueIsInvalid; +use TinyBlocks\Http\Internal\Server\Exceptions\CookieValueIsInvalid; final readonly class CookieValue { diff --git a/src/Internal/Exceptions/ConflictingLifetimeAttributes.php b/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php similarity index 89% rename from src/Internal/Exceptions/ConflictingLifetimeAttributes.php rename to src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php index 9befc6f..8ba8f24 100644 --- a/src/Internal/Exceptions/ConflictingLifetimeAttributes.php +++ b/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Exceptions; +namespace TinyBlocks\Http\Internal\Server\Exceptions; use DomainException; diff --git a/src/Internal/Exceptions/CookieNameIsInvalid.php b/src/Internal/Server/Exceptions/CookieNameIsInvalid.php similarity index 90% rename from src/Internal/Exceptions/CookieNameIsInvalid.php rename to src/Internal/Server/Exceptions/CookieNameIsInvalid.php index 304394e..a6ab5c4 100644 --- a/src/Internal/Exceptions/CookieNameIsInvalid.php +++ b/src/Internal/Server/Exceptions/CookieNameIsInvalid.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Exceptions; +namespace TinyBlocks\Http\Internal\Server\Exceptions; use InvalidArgumentException; diff --git a/src/Internal/Exceptions/CookieValueIsInvalid.php b/src/Internal/Server/Exceptions/CookieValueIsInvalid.php similarity index 91% rename from src/Internal/Exceptions/CookieValueIsInvalid.php rename to src/Internal/Server/Exceptions/CookieValueIsInvalid.php index 418b71c..1b200bb 100644 --- a/src/Internal/Exceptions/CookieValueIsInvalid.php +++ b/src/Internal/Server/Exceptions/CookieValueIsInvalid.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Exceptions; +namespace TinyBlocks\Http\Internal\Server\Exceptions; use InvalidArgumentException; diff --git a/src/Internal/Exceptions/InvalidResource.php b/src/Internal/Server/Exceptions/InvalidResource.php similarity index 81% rename from src/Internal/Exceptions/InvalidResource.php rename to src/Internal/Server/Exceptions/InvalidResource.php index 32d8f33..719c3ec 100644 --- a/src/Internal/Exceptions/InvalidResource.php +++ b/src/Internal/Server/Exceptions/InvalidResource.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Exceptions; +namespace TinyBlocks\Http\Internal\Server\Exceptions; use RuntimeException; diff --git a/src/Internal/Exceptions/MissingResourceStream.php b/src/Internal/Server/Exceptions/MissingResourceStream.php similarity index 80% rename from src/Internal/Exceptions/MissingResourceStream.php rename to src/Internal/Server/Exceptions/MissingResourceStream.php index 12cd2d0..b335c32 100644 --- a/src/Internal/Exceptions/MissingResourceStream.php +++ b/src/Internal/Server/Exceptions/MissingResourceStream.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Exceptions; +namespace TinyBlocks\Http\Internal\Server\Exceptions; use RuntimeException; diff --git a/src/Internal/Exceptions/NonReadableStream.php b/src/Internal/Server/Exceptions/NonReadableStream.php similarity index 80% rename from src/Internal/Exceptions/NonReadableStream.php rename to src/Internal/Server/Exceptions/NonReadableStream.php index a8bb6b9..f633908 100644 --- a/src/Internal/Exceptions/NonReadableStream.php +++ b/src/Internal/Server/Exceptions/NonReadableStream.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Exceptions; +namespace TinyBlocks\Http\Internal\Server\Exceptions; use RuntimeException; diff --git a/src/Internal/Exceptions/NonSeekableStream.php b/src/Internal/Server/Exceptions/NonSeekableStream.php similarity index 80% rename from src/Internal/Exceptions/NonSeekableStream.php rename to src/Internal/Server/Exceptions/NonSeekableStream.php index cda8cdc..436a5f1 100644 --- a/src/Internal/Exceptions/NonSeekableStream.php +++ b/src/Internal/Server/Exceptions/NonSeekableStream.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Exceptions; +namespace TinyBlocks\Http\Internal\Server\Exceptions; use RuntimeException; diff --git a/src/Internal/Exceptions/NonWritableStream.php b/src/Internal/Server/Exceptions/NonWritableStream.php similarity index 80% rename from src/Internal/Exceptions/NonWritableStream.php rename to src/Internal/Server/Exceptions/NonWritableStream.php index 227a71a..67360c9 100644 --- a/src/Internal/Exceptions/NonWritableStream.php +++ b/src/Internal/Server/Exceptions/NonWritableStream.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Exceptions; +namespace TinyBlocks\Http\Internal\Server\Exceptions; use RuntimeException; diff --git a/src/Internal/Exceptions/SameSiteNoneRequiresSecure.php b/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php similarity index 89% rename from src/Internal/Exceptions/SameSiteNoneRequiresSecure.php rename to src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php index 14142e9..be8f880 100644 --- a/src/Internal/Exceptions/SameSiteNoneRequiresSecure.php +++ b/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Exceptions; +namespace TinyBlocks\Http\Internal\Server\Exceptions; use DomainException; diff --git a/src/Internal/Request/DecodedRequest.php b/src/Internal/Server/Request/DecodedRequest.php similarity index 82% rename from src/Internal/Request/DecodedRequest.php rename to src/Internal/Server/Request/DecodedRequest.php index 24dcc5f..c2d1df2 100644 --- a/src/Internal/Request/DecodedRequest.php +++ b/src/Internal/Server/Request/DecodedRequest.php @@ -2,7 +2,9 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Request; +namespace TinyBlocks\Http\Internal\Server\Request; + +use TinyBlocks\Http\Internal\Shared\Body; final readonly class DecodedRequest { diff --git a/src/Internal/Request/Decoder.php b/src/Internal/Server/Request/Decoder.php similarity index 76% rename from src/Internal/Request/Decoder.php rename to src/Internal/Server/Request/Decoder.php index b8bf341..d759aab 100644 --- a/src/Internal/Request/Decoder.php +++ b/src/Internal/Server/Request/Decoder.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Request; +namespace TinyBlocks\Http\Internal\Server\Request; use Psr\Http\Message\ServerRequestInterface; +use TinyBlocks\Http\Internal\Shared\Body; final readonly class Decoder { @@ -16,7 +17,7 @@ public static function from(ServerRequestInterface $request): Decoder { return new Decoder( uri: Uri::from(request: $request), - body: Body::from(request: $request) + body: Body::fromServerRequest(request: $request) ); } diff --git a/src/Internal/Request/QueryParameters.php b/src/Internal/Server/Request/QueryParameters.php similarity index 85% rename from src/Internal/Request/QueryParameters.php rename to src/Internal/Server/Request/QueryParameters.php index 413e17d..e588a03 100644 --- a/src/Internal/Request/QueryParameters.php +++ b/src/Internal/Server/Request/QueryParameters.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Request; +namespace TinyBlocks\Http\Internal\Server\Request; use Psr\Http\Message\ServerRequestInterface; +use TinyBlocks\Http\Internal\Shared\Attribute; final readonly class QueryParameters { diff --git a/src/Internal/Request/RouteParameterResolver.php b/src/Internal/Server/Request/RouteParameterResolver.php similarity index 98% rename from src/Internal/Request/RouteParameterResolver.php rename to src/Internal/Server/Request/RouteParameterResolver.php index eed47e1..21edd6e 100644 --- a/src/Internal/Request/RouteParameterResolver.php +++ b/src/Internal/Server/Request/RouteParameterResolver.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Request; +namespace TinyBlocks\Http\Internal\Server\Request; use Psr\Http\Message\ServerRequestInterface; diff --git a/src/Internal/Request/Uri.php b/src/Internal/Server/Request/Uri.php similarity index 97% rename from src/Internal/Request/Uri.php rename to src/Internal/Server/Request/Uri.php index 08a1b1e..3a0a952 100644 --- a/src/Internal/Request/Uri.php +++ b/src/Internal/Server/Request/Uri.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Request; +namespace TinyBlocks\Http\Internal\Server\Request; use Psr\Http\Message\ServerRequestInterface; +use TinyBlocks\Http\Internal\Shared\Attribute; /** * Provides access to URI components and route parameters extracted from a PSR-7 ServerRequestInterface. diff --git a/src/Internal/Response/InternalResponse.php b/src/Internal/Server/Response/InternalResponse.php similarity index 92% rename from src/Internal/Response/InternalResponse.php rename to src/Internal/Server/Response/InternalResponse.php index 5532442..d01a366 100644 --- a/src/Internal/Response/InternalResponse.php +++ b/src/Internal/Server/Response/InternalResponse.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Response; +namespace TinyBlocks\Http\Internal\Server\Response; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; use TinyBlocks\Http\Code; -use TinyBlocks\Http\Headers; -use TinyBlocks\Http\Internal\Stream\StreamFactory; +use TinyBlocks\Http\Headerable; +use TinyBlocks\Http\Internal\Server\Stream\StreamFactory; final readonly class InternalResponse implements ResponseInterface { @@ -21,7 +21,7 @@ private function __construct( ) { } - public static function createWithBody(mixed $body, Code $code, Headers ...$headers): ResponseInterface + public static function createWithBody(mixed $body, Code $code, Headerable ...$headers): ResponseInterface { return new InternalResponse( body: StreamFactory::fromBody(body: $body)->write(), @@ -31,7 +31,7 @@ public static function createWithBody(mixed $body, Code $code, Headers ...$heade ); } - public static function createWithoutBody(Code $code, Headers ...$headers): ResponseInterface + public static function createWithoutBody(Code $code, Headerable ...$headers): ResponseInterface { return new InternalResponse( body: StreamFactory::fromEmptyBody()->write(), diff --git a/src/Internal/Response/ProtocolVersion.php b/src/Internal/Server/Response/ProtocolVersion.php similarity index 90% rename from src/Internal/Response/ProtocolVersion.php rename to src/Internal/Server/Response/ProtocolVersion.php index 2767f9b..f0509df 100644 --- a/src/Internal/Response/ProtocolVersion.php +++ b/src/Internal/Server/Response/ProtocolVersion.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Response; +namespace TinyBlocks\Http\Internal\Server\Response; final readonly class ProtocolVersion { diff --git a/src/Internal/Response/ResponseHeaders.php b/src/Internal/Server/Response/ResponseHeaders.php similarity index 91% rename from src/Internal/Response/ResponseHeaders.php rename to src/Internal/Server/Response/ResponseHeaders.php index 7df7172..d735c87 100644 --- a/src/Internal/Response/ResponseHeaders.php +++ b/src/Internal/Server/Response/ResponseHeaders.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Response; +namespace TinyBlocks\Http\Internal\Server\Response; use TinyBlocks\Http\Charset; use TinyBlocks\Http\ContentType; -use TinyBlocks\Http\Headers; +use TinyBlocks\Http\Headerable; -final readonly class ResponseHeaders implements Headers +final readonly class ResponseHeaders implements Headerable { private function __construct(private array $headers) { } - public static function fromOrDefault(Headers ...$headers): ResponseHeaders + public static function fromOrDefault(Headerable ...$headers): ResponseHeaders { if (empty($headers)) { return new ResponseHeaders(headers: ContentType::applicationJson(charset: Charset::UTF_8)->toArray()); diff --git a/src/Internal/Stream/Stream.php b/src/Internal/Server/Stream/Stream.php similarity index 90% rename from src/Internal/Stream/Stream.php rename to src/Internal/Server/Stream/Stream.php index f46ff17..f2a52a5 100644 --- a/src/Internal/Stream/Stream.php +++ b/src/Internal/Server/Stream/Stream.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Stream; +namespace TinyBlocks\Http\Internal\Server\Stream; use Psr\Http\Message\StreamInterface; -use TinyBlocks\Http\Internal\Exceptions\InvalidResource; -use TinyBlocks\Http\Internal\Exceptions\MissingResourceStream; -use TinyBlocks\Http\Internal\Exceptions\NonReadableStream; -use TinyBlocks\Http\Internal\Exceptions\NonSeekableStream; -use TinyBlocks\Http\Internal\Exceptions\NonWritableStream; +use TinyBlocks\Http\Internal\Server\Exceptions\InvalidResource; +use TinyBlocks\Http\Internal\Server\Exceptions\MissingResourceStream; +use TinyBlocks\Http\Internal\Server\Exceptions\NonReadableStream; +use TinyBlocks\Http\Internal\Server\Exceptions\NonSeekableStream; +use TinyBlocks\Http\Internal\Server\Exceptions\NonWritableStream; final class Stream implements StreamInterface { diff --git a/src/Internal/Stream/StreamFactory.php b/src/Internal/Server/Stream/StreamFactory.php similarity index 95% rename from src/Internal/Stream/StreamFactory.php rename to src/Internal/Server/Stream/StreamFactory.php index d867f05..eafb2c5 100644 --- a/src/Internal/Stream/StreamFactory.php +++ b/src/Internal/Server/Stream/StreamFactory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Stream; +namespace TinyBlocks\Http\Internal\Server\Stream; use BackedEnum; use Psr\Http\Message\StreamInterface; @@ -53,14 +53,6 @@ public static function fromStream(StreamInterface $stream): StreamFactory return new StreamFactory(body: $body); } - public function write(): StreamInterface - { - $this->stream->write(string: $this->body); - $this->stream->rewind(); - - return $this->stream; - } - public function content(): string { return (string)$this->body; @@ -68,7 +60,15 @@ public function content(): string public function isEmptyContent(): bool { - return empty($this->body); + return $this->body === ''; + } + + public function write(): StreamInterface + { + $this->stream->write(string: $this->body); + $this->stream->rewind(); + + return $this->stream; } private static function toJsonFrom(mixed $body): string diff --git a/src/Internal/Stream/StreamMetaData.php b/src/Internal/Server/Stream/StreamMetaData.php similarity index 94% rename from src/Internal/Stream/StreamMetaData.php rename to src/Internal/Server/Stream/StreamMetaData.php index 7a85248..c4762fc 100644 --- a/src/Internal/Stream/StreamMetaData.php +++ b/src/Internal/Server/Stream/StreamMetaData.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Stream; +namespace TinyBlocks\Http\Internal\Server\Stream; final readonly class StreamMetaData { diff --git a/src/Internal/Request/Attribute.php b/src/Internal/Shared/Attribute.php similarity index 96% rename from src/Internal/Request/Attribute.php rename to src/Internal/Shared/Attribute.php index c8334b1..57c4f70 100644 --- a/src/Internal/Request/Attribute.php +++ b/src/Internal/Shared/Attribute.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Request; +namespace TinyBlocks\Http\Internal\Shared; final readonly class Attribute { diff --git a/src/Internal/Shared/Body.php b/src/Internal/Shared/Body.php new file mode 100644 index 0000000..7af9f6d --- /dev/null +++ b/src/Internal/Shared/Body.php @@ -0,0 +1,79 @@ +getBody(); + + if ($stream->isSeekable()) { + $stream->rewind(); + } + + $raw = $stream->getContents(); + + if ($stream->isSeekable()) { + $stream->rewind(); + } + + try { + $decoded = json_decode( + $raw, + true, + self::MAX_JSON_DEPTH, + JSON_THROW_ON_ERROR + ); + } catch (JsonException) { + return new Body(data: []); + } + + return new Body(data: is_array($decoded) ? $decoded : []); + } + + public static function fromServerRequest(ServerRequestInterface $request): Body + { + $streamFactory = StreamFactory::fromStream(stream: $request->getBody()); + + if (!$streamFactory->isEmptyContent()) { + $decoded = json_decode($streamFactory->content(), true); + + return new Body(data: is_array($decoded) ? $decoded : []); + } + + $parsedBody = $request->getParsedBody(); + + return new Body(data: is_array($parsedBody) ? $parsedBody : []); + } + + public function get(string $key): Attribute + { + $value = ($this->data[$key] ?? null); + + return Attribute::from(value: $value); + } + + public function toArray(): array + { + return $this->data; + } +} diff --git a/src/ResponseCacheDirectives.php b/src/ResponseCacheDirectives.php index e6efc08..b9ffbca 100644 --- a/src/ResponseCacheDirectives.php +++ b/src/ResponseCacheDirectives.php @@ -4,8 +4,8 @@ namespace TinyBlocks\Http; -use TinyBlocks\Http\Internal\CacheControl\CacheControlDirective; -use TinyBlocks\Http\Internal\CacheControl\Directives; +use TinyBlocks\Http\Internal\Server\CacheControl\CacheControlDirective; +use TinyBlocks\Http\Internal\Server\CacheControl\Directives; /** * Represents a single Cache-Control directive for HTTP responses. diff --git a/src/Request.php b/src/Server/Request.php similarity index 76% rename from src/Request.php rename to src/Server/Request.php index 7d65bba..bd01756 100644 --- a/src/Request.php +++ b/src/Server/Request.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace TinyBlocks\Http; +namespace TinyBlocks\Http\Server; use Psr\Http\Message\ServerRequestInterface; -use TinyBlocks\Http\Internal\Request\DecodedRequest; -use TinyBlocks\Http\Internal\Request\Decoder; +use TinyBlocks\Http\Internal\Server\Request\DecodedRequest; +use TinyBlocks\Http\Internal\Server\Request\Decoder; +use TinyBlocks\Http\Method; final readonly class Request { diff --git a/src/Response.php b/src/Server/Response.php similarity index 57% rename from src/Response.php rename to src/Server/Response.php index d673034..ff8fa7f 100644 --- a/src/Response.php +++ b/src/Server/Response.php @@ -2,69 +2,71 @@ declare(strict_types=1); -namespace TinyBlocks\Http; +namespace TinyBlocks\Http\Server; use Psr\Http\Message\ResponseInterface; -use TinyBlocks\Http\Internal\Response\InternalResponse; +use TinyBlocks\Http\Code; +use TinyBlocks\Http\Headerable; +use TinyBlocks\Http\Internal\Server\Response\InternalResponse; final readonly class Response implements Responses { - public static function from(Code $code, mixed $body, Headers ...$headers): ResponseInterface + public static function from(Code $code, mixed $body, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, $code, ...$headers); } - public static function ok(mixed $body, Headers ...$headers): ResponseInterface + public static function ok(mixed $body, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, Code::OK, ...$headers); } - public static function created(mixed $body, Headers ...$headers): ResponseInterface + public static function created(mixed $body, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, Code::CREATED, ...$headers); } - public static function accepted(mixed $body, Headers ...$headers): ResponseInterface + public static function accepted(mixed $body, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, Code::ACCEPTED, ...$headers); } - public static function noContent(Headers ...$headers): ResponseInterface + public static function noContent(Headerable ...$headers): ResponseInterface { return InternalResponse::createWithoutBody(Code::NO_CONTENT, ...$headers); } - public static function badRequest(mixed $body, Headers ...$headers): ResponseInterface + public static function badRequest(mixed $body, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, Code::BAD_REQUEST, ...$headers); } - public static function unauthorized(mixed $body, Headers ...$headers): ResponseInterface + public static function unauthorized(mixed $body, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, Code::UNAUTHORIZED, ...$headers); } - public static function forbidden(mixed $body, Headers ...$headers): ResponseInterface + public static function forbidden(mixed $body, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, Code::FORBIDDEN, ...$headers); } - public static function notFound(mixed $body, Headers ...$headers): ResponseInterface + public static function notFound(mixed $body, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, Code::NOT_FOUND, ...$headers); } - public static function conflict(mixed $body, Headers ...$headers): ResponseInterface + public static function conflict(mixed $body, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, Code::CONFLICT, ...$headers); } - public static function unprocessableEntity(mixed $body, Headers ...$headers): ResponseInterface + public static function unprocessableEntity(mixed $body, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, Code::UNPROCESSABLE_ENTITY, ...$headers); } - public static function internalServerError(mixed $body, Headers ...$headers): ResponseInterface + public static function internalServerError(mixed $body, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, Code::INTERNAL_SERVER_ERROR, ...$headers); } diff --git a/src/Responses.php b/src/Server/Responses.php similarity index 59% rename from src/Responses.php rename to src/Server/Responses.php index 73b257e..880f7c9 100644 --- a/src/Responses.php +++ b/src/Server/Responses.php @@ -2,9 +2,11 @@ declare(strict_types=1); -namespace TinyBlocks\Http; +namespace TinyBlocks\Http\Server; use Psr\Http\Message\ResponseInterface; +use TinyBlocks\Http\Code; +use TinyBlocks\Http\Headerable; /** * Define standard HTTP response methods. @@ -18,106 +20,106 @@ interface Responses * * @param Code $code The HTTP status code for the response. * @param mixed $body The body of the response. - * @param Headers ...$headers Optional additional headers for the response. + * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated response with the specified status code, body, and headers. */ - public static function from(Code $code, mixed $body, Headers ...$headers): ResponseInterface; + public static function from(Code $code, mixed $body, Headerable ...$headers): ResponseInterface; /** * Creates a response with a 200 OK status. * * @param mixed $body The body of the response. - * @param Headers ...$headers Optional additional headers for the response. + * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated 200 OK response. */ - public static function ok(mixed $body, Headers ...$headers): ResponseInterface; + public static function ok(mixed $body, Headerable ...$headers): ResponseInterface; /** * Creates a response with a 201 Created status. * * @param mixed $body The body of the response. - * @param Headers ...$headers Optional additional headers for the response. + * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated 201 Created response. */ - public static function created(mixed $body, Headers ...$headers): ResponseInterface; + public static function created(mixed $body, Headerable ...$headers): ResponseInterface; /** * Creates a response with a 202 Accepted status. * * @param mixed $body The body of the response. - * @param Headers ...$headers Optional additional headers for the response. + * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated 202 Accepted response. */ - public static function accepted(mixed $body, Headers ...$headers): ResponseInterface; + public static function accepted(mixed $body, Headerable ...$headers): ResponseInterface; /** * Creates a response with a 204 No Content status. * - * @param Headers ...$headers Optional additional headers for the response. + * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated 204 No Content response. */ - public static function noContent(Headers ...$headers): ResponseInterface; + public static function noContent(Headerable ...$headers): ResponseInterface; /** * Creates a response with a 400 Bad Request status. * * @param mixed $body The body of the response. - * @param Headers ...$headers Optional additional headers for the response. + * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated 400 Bad Request response. */ - public static function badRequest(mixed $body, Headers ...$headers): ResponseInterface; + public static function badRequest(mixed $body, Headerable ...$headers): ResponseInterface; /** * Creates a response with a 401 Unauthorized status. * * @param mixed $body The body of the response. - * @param Headers ...$headers Optional additional headers for the response. + * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated 401 Unauthorized response. */ - public static function unauthorized(mixed $body, Headers ...$headers): ResponseInterface; + public static function unauthorized(mixed $body, Headerable ...$headers): ResponseInterface; /** * Creates a response with a 403 Forbidden status. * * @param mixed $body The body of the response. - * @param Headers ...$headers Optional additional headers for the response. + * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated 403 Forbidden response. */ - public static function forbidden(mixed $body, Headers ...$headers): ResponseInterface; + public static function forbidden(mixed $body, Headerable ...$headers): ResponseInterface; /** * Creates a response with a 404 Not Found status. * * @param mixed $body The body of the response. - * @param Headers ...$headers Optional additional headers for the response. + * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated 404 Not Found response. */ - public static function notFound(mixed $body, Headers ...$headers): ResponseInterface; + public static function notFound(mixed $body, Headerable ...$headers): ResponseInterface; /** * Creates a response with a 409 Conflict status. * * @param mixed $body The body of the response. - * @param Headers ...$headers Optional additional headers for the response. + * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated 409 Conflict response. */ - public static function conflict(mixed $body, Headers ...$headers): ResponseInterface; + public static function conflict(mixed $body, Headerable ...$headers): ResponseInterface; /** * Creates a response with a 422 Unprocessable Entity status. * * @param mixed $body The body of the response. - * @param Headers ...$headers Optional additional headers for the response. + * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated 422 Unprocessable Entity response. */ - public static function unprocessableEntity(mixed $body, Headers ...$headers): ResponseInterface; + public static function unprocessableEntity(mixed $body, Headerable ...$headers): ResponseInterface; /** * Creates a response with a 500 Internal Server Error status. * * @param mixed $body The body of the response. - * @param Headers ...$headers Optional additional headers for the response. + * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated 500 Internal Server Error response. */ - public static function internalServerError(mixed $body, Headers ...$headers): ResponseInterface; + public static function internalServerError(mixed $body, Headerable ...$headers): ResponseInterface; } diff --git a/tests/Drivers/Laminas/LaminasTest.php b/tests/Drivers/Laminas/LaminasTest.php index 9bfdd06..ff750cc 100644 --- a/tests/Drivers/Laminas/LaminasTest.php +++ b/tests/Drivers/Laminas/LaminasTest.php @@ -15,8 +15,8 @@ use TinyBlocks\Http\Charset; use TinyBlocks\Http\Code; use TinyBlocks\Http\ContentType; -use TinyBlocks\Http\Response; use TinyBlocks\Http\ResponseCacheDirectives; +use TinyBlocks\Http\Server\Response; final class LaminasTest extends TestCase { diff --git a/tests/Drivers/Slim/SlimTest.php b/tests/Drivers/Slim/SlimTest.php index f41aa45..2b83ffd 100644 --- a/tests/Drivers/Slim/SlimTest.php +++ b/tests/Drivers/Slim/SlimTest.php @@ -15,8 +15,8 @@ use TinyBlocks\Http\Charset; use TinyBlocks\Http\Code; use TinyBlocks\Http\ContentType; -use TinyBlocks\Http\Response; use TinyBlocks\Http\ResponseCacheDirectives; +use TinyBlocks\Http\Server\Response; final class SlimTest extends TestCase { diff --git a/tests/Unit/Client/RequestTest.php b/tests/Unit/Client/RequestTest.php new file mode 100644 index 0000000..be7735d --- /dev/null +++ b/tests/Unit/Client/RequestTest.php @@ -0,0 +1,170 @@ +url); + self::assertSame(Method::GET, $request->method); + self::assertNull($request->body); + self::assertSame([], $request->query); + self::assertSame([], $request->headers->toArray()); + } + + public function testCreateWithNullBodyCarriesNoBody(): void + { + /** @When creating a request with an explicit null body */ + $request = Request::create(url: '/dragons'); + + /** @Then the body is null */ + self::assertNull($request->body); + } + + public function testCreateMergesMultipleHeaders(): void + { + /** @Given two distinct headers */ + $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + $accept = ContentType::applicationJson(); + + /** @When creating a request with both headers positionally (multiple variadics) */ + $request = Request::create('/dragons', null, [], Method::POST, $contentType, $accept); + + /** @Then the merged headers contain entries from both */ + self::assertTrue($request->headers->has('Content-Type')); + } + + public function testCreatePreservesLastHeaderWhenSameNameProvided(): void + { + /** @Given two Content-Type headers with different values */ + $first = ContentType::applicationJson(charset: Charset::UTF_8); + $second = ContentType::applicationJson(); + + /** @When creating the request with both (last one wins) */ + $request = Request::create('/dragons', null, [], Method::POST, $first, $second); + + /** @Then the last one wins for the Content-Type key */ + self::assertSame('application/json', $request->headers->get('Content-Type')); + } + + public function testCreatePreservesQueryArray(): void + { + /** @Given query parameters */ + $query = ['sort' => 'name', 'order' => 'asc']; + + /** @When creating the request with query */ + $request = Request::create(url: '/dragons', query: $query); + + /** @Then the query is preserved */ + self::assertSame($query, $request->query); + } + + public function testWithUrlReturnsNewInstanceWithReplacedUrl(): void + { + /** @Given a request with an original URL */ + $request = Request::create(url: '/dragons'); + + /** @When calling withUrl */ + $updated = $request->withUrl(url: '/dragons/42'); + + /** @Then a new instance is returned with the URL replaced */ + self::assertNotSame($request, $updated); + self::assertSame('/dragons/42', $updated->url); + self::assertSame('/dragons', $request->url); + } + + public function testWithQueryReturnsNewInstanceWithReplacedQuery(): void + { + /** @Given a request with an original query */ + $request = Request::create(url: '/dragons', query: ['sort' => 'name']); + + /** @When calling withQuery */ + $updated = $request->withQuery(query: ['order' => 'asc']); + + /** @Then a new instance is returned with the query replaced */ + self::assertNotSame($request, $updated); + self::assertSame(['order' => 'asc'], $updated->query); + self::assertSame(['sort' => 'name'], $request->query); + } + + public function testCreateWithDistinctKeyHeadersProducesBothEntries(): void + { + /** @Given two headers with distinct keys */ + $contentType = ContentType::applicationJson(); + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::mustRevalidate()); + + /** @When creating a request with both headers */ + $request = Request::create('/dragons', null, [], Method::GET, $contentType, $cacheControl); + + /** @Then both header keys are present in the merged result */ + self::assertCount(2, $request->headers->toArray()); + } + + public function testWithMergedHeadersCustomHeadersWinOverDefaults(): void + { + /** @Given a request with a custom Content-Type header */ + $request = Request::create( + url: '/dragons', + method: Method::POST, + headerables: ContentType::applicationJson(charset: Charset::UTF_8) + ); + + /** @When merging defaults that include the same header */ + $defaults = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; + $resolved = $request->withMergedHeaders(defaults: $defaults); + + /** @Then the custom header wins over the default */ + self::assertSame('application/json; charset=utf-8', $resolved->headers->get('Content-Type')); + self::assertSame('application/json', $resolved->headers->get('Accept')); + } + + public function testHeadersAreCaseInsensitiveLookup(): void + { + /** @Given a request with a Content-Type header */ + $request = Request::create( + url: '/dragons', + headerables: ContentType::applicationJson() + ); + + /** @When looking up the header with different casing */ + /** @Then the lookup succeeds regardless of case */ + self::assertTrue($request->headers->has('content-type')); + self::assertSame('application/json', $request->headers->get('CONTENT-TYPE')); + } + + public function testHeadersReturnNullForMissingKey(): void + { + /** @Given a request with no headers */ + $request = Request::create(url: '/dragons'); + + /** @When looking up a non-existent header */ + /** @Then null is returned */ + self::assertNull($request->headers->get('X-Missing')); + } + + public function testHeadersInstanceIsReturnedOnRequest(): void + { + /** @Given a request */ + $request = Request::create(url: '/dragons'); + + /** @When accessing headers */ + /** @Then a Headers instance is returned */ + self::assertInstanceOf(Headers::class, $request->headers); + } +} diff --git a/tests/Unit/Client/ResponseTest.php b/tests/Unit/Client/ResponseTest.php new file mode 100644 index 0000000..f1d08b9 --- /dev/null +++ b/tests/Unit/Client/ResponseTest.php @@ -0,0 +1,201 @@ +factory = new Psr17Factory(); + } + + public function testResponseWith200AndJsonBodyAllowsTypedAccess(): void + { + /** @Given a 200 response with a JSON body */ + $psrResponse = $this->factory->createResponse(200) + ->withBody($this->factory->createStream('{"id":42,"name":"Hydra"}')); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then typed body access works correctly */ + self::assertSame(42, $response->body()->get(key: 'id')->toInteger()); + self::assertSame('Hydra', $response->body()->get(key: 'name')->toString()); + self::assertSame(Code::OK, $response->code()); + } + + public function testResponseWith204AndNoBodyReturnsEmptyArray(): void + { + /** @Given a 204 response with no body */ + $psrResponse = $this->factory->createResponse(204); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then the body array is empty */ + self::assertSame([], $response->body()->toArray()); + self::assertSame(Code::NO_CONTENT, $response->code()); + } + + public function testResponseWithNonJsonBodyReturnsSafeEmptyArray(): void + { + /** @Given a 200 response with a non-JSON body */ + $psrResponse = $this->factory->createResponse(200) + ->withBody($this->factory->createStream('plain text')); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then the body gracefully returns an empty array */ + self::assertSame([], $response->body()->toArray()); + } + + public function testResponseWith200IsSuccessAndNotError(): void + { + /** @Given a 200 response */ + $psrResponse = $this->factory->createResponse(200); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then isSuccess is true and isError is false */ + self::assertTrue($response->isSuccess()); + self::assertFalse($response->isError()); + } + + public function testResponseWith500IsErrorAndNotSuccess(): void + { + /** @Given a 500 response */ + $psrResponse = $this->factory->createResponse(500); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then isError is true and isSuccess is false */ + self::assertTrue($response->isError()); + self::assertFalse($response->isSuccess()); + } + + public function testResponseHeadersAreFlattenedToStrings(): void + { + /** @Given a response with two distinct headers */ + $psrResponse = $this->factory->createResponse(200) + ->withHeader('X-Trace', 'abc') + ->withHeader('X-Request-ID', '123'); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then headers() returns all headers accessible via the Headers value object */ + self::assertSame('abc', $response->headers()->get('X-Trace')); + self::assertSame('123', $response->headers()->get('X-Request-ID')); + } + + public function testRawReturnsUnderlyingPsrResponse(): void + { + /** @Given a PSR response */ + $psrResponse = $this->factory->createResponse(200); + + /** @When wrapping and then unwrapping */ + $response = Response::from(response: $psrResponse); + + /** @Then raw() returns the exact original instance */ + self::assertSame($psrResponse, $response->raw()); + } + + public function testSynthesizedResponseWithCodeAndBodyWorks(): void + { + /** @Given code and body data */ + /** @When synthesizing a response via with() */ + $response = Response::with(code: Code::CREATED, body: ['id' => 1]); + + /** @Then code and body are accessible */ + self::assertSame(Code::CREATED, $response->code()); + self::assertSame(1, $response->body()->get(key: 'id')->toInteger()); + self::assertTrue($response->isSuccess()); + self::assertFalse($response->isError()); + } + + public function testSynthesizedResponseRawThrowsSynthesizedResponseHasNoRaw(): void + { + /** @Given a synthesized response */ + $response = Response::with(code: Code::OK); + + /** @Then SynthesizedResponseHasNoRaw is thrown */ + $this->expectException(SynthesizedResponseHasNoRaw::class); + + /** @When calling raw() */ + $response->raw(); + } + + public function testSynthesizedResponseWithNullBodyReturnsEmptyArray(): void + { + /** @Given a synthesized response with null body */ + /** @When creating the response */ + $response = Response::with(code: Code::NO_CONTENT); + + /** @Then body is empty */ + self::assertSame([], $response->body()->toArray()); + } + + public function testResponseWithSeekableStreamCanBeConsumedAgainViaRaw(): void + { + /** @Given a 200 response with a JSON body in a seekable stream */ + $psrResponse = $this->factory->createResponse(200) + ->withBody($this->factory->createStream('{"name":"Hydra"}')); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then the body was parsed correctly */ + self::assertSame('Hydra', $response->body()->get(key: 'name')->toString()); + + /** @And the underlying stream is still readable via raw() */ + $raw = $response->raw()->getBody(); + $raw->rewind(); + self::assertSame('{"name":"Hydra"}', $raw->getContents()); + } + + public function testResponseFromAdvancedSeekableStreamParsesBodyFromStart(): void + { + /** @Given a seekable stream advanced past its start */ + $stream = $this->factory->createStream('{"name":"Hydra"}'); + $stream->getContents(); + + /** @And a 200 response using that stream */ + $psrResponse = $this->factory->createResponse(200)->withBody($stream); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then the body is parsed correctly despite the advanced stream position */ + self::assertSame('Hydra', $response->body()->get(key: 'name')->toString()); + + /** @And the stream is at position zero after parsing so it can be re-read without a manual rewind */ + self::assertSame('{"name":"Hydra"}', $response->raw()->getBody()->getContents()); + } + + public function testResponseWithDeeplyNestedJsonBeyondDepthDegradesToEmpty(): void + { + /** @Given a JSON string nested deeper than 64 levels */ + $json = str_repeat('{"a":', 65) . '1' . str_repeat('}', 65); + $psrResponse = $this->factory->createResponse(200) + ->withBody($this->factory->createStream($json)); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then body degrades gracefully to an empty array */ + self::assertSame([], $response->body()->toArray()); + } +} diff --git a/tests/Unit/Client/Transports/InMemoryTransportTest.php b/tests/Unit/Client/Transports/InMemoryTransportTest.php new file mode 100644 index 0000000..bd9ab38 --- /dev/null +++ b/tests/Unit/Client/Transports/InMemoryTransportTest.php @@ -0,0 +1,70 @@ +send(request: $request); + $responseTwo = $transport->send(request: $request); + + /** @Then responses are returned in FIFO order */ + self::assertSame(Code::OK, $responseOne->code()); + self::assertSame(Code::CREATED, $responseTwo->code()); + } + + public function testExhaustedTransportThrowsNoMoreResponses(): void + { + /** @Given a transport seeded with one response */ + $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); + $request = Request::create(url: '/dragons'); + $transport->send(request: $request); + + /** @Then NoMoreResponses is thrown on the second call */ + $this->expectException(NoMoreResponses::class); + + /** @When sending a second request */ + $transport->send(request: $request); + } + + public function testEmptyTransportThrowsNoMoreResponsesImmediately(): void + { + /** @Given a transport seeded with zero responses */ + $transport = InMemoryTransport::with(responses: []); + + /** @Then NoMoreResponses is thrown immediately */ + $this->expectException(NoMoreResponses::class); + + /** @When calling send */ + $transport->send(request: Request::create(url: '/dragons')); + } + + public function testSingleResponseTransportReturnsCorrectResponse(): void + { + /** @Given a transport seeded with one response */ + $transport = InMemoryTransport::with(responses: [Response::with(code: Code::CREATED)]); + + /** @When sending one request */ + $response = $transport->send(request: Request::create(url: '/dragons')); + + /** @Then the response is correct */ + self::assertSame(Code::CREATED, $response->code()); + } +} diff --git a/tests/Unit/Client/Transports/NetworkTransportTest.php b/tests/Unit/Client/Transports/NetworkTransportTest.php new file mode 100644 index 0000000..0401cb6 --- /dev/null +++ b/tests/Unit/Client/Transports/NetworkTransportTest.php @@ -0,0 +1,263 @@ +factory = new Psr17Factory(); + } + + public function testRequestWithBodySendsJsonEncodedBodyAndContentTypeHeader(): void + { + /** @Given a client that captures the PSR-7 request */ + $captured = null; + $client = $this->buildCapturingClient(captured: $captured, statusCode: 201); + $transport = NetworkTransport::with( + client: $client, + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + /** @When sending a request with a JSON body */ + $transport->send( + request: Request::create( + url: 'https://api.example.com/dragons', + body: ['name' => 'Hydra'], + method: Method::POST + )->withMergedHeaders(defaults: ['Content-Type' => 'application/json']) + ); + + /** @Then the PSR-7 request carries JSON and the Content-Type header */ + self::assertSame('{"name":"Hydra"}', (string)$captured->getBody()); + self::assertSame('application/json', $captured->getHeaderLine('Content-Type')); + } + + public function testRequestWithoutBodySendsNoBody(): void + { + /** @Given a client that captures the PSR-7 request */ + $captured = null; + $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); + $transport = NetworkTransport::with( + client: $client, + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + /** @When sending a request without body */ + $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); + + /** @Then the PSR-7 request body is empty */ + self::assertSame('', (string)$captured->getBody()); + } + + public function testCustomHeadersAreForwardedToPsrRequest(): void + { + /** @Given a client that captures the PSR-7 request */ + $captured = null; + $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); + $transport = NetworkTransport::with( + client: $client, + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + /** @When sending a request with a custom header */ + $transport->send( + request: Request::create(url: 'https://api.example.com/dragons') + ->withMergedHeaders(defaults: ['X-Correlation-ID' => 'abc-123']) + ); + + /** @Then the PSR-7 request carries the custom header */ + self::assertSame('abc-123', $captured->getHeaderLine('X-Correlation-ID')); + } + + public function testNetworkExceptionMapsToHttpNetworkFailed(): void + { + /** @Given a PSR-18 client that throws NetworkExceptionInterface */ + $networkException = new class ('connection refused') extends RuntimeException implements + NetworkExceptionInterface { + public function getRequest(): RequestInterface + { + return (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + } + }; + + $transport = NetworkTransport::with( + client: $this->buildThrowingClient(exception: $networkException), + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + /** @Then HttpNetworkFailed is thrown with previous set */ + $this->expectException(HttpNetworkFailed::class); + + /** @When sending the request */ + $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); + } + + public function testRequestExceptionMapsToHttpRequestInvalid(): void + { + /** @Given a PSR-18 client that throws RequestExceptionInterface */ + $requestException = new class ('bad request') extends RuntimeException implements RequestExceptionInterface { + public function getRequest(): RequestInterface + { + return (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + } + }; + + $transport = NetworkTransport::with( + client: $this->buildThrowingClient(exception: $requestException), + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + /** @Then HttpRequestInvalid is thrown */ + $this->expectException(HttpRequestInvalid::class); + + /** @When sending the request */ + $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); + } + + public function testGenericClientExceptionMapsToHttpRequestFailed(): void + { + /** @Given a PSR-18 client that throws a generic ClientExceptionInterface */ + $clientException = new class ('generic failure') extends RuntimeException implements ClientExceptionInterface { + }; + + $transport = NetworkTransport::with( + client: $this->buildThrowingClient(exception: $clientException), + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + /** @Then HttpRequestFailed is thrown */ + $this->expectException(HttpRequestFailed::class); + + /** @When sending the request */ + $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); + } + + public function testSuccessfulResponseIsWrappedInClientResponse(): void + { + /** @Given a client that returns a 200 response */ + $captured = null; + $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); + $transport = NetworkTransport::with( + client: $client, + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + /** @When sending a request */ + $response = $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); + + /** @Then the response code is correct */ + self::assertSame(Code::OK, $response->code()); + } + + public function testBodyWithUnencodableValueThrowsHttpRequestInvalid(): void + { + /** @Given a request body containing a float value that cannot be JSON-encoded */ + $captured = null; + $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); + $transport = NetworkTransport::with( + client: $client, + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + /** @Then HttpRequestInvalid is thrown due to JSON encoding failure */ + $this->expectException(HttpRequestInvalid::class); + + /** @When sending a request body containing INF (unencodable in JSON) */ + $transport->send( + request: Request::create( + url: 'https://api.example.com/dragons', + body: ['value' => INF], + method: Method::POST + ) + ); + } + + public function testBodyWithInvalidUtf8SubstitutesAndDoesNotCrash(): void + { + /** @Given a request body containing invalid UTF-8 bytes */ + $captured = null; + $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); + $transport = NetworkTransport::with( + client: $client, + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + /** @When sending the request */ + $transport->send( + request: Request::create( + url: 'https://api.example.com/dragons', + body: ['value' => "\xB0\xB1\xB2"], + method: Method::POST + ) + ); + + /** @Then no exception is thrown and the body is encoded safely */ + self::assertNotEmpty((string)$captured->getBody()); + } + + private function buildCapturingClient(?RequestInterface &$captured, int $statusCode): ClientInterface + { + $response = $this->factory->createResponse($statusCode); + + return new class ($response, $captured) implements ClientInterface { + public function __construct( + private readonly ResponseInterface $response, + private ?RequestInterface &$captured + ) { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->captured = $request; + + return $this->response; + } + }; + } + + private function buildThrowingClient(Throwable $exception): ClientInterface + { + return new class ($exception) implements ClientInterface { + public function __construct(private readonly Throwable $exception) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + throw $this->exception; + } + }; + } +} diff --git a/tests/CodeTest.php b/tests/Unit/CodeTest.php similarity index 88% rename from tests/CodeTest.php rename to tests/Unit/CodeTest.php index 4e7b6ed..e2d7692 100644 --- a/tests/CodeTest.php +++ b/tests/Unit/CodeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -54,6 +54,24 @@ public function testIsSuccessCode(int $code, bool $expected): void self::assertSame($expected, $actual); } + public function testCodeOkIsSuccessAndNotError(): void + { + /** @Given Code::OK */ + /** @When checking instance methods */ + /** @Then isSuccess is true and isError is false */ + self::assertTrue(Code::OK->isSuccess()); + self::assertFalse(Code::OK->isError()); + } + + public function testCodeInternalServerErrorIsErrorAndNotSuccess(): void + { + /** @Given Code::INTERNAL_SERVER_ERROR */ + /** @When checking instance methods */ + /** @Then isError is true and isSuccess is false */ + self::assertTrue(Code::INTERNAL_SERVER_ERROR->isError()); + self::assertFalse(Code::INTERNAL_SERVER_ERROR->isSuccess()); + } + public static function messagesDataProvider(): array { return [ diff --git a/tests/CookieTest.php b/tests/Unit/CookieTest.php similarity index 87% rename from tests/CookieTest.php rename to tests/Unit/CookieTest.php index 1576155..17e133d 100644 --- a/tests/CookieTest.php +++ b/tests/Unit/CookieTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit; use DateTimeImmutable; use DateTimeZone; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\Cookie; -use TinyBlocks\Http\Internal\Exceptions\ConflictingLifetimeAttributes; -use TinyBlocks\Http\Internal\Exceptions\CookieNameIsInvalid; -use TinyBlocks\Http\Internal\Exceptions\CookieValueIsInvalid; -use TinyBlocks\Http\Internal\Exceptions\SameSiteNoneRequiresSecure; +use TinyBlocks\Http\Internal\Server\Exceptions\ConflictingLifetimeAttributes; +use TinyBlocks\Http\Internal\Server\Exceptions\CookieNameIsInvalid; +use TinyBlocks\Http\Internal\Server\Exceptions\CookieValueIsInvalid; +use TinyBlocks\Http\Internal\Server\Exceptions\SameSiteNoneRequiresSecure; use TinyBlocks\Http\SameSite; final class CookieTest extends TestCase @@ -115,7 +115,9 @@ public function testSameSiteNoneWithoutSecureThrows(): void /** @Then an exception indicating the missing Secure flag should be thrown */ $this->expectException(SameSiteNoneRequiresSecure::class); - $this->expectExceptionMessage('Cookies with SameSite=None require the Secure flag to be set; modern browsers reject such cookies otherwise. Call secure() on the Cookie instance.'); + $this->expectExceptionMessage( + 'Cookies with SameSite=None require the Secure flag to be set; modern browsers reject such cookies otherwise. Call secure() on the Cookie instance.' + ); /** @When the header is serialized */ $cookie->toArray(); @@ -144,7 +146,9 @@ public function testMaxAgeAndExpiresTogetherThrows(): void /** @Then an exception indicating conflicting lifetime attributes should be thrown */ $this->expectException(ConflictingLifetimeAttributes::class); - $this->expectExceptionMessage('Cookie lifetime attributes are conflicting. A cookie must declare its lifetime via either Max-Age or Expires, not both. Choose one and reset the other with a new Cookie instance.'); + $this->expectExceptionMessage( + 'Cookie lifetime attributes are conflicting. A cookie must declare its lifetime via either Max-Age or Expires, not both. Choose one and reset the other with a new Cookie instance.' + ); /** @When the header is serialized */ $cookie->toArray(); @@ -178,7 +182,9 @@ public function testExpireValidatesTheName(): void { /** @Then an exception indicating the name is invalid should be thrown */ $this->expectException(CookieNameIsInvalid::class); - $this->expectExceptionMessage('Cookie name is invalid. A name must not be empty and must not contain control characters, whitespace, or any of the following separators: ( ) < > @ , ; : \\ " / [ ] ? = { }.'); + $this->expectExceptionMessage( + 'Cookie name is invalid. A name must not be empty and must not contain control characters, whitespace, or any of the following separators: ( ) < > @ , ; : \\ " / [ ] ? = { }.' + ); /** @When expiring a cookie with an invalid name */ Cookie::expire(name: 'bad name'); @@ -188,7 +194,9 @@ public function testCreateExposesInvalidValueMessage(): void { /** @Then an exception indicating the value is invalid should be thrown */ $this->expectException(CookieValueIsInvalid::class); - $this->expectExceptionMessage('Cookie value is invalid. A value must not contain control characters, whitespace, double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or Base64) before passing it.'); + $this->expectExceptionMessage( + 'Cookie value is invalid. A value must not contain control characters, whitespace, double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or Base64) before passing it.' + ); /** @When creating a cookie with the invalid value */ Cookie::create(name: 'session', value: 'abc;def'); diff --git a/tests/Unit/Exceptions/HttpConfigurationInvalidTest.php b/tests/Unit/Exceptions/HttpConfigurationInvalidTest.php new file mode 100644 index 0000000..82b3cf0 --- /dev/null +++ b/tests/Unit/Exceptions/HttpConfigurationInvalidTest.php @@ -0,0 +1,32 @@ +getMessage()); + self::assertInstanceOf(LogicException::class, $exception); + } + + public function testMissingBaseUrlCreatesExceptionWithCorrectMessage(): void + { + /** @When creating the exception for missing base URL */ + $exception = HttpConfigurationInvalid::missingBaseUrl(); + + /** @Then the message describes the missing base URL */ + self::assertSame('Base URL is required to build Http.', $exception->getMessage()); + self::assertInstanceOf(LogicException::class, $exception); + } +} diff --git a/tests/Unit/Exceptions/HttpNetworkFailedTest.php b/tests/Unit/Exceptions/HttpNetworkFailedTest.php new file mode 100644 index 0000000..8685c11 --- /dev/null +++ b/tests/Unit/Exceptions/HttpNetworkFailedTest.php @@ -0,0 +1,87 @@ +url()); + self::assertSame($method, $exception->method()); + self::assertSame($reason, $exception->reason()); + self::assertSame($reason, $exception->getMessage()); + } + + public function testFromChainsPreviousThrowable(): void + { + /** @Given a previous throwable */ + $previous = new RuntimeException('socket error'); + + /** @When constructing with a previous */ + $exception = HttpNetworkFailed::from( + url: 'https://api.example.com', + method: Method::DELETE, + reason: 'Network failed.', + previous: $previous + ); + + /** @Then the chain is preserved */ + self::assertSame($previous, $exception->getPrevious()); + } + + public function testFromClientExceptionBuildsFromNetworkException(): void + { + /** @Given a request and a network exception */ + $request = Request::create(url: 'https://api.example.com/dragons'); + $networkException = new class ('DNS failure') extends RuntimeException implements NetworkExceptionInterface { + public function getRequest(): RequestInterface + { + return (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + } + }; + + /** @When constructing from a client exception */ + $exception = HttpNetworkFailed::fromClientException(request: $request, exception: $networkException); + + /** @Then the exception wraps the original and implements HttpException */ + self::assertSame('https://api.example.com/dragons', $exception->url()); + self::assertSame($networkException, $exception->getPrevious()); + self::assertInstanceOf(HttpException::class, $exception); + } + + public function testExceptionIsCatchableAsHttpRequestFailed(): void + { + /** @Given an HttpNetworkFailed exception */ + $exception = HttpNetworkFailed::from( + url: 'https://api.example.com', + method: Method::GET, + reason: 'Failure.' + ); + + /** @Then it is catchable as HttpRequestFailed */ + self::assertInstanceOf(HttpRequestFailed::class, $exception); + } +} diff --git a/tests/Unit/Exceptions/HttpRequestFailedTest.php b/tests/Unit/Exceptions/HttpRequestFailedTest.php new file mode 100644 index 0000000..ae4e741 --- /dev/null +++ b/tests/Unit/Exceptions/HttpRequestFailedTest.php @@ -0,0 +1,110 @@ +url()); + self::assertSame($method, $exception->method()); + self::assertSame($reason, $exception->reason()); + self::assertSame($reason, $exception->getMessage()); + self::assertNull($exception->getPrevious()); + } + + public function testFromChainsPreviousThrowable(): void + { + /** @Given a previous throwable */ + $previous = new RuntimeException('root cause'); + + /** @When constructing the exception with a previous */ + $exception = HttpRequestFailed::from( + url: 'https://api.example.com', + method: Method::GET, + reason: 'Failed.', + previous: $previous + ); + + /** @Then the previous is preserved in the chain */ + self::assertSame($previous, $exception->getPrevious()); + } + + public function testFromClientExceptionBuildsExceptionFromRequest(): void + { + /** @Given a request and a client exception */ + $request = Request::create(url: 'https://api.example.com/dragons', method: Method::DELETE); + $clientException = new class ('PSR-18 error') extends RuntimeException implements ClientExceptionInterface { + }; + + /** @When constructing from a client exception */ + $exception = HttpRequestFailed::fromClientException(request: $request, exception: $clientException); + + /** @Then the exception reflects the request and wraps the original */ + self::assertSame('https://api.example.com/dragons', $exception->url()); + self::assertSame(Method::DELETE, $exception->method()); + self::assertSame($clientException, $exception->getPrevious()); + self::assertInstanceOf(HttpException::class, $exception); + } + + public function testFromJsonErrorBuildsExceptionFromRequest(): void + { + /** @Given a request and a JSON exception */ + $request = Request::create(url: 'https://api.example.com/dragons', method: Method::POST); + $jsonException = new JsonException('Malformed UTF-8'); + + /** @When constructing from a JSON error */ + $exception = HttpRequestFailed::fromJsonError(request: $request, exception: $jsonException); + + /** @Then the exception reflects the encoding failure */ + self::assertSame('https://api.example.com/dragons', $exception->url()); + self::assertStringContainsString('Failed to encode request body', $exception->reason()); + self::assertSame($jsonException, $exception->getPrevious()); + } + + public function testExceptionIsInstanceOfHttpException(): void + { + /** @When building an HttpRequestFailed */ + $exception = HttpRequestFailed::from( + url: 'https://api.example.com', + method: Method::GET, + reason: 'Failure.' + ); + + /** @Then it implements HttpException */ + self::assertInstanceOf(HttpException::class, $exception); + } + + public function testExceptionCodeIsZero(): void + { + /** @When building an HttpRequestFailed */ + $exception = HttpRequestFailed::from( + url: 'https://api.example.com', + method: Method::GET, + reason: 'Failure.' + ); + + /** @Then the exception code is zero */ + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Exceptions/HttpRequestInvalidTest.php b/tests/Unit/Exceptions/HttpRequestInvalidTest.php new file mode 100644 index 0000000..31c46c4 --- /dev/null +++ b/tests/Unit/Exceptions/HttpRequestInvalidTest.php @@ -0,0 +1,87 @@ +url()); + self::assertSame($method, $exception->method()); + self::assertSame($reason, $exception->reason()); + self::assertSame($reason, $exception->getMessage()); + } + + public function testFromChainsPreviousThrowable(): void + { + /** @Given a previous throwable */ + $previous = new RuntimeException('bad request object'); + + /** @When constructing with a previous */ + $exception = HttpRequestInvalid::from( + url: 'https://api.example.com', + method: Method::PUT, + reason: 'Request is invalid.', + previous: $previous + ); + + /** @Then the chain is preserved */ + self::assertSame($previous, $exception->getPrevious()); + } + + public function testFromClientExceptionBuildsFromRequestException(): void + { + /** @Given a request and a request exception */ + $request = Request::create(url: 'https://api.example.com/dragons', method: Method::POST); + $requestException = new class ('bad URI') extends RuntimeException implements RequestExceptionInterface { + public function getRequest(): RequestInterface + { + return (new Psr17Factory())->createRequest('POST', 'https://api.example.com'); + } + }; + + /** @When constructing from a client exception */ + $exception = HttpRequestInvalid::fromClientException(request: $request, exception: $requestException); + + /** @Then the exception reflects the request and wraps the original */ + self::assertSame('https://api.example.com/dragons', $exception->url()); + self::assertSame($requestException, $exception->getPrevious()); + self::assertInstanceOf(HttpException::class, $exception); + } + + public function testExceptionIsCatchableAsHttpRequestFailed(): void + { + /** @Given an HttpRequestInvalid exception */ + $exception = HttpRequestInvalid::from( + url: 'https://api.example.com', + method: Method::GET, + reason: 'Invalid.' + ); + + /** @Then it is catchable as HttpRequestFailed */ + self::assertInstanceOf(HttpRequestFailed::class, $exception); + } +} diff --git a/tests/Unit/Exceptions/MalformedPathTest.php b/tests/Unit/Exceptions/MalformedPathTest.php new file mode 100644 index 0000000..8e44edf --- /dev/null +++ b/tests/Unit/Exceptions/MalformedPathTest.php @@ -0,0 +1,54 @@ +url()); + self::assertSame(Method::GET, $exception->method()); + self::assertStringContainsString('//evil.example.com/attack', $exception->reason()); + } + + public function testMalformedPathIsCatchableAsHttpRequestFailed(): void + { + /** @Given a MalformedPath exception */ + $exception = MalformedPath::fromRequest( + request: Request::create(url: 'javascript:alert(1)') + ); + + /** @Then it is catchable as HttpRequestFailed and HttpException */ + self::assertInstanceOf(HttpRequestFailed::class, $exception); + self::assertInstanceOf(HttpException::class, $exception); + } + + public function testMalformedPathReasonDescribesThePath(): void + { + /** @Given a request with a scheme-containing path */ + $request = Request::create(url: 'https://attacker.com/steal'); + + /** @When constructing from the request */ + $exception = MalformedPath::fromRequest(request: $request); + + /** @Then the reason message references the malformed path */ + self::assertStringContainsString('https://attacker.com/steal', $exception->reason()); + self::assertStringContainsString('malformed', $exception->reason()); + } +} diff --git a/tests/Unit/Exceptions/NoMoreResponsesTest.php b/tests/Unit/Exceptions/NoMoreResponsesTest.php new file mode 100644 index 0000000..285e1e5 --- /dev/null +++ b/tests/Unit/Exceptions/NoMoreResponsesTest.php @@ -0,0 +1,32 @@ +getMessage()); + self::assertInstanceOf(LogicException::class, $exception); + } + + public function testAtIndexZeroCreatesExceptionWithCorrectMessage(): void + { + /** @When creating the exception at index 0 */ + $exception = NoMoreResponses::atIndex(index: 0); + + /** @Then the message references index 0 */ + self::assertStringContainsString('0', $exception->getMessage()); + self::assertStringContainsString('InMemoryTransport', $exception->getMessage()); + } +} diff --git a/tests/Unit/HeadersTest.php b/tests/Unit/HeadersTest.php new file mode 100644 index 0000000..2195a2a --- /dev/null +++ b/tests/Unit/HeadersTest.php @@ -0,0 +1,149 @@ + 'application/json', 'Accept' => 'application/json']); + + /** @Then the entries are accessible */ + self::assertSame('application/json', $headers->get('Content-Type')); + self::assertSame('application/json', $headers->get('Accept')); + } + + public function testFromMergesMultipleHeaderables(): void + { + /** @Given two headerable instances */ + $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + $cookie = Cookie::create(name: 'session', value: 'abc123'); + + /** @When creating Headers from multiple headerables */ + $headers = Headers::from($contentType, $cookie); + + /** @Then both header entries are present */ + self::assertTrue($headers->has('Content-Type')); + self::assertTrue($headers->has('Set-Cookie')); + } + + public function testFromWithNoArgumentsReturnsEmptyHeaders(): void + { + /** @When creating Headers with no headerable arguments */ + $headers = Headers::from(); + + /** @Then the headers are empty */ + self::assertSame([], $headers->toArray()); + } + + public function testGetIsCaseInsensitive(): void + { + /** @Given headers with a mixed-case key */ + $headers = Headers::fromArray(['Content-Type' => 'application/json']); + + /** @When looking up with different casing */ + /** @Then the lookup succeeds */ + self::assertSame('application/json', $headers->get('content-type')); + self::assertSame('application/json', $headers->get('CONTENT-TYPE')); + self::assertSame('application/json', $headers->get('Content-Type')); + } + + public function testGetReturnsNullForMissingKey(): void + { + /** @Given headers with one entry */ + $headers = Headers::fromArray(['Content-Type' => 'application/json']); + + /** @When looking up a non-existent header */ + /** @Then null is returned */ + self::assertNull($headers->get('X-Missing')); + } + + public function testHasIsCaseInsensitive(): void + { + /** @Given headers with a mixed-case key */ + $headers = Headers::fromArray(['X-Trace' => 'abc']); + + /** @When checking existence with different casing */ + /** @Then has() returns true regardless of case */ + self::assertTrue($headers->has('x-trace')); + self::assertTrue($headers->has('X-TRACE')); + self::assertTrue($headers->has('X-Trace')); + } + + public function testHasReturnsFalseForMissingKey(): void + { + /** @Given empty headers */ + $headers = Headers::fromArray([]); + + /** @When checking for a non-existent header */ + /** @Then has() returns false */ + self::assertFalse($headers->has('Content-Type')); + } + + public function testMergedWithDefaultAppearsWhenNoConflict(): void + { + /** @Given headers with one entry */ + $headers = Headers::fromArray(['Accept' => 'application/json']); + + /** @When merging with a default that does not conflict */ + $merged = $headers->mergedWith(defaults: ['Content-Type' => 'application/json']); + + /** @Then both entries are present */ + self::assertSame('application/json', $merged->get('Accept')); + self::assertSame('application/json', $merged->get('Content-Type')); + } + + public function testMergedWithExistingHeaderWinsOverDefault(): void + { + /** @Given headers with a Content-Type entry */ + $headers = Headers::fromArray(['Content-Type' => 'application/json; charset=utf-8']); + + /** @When merging with a default Content-Type */ + $merged = $headers->mergedWith(defaults: ['Content-Type' => 'application/json']); + + /** @Then the existing header wins */ + self::assertSame('application/json; charset=utf-8', $merged->get('Content-Type')); + + /** @And only one Content-Type entry exists in the merged result */ + self::assertCount(1, $merged->toArray()); + } + + public function testMergedWithIsCaseInsensitiveWhenCheckingConflicts(): void + { + /** @Given headers with a lowercase key */ + $headers = Headers::fromArray(['content-type' => 'application/json; charset=utf-8']); + + /** @When merging with a default that uses mixed casing */ + $merged = $headers->mergedWith(defaults: ['Content-Type' => 'application/json']); + + /** @Then the existing header wins despite different casing */ + self::assertSame('application/json; charset=utf-8', $merged->get('content-type')); + + /** @And only one Content-Type entry exists in the merged result */ + self::assertCount(1, $merged->toArray()); + } + + public function testToArrayReturnsAllEntries(): void + { + /** @Given headers with two entries */ + $headers = Headers::fromArray(['X-Trace' => 'abc', 'X-Request-ID' => '123']); + + /** @When converting to array */ + $array = $headers->toArray(); + + /** @Then all entries are present */ + self::assertSame('abc', $array['X-Trace']); + self::assertSame('123', $array['X-Request-ID']); + self::assertCount(2, $array); + } +} diff --git a/tests/Unit/HttpBuilderTest.php b/tests/Unit/HttpBuilderTest.php new file mode 100644 index 0000000..2a7a747 --- /dev/null +++ b/tests/Unit/HttpBuilderTest.php @@ -0,0 +1,118 @@ +createResponse(200); + } + }, + streamFactory: $factory, + requestFactory: $factory + ); + + /** @When calling withTransport */ + $updated = $original->withTransport(transport: $transport); + + /** @Then a new builder instance is returned and original build still throws */ + self::assertNotSame($original, $updated); + $this->expectException(HttpConfigurationInvalid::class); + $original->build(); + } + + public function testWithBaseUrlReturnsNewBuilderAndOriginalIsUnchanged(): void + { + /** @Given an empty builder */ + $original = Http::create(); + + /** @When calling withBaseUrl */ + $updated = $original->withBaseUrl(url: 'https://api.example.com'); + + /** @Then a new builder instance is returned and original build still throws */ + self::assertNotSame($original, $updated); + $this->expectException(HttpConfigurationInvalid::class); + $original->build(); + } + + public function testBuildWithoutTransportThrowsHttpConfigurationInvalid(): void + { + /** @Given a builder with no transport */ + $builder = Http::create()->withBaseUrl(url: 'https://api.example.com'); + + /** @Then HttpConfigurationInvalid is thrown */ + $this->expectException(HttpConfigurationInvalid::class); + $this->expectExceptionMessage('Transport is required to build Http.'); + + /** @When calling build */ + $builder->build(); + } + + public function testBuildWithoutBaseUrlThrowsHttpConfigurationInvalid(): void + { + /** @Given a builder with no base URL */ + $builder = Http::create()->withTransport( + transport: InMemoryTransport::with(responses: []) + ); + + /** @Then HttpConfigurationInvalid is thrown */ + $this->expectException(HttpConfigurationInvalid::class); + $this->expectExceptionMessage('Base URL is required to build Http.'); + + /** @When calling build */ + $builder->build(); + } + + public function testFullyConfiguredBuilderProducesWorkingHttp(): void + { + /** @Given a fully configured builder */ + $transport = InMemoryTransport::with( + responses: [Response::with(code: Code::OK)] + ); + + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); + + /** @When sending a request */ + $response = $http->send(request: Request::create(url: '/dragons')); + + /** @Then the response is returned correctly */ + self::assertSame(Code::OK, $response->code()); + } +} diff --git a/tests/Unit/HttpTest.php b/tests/Unit/HttpTest.php new file mode 100644 index 0000000..b46cff7 --- /dev/null +++ b/tests/Unit/HttpTest.php @@ -0,0 +1,305 @@ +factory = new Psr17Factory(); + } + + public function testSendReturnsResponseWithCorrectCode(): void + { + /** @Given a transport seeded with a 200 response */ + $transport = $this->buildTransport(statusCode: 200); + + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); + + /** @When sending a valid request */ + $response = $http->send(request: Request::create(url: '/dragons')); + + /** @Then the response code is correct */ + self::assertSame(Code::OK, $response->code()); + } + + public function testBaseUrlWithTrailingSlashAndPathWithLeadingSlashProducesNoDoubleSlash(): void + { + /** @Given a transport seeded with a 200 response and a base URL ending in slash */ + $transport = $this->buildTransport(statusCode: 200); + + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com/') + ->withTransport(transport: $transport) + ->build(); + + /** @When sending a request whose path starts with a slash */ + $response = $http->send(request: Request::create(url: '/dragons')); + + /** @Then the response is returned without double slash in the URL */ + self::assertSame(Code::OK, $response->code()); + } + + public function testQueryParametersAreAppendedAsRfc3986(): void + { + /** @Given a transport seeded with a 200 response */ + $transport = $this->buildTransport(statusCode: 200); + + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); + + /** @When sending a request with query parameters */ + $response = $http->send( + request: Request::create(url: '/dragons', query: ['sort' => 'name', 'order' => 'asc']) + ); + + /** @Then the response code is correct */ + self::assertSame(Code::OK, $response->code()); + } + + public function testRequestWithBodySendsJsonPayload(): void + { + /** @Given a transport seeded with a 201 response */ + $transport = $this->buildTransport(statusCode: 201); + + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); + + /** @When sending a request with a JSON body */ + $response = $http->send( + request: Request::create(url: '/dragons', body: ['name' => 'Hydra'], method: Method::POST) + ); + + /** @Then the response code is correct */ + self::assertSame(Code::CREATED, $response->code()); + } + + public function testNetworkExceptionMapsToHttpNetworkFailed(): void + { + /** @Given a PSR-18 client that throws NetworkExceptionInterface */ + $networkException = new class ('connection refused') extends RuntimeException implements + NetworkExceptionInterface { + public function getRequest(): RequestInterface + { + return (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + } + }; + + $transport = NetworkTransport::with( + client: $this->buildFailingClient(exception: $networkException), + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); + + /** @Then HttpNetworkFailed is thrown */ + $this->expectException(HttpNetworkFailed::class); + + /** @When sending the request */ + $http->send(request: Request::create(url: '/dragons')); + } + + public function testRequestExceptionMapsToHttpRequestInvalid(): void + { + /** @Given a PSR-18 client that throws RequestExceptionInterface */ + $requestException = new class ('bad request') extends RuntimeException implements RequestExceptionInterface { + public function getRequest(): RequestInterface + { + return (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + } + }; + + $transport = NetworkTransport::with( + client: $this->buildFailingClient(exception: $requestException), + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); + + /** @Then HttpRequestInvalid is thrown */ + $this->expectException(HttpRequestInvalid::class); + + /** @When sending the request */ + $http->send(request: Request::create(url: '/dragons')); + } + + public function testGenericClientExceptionMapsToHttpRequestFailed(): void + { + /** @Given a PSR-18 client that throws a generic ClientExceptionInterface */ + $clientException = new class ('generic failure') extends RuntimeException implements ClientExceptionInterface { + }; + + $transport = NetworkTransport::with( + client: $this->buildFailingClient(exception: $clientException), + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); + + /** @Then HttpRequestFailed is thrown */ + $this->expectException(HttpRequestFailed::class); + + /** @When sending the request */ + $http->send(request: Request::create(url: '/dragons')); + } + + public function testMalformedPathWithProtocolRelativeThrowsMalformedPath(): void + { + /** @Given an Http instance with a base URL */ + $transport = $this->buildTransport(statusCode: 200); + + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); + + /** @Then MalformedPath is thrown without invoking the transport */ + $this->expectException(MalformedPath::class); + + /** @When sending a request whose path is protocol-relative */ + $http->send(request: Request::create(url: '//evil.example.com/attack')); + } + + public function testMalformedPathWithSchemeThrowsMalformedPath(): void + { + /** @Given an Http instance with a base URL */ + $transport = $this->buildTransport(statusCode: 200); + + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); + + /** @Then MalformedPath is thrown */ + $this->expectException(MalformedPath::class); + + /** @When sending a request whose path contains a scheme */ + $http->send(request: Request::create(url: 'javascript:alert(1)')); + } + + public function testMalformedPathWithControlCharactersThrowsMalformedPath(): void + { + /** @Given an Http instance with a base URL */ + $transport = $this->buildTransport(statusCode: 200); + + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); + + /** @Then MalformedPath is thrown */ + $this->expectException(MalformedPath::class); + + /** @When sending a request whose path contains control characters */ + $http->send(request: Request::create(url: "/dragons\x00/evil")); + } + + public function testNetworkExceptionPreservesPreviousChain(): void + { + /** @Given a network exception */ + $networkException = new class ('timeout') extends RuntimeException implements NetworkExceptionInterface { + public function getRequest(): RequestInterface + { + return (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + } + }; + + $transport = NetworkTransport::with( + client: $this->buildFailingClient(exception: $networkException), + streamFactory: $this->factory, + requestFactory: $this->factory + ); + + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) + ->build(); + + /** @When sending the request */ + try { + $http->send(request: Request::create(url: '/dragons')); + } catch (HttpNetworkFailed $exception) { + /** @Then the previous exception is preserved in the chain */ + self::assertSame($networkException, $exception->getPrevious()); + } + } + + private function buildTransport(int $statusCode): NetworkTransport + { + $response = $this->factory->createResponse($statusCode); + + $client = new class ($response) implements ClientInterface { + public function __construct(private readonly ResponseInterface $response) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return $this->response; + } + }; + + return NetworkTransport::with( + client: $client, + streamFactory: $this->factory, + requestFactory: $this->factory + ); + } + + private function buildFailingClient(Throwable $exception): ClientInterface + { + return new class ($exception) implements ClientInterface { + public function __construct(private readonly Throwable $exception) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + throw $this->exception; + } + }; + } +} diff --git a/tests/Unit/Internal/Client/CursorTest.php b/tests/Unit/Internal/Client/CursorTest.php new file mode 100644 index 0000000..a4ccee4 --- /dev/null +++ b/tests/Unit/Internal/Client/CursorTest.php @@ -0,0 +1,50 @@ +advance(); + + /** @Then the position is 0 */ + self::assertSame(0, $position); + } + + public function testSecondAdvanceReturnsOne(): void + { + /** @Given a cursor that has been advanced once */ + $cursor = new Cursor(); + $cursor->advance(); + + /** @When advancing a second time */ + $position = $cursor->advance(); + + /** @Then the position is 1 */ + self::assertSame(1, $position); + } + + public function testThirdAdvanceReturnsTwo(): void + { + /** @Given a cursor that has been advanced twice */ + $cursor = new Cursor(); + $cursor->advance(); + $cursor->advance(); + + /** @When advancing a third time */ + $position = $cursor->advance(); + + /** @Then the position is 2 */ + self::assertSame(2, $position); + } +} diff --git a/tests/Unit/Internal/Client/RequestResolverTest.php b/tests/Unit/Internal/Client/RequestResolverTest.php new file mode 100644 index 0000000..eeff83d --- /dev/null +++ b/tests/Unit/Internal/Client/RequestResolverTest.php @@ -0,0 +1,88 @@ +resolve(request: $request); + + /** @Then the resolved request carries Content-Type and Accept defaults */ + self::assertSame('application/json', $resolved->headers->get('Content-Type')); + self::assertSame('application/json', $resolved->headers->get('Accept')); + } + + public function testExplicitContentTypeWinsOverDefault(): void + { + /** @Given a resolver and a request with an explicit Content-Type */ + $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); + $request = Request::create( + url: '/dragons', + method: Method::POST, + headerables: ContentType::applicationJson(charset: Charset::UTF_8) + ); + + /** @When resolving the request */ + $resolved = $resolver->resolve(request: $request); + + /** @Then the explicit Content-Type wins over the default */ + self::assertSame('application/json; charset=utf-8', $resolved->headers->get('Content-Type')); + } + + public function testRelativeUrlIsComposedWithBaseUrl(): void + { + /** @Given a resolver with a base URL and a request with a relative path */ + $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); + $request = Request::create(url: '/dragons'); + + /** @When resolving the request */ + $resolved = $resolver->resolve(request: $request); + + /** @Then the resolved URL is absolute */ + self::assertSame('https://api.example.com/dragons', $resolved->url); + } + + public function testQueryIsEmbeddedInUrlAndClearedFromRequest(): void + { + /** @Given a resolver and a request with query parameters */ + $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); + $request = Request::create(url: '/dragons', query: ['sort' => 'name', 'order' => 'asc']); + + /** @When resolving the request */ + $resolved = $resolver->resolve(request: $request); + + /** @Then the query is embedded in the URL and cleared from the request object */ + self::assertStringContainsString('sort=name', $resolved->url); + self::assertStringContainsString('order=asc', $resolved->url); + self::assertSame([], $resolved->query); + } + + public function testMalformedPathThrowsMalformedPath(): void + { + /** @Given a resolver and a request with a malformed path */ + $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); + $request = Request::create(url: '//evil.example.com/attack'); + + /** @Then MalformedPath is thrown */ + $this->expectException(MalformedPath::class); + + /** @When resolving the request */ + $resolver->resolve(request: $request); + } +} diff --git a/tests/Unit/Internal/Client/UrlTest.php b/tests/Unit/Internal/Client/UrlTest.php new file mode 100644 index 0000000..eb0d297 --- /dev/null +++ b/tests/Unit/Internal/Client/UrlTest.php @@ -0,0 +1,125 @@ +value); + } + + public function testBaseUrlWithoutTrailingSlashAndPathWithLeadingSlashProducesCorrectUrl(): void + { + /** @When composing a URL without trailing slash and with leading path slash */ + $url = Url::compose(path: '/dragons', query: [], baseUrl: 'https://api.example.com'); + + /** @Then the result is correct without double slash */ + self::assertSame('https://api.example.com/dragons', $url->value); + } + + public function testEmptyBaseUrlUsesRelativePathAsIs(): void + { + /** @When composing with no base URL and a relative path */ + $url = Url::compose(path: '/dragons', query: [], baseUrl: ''); + + /** @Then the relative path is used as-is */ + self::assertSame('/dragons', $url->value); + } + + public function testQueryParametersAreAppendedAsRfc3986(): void + { + /** @Given a path and query parameters */ + /** @When composing with query parameters */ + $url = Url::compose( + path: '/dragons', + query: ['sort' => 'name', 'order' => 'asc'], + baseUrl: 'https://api.example.com' + ); + + /** @Then the query is appended with RFC 3986 encoding */ + self::assertStringContainsString('?sort=name&order=asc', $url->value); + } + + public function testEmptyQueryProducesNoTrailingQuestionMark(): void + { + /** @When composing with an empty query array */ + $url = Url::compose(path: '/dragons', query: [], baseUrl: 'https://api.example.com'); + + /** @Then the URL has no trailing question mark */ + self::assertStringNotContainsString('?', $url->value); + } + + public function testProtocolRelativePathThrowsInvalidArgumentException(): void + { + /** @Then InvalidArgumentException is thrown */ + $this->expectException(InvalidArgumentException::class); + + /** @When composing a path starting with // */ + Url::compose(path: '//evil.example.com/attack', query: [], baseUrl: 'https://api.example.com'); + } + + public function testProtocolRelativePathThrowsEvenWithEmptyBaseUrl(): void + { + /** @Then InvalidArgumentException is thrown */ + $this->expectException(InvalidArgumentException::class); + + /** @When composing a protocol-relative path with an empty base URL */ + Url::compose(path: '//evil.example.com/attack', query: [], baseUrl: ''); + } + + public function testPathWithSchemeThrowsInvalidArgumentException(): void + { + /** @Then InvalidArgumentException is thrown */ + $this->expectException(InvalidArgumentException::class); + + /** @When composing a path with https:// scheme */ + Url::compose(path: 'https://attacker.com/steal', query: [], baseUrl: 'https://api.example.com'); + } + + public function testPathWithSchemeThrowsEvenWithEmptyBaseUrl(): void + { + /** @Then InvalidArgumentException is thrown */ + $this->expectException(InvalidArgumentException::class); + + /** @When composing a path with a scheme and empty base URL */ + Url::compose(path: 'https://attacker.com/steal', query: [], baseUrl: ''); + } + + public function testJavascriptSchemePathThrowsInvalidArgumentException(): void + { + /** @Then InvalidArgumentException is thrown */ + $this->expectException(InvalidArgumentException::class); + + /** @When composing a path with javascript: scheme */ + Url::compose(path: 'javascript:alert(1)', query: [], baseUrl: 'https://api.example.com'); + } + + public function testPathWithControlCharactersThrowsInvalidArgumentException(): void + { + /** @Then InvalidArgumentException is thrown */ + $this->expectException(InvalidArgumentException::class); + + /** @When composing a path containing a null byte */ + Url::compose(path: "/dragons\x00/evil", query: [], baseUrl: 'https://api.example.com'); + } + + public function testToStringReturnsSameValueAsPublicProperty(): void + { + /** @Given a composed URL */ + $url = Url::compose(path: '/dragons', query: [], baseUrl: 'https://api.example.com'); + + /** @Then toString() returns the same value as the value property */ + self::assertSame($url->value, $url->toString()); + } +} diff --git a/tests/Internal/Request/RouteParameterResolverTest.php b/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php similarity index 98% rename from tests/Internal/Request/RouteParameterResolverTest.php rename to tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php index 64577bf..562eb66 100644 --- a/tests/Internal/Request/RouteParameterResolverTest.php +++ b/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http\Internal\Request; +namespace Test\TinyBlocks\Http\Unit\Internal\Server\Request; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; -use TinyBlocks\Http\Internal\Request\RouteParameterResolver; +use TinyBlocks\Http\Internal\Server\Request\RouteParameterResolver; final class RouteParameterResolverTest extends TestCase { diff --git a/tests/Internal/Stream/StreamFactoryTest.php b/tests/Unit/Internal/Server/Stream/StreamFactoryTest.php similarity index 95% rename from tests/Internal/Stream/StreamFactoryTest.php rename to tests/Unit/Internal/Server/Stream/StreamFactoryTest.php index e90f0f9..de99e2b 100644 --- a/tests/Internal/Stream/StreamFactoryTest.php +++ b/tests/Unit/Internal/Server/Stream/StreamFactoryTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http\Internal\Stream; +namespace Test\TinyBlocks\Http\Unit\Internal\Server\Stream; use PHPUnit\Framework\TestCase; use Psr\Http\Message\StreamInterface; -use TinyBlocks\Http\Internal\Stream\StreamFactory; +use TinyBlocks\Http\Internal\Server\Stream\StreamFactory; final class StreamFactoryTest extends TestCase { diff --git a/tests/Internal/Stream/StreamTest.php b/tests/Unit/Internal/Server/Stream/StreamTest.php similarity index 95% rename from tests/Internal/Stream/StreamTest.php rename to tests/Unit/Internal/Server/Stream/StreamTest.php index a831e44..010a61e 100644 --- a/tests/Internal/Stream/StreamTest.php +++ b/tests/Unit/Internal/Server/Stream/StreamTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http\Internal\Stream; +namespace Test\TinyBlocks\Http\Unit\Internal\Server\Stream; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use TinyBlocks\Http\Internal\Exceptions\InvalidResource; -use TinyBlocks\Http\Internal\Exceptions\MissingResourceStream; -use TinyBlocks\Http\Internal\Exceptions\NonReadableStream; -use TinyBlocks\Http\Internal\Exceptions\NonSeekableStream; -use TinyBlocks\Http\Internal\Exceptions\NonWritableStream; -use TinyBlocks\Http\Internal\Stream\Stream; -use TinyBlocks\Http\Internal\Stream\StreamMetaData; +use TinyBlocks\Http\Internal\Server\Exceptions\InvalidResource; +use TinyBlocks\Http\Internal\Server\Exceptions\MissingResourceStream; +use TinyBlocks\Http\Internal\Server\Exceptions\NonReadableStream; +use TinyBlocks\Http\Internal\Server\Exceptions\NonSeekableStream; +use TinyBlocks\Http\Internal\Server\Exceptions\NonWritableStream; +use TinyBlocks\Http\Internal\Server\Stream\Stream; +use TinyBlocks\Http\Internal\Server\Stream\StreamMetaData; final class StreamTest extends TestCase { diff --git a/tests/SameSiteTest.php b/tests/Unit/SameSiteTest.php similarity index 95% rename from tests/SameSiteTest.php rename to tests/Unit/SameSiteTest.php index 7de269a..4aaadc0 100644 --- a/tests/SameSiteTest.php +++ b/tests/Unit/SameSiteTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/HeadersTest.php b/tests/Unit/Server/HeadersTest.php similarity index 99% rename from tests/HeadersTest.php rename to tests/Unit/Server/HeadersTest.php index 8757325..e222550 100644 --- a/tests/HeadersTest.php +++ b/tests/Unit/Server/HeadersTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit\Server; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\CacheControl; use TinyBlocks\Http\ContentType; -use TinyBlocks\Http\Response; use TinyBlocks\Http\ResponseCacheDirectives; +use TinyBlocks\Http\Server\Response; final class HeadersTest extends TestCase { diff --git a/tests/ProtocolVersionTest.php b/tests/Unit/Server/ProtocolVersionTest.php similarity index 89% rename from tests/ProtocolVersionTest.php rename to tests/Unit/Server/ProtocolVersionTest.php index 2737309..98dd163 100644 --- a/tests/ProtocolVersionTest.php +++ b/tests/Unit/Server/ProtocolVersionTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit\Server; use PHPUnit\Framework\TestCase; -use TinyBlocks\Http\Response; +use TinyBlocks\Http\Server\Response; final class ProtocolVersionTest extends TestCase { diff --git a/tests/RequestTest.php b/tests/Unit/Server/RequestTest.php similarity index 87% rename from tests/RequestTest.php rename to tests/Unit/Server/RequestTest.php index 2f6d767..2b05b51 100644 --- a/tests/RequestTest.php +++ b/tests/Unit/Server/RequestTest.php @@ -2,15 +2,16 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit\Server; +use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; use TinyBlocks\Http\Method; -use TinyBlocks\Http\Request; +use TinyBlocks\Http\Server\Request; final class RequestTest extends TestCase { @@ -613,4 +614,81 @@ public static function attributeConversionsProvider(): array 'Non-scalar attribute conversion toBoolean defaults to false' => ['meta', ['x' => 1], 'toBoolean', false] ]; } + + public function testRequestDecodingWithSeekableStreamAndJsonBody(): void + { + /** @Given a seekable stream with a JSON body */ + $stream = $this->createStub(StreamInterface::class); + $stream->method('isSeekable')->willReturn(true); + $stream->method('getContents')->willReturn('{"name":"Hydra"}'); + + $serverRequest = $this->createStub(ServerRequestInterface::class); + $serverRequest->method('getMethod')->willReturn('POST'); + $serverRequest->method('getBody')->willReturn($stream); + + /** @When decoding the request */ + $decoded = Request::from(request: $serverRequest)->decode(); + + /** @Then the body is parsed correctly from the seekable stream */ + self::assertSame('Hydra', $decoded->body()->get(key: 'name')->toString()); + } + + public function testRequestDecodingWithInvalidJsonBodyReturnsEmpty(): void + { + /** @Given a stream with an invalid JSON body */ + $stream = $this->createStub(StreamInterface::class); + $stream->method('isSeekable')->willReturn(false); + $stream->method('getContents')->willReturn('{not valid json]'); + + $serverRequest = $this->createStub(ServerRequestInterface::class); + $serverRequest->method('getMethod')->willReturn('POST'); + $serverRequest->method('getBody')->willReturn($stream); + + /** @When decoding the request */ + $decoded = Request::from(request: $serverRequest)->decode(); + + /** @Then the body gracefully returns an empty array */ + self::assertSame([], $decoded->body()->toArray()); + } + + public function testRequestDecodingWithSeekableStreamAtNonZeroPositionParsesFromStart(): void + { + /** @Given a seekable stream advanced past its start */ + $factory = new Psr17Factory(); + $stream = $factory->createStream('{"name":"Hydra"}'); + $stream->getContents(); + + /** @And a server request using that stream */ + $serverRequest = $this->createStub(ServerRequestInterface::class); + $serverRequest->method('getMethod')->willReturn('POST'); + $serverRequest->method('getBody')->willReturn($stream); + + /** @When decoding the request body */ + $decoded = Request::from(request: $serverRequest)->decode()->body(); + + /** @Then the body is parsed correctly despite the advanced stream position */ + self::assertSame('Hydra', $decoded->get(key: 'name')->toString()); + + /** @And the stream is at position zero after parsing so it can be re-read without a manual rewind */ + self::assertSame('{"name":"Hydra"}', $stream->getContents()); + } + + public function testRequestDecodingWithEmptyStreamAndNonArrayParsedBodyReturnsEmpty(): void + { + /** @Given a stream with no body and a non-array parsed body */ + $stream = $this->createStub(StreamInterface::class); + $stream->method('isSeekable')->willReturn(false); + $stream->method('getContents')->willReturn(''); + + $serverRequest = $this->createStub(ServerRequestInterface::class); + $serverRequest->method('getMethod')->willReturn('POST'); + $serverRequest->method('getBody')->willReturn($stream); + $serverRequest->method('getParsedBody')->willReturn('not-an-array'); + + /** @When decoding the request */ + $decoded = Request::from(request: $serverRequest)->decode(); + + /** @Then the body gracefully returns an empty array */ + self::assertSame([], $decoded->body()->toArray()); + } } diff --git a/tests/ResponseTest.php b/tests/Unit/Server/ResponseTest.php similarity index 99% rename from tests/ResponseTest.php rename to tests/Unit/Server/ResponseTest.php index db0dc39..db32332 100644 --- a/tests/ResponseTest.php +++ b/tests/Unit/Server/ResponseTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit\Server; use DateTime; use PHPUnit\Framework\Attributes\DataProvider; @@ -16,8 +16,8 @@ use Test\TinyBlocks\Http\Models\Products; use Test\TinyBlocks\Http\Models\Status; use TinyBlocks\Http\Code; -use TinyBlocks\Http\Internal\Stream\StreamFactory; -use TinyBlocks\Http\Response; +use TinyBlocks\Http\Internal\Server\Stream\StreamFactory; +use TinyBlocks\Http\Server\Response; final class ResponseTest extends TestCase { diff --git a/tests/ResponseWithCookiesTest.php b/tests/Unit/Server/ResponseWithCookiesTest.php similarity index 97% rename from tests/ResponseWithCookiesTest.php rename to tests/Unit/Server/ResponseWithCookiesTest.php index f93aebb..c8fc79e 100644 --- a/tests/ResponseWithCookiesTest.php +++ b/tests/Unit/Server/ResponseWithCookiesTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http; +namespace Test\TinyBlocks\Http\Unit\Server; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\CacheControl; use TinyBlocks\Http\Charset; use TinyBlocks\Http\ContentType; use TinyBlocks\Http\Cookie; -use TinyBlocks\Http\Response; use TinyBlocks\Http\ResponseCacheDirectives; use TinyBlocks\Http\SameSite; +use TinyBlocks\Http\Server\Response; final class ResponseWithCookiesTest extends TestCase { From 57aff70dba65347ca8e0b2a6d679410d88b074e2 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 14 May 2026 22:57:28 -0300 Subject: [PATCH 02/27] refactor: add named arguments at every own-code call site Co-Authored-By: Claude Sonnet 4.6 --- src/Headers.php | 6 +++--- tests/Unit/HeadersTest.php | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Headers.php b/src/Headers.php index d1c15d2..aa5f427 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -23,7 +23,7 @@ public function __construct(array $entries) public static function fromArray(array $entries): Headers { - return new Headers($entries); + return new Headers(entries: $entries); } public static function from(Headerable ...$headerables): Headers @@ -36,7 +36,7 @@ public static function from(Headerable ...$headerables): Headers } } - return new Headers($entries); + return new Headers(entries: $entries); } public function get(string $name): ?string @@ -71,7 +71,7 @@ public function mergedWith(array $defaults): Headers $merged[$name] = $value; } - return new Headers($merged); + return new Headers(entries: $merged); } public function toArray(): array diff --git a/tests/Unit/HeadersTest.php b/tests/Unit/HeadersTest.php index 2195a2a..6b41e1f 100644 --- a/tests/Unit/HeadersTest.php +++ b/tests/Unit/HeadersTest.php @@ -16,7 +16,7 @@ public function testFromArrayCreatesHeadersWithEntries(): void { /** @Given an array of headers */ /** @When creating Headers from an array */ - $headers = Headers::fromArray(['Content-Type' => 'application/json', 'Accept' => 'application/json']); + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); /** @Then the entries are accessible */ self::assertSame('application/json', $headers->get('Content-Type')); @@ -49,7 +49,7 @@ public function testFromWithNoArgumentsReturnsEmptyHeaders(): void public function testGetIsCaseInsensitive(): void { /** @Given headers with a mixed-case key */ - $headers = Headers::fromArray(['Content-Type' => 'application/json']); + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); /** @When looking up with different casing */ /** @Then the lookup succeeds */ @@ -61,7 +61,7 @@ public function testGetIsCaseInsensitive(): void public function testGetReturnsNullForMissingKey(): void { /** @Given headers with one entry */ - $headers = Headers::fromArray(['Content-Type' => 'application/json']); + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); /** @When looking up a non-existent header */ /** @Then null is returned */ @@ -71,7 +71,7 @@ public function testGetReturnsNullForMissingKey(): void public function testHasIsCaseInsensitive(): void { /** @Given headers with a mixed-case key */ - $headers = Headers::fromArray(['X-Trace' => 'abc']); + $headers = Headers::fromArray(entries: ['X-Trace' => 'abc']); /** @When checking existence with different casing */ /** @Then has() returns true regardless of case */ @@ -83,7 +83,7 @@ public function testHasIsCaseInsensitive(): void public function testHasReturnsFalseForMissingKey(): void { /** @Given empty headers */ - $headers = Headers::fromArray([]); + $headers = Headers::fromArray(entries: []); /** @When checking for a non-existent header */ /** @Then has() returns false */ @@ -93,7 +93,7 @@ public function testHasReturnsFalseForMissingKey(): void public function testMergedWithDefaultAppearsWhenNoConflict(): void { /** @Given headers with one entry */ - $headers = Headers::fromArray(['Accept' => 'application/json']); + $headers = Headers::fromArray(entries: ['Accept' => 'application/json']); /** @When merging with a default that does not conflict */ $merged = $headers->mergedWith(defaults: ['Content-Type' => 'application/json']); @@ -106,7 +106,7 @@ public function testMergedWithDefaultAppearsWhenNoConflict(): void public function testMergedWithExistingHeaderWinsOverDefault(): void { /** @Given headers with a Content-Type entry */ - $headers = Headers::fromArray(['Content-Type' => 'application/json; charset=utf-8']); + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json; charset=utf-8']); /** @When merging with a default Content-Type */ $merged = $headers->mergedWith(defaults: ['Content-Type' => 'application/json']); @@ -121,7 +121,7 @@ public function testMergedWithExistingHeaderWinsOverDefault(): void public function testMergedWithIsCaseInsensitiveWhenCheckingConflicts(): void { /** @Given headers with a lowercase key */ - $headers = Headers::fromArray(['content-type' => 'application/json; charset=utf-8']); + $headers = Headers::fromArray(entries: ['content-type' => 'application/json; charset=utf-8']); /** @When merging with a default that uses mixed casing */ $merged = $headers->mergedWith(defaults: ['Content-Type' => 'application/json']); @@ -136,7 +136,7 @@ public function testMergedWithIsCaseInsensitiveWhenCheckingConflicts(): void public function testToArrayReturnsAllEntries(): void { /** @Given headers with two entries */ - $headers = Headers::fromArray(['X-Trace' => 'abc', 'X-Request-ID' => '123']); + $headers = Headers::fromArray(entries: ['X-Trace' => 'abc', 'X-Request-ID' => '123']); /** @When converting to array */ $array = $headers->toArray(); From 63ea2502e4727d6a1ddd1dcff953617b01716ddb Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 14 May 2026 23:01:55 -0300 Subject: [PATCH 03/27] feat: encapsulate header operations in Headers VO with fromMessage and applyTo Co-Authored-By: Claude Sonnet 4.6 --- src/Client/Response.php | 8 +-- src/Client/Transports/NetworkTransport.php | 4 +- src/Headers.php | 44 ++++++++++++---- tests/Unit/HeadersTest.php | 60 ++++++++++++++++++++++ 4 files changed, 96 insertions(+), 20 deletions(-) diff --git a/src/Client/Response.php b/src/Client/Response.php index 34d6a10..5507dbb 100644 --- a/src/Client/Response.php +++ b/src/Client/Response.php @@ -22,17 +22,11 @@ private function __construct( public static function from(ResponseInterface $response): Response { - $entries = []; - - foreach ($response->getHeaders() as $name => $values) { - $entries[$name] = implode(', ', $values); - } - return new Response( psr: $response, body: Body::fromResponse(response: $response), code: Code::from($response->getStatusCode()), - headers: Headers::fromArray(entries: $entries) + headers: Headers::fromMessage(message: $response) ); } diff --git a/src/Client/Transports/NetworkTransport.php b/src/Client/Transports/NetworkTransport.php index 023cf46..9fdbbe4 100644 --- a/src/Client/Transports/NetworkTransport.php +++ b/src/Client/Transports/NetworkTransport.php @@ -49,9 +49,7 @@ public function send(Request $request): Response uri: $request->url ); - foreach ($request->headers->toArray() as $name => $value) { - $psrRequest = $psrRequest->withHeader($name, $value); - } + $psrRequest = $request->headers->applyTo(message: $psrRequest); if (!is_null($request->body)) { try { diff --git a/src/Headers.php b/src/Headers.php index aa5f427..bbcc35e 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -4,6 +4,8 @@ namespace TinyBlocks\Http; +use Psr\Http\Message\MessageInterface; + final readonly class Headers { private array $entries; @@ -26,6 +28,17 @@ public static function fromArray(array $entries): Headers return new Headers(entries: $entries); } + public static function fromMessage(MessageInterface $message): Headers + { + $entries = []; + + foreach ($message->getHeaders() as $name => $values) { + $entries[$name] = implode(', ', $values); + } + + return new Headers(entries: $entries); + } + public static function from(Headerable ...$headerables): Headers { $entries = []; @@ -39,6 +52,27 @@ public static function from(Headerable ...$headerables): Headers return new Headers(entries: $entries); } + public function has(string $name): bool + { + return isset($this->lowerIndex[strtolower($name)]); + } + + public function toArray(): array + { + return $this->entries; + } + + public function applyTo(MessageInterface $message): MessageInterface + { + $applied = $message; + + foreach ($this->entries as $name => $value) { + $applied = $applied->withHeader($name, $value); + } + + return $applied; + } + public function get(string $name): ?string { $key = strtolower($name); @@ -50,11 +84,6 @@ public function get(string $name): ?string return $this->entries[$this->lowerIndex[$key]]; } - public function has(string $name): bool - { - return isset($this->lowerIndex[strtolower($name)]); - } - public function mergedWith(array $defaults): Headers { $merged = []; @@ -73,9 +102,4 @@ public function mergedWith(array $defaults): Headers return new Headers(entries: $merged); } - - public function toArray(): array - { - return $this->entries; - } } diff --git a/tests/Unit/HeadersTest.php b/tests/Unit/HeadersTest.php index 6b41e1f..8a583fa 100644 --- a/tests/Unit/HeadersTest.php +++ b/tests/Unit/HeadersTest.php @@ -4,6 +4,7 @@ namespace Test\TinyBlocks\Http\Unit; +use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\Charset; use TinyBlocks\Http\ContentType; @@ -46,6 +47,65 @@ public function testFromWithNoArgumentsReturnsEmptyHeaders(): void self::assertSame([], $headers->toArray()); } + public function testFromMessageWithEmptyHeadersReturnsEmptyHeaders(): void + { + /** @Given a PSR-7 response with no headers */ + $psrResponse = (new Psr17Factory())->createResponse(200); + + /** @When building Headers from the message */ + $headers = Headers::fromMessage(message: $psrResponse); + + /** @Then the Headers instance is empty */ + self::assertSame([], $headers->toArray()); + } + + public function testFromMessageFoldsMultiValueHeadersWithCommaSeparator(): void + { + /** @Given a PSR-7 response with a header that carries multiple values */ + $psrResponse = (new Psr17Factory())->createResponse(200) + ->withHeader('Accept', 'application/json') + ->withAddedHeader('Accept', 'text/html'); + + /** @When building Headers from the message */ + $headers = Headers::fromMessage(message: $psrResponse); + + /** @Then the multi-value header is folded with a comma separator */ + self::assertSame('application/json, text/html', $headers->get('Accept')); + } + + public function testApplyToOnEmptyHeadersReturnsOriginalMessageUnchanged(): void + { + /** @Given an empty Headers instance */ + $headers = Headers::fromArray(entries: []); + + /** @And a PSR-7 request */ + $psrRequest = (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + + /** @When applying the empty headers to the request */ + $applied = $headers->applyTo(message: $psrRequest); + + /** @Then the same request instance is returned without modification */ + self::assertSame($psrRequest, $applied); + } + + public function testApplyToAttachesEntriesAndLeavesOriginalUnchanged(): void + { + /** @Given a Headers instance with one entry */ + $headers = Headers::fromArray(entries: ['X-Trace' => 'abc']); + + /** @And a PSR-7 request */ + $psrRequest = (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + + /** @When applying the headers to the request */ + $applied = $headers->applyTo(message: $psrRequest); + + /** @Then the resulting message carries the header */ + self::assertSame('abc', $applied->getHeaderLine('X-Trace')); + + /** @And the original request is unchanged */ + self::assertSame('', $psrRequest->getHeaderLine('X-Trace')); + } + public function testGetIsCaseInsensitive(): void { /** @Given headers with a mixed-case key */ From f4e9d9da205304f7928c608a57dad269e91f0314 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 14 May 2026 23:02:29 -0300 Subject: [PATCH 04/27] fix: remove JSON_INVALID_UTF8_SUBSTITUTE so invalid UTF-8 surfaces as HttpRequestInvalid Co-Authored-By: Claude Sonnet 4.6 --- src/Client/Transports/NetworkTransport.php | 2 +- .../Client/Transports/NetworkTransportTest.php | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Client/Transports/NetworkTransport.php b/src/Client/Transports/NetworkTransport.php index 9fdbbe4..b1efecd 100644 --- a/src/Client/Transports/NetworkTransport.php +++ b/src/Client/Transports/NetworkTransport.php @@ -20,7 +20,7 @@ final readonly class NetworkTransport implements Transport { - private const int JSON_FLAGS = JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE; + private const int JSON_FLAGS = JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES; private const int MAX_JSON_DEPTH = 64; private function __construct( diff --git a/tests/Unit/Client/Transports/NetworkTransportTest.php b/tests/Unit/Client/Transports/NetworkTransportTest.php index 0401cb6..cc6f242 100644 --- a/tests/Unit/Client/Transports/NetworkTransportTest.php +++ b/tests/Unit/Client/Transports/NetworkTransportTest.php @@ -203,18 +203,20 @@ public function testBodyWithUnencodableValueThrowsHttpRequestInvalid(): void ); } - public function testBodyWithInvalidUtf8SubstitutesAndDoesNotCrash(): void + public function testBodyWithInvalidUtf8ThrowsHttpRequestInvalid(): void { - /** @Given a request body containing invalid UTF-8 bytes */ + /** @Given a transport configured with a capturing client */ $captured = null; - $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); $transport = NetworkTransport::with( - client: $client, + client: $this->buildCapturingClient(captured: $captured, statusCode: 200), streamFactory: $this->factory, requestFactory: $this->factory ); - /** @When sending the request */ + /** @Then HttpRequestInvalid is thrown with the JsonException chained as previous */ + $this->expectException(HttpRequestInvalid::class); + + /** @When sending a request whose body contains a non-UTF-8 byte sequence */ $transport->send( request: Request::create( url: 'https://api.example.com/dragons', @@ -222,9 +224,6 @@ public function testBodyWithInvalidUtf8SubstitutesAndDoesNotCrash(): void method: Method::POST ) ); - - /** @Then no exception is thrown and the body is encoded safely */ - self::assertNotEmpty((string)$captured->getBody()); } private function buildCapturingClient(?RequestInterface &$captured, int $statusCode): ClientInterface From 9243245b938533a2dbcac82cc1336ae663269cc8 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 14 May 2026 23:03:30 -0300 Subject: [PATCH 05/27] refactor: reorder body before code in Server\Response::from and Responses::from Co-Authored-By: Claude Sonnet 4.6 --- README.md | 10 ++++++++++ src/Server/Response.php | 2 +- src/Server/Responses.php | 4 ++-- tests/Unit/Server/ResponseTest.php | 2 +- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 764f53e..223dbcf 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,16 @@ Version 2.x moves the server-side `Request` and `Response` classes into the `Ser primitives (`Method`, `Code`, `ContentType`, `CacheControl`, `Cookie`, `Headers`, etc.) stay at the root and are unchanged. +`Response::from(...)` and `Responses::from(...)` now take `$body` before `$code`. Update every call site: + +```bash +# Before +Response::from(code: Code::OK, body: $payload) + +# After +Response::from(body: $payload, code: Code::OK) +``` + Run the following find/replace commands in your project: ```bash diff --git a/src/Server/Response.php b/src/Server/Response.php index ff8fa7f..7210ce6 100644 --- a/src/Server/Response.php +++ b/src/Server/Response.php @@ -11,7 +11,7 @@ final readonly class Response implements Responses { - public static function from(Code $code, mixed $body, Headerable ...$headers): ResponseInterface + public static function from(mixed $body, Code $code, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, $code, ...$headers); } diff --git a/src/Server/Responses.php b/src/Server/Responses.php index 880f7c9..6aa9f60 100644 --- a/src/Server/Responses.php +++ b/src/Server/Responses.php @@ -18,12 +18,12 @@ interface Responses /** * Creates a response with the specified status code, body, and headers. * - * @param Code $code The HTTP status code for the response. * @param mixed $body The body of the response. + * @param Code $code The HTTP status code for the response. * @param Headerable ...$headers Optional additional headers for the response. * @return ResponseInterface The generated response with the specified status code, body, and headers. */ - public static function from(Code $code, mixed $body, Headerable ...$headers): ResponseInterface; + public static function from(mixed $body, Code $code, Headerable ...$headers): ResponseInterface; /** * Creates a response with a 200 OK status. diff --git a/tests/Unit/Server/ResponseTest.php b/tests/Unit/Server/ResponseTest.php index db32332..f6b6bf9 100644 --- a/tests/Unit/Server/ResponseTest.php +++ b/tests/Unit/Server/ResponseTest.php @@ -26,7 +26,7 @@ public function testResponseFrom(Code $code, mixed $body, string $expectedBody): { /** @Given a specific status code and body */ /** @When we create the HTTP response using the generic from method */ - $actual = Response::from(code: $code, body: $body); + $actual = Response::from(body: $body, code: $code); /** @Then the protocol version should be "1.1" */ self::assertSame('1.1', $actual->getProtocolVersion()); From 5c326e1e4756c436e7879b79f9115c491a041409 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 14 May 2026 23:04:18 -0300 Subject: [PATCH 06/27] refactor: make Http constructor private and introduce Http::with static factory Co-Authored-By: Claude Sonnet 4.6 --- src/Http.php | 15 ++++++--------- src/HttpBuilder.php | 2 +- tests/Unit/HttpBuilderTest.php | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/Http.php b/src/Http.php index 445a2ad..9ac3fe2 100644 --- a/src/Http.php +++ b/src/Http.php @@ -7,14 +7,13 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Client\Response; use TinyBlocks\Http\Client\Transport; -use TinyBlocks\Http\Exceptions\HttpException; use TinyBlocks\Http\Internal\Client\RequestResolver; final readonly class Http { private RequestResolver $resolver; - public function __construct(string $baseUrl, private Transport $transport) + private function __construct(string $baseUrl, private Transport $transport) { $this->resolver = RequestResolver::withBaseUrl(baseUrl: $baseUrl); } @@ -24,13 +23,11 @@ public static function create(): HttpBuilder return new HttpBuilder(baseUrl: null, transport: null); } - /** - * Sends a request through the configured transport and returns the response. - * - * @param Request $request The outbound request to send. - * @return Response The response returned by the transport. - * @throws HttpException When resolution or the transport fails. - */ + public static function with(string $baseUrl, Transport $transport): Http + { + return new Http(baseUrl: $baseUrl, transport: $transport); + } + public function send(Request $request): Response { return $this->transport->send(request: $this->resolver->resolve(request: $request)); diff --git a/src/HttpBuilder.php b/src/HttpBuilder.php index 37aac7e..5124aa5 100644 --- a/src/HttpBuilder.php +++ b/src/HttpBuilder.php @@ -33,6 +33,6 @@ public function build(): Http throw HttpConfigurationInvalid::missingBaseUrl(); } - return new Http(baseUrl: $this->baseUrl, transport: $this->transport); + return Http::with(baseUrl: $this->baseUrl, transport: $this->transport); } } diff --git a/tests/Unit/HttpBuilderTest.php b/tests/Unit/HttpBuilderTest.php index 2a7a747..fde7bfd 100644 --- a/tests/Unit/HttpBuilderTest.php +++ b/tests/Unit/HttpBuilderTest.php @@ -115,4 +115,18 @@ public function testFullyConfiguredBuilderProducesWorkingHttp(): void /** @Then the response is returned correctly */ self::assertSame(Code::OK, $response->code()); } + + public function testWithReturnsWorkingHttpInstance(): void + { + /** @Given a transport seeded with one response */ + $transport = InMemoryTransport::with( + responses: [Response::with(code: Code::OK)] + ); + + /** @When constructing Http directly via Http::with */ + $http = Http::with(baseUrl: 'https://api.example.com', transport: $transport); + + /** @Then the instance can send requests and returns the correct response */ + self::assertSame(Code::OK, $http->send(request: Request::create(url: '/dragons'))->code()); + } } From 604517b2b28d606f7ab8936db36310e3e9bdfd63 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 14 May 2026 23:34:23 -0300 Subject: [PATCH 07/27] fix: restore JSON_INVALID_UTF8_SUBSTITUTE, remove json_encode try/catch, delete fromJsonError Co-Authored-By: Claude Sonnet 4.6 --- src/Client/Transports/NetworkTransport.php | 17 ++-------- src/Exceptions/HttpRequestFailed.php | 13 -------- src/Exceptions/HttpRequestInvalid.php | 11 ------- .../Transports/NetworkTransportTest.php | 32 +++---------------- .../Unit/Exceptions/HttpRequestFailedTest.php | 16 ---------- 5 files changed, 7 insertions(+), 82 deletions(-) diff --git a/src/Client/Transports/NetworkTransport.php b/src/Client/Transports/NetworkTransport.php index b1efecd..7787169 100644 --- a/src/Client/Transports/NetworkTransport.php +++ b/src/Client/Transports/NetworkTransport.php @@ -4,7 +4,6 @@ namespace TinyBlocks\Http\Client\Transports; -use JsonException; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Client\NetworkExceptionInterface; @@ -20,7 +19,7 @@ final readonly class NetworkTransport implements Transport { - private const int JSON_FLAGS = JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES; + private const int JSON_FLAGS = JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE; private const int MAX_JSON_DEPTH = 64; private function __construct( @@ -52,18 +51,8 @@ public function send(Request $request): Response $psrRequest = $request->headers->applyTo(message: $psrRequest); if (!is_null($request->body)) { - try { - $encoded = json_encode( - $request->body, - self::JSON_FLAGS, - self::MAX_JSON_DEPTH - ); - } catch (JsonException $exception) { - throw HttpRequestInvalid::fromJsonError(request: $request, exception: $exception); - } - - $stream = $this->streamFactory->createStream(content: $encoded); - $psrRequest = $psrRequest->withBody($stream); + $encoded = json_encode($request->body, self::JSON_FLAGS, self::MAX_JSON_DEPTH); + $psrRequest = $psrRequest->withBody(body: $this->streamFactory->createStream(content: $encoded)); } try { diff --git a/src/Exceptions/HttpRequestFailed.php b/src/Exceptions/HttpRequestFailed.php index 4ae2947..7e639a2 100644 --- a/src/Exceptions/HttpRequestFailed.php +++ b/src/Exceptions/HttpRequestFailed.php @@ -4,7 +4,6 @@ namespace TinyBlocks\Http\Exceptions; -use JsonException; use Psr\Http\Client\ClientExceptionInterface; use RuntimeException; use Throwable; @@ -13,8 +12,6 @@ class HttpRequestFailed extends RuntimeException implements HttpException { - protected const string JSON_ERROR_REASON_TEMPLATE = 'Failed to encode request body: %s.'; - protected function __construct( private readonly string $url, private readonly Method $method, @@ -39,16 +36,6 @@ public static function fromClientException(Request $request, ClientExceptionInte ); } - public static function fromJsonError(Request $request, JsonException $exception): self - { - return new self( - url: $request->url, - method: $request->method, - reason: sprintf(self::JSON_ERROR_REASON_TEMPLATE, $exception->getMessage()), - previous: $exception - ); - } - public function method(): Method { return $this->method; diff --git a/src/Exceptions/HttpRequestInvalid.php b/src/Exceptions/HttpRequestInvalid.php index f5fb1ac..dcd0433 100644 --- a/src/Exceptions/HttpRequestInvalid.php +++ b/src/Exceptions/HttpRequestInvalid.php @@ -4,7 +4,6 @@ namespace TinyBlocks\Http\Exceptions; -use JsonException; use Psr\Http\Client\ClientExceptionInterface; use Throwable; use TinyBlocks\Http\Client\Request; @@ -26,14 +25,4 @@ public static function fromClientException(Request $request, ClientExceptionInte previous: $exception ); } - - public static function fromJsonError(Request $request, JsonException $exception): static - { - return new self( - url: $request->url, - method: $request->method, - reason: sprintf(self::JSON_ERROR_REASON_TEMPLATE, $exception->getMessage()), - previous: $exception - ); - } } diff --git a/tests/Unit/Client/Transports/NetworkTransportTest.php b/tests/Unit/Client/Transports/NetworkTransportTest.php index cc6f242..a8c13a3 100644 --- a/tests/Unit/Client/Transports/NetworkTransportTest.php +++ b/tests/Unit/Client/Transports/NetworkTransportTest.php @@ -179,31 +179,7 @@ public function testSuccessfulResponseIsWrappedInClientResponse(): void self::assertSame(Code::OK, $response->code()); } - public function testBodyWithUnencodableValueThrowsHttpRequestInvalid(): void - { - /** @Given a request body containing a float value that cannot be JSON-encoded */ - $captured = null; - $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); - $transport = NetworkTransport::with( - client: $client, - streamFactory: $this->factory, - requestFactory: $this->factory - ); - - /** @Then HttpRequestInvalid is thrown due to JSON encoding failure */ - $this->expectException(HttpRequestInvalid::class); - - /** @When sending a request body containing INF (unencodable in JSON) */ - $transport->send( - request: Request::create( - url: 'https://api.example.com/dragons', - body: ['value' => INF], - method: Method::POST - ) - ); - } - - public function testBodyWithInvalidUtf8ThrowsHttpRequestInvalid(): void + public function testBodyWithInvalidUtf8IsSubstitutedAndRequestSendsNormally(): void { /** @Given a transport configured with a capturing client */ $captured = null; @@ -213,9 +189,6 @@ public function testBodyWithInvalidUtf8ThrowsHttpRequestInvalid(): void requestFactory: $this->factory ); - /** @Then HttpRequestInvalid is thrown with the JsonException chained as previous */ - $this->expectException(HttpRequestInvalid::class); - /** @When sending a request whose body contains a non-UTF-8 byte sequence */ $transport->send( request: Request::create( @@ -224,6 +197,9 @@ public function testBodyWithInvalidUtf8ThrowsHttpRequestInvalid(): void method: Method::POST ) ); + + /** @Then the PSR-7 request body carries the JSON-escaped replacement character and no exception is thrown */ + self::assertStringContainsString('\ufffd', (string)$captured->getBody()); } private function buildCapturingClient(?RequestInterface &$captured, int $statusCode): ClientInterface diff --git a/tests/Unit/Exceptions/HttpRequestFailedTest.php b/tests/Unit/Exceptions/HttpRequestFailedTest.php index ae4e741..36d2894 100644 --- a/tests/Unit/Exceptions/HttpRequestFailedTest.php +++ b/tests/Unit/Exceptions/HttpRequestFailedTest.php @@ -4,7 +4,6 @@ namespace Test\TinyBlocks\Http\Unit\Exceptions; -use JsonException; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientExceptionInterface; use RuntimeException; @@ -67,21 +66,6 @@ public function testFromClientExceptionBuildsExceptionFromRequest(): void self::assertInstanceOf(HttpException::class, $exception); } - public function testFromJsonErrorBuildsExceptionFromRequest(): void - { - /** @Given a request and a JSON exception */ - $request = Request::create(url: 'https://api.example.com/dragons', method: Method::POST); - $jsonException = new JsonException('Malformed UTF-8'); - - /** @When constructing from a JSON error */ - $exception = HttpRequestFailed::fromJsonError(request: $request, exception: $jsonException); - - /** @Then the exception reflects the encoding failure */ - self::assertSame('https://api.example.com/dragons', $exception->url()); - self::assertStringContainsString('Failed to encode request body', $exception->reason()); - self::assertSame($jsonException, $exception->getPrevious()); - } - public function testExceptionIsInstanceOfHttpException(): void { /** @When building an HttpRequestFailed */ From e8f62942fb6873fd16132fc5c98d15827315d0ea Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 14 May 2026 23:52:01 -0300 Subject: [PATCH 08/27] docs(rules): expand PHPDoc rule to cover concrete entry-point classes Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/php-library-code-style.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md index 7ec196e..e7981b7 100644 --- a/.claude/rules/php-library-code-style.md +++ b/.claude/rules/php-library-code-style.md @@ -122,12 +122,21 @@ All identifiers, enum values, comments, and error codes use American English spe ## PHPDoc -- PHPDoc is restricted to interfaces only, documenting obligations, `@throws`, and complexity. -- Never add PHPDoc to concrete classes. - Document `@throws` for every exception the method may raise. -- Document time and space complexity in Big O form. When a method participates in a fused pipeline (e.g., collection - pipelines), express cost as a two-part form: call-site cost + fused-pass contribution. Include a legend defining - variables (e.g., `N` for input size, `K` for number of stages). +- Document time and space complexity in Big O form. When a method participates in + a fused pipeline (e.g., collection pipelines), express cost as a two-part form: + call-site cost + fused-pass contribution. Include a legend defining variables + (e.g., `N` for input size, `K` for number of stages). +- PHPDoc is required on: + - Every method of an interface. + - Every public method of a concrete class at the public API entry point + (façades, builders, value objects that consumers interact with directly) + when the method is not already documented by an implemented interface. +- PHPDoc is prohibited on: + - Private and protected methods. + - Public methods of concrete classes whose contract is already documented + on an implemented interface — the interface carries the docblock. + - Anything inside `src/Internal/`. ## Collection usage From b805bf8311806486b8606e8ca22b21e5cf351ea6 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 14 May 2026 23:53:38 -0300 Subject: [PATCH 09/27] docs: add PHPDoc to Http and HttpBuilder public entry-point methods Co-Authored-By: Claude Sonnet 4.6 --- src/Http.php | 36 ++++++++++++++++++++++++++++++++++++ src/HttpBuilder.php | 21 +++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/Http.php b/src/Http.php index 9ac3fe2..d2a97e8 100644 --- a/src/Http.php +++ b/src/Http.php @@ -7,6 +7,7 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Client\Response; use TinyBlocks\Http\Client\Transport; +use TinyBlocks\Http\Exceptions\HttpException; use TinyBlocks\Http\Internal\Client\RequestResolver; final readonly class Http @@ -18,16 +19,51 @@ private function __construct(string $baseUrl, private Transport $transport) $this->resolver = RequestResolver::withBaseUrl(baseUrl: $baseUrl); } + /** + * Returns a fluent builder used to assemble an Http instance. + * + * Both a transport and a base URL must be supplied through the builder + * before calling build(); otherwise HttpConfigurationInvalid is raised. + * + * @return HttpBuilder A new, empty builder. + */ public static function create(): HttpBuilder { return new HttpBuilder(baseUrl: null, transport: null); } + /** + * Creates an Http instance directly from a base URL and a transport. + * + * Explicit single-call alternative to the fluent builder returned by + * create(). Both arguments are required. + * + * @param string $baseUrl The absolute base URL prepended to every request path. + * @param Transport $transport The transport that delivers resolved requests. + * @return Http A configured Http façade. + */ public static function with(string $baseUrl, Transport $transport): Http { return new Http(baseUrl: $baseUrl, transport: $transport); } + /** + * Sends a request through the configured transport and returns the response. + * + * The request is first resolved against the configured base URL and the + * library's JSON defaults. A path that escapes the base URL raises + * MalformedPath before the transport is invoked. Transport-level failures + * surface as HttpException subclasses. + * + * @param Request $request The outbound request to send. + * @return Response The response returned by the transport. + * @throws HttpException When request resolution or the transport fails. + * + * @complexity O(H + B + R) CPU, where H is the header count, B is the + * encoded body size in bytes, and R is the response body size + * in bytes. Network I/O is not accounted for; it depends on + * the underlying PSR-18 client. + */ public function send(Request $request): Response { return $this->transport->send(request: $this->resolver->resolve(request: $request)); diff --git a/src/HttpBuilder.php b/src/HttpBuilder.php index 5124aa5..6adbe2e 100644 --- a/src/HttpBuilder.php +++ b/src/HttpBuilder.php @@ -13,16 +13,37 @@ public function __construct(private ?string $baseUrl, private ?Transport $transp { } + /** + * Returns a new builder carrying the given base URL. + * + * @param string $url The absolute base URL prepended to every request path. + * @return HttpBuilder A new builder instance. + */ public function withBaseUrl(string $url): HttpBuilder { return new HttpBuilder(baseUrl: $url, transport: $this->transport); } + /** + * Returns a new builder carrying the given transport. + * + * @param Transport $transport The transport that will deliver resolved requests. + * @return HttpBuilder A new builder instance. + */ public function withTransport(Transport $transport): HttpBuilder { return new HttpBuilder(baseUrl: $this->baseUrl, transport: $transport); } + /** + * Assembles the configured Http façade. + * + * Both a base URL and a transport must have been supplied via withBaseUrl() + * and withTransport() before this call. + * + * @return Http A configured Http façade. + * @throws HttpConfigurationInvalid When the base URL or the transport is missing. + */ public function build(): Http { if (is_null($this->transport)) { From 68a8c948c6146eb9363c86ca259236ef4ecb0b48 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 14 May 2026 23:57:14 -0300 Subject: [PATCH 10/27] docs: rewrite README for v2 public API Co-Authored-By: Claude Sonnet 4.6 --- README.md | 809 ++++++++++++++++++++++-------------------------------- 1 file changed, 321 insertions(+), 488 deletions(-) diff --git a/README.md b/README.md index 223dbcf..e8b5c3a 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,27 @@ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +PSR-7/PSR-15 HTTP primitives for PHP with a fluent server-side response builder and a hexagonal client built on PSR-18. + * [Overview](#overview) * [Installation](#installation) -* [Upgrading from 1.x to 2.x](#upgrading-from-1x-to-2x) * [How to use](#how-to-use) * [Server](#server) - * [Request](#request) - * [Response](#response) - * [Status codes](#status-codes) + * [Decoding a request](#decoding-a-request) + * [Creating a response](#creating-a-response) + * [Setting cookies](#setting-cookies) + * [Status code](#status-code) * [Client](#client) - * [Configuring Http](#configuring-http) + * [Building Http with a PSR-18 client and PSR-17 factories](#building-http-with-a-psr-18-client-and-psr-17-factories) * [Making a request](#making-a-request) * [Reading the response](#reading-the-response) * [Query parameters](#query-parameters) * [Custom headers and content type](#custom-headers-and-content-type) * [Error handling](#error-handling) * [Configuring timeouts](#configuring-timeouts) + * [Testing with InMemoryTransport](#testing-with-inmemorytransport) + * [Extending with custom transports](#extending-with-custom-transports) +* [FAQ](#faq) * [License](#license) * [Contributing](#contributing) @@ -25,22 +30,19 @@ ## Overview -Implements [PSR-7](https://www.php-fig.org/psr/psr-7) and [PSR-15](https://www.php-fig.org/psr/psr-15) HTTP primitives -for PHP, covering requests, responses, streams, cookies, headers, methods, status codes, and cache-control directives. -Ships with a fluent response builder that maps common outcomes to the correct HTTP semantics out of the box. -Interoperable with Slim, Laminas, and any PSR-compliant framework. +The library covers both sides of an HTTP exchange: + +- **Server side** (`TinyBlocks\Http\Server`) — decodes a PSR-7 `ServerRequestInterface` into typed accessors and builds outgoing `ResponseInterface` instances with cookies, cache-control, and status codes. +- **Client side** (`TinyBlocks\Http\Client`) — composes outbound requests, sends them through a `Transport` port backed by any PSR-18 client, and exposes responses with typed body and header access. -In addition, the library provides a thin [PSR-18](https://www.php-fig.org/psr/psr-18) outbound HTTP client façade -(`Http`) that composes any PSR-18 client with PSR-17 factories, handles URL construction, serializes JSON bodies, -and maps transport exceptions to typed exceptions — without bundling any HTTP client implementation. +Shared primitives (`Method`, `Code`, `Headers`, `Headerable`, `ContentType`, `Cookie`, `CacheControl`) live at the root namespace. -The public API is organized by role: +Design choices: -| Namespace | Purpose | -|---------------------------|-----------------------------------------------------------------------------------------------| -| `TinyBlocks\Http\` | Shared primitives: `Method`, `Code`, `Headers`, `ContentType`, `CacheControl`, `Cookie`, etc. | -| `TinyBlocks\Http\Server\` | PSR-7 / PSR-15 server-side request decoding and response building | -| `TinyBlocks\Http\Client\` | Outbound HTTP: `Request`, `Response`, and typed exceptions | +- PSR alignment at every boundary (PSR-7, PSR-15, PSR-17, PSR-18). No proprietary HTTP client wrapper. +- Hexagonal architecture on the client: one `Transport` port with `NetworkTransport` (PSR-18 adapter) and `InMemoryTransport` (in-process test adapter). Decorators (retry, logging, circuit breakers) plug in by implementing `Transport`. +- Total immutability for every public type. +- Typed access over raw arrays: `Code` enum, `Method` enum, `Headers` value object, typed body fields via `Attribute`.
@@ -50,47 +52,6 @@ The public API is organized by role: composer require tiny-blocks/http ``` -To make outbound HTTP requests, also require a [PSR-18](https://www.php-fig.org/psr/psr-18) client and -[PSR-17](https://www.php-fig.org/psr/psr-17) factories. For example, using Guzzle and Nyholm: - -```bash -composer require guzzlehttp/guzzle nyholm/psr7 -``` - -
- -## Upgrading from 1.x to 2.x - -Version 2.x moves the server-side `Request` and `Response` classes into the `Server\` sub-namespace. The shared -primitives (`Method`, `Code`, `ContentType`, `CacheControl`, `Cookie`, `Headers`, etc.) stay at the root and are -unchanged. - -`Response::from(...)` and `Responses::from(...)` now take `$body` before `$code`. Update every call site: - -```bash -# Before -Response::from(code: Code::OK, body: $payload) - -# After -Response::from(body: $payload, code: Code::OK) -``` - -Run the following find/replace commands in your project: - -```bash -# Request -grep -rn 'TinyBlocks\\Http\\Request' . -sed -i 's/use TinyBlocks\\Http\\Request;/use TinyBlocks\\Http\\Server\\Request;/g' $(grep -rl 'use TinyBlocks\\Http\\Request;' .) - -# Response -grep -rn 'TinyBlocks\\Http\\Response' . -sed -i 's/use TinyBlocks\\Http\\Response;/use TinyBlocks\\Http\\Server\\Response;/g' $(grep -rl 'use TinyBlocks\\Http\\Response;' .) - -# Responses interface -grep -rn 'TinyBlocks\\Http\\Responses' . -sed -i 's/use TinyBlocks\\Http\\Responses;/use TinyBlocks\\Http\\Server\\Responses;/g' $(grep -rl 'use TinyBlocks\\Http\\Responses;' .) -``` -
## How to use @@ -99,386 +60,179 @@ sed -i 's/use TinyBlocks\\Http\\Responses;/use TinyBlocks\\Http\\Server\\Respons ### Server -
- -#### Request - -##### Decoding a request - -The library provides a small public API to decode a PSR-7 `ServerRequestInterface` into a typed structure, allowing you -to access route parameters and JSON body fields consistently. - -- **Decode a request**: Use `Request::from(...)` to wrap the PSR-7 request and call `decode()`. The decoded object - exposes `uri` and `body`. - - ```php - use Psr\Http\Message\ServerRequestInterface; - use TinyBlocks\Http\Server\Request; - - /** @var ServerRequestInterface $psrRequest */ - $decoded = Request::from(request: $psrRequest)->decode(); - - $name = $decoded->body()->get(key: 'name')->toString(); - $payload = $decoded->body()->toArray(); - - $id = $decoded->uri()->route()->get(key: 'id')->toInteger(); - ``` - -- **Access the HTTP method**: Use `method()` directly on the `Request` to retrieve the HTTP verb as a typed `Method` - enum. - - ```php - use Psr\Http\Message\ServerRequestInterface; - use TinyBlocks\Http\Server\Request; - - /** @var ServerRequestInterface $psrRequest */ - $request = Request::from(request: $psrRequest); - - $method = $request->method(); # Method::POST - $method->value; # "POST" - ``` - -- **Access the full URI**: Use `toString()` on the decoded `uri()` to retrieve the complete request URI as a string. - - ```php - use TinyBlocks\Http\Server\Request; - - $decoded = Request::from(request: $psrRequest)->decode(); - - $fullUri = $decoded->uri()->toString(); # "https://api.example.com/v1/dragons?sort=name" - ``` - -- **Access query parameters**: Use `queryParameters()` on the decoded `uri()` to retrieve typed access to query string - values. Each value is returned as an `Attribute`, providing safe conversions and defaults. - - ```php - use TinyBlocks\Http\Server\Request; - - $decoded = Request::from(request: $psrRequest)->decode(); - - $queryParams = $decoded->uri()->queryParameters()->toArray(); # ['sort' => 'name', 'limit' => '50'] - $sort = $decoded->uri()->queryParameters()->get(key: 'sort')->toString(); # "name" - $limit = $decoded->uri()->queryParameters()->get(key: 'limit')->toInteger(); # 50 - $active = $decoded->uri()->queryParameters()->get(key: 'active')->toBoolean(); # default: false - ``` - -- **Typed access with defaults**: Each value is returned as an `Attribute`, which provides safe conversions and default - values when the underlying value is missing or not compatible. - - ```php - use TinyBlocks\Http\Server\Request; - - $request = Request::from(request: $psrRequest); - $decoded = $request->decode(); +
- $method = $request->method(); # Method enum +#### Decoding a request - $id = $decoded->uri()->route()->get(key: 'id')->toInteger(); # default: 0 - $uri = $decoded->uri()->toString(); # default: "" - $sort = $decoded->uri()->queryParameters()->get(key: 'sort')->toString(); # default: "" - $limit = $decoded->uri()->queryParameters()->get(key: 'limit')->toInteger(); # default: 0 - - $note = $decoded->body()->get(key: 'note')->toString(); # default: "" - $tags = $decoded->body()->get(key: 'tags')->toArray(); # default: [] - $price = $decoded->body()->get(key: 'price')->toFloat(); # default: 0.00 - $active = $decoded->body()->get(key: 'active')->toBoolean(); # default: false - ``` - -- **Custom route attribute name**: If your framework stores route params in a different request attribute, specify it - via `route()`. - - ```php - use TinyBlocks\Http\Server\Request; - - $decoded = Request::from(request: $psrRequest)->decode(); - - $id = $decoded->uri()->route(name: '_route_params')->get(key: 'id')->toInteger(); - ``` - -##### How route parameters are resolved - -The library resolves route parameters from the PSR-7 `ServerRequestInterface` using a **multistep fallback strategy**, -designed to work across different frameworks without importing any framework-specific code. - -**Resolution order** (when using the default `route()` or `route(name: '...')`): - -1. **Specified attribute lookup** — Reads the attribute from the request using the configured name (default: - `__route__`). - - If the value is an **array**, the key is looked up directly. - - If the value is an **object**, the resolver tries known accessor methods (`getArguments()`, - `getMatchedParams()`, `getParameters()`, `getParams()`) and then public properties (`arguments`, `params`, - `vars`, `parameters`). - - If the value is a **scalar** (e.g., a string), it is returned as-is. - -2. **Known attribute scan** (only when using the default `__route__` name) — Scans all commonly used attribute keys - across frameworks: `__route__`, `_route_params`, `route`, `routing`, `routeResult`, `routeInfo`. - -3. **Direct attribute fallback** — As a last resort, tries `$request->getAttribute($key)` directly, which supports - frameworks like Laravel that store route params as individual request attributes. - -4. **Safe default** — If nothing is found, returns `Attribute::from(null)`, which provides safe conversions: - `toInteger()` → `0`, `toString()` → `""`, `toFloat()` → `0.00`, `toBoolean()` → `false`, `toArray()` → `[]`. - -**Supported frameworks and attribute formats:** - -| Framework | Attribute Key | Format | -|-------------------------|-----------------|-----------------------------------------------| -| **Slim 4** | `__route__` | Object with `getArguments()` | -| **Mezzio / Expressive** | `routeResult` | Object with `getMatchedParams()` | -| **Symfony** | `_route_params` | `array` | -| **Laravel** | *(direct)* | `getAttribute('id')` directly on the request | -| **FastRoute (generic)** | `routeInfo` | Array with route parameters | -| **Manual injection** | Any custom key | `$request->withAttribute('__route__', [...])` | - -##### Manually injecting route parameters - -If your framework or middleware does not automatically populate route attributes, inject them manually using -PSR-7's `withAttribute()`: +Wrap a PSR-7 `ServerRequestInterface` and read typed fields from the body, route parameters, and query string. ```php +use Psr\Http\Message\ServerRequestInterface; use TinyBlocks\Http\Server\Request; -$psrRequest = $psrRequest->withAttribute('__route__', [ - 'id' => '42', - 'email' => 'user@example.com' -]); - +/** @var ServerRequestInterface $psrRequest */ $decoded = Request::from(request: $psrRequest)->decode(); -$id = $decoded->uri()->route()->get(key: 'id')->toInteger(); # 42 - -$psrRequest = $psrRequest->withAttribute('my_params', ['slug' => 'hello-world']); -$slug = Request::from(request: $psrRequest) - ->decode() - ->uri() - ->route(name: 'my_params') - ->get(key: 'slug') - ->toString(); # "hello-world" -``` - -
- -#### Response - -##### Creating a response - -The library provides an easy and flexible way to create HTTP responses, allowing you to specify the status code, -headers, and body. You can use the `Response` class to generate responses, and the result will always be a -`ResponseInterface` from the PSR, ensuring compatibility with any framework that adheres -to the [PSR-7](https://www.php-fig.org/psr/psr-7) standard. -- **Creating a response with a body**: To create an HTTP response, you can pass any type of data as the body. - Optionally, you can also specify one or more headers. If no headers are provided, the response will default to - `application/json` content type. - - ```php - use TinyBlocks\Http\Server\Response; - - Response::ok(body: ['message' => 'Resource created successfully.']); - ``` - -- **Creating a response with a body and custom headers**: You can also add custom headers to the response. For instance, - if you want to specify a custom content type or any other header, you can pass the headers as additional arguments. - - ```php - use TinyBlocks\Http\CacheControl; - use TinyBlocks\Http\ContentType; - use TinyBlocks\Http\ResponseCacheDirectives; - use TinyBlocks\Http\Server\Response; - - $contentType = ContentType::textPlain(); - - $cacheControl = CacheControl::fromResponseDirectives( - maxAge: ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000), - staleIfError: ResponseCacheDirectives::staleIfError() - ); - - Response::ok('This is a plain text response', $contentType, $cacheControl) - ->withHeader(name: 'X-ID', value: 100) - ->withHeader(name: 'X-NAME', value: 'Xpto'); - ``` - -##### Setting cookies - -The library models the `Set-Cookie` HTTP response header through the `Cookie` value object, covering the full -[RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265) attribute set plus modern additions such as `SameSite` and -`Partitioned`. Instances are immutable and fluent — every builder call returns a new `Cookie`. Like `ContentType` and -`CacheControl`, `Cookie` implements `Headers`, so it composes naturally with any `Response` factory via varargs. - -- **Setting a session cookie**: Build a cookie with the required security flags and attach it to a response. - - ```php - use TinyBlocks\Http\Cookie; - use TinyBlocks\Http\SameSite; - use TinyBlocks\Http\Server\Response; - - $cookie = Cookie::create(name: 'refresh_token', value: $opaqueToken) - ->httpOnly() - ->secure() - ->withSameSite(sameSite: SameSite::STRICT) - ->withPath(path: '/v1/sessions') - ->withMaxAge(seconds: 604800); - - Response::ok(body: ['ok' => true], $cookie); - ``` - -- **Setting multiple cookies**: Pass each `Cookie` as an additional header argument. The response emits one - `Set-Cookie` header per cookie, preserving all of them. - - ```php - use TinyBlocks\Http\Cookie; - use TinyBlocks\Http\SameSite; - use TinyBlocks\Http\Server\Response; - - $accessCookie = Cookie::create(name: 'access_token', value: $accessToken) - ->httpOnly() - ->secure() - ->withPath(path: '/'); - - $refreshCookie = Cookie::create(name: 'refresh_token', value: $refreshToken) - ->httpOnly() - ->secure() - ->withSameSite(sameSite: SameSite::STRICT) - ->withPath(path: '/v1/sessions') - ->withMaxAge(seconds: 604800); +$id = $decoded->uri()->route()->get(key: 'id')->toInteger(); +$sort = $decoded->uri()->queryParameters()->get(key: 'sort')->toString(); +$name = $decoded->body()->get(key: 'name')->toString(); +$amount = $decoded->body()->get(key: 'amount')->toFloat(); +``` - Response::ok(body: ['ok' => true], $accessCookie, $refreshCookie); - ``` +The HTTP method is available as a typed `Method` enum: -- **Expiring a cookie**: Use `Cookie::expire()` to instruct the browser to delete a previously set cookie. Chain the - same `Path` (and `Domain`, if applicable) used when the cookie was issued. +```php +use Psr\Http\Message\ServerRequestInterface; +use TinyBlocks\Http\Server\Request; - ```php - use TinyBlocks\Http\Cookie; - use TinyBlocks\Http\SameSite; - use TinyBlocks\Http\Server\Response; +/** @var ServerRequestInterface $psrRequest */ +$method = Request::from(request: $psrRequest)->method(); +``` - $expired = Cookie::expire(name: 'refresh_token') - ->httpOnly() - ->secure() - ->withSameSite(sameSite: SameSite::STRICT) - ->withPath(path: '/v1/sessions'); +
- Response::noContent($expired); - ``` +#### Creating a response -- **Using an absolute expiration date**: When an explicit deletion moment is preferable over `Max-Age`, use - `withExpires()`. `Max-Age` and `Expires` are mutually exclusive — setting both throws - `ConflictingLifetimeAttributes` when the response is serialized. +Each helper returns a PSR-7 `ResponseInterface` and defaults to `application/json`: - ```php - use DateTimeImmutable; - use DateTimeZone; - use TinyBlocks\Http\Cookie; +```php +use TinyBlocks\Http\Server\Response; - Cookie::create(name: 'preference', value: 'dark-mode')->withExpires( - expires: new DateTimeImmutable(datetime: '2030-01-15 12:00:00', timezone: new DateTimeZone(timezone: 'UTC')) - ); - ``` +Response::ok(body: ['message' => 'Resource created successfully.']); +Response::created(body: ['id' => 42]); +Response::noContent(); +Response::notFound(body: ['error' => 'Resource not found.']); +``` -- **Cross-site cookies**: `SameSite::NONE` requires the `Secure` flag — modern browsers reject `SameSite=None` - cookies sent over insecure connections. The library enforces this invariant at serialization time and throws - `SameSiteNoneRequiresSecure` when the combination is incomplete. +For custom status codes, use `from(...)`: - ```php - use TinyBlocks\Http\Cookie; - use TinyBlocks\Http\SameSite; +```php +use TinyBlocks\Http\Code; +use TinyBlocks\Http\Server\Response; - Cookie::create(name: 'embed_session', value: $token) - ->withSameSite(sameSite: SameSite::NONE) - ->secure(); - ``` +Response::from(body: ['status' => 'accepted'], code: Code::ACCEPTED); +``` -- **Validation at construction time**: Cookie names and values are validated against - [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265). Names cannot be empty nor contain control characters, - whitespace, or token separators. Values cannot contain control characters, whitespace, double quotes, commas, - semicolons, or backslashes. Encode the value before passing it when it may contain arbitrary text. +Attach additional headers via varargs of `Headerable`: - ```php - use TinyBlocks\Http\Cookie; +```php +use TinyBlocks\Http\CacheControl; +use TinyBlocks\Http\ContentType; +use TinyBlocks\Http\ResponseCacheDirectives; +use TinyBlocks\Http\Server\Response; - Cookie::create(name: 'user_id', value: (string)$userId); # valid - Cookie::create(name: 'payload', value: base64_encode($jsonBody)); # encode arbitrary values first - ``` +$cacheControl = CacheControl::fromResponseDirectives( + maxAge: ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000) +); -
+Response::ok(body: ['ok' => true], $cacheControl, ContentType::applicationJson()) + ->withHeader(name: 'X-Trace-Id', value: 'abc-123'); +``` -#### Status codes +
-The library exposes a concrete implementation through the `Code` enum. You can retrieve the status codes, their -corresponding messages, and check for various status code ranges using the methods provided. +#### Setting cookies -- **Get message**: Returns the [HTTP status message](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages) - associated with the enum's code. +`Cookie` implements `Headerable` and composes naturally with `Response`: - ```php - use TinyBlocks\Http\Code; +```php +use TinyBlocks\Http\Cookie; +use TinyBlocks\Http\SameSite; +use TinyBlocks\Http\Server\Response; + +$session = Cookie::create(name: 'session', value: $token) + ->httpOnly() + ->secure() + ->withSameSite(sameSite: SameSite::STRICT) + ->withPath(path: '/v1/sessions') + ->withMaxAge(seconds: 604800); + +Response::ok(body: ['ok' => true], $session); +``` - Code::OK->value; # 200 - Code::OK->message(); # OK - Code::IM_A_TEAPOT->message(); # I'm a teapot - Code::INTERNAL_SERVER_ERROR->message(); # Internal Server Error - ``` +To expire a cookie, use `Cookie::expire(...)` with the same `Path` and `Domain` used at creation. -- **Check if the code is valid**: Determines if the given code is a valid HTTP status code represented by the enum. +```php +use TinyBlocks\Http\Cookie; +use TinyBlocks\Http\SameSite; +use TinyBlocks\Http\Server\Response; - ```php - use TinyBlocks\Http\Code; +$expired = Cookie::expire(name: 'session') + ->httpOnly() + ->secure() + ->withSameSite(sameSite: SameSite::STRICT) + ->withPath(path: '/v1/sessions'); - Code::isValidCode(code: 200); # true - Code::isValidCode(code: 999); # false - ``` +Response::noContent($expired); +``` -- **Check if the code is an error**: Determines if the given code is in the error range (**4xx** or **5xx**). +
- ```php - use TinyBlocks\Http\Code; +#### Status code - Code::isErrorCode(code: 500); # true - Code::isErrorCode(code: 200); # false - ``` +The `Code` enum carries the full RFC HTTP status set with typed helpers: -- **Check if the code is a success**: Determines if the given code is in the success range (**2xx**). +```php +use TinyBlocks\Http\Code; - ```php - use TinyBlocks\Http\Code; +Code::OK->value; // 200 +Code::OK->message(); // "OK" +Code::OK->isSuccess(); // true +Code::INTERNAL_SERVER_ERROR->isError(); // true - Code::isSuccessCode(code: 500); # false - Code::isSuccessCode(code: 200); # true - ``` +Code::isValidCode(code: 200); // true +Code::isErrorCode(code: 500); // true +Code::isSuccessCode(code: 200); // true +```
### Client -
+
-#### Configuring Http +#### Building Http with a PSR-18 client and PSR-17 factories -`Http` is a thin, immutable façade over any [PSR-18](https://www.php-fig.org/psr/psr-18) HTTP client. It does not -bundle any transport implementation — bring your own. +Assemble the façade with any PSR-18 client and PSR-17 factories. ```php -use GuzzleHttp\Client as GuzzleClient; -use Nyholm\Psr7\Factory\Psr17Factory; +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\HttpFactory; +use TinyBlocks\Http\Client\Transports\NetworkTransport; use TinyBlocks\Http\Http; -$factory = new Psr17Factory(); +$factory = new HttpFactory(); +$client = new Client(['timeout' => 30, 'connect_timeout' => 5]); -$http = Http::from( - client: new GuzzleClient(), - requestFactory: $factory, - streamFactory: $factory -); +$http = Http::create() + ->withTransport( + transport: NetworkTransport::with( + client: $client, + streamFactory: $factory, + requestFactory: $factory + ) + ) + ->withBaseUrl(url: 'https://api.example.com') + ->build(); ``` -Pass an optional `baseUrl` to avoid repeating the host on every request: +For a single-call construction without the fluent builder: ```php -$http = Http::from( - client: new GuzzleClient(), - requestFactory: $factory, - streamFactory: $factory, - baseUrl: 'https://api.example.com' +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\HttpFactory; +use TinyBlocks\Http\Client\Transports\NetworkTransport; +use TinyBlocks\Http\Http; + +$factory = new HttpFactory(); + +$http = Http::with( + baseUrl: 'https://api.example.com', + transport: NetworkTransport::with( + client: new Client(['timeout' => 30, 'connect_timeout' => 5]), + streamFactory: $factory, + requestFactory: $factory + ) ); ``` @@ -486,200 +240,279 @@ $http = Http::from( #### Making a request -Build a `Client\Request` with `Request::create()` and pass it to `Http::send()`. +`Request::create(...)` accepts only `url` as required. Everything else has sensible defaults. ```php use TinyBlocks\Http\Client\Request; -use TinyBlocks\Http\Http; use TinyBlocks\Http\Method; $response = $http->send( request: Request::create( - url: '/dragons', - method: Method::GET + url: '/v1/charges', + body: ['amount' => 1000, 'currency' => 'usd'], + method: Method::POST ) ); ``` -For requests with a JSON body: +A simple `GET` needs only the URL: ```php use TinyBlocks\Http\Client\Request; -use TinyBlocks\Http\Http; -use TinyBlocks\Http\Method; -$response = $http->send( - request: Request::create( - url: '/dragons', - method: Method::POST, - body: ['name' => 'Hydra', 'type' => 'water'] - ) -); +$response = $http->send(request: Request::create(url: '/v1/charges/abc123')); ```
#### Reading the response -`Http::send()` returns an immutable `Client\Response`. It provides typed access to the status code, headers, and -JSON body. - ```php -use TinyBlocks\Http\Client\Request; -use TinyBlocks\Http\Method; - -$response = $http->send( - request: Request::create(url: '/dragons/42', method: Method::GET) -); +if ($response->isSuccess()) { + $id = $response->body()->get(key: 'id')->toString(); + $amount = $response->body()->get(key: 'amount')->toInteger(); +} -$response->statusCode(); # 200 -$response->code(); # Code::OK (null if not in the Code enum) -$response->isSuccess(); # true -$response->isError(); # false +$response->code(); // Code enum +$response->headers(); // TinyBlocks\Http\Headers value object +$response->raw(); // Psr\Http\Message\ResponseInterface +``` -$response->body()->get(key: 'id')->toInteger(); # 42 -$response->body()->get(key: 'name')->toString(); # "Hydra" -$response->body()->toArray(); # ['id' => 42, 'name' => 'Hydra'] +`Headers` exposes case-insensitive lookup: -$response->headers(); # ['Content-Type' => 'application/json', ...] -$response->raw(); # the underlying PSR-7 ResponseInterface +```php +$contentType = $response->headers()->get(name: 'content-type'); // "application/json" +$hasTrace = $response->headers()->has(name: 'X-Trace-Id'); // true ```
#### Query parameters -Pass an associative array to `query`. Values are encoded -using [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). +Pass the query as a named parameter — the library encodes it in RFC3986 form. ```php use TinyBlocks\Http\Client\Request; -use TinyBlocks\Http\Method; -$http->send( +$response = $http->send( request: Request::create( - url: '/dragons', - method: Method::GET, - query: ['sort' => 'name', 'order' => 'asc', 'limit' => 50] + url: '/v1/charges', + query: ['status' => 'succeeded', 'limit' => 50] ) ); -# Sends: GET /dragons?sort=name&order=asc&limit=50 ```
#### Custom headers and content type -By default, requests with a body are sent with `Content-Type: application/json` and `Accept: application/json`. -Pass one or more `Headers` instances to override or extend the defaults. +Any `Headerable` composes via varargs: ```php -use TinyBlocks\Http\Charset; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\ContentType; +use TinyBlocks\Http\Headerable; use TinyBlocks\Http\Method; -$http->send( +final readonly class IdempotencyKey implements Headerable +{ + public function __construct(private string $value) + { + } + + public function toArray(): array + { + return ['Idempotency-Key' => $this->value]; + } +} + +$response = $http->send( request: Request::create( - url: '/dragons', + url: '/v1/charges', + body: ['amount' => 1000], method: Method::POST, - body: ['name' => 'Hydra'], - headers: ContentType::applicationJson(charset: Charset::UTF_8) + ContentType::applicationJson(), + new IdempotencyKey(value: $key) ) ); -# Sends: Content-Type: application/json; charset=utf-8 ``` +Custom headers always win over the library's JSON defaults. +
#### Error handling -`Http::send()` maps PSR-18 transport exceptions to three typed exceptions. All three extend `HttpRequestFailed`, -which carries the target `$url`, the `Method`, and the `$reason` string. - -| Exception | Cause | -|----------------------|------------------------------------------------------------------------| -| `HttpNetworkFailed` | `NetworkExceptionInterface` — connection refused, timeout, DNS failure | -| `HttpRequestInvalid` | `RequestExceptionInterface` — malformed request object | -| `HttpRequestFailed` | Any other `ClientExceptionInterface` | +Every failure raises an `HttpException`. Catch by specificity: ```php -use TinyBlocks\Http\Client\Exceptions\HttpNetworkFailed; -use TinyBlocks\Http\Client\Exceptions\HttpRequestFailed; -use TinyBlocks\Http\Client\Exceptions\HttpRequestInvalid; -use TinyBlocks\Http\Client\Request; -use TinyBlocks\Http\Method; +use TinyBlocks\Http\Exceptions\HttpException; +use TinyBlocks\Http\Exceptions\HttpNetworkFailed; +use TinyBlocks\Http\Exceptions\MalformedPath; try { - $response = $http->send( - request: Request::create(url: '/dragons', method: Method::GET) - ); + $response = $http->send(request: $request); } catch (HttpNetworkFailed $exception) { - # connection-level failure — retry is often appropriate - echo $exception->url; # "https://api.example.com/dragons" - echo $exception->method; # Method::GET - echo $exception->reason; # "connection refused" -} catch (HttpRequestInvalid $exception) { - # malformed request — do not retry -} catch (HttpRequestFailed $exception) { - # catch-all for any other PSR-18 client exception + // DNS, connection refused, timeout — retry candidate +} catch (MalformedPath $exception) { + // path contained a scheme, was protocol-relative, or held control chars +} catch (HttpException $exception) { + // any other library-thrown failure + $exception->url(); + $exception->method(); + $exception->reason(); } ``` -The original PSR-18 exception is always preserved as the previous exception (`$exception->getPrevious()`). +| Exception | Cause | +|---|---| +| `HttpRequestFailed` | Generic PSR-18 `ClientExceptionInterface`. Base class for the others below. | +| `HttpNetworkFailed` | PSR-18 `NetworkExceptionInterface` — DNS, timeout, connection refused. | +| `HttpRequestInvalid` | PSR-18 `RequestExceptionInterface` — request malformed before transport. | +| `MalformedPath` | Path attempts to escape the base URL (scheme, protocol-relative, control characters). | +| `NoMoreResponses` | `InMemoryTransport` exhausted. | +| `HttpConfigurationInvalid` | Builder called without `withBaseUrl` or `withTransport`. | +| `SynthesizedResponseHasNoRaw` | `Response::raw()` called on a response created via `Response::with(...)`. |
#### Configuring timeouts -Timeouts are not part of this library's public API. Configure them directly on the PSR-18 client you inject. +PSR-18 does not standardize timeouts. Configure them on the underlying client before injection. **Guzzle:** ```php -use GuzzleHttp\Client as GuzzleClient; -use Nyholm\Psr7\Factory\Psr17Factory; -use TinyBlocks\Http\Http; - -$factory = new Psr17Factory(); +use GuzzleHttp\Client; -$http = Http::from( - client: new GuzzleClient([ - 'timeout' => 5.0, - 'connect_timeout' => 2.0 - ]), - requestFactory: $factory, - streamFactory: $factory, - baseUrl: 'https://api.example.com' -); +$client = new Client(['timeout' => 30, 'connect_timeout' => 5]); ``` **Symfony HttpClient:** ```php -use Nyholm\Psr7\Factory\Psr17Factory; +use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\Psr18Client; + +$client = new Psr18Client(client: HttpClient::create(['timeout' => 30])); +``` + +
+ +#### Testing with InMemoryTransport + +Pre-program responses with `Response::with(...)` and feed them to `InMemoryTransport`: + +```php +use TinyBlocks\Http\Client\Response; +use TinyBlocks\Http\Client\Transports\InMemoryTransport; +use TinyBlocks\Http\Code; use TinyBlocks\Http\Http; -$factory = new Psr17Factory(); - -$http = Http::from( - client: new Psr18Client( - \Symfony\Component\HttpClient\HttpClient::create([ - 'timeout' => 5.0 - ]) - ), - requestFactory: $factory, - streamFactory: $factory, - baseUrl: 'https://api.example.com' +$transport = InMemoryTransport::with( + responses: [ + Response::with(code: Code::CREATED, body: ['id' => 'ch_abc123']), + Response::with(code: Code::OK, body: ['status' => 'paid']) + ] ); + +$http = Http::create() + ->withTransport(transport: $transport) + ->withBaseUrl(url: 'https://api.example.com') + ->build(); ``` +Calls consume responses in FIFO order. Exhaustion raises `NoMoreResponses`. + +
+ +#### Extending with custom transports + +Implement `Transport` to add retry, logging, circuit breaker, or any other cross-cutting concern. The decorator wraps any inner `Transport`. + +```php +use TinyBlocks\Http\Client\Request; +use TinyBlocks\Http\Client\Response; +use TinyBlocks\Http\Client\Transport; +use TinyBlocks\Http\Exceptions\HttpNetworkFailed; + +final readonly class RetryingTransport implements Transport +{ + public function __construct( + private Transport $inner, + private int $maxAttempts + ) { + } + + public function send(Request $request): Response + { + $attempt = 0; + + while (true) { + try { + return $this->inner->send(request: $request); + } catch (HttpNetworkFailed $exception) { + $attempt++; + + if ($attempt >= $this->maxAttempts) { + throw $exception; + } + } + } + } +} +``` + +Compose it into the façade: + +```php +$http = Http::create() + ->withTransport( + transport: new RetryingTransport( + inner: NetworkTransport::with( + client: $client, + streamFactory: $factory, + requestFactory: $factory + ), + maxAttempts: 3 + ) + ) + ->withBaseUrl(url: 'https://api.example.com') + ->build(); +``` + +
+ +## FAQ + +### 01. Why is there a `Headerable` interface and a `Headers` value object? + +`Headerable` is the contract implemented by classes that emit one or more header lines — `ContentType`, `Cookie`, `CacheControl`, and any custom header type. `Headers` is the value object that carries the consolidated header set of an HTTP request or response, with case-insensitive lookup and merging. + +### 02. Why are timeouts not part of the public API? + +PSR-18 does not standardize timeouts. Exposing them in the façade would require a transport-specific contract that leaks the underlying client. Configure timeouts on the PSR-18 client before injecting it. + +### 03. Why does `Response::raw()` throw on a synthesized response? + +A response created via `Response::with(...)` has no PSR-7 backing — it exists only for in-process scenarios (tests, `InMemoryTransport`). Calling `raw()` in that mode is a programmer error and raises `SynthesizedResponseHasNoRaw`. + +### 04. Why is path validation enforced at the resolver? + +To protect the configured base URL from being hijacked by paths that contain a scheme, are protocol-relative, or carry control characters. Such inputs raise `MalformedPath` before the transport is invoked. + +### 05. What happens to status codes outside the `Code` enum? + +`Response::from()` requires a code present in the enum, which covers every RFC code in use. Non-RFC status codes are reachable through `Response::raw()->getStatusCode()`. + +
+ ## License Http is licensed under [MIT](LICENSE). +
+ ## Contributing -Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to -contribute to the project. +Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to contribute to the project. From 968aa1cf66a2e63c3232ef4b7d393ec83d414955 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 15 May 2026 01:17:32 -0300 Subject: [PATCH 11/27] refactor: collapse NetworkTransport::with to single intersection-typed factory param Co-Authored-By: Claude Sonnet 4.6 --- src/Client/Transports/NetworkTransport.php | 16 ++++------ .../Transports/NetworkTransportTest.php | 24 +++++---------- tests/Unit/HttpBuilderTest.php | 5 ++-- tests/Unit/HttpTest.php | 29 ++++++++----------- 4 files changed, 27 insertions(+), 47 deletions(-) diff --git a/src/Client/Transports/NetworkTransport.php b/src/Client/Transports/NetworkTransport.php index 7787169..32fca41 100644 --- a/src/Client/Transports/NetworkTransport.php +++ b/src/Client/Transports/NetworkTransport.php @@ -24,26 +24,20 @@ private function __construct( private ClientInterface $client, - private StreamFactoryInterface $streamFactory, - private RequestFactoryInterface $requestFactory + private RequestFactoryInterface&StreamFactoryInterface $factory ) { } public static function with( ClientInterface $client, - StreamFactoryInterface $streamFactory, - RequestFactoryInterface $requestFactory + RequestFactoryInterface&StreamFactoryInterface $factory ): NetworkTransport { - return new NetworkTransport( - client: $client, - streamFactory: $streamFactory, - requestFactory: $requestFactory - ); + return new NetworkTransport(client: $client, factory: $factory); } public function send(Request $request): Response { - $psrRequest = $this->requestFactory->createRequest( + $psrRequest = $this->factory->createRequest( method: $request->method->value, uri: $request->url ); @@ -52,7 +46,7 @@ public function send(Request $request): Response if (!is_null($request->body)) { $encoded = json_encode($request->body, self::JSON_FLAGS, self::MAX_JSON_DEPTH); - $psrRequest = $psrRequest->withBody(body: $this->streamFactory->createStream(content: $encoded)); + $psrRequest = $psrRequest->withBody(body: $this->factory->createStream(content: $encoded)); } try { diff --git a/tests/Unit/Client/Transports/NetworkTransportTest.php b/tests/Unit/Client/Transports/NetworkTransportTest.php index a8c13a3..7ebd33f 100644 --- a/tests/Unit/Client/Transports/NetworkTransportTest.php +++ b/tests/Unit/Client/Transports/NetworkTransportTest.php @@ -38,8 +38,7 @@ public function testRequestWithBodySendsJsonEncodedBodyAndContentTypeHeader(): v $client = $this->buildCapturingClient(captured: $captured, statusCode: 201); $transport = NetworkTransport::with( client: $client, - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); /** @When sending a request with a JSON body */ @@ -63,8 +62,7 @@ public function testRequestWithoutBodySendsNoBody(): void $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); $transport = NetworkTransport::with( client: $client, - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); /** @When sending a request without body */ @@ -81,8 +79,7 @@ public function testCustomHeadersAreForwardedToPsrRequest(): void $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); $transport = NetworkTransport::with( client: $client, - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); /** @When sending a request with a custom header */ @@ -108,8 +105,7 @@ public function getRequest(): RequestInterface $transport = NetworkTransport::with( client: $this->buildThrowingClient(exception: $networkException), - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); /** @Then HttpNetworkFailed is thrown with previous set */ @@ -131,8 +127,7 @@ public function getRequest(): RequestInterface $transport = NetworkTransport::with( client: $this->buildThrowingClient(exception: $requestException), - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); /** @Then HttpRequestInvalid is thrown */ @@ -150,8 +145,7 @@ public function testGenericClientExceptionMapsToHttpRequestFailed(): void $transport = NetworkTransport::with( client: $this->buildThrowingClient(exception: $clientException), - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); /** @Then HttpRequestFailed is thrown */ @@ -168,8 +162,7 @@ public function testSuccessfulResponseIsWrappedInClientResponse(): void $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); $transport = NetworkTransport::with( client: $client, - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); /** @When sending a request */ @@ -185,8 +178,7 @@ public function testBodyWithInvalidUtf8IsSubstitutedAndRequestSendsNormally(): v $captured = null; $transport = NetworkTransport::with( client: $this->buildCapturingClient(captured: $captured, statusCode: 200), - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); /** @When sending a request whose body contains a non-UTF-8 byte sequence */ diff --git a/tests/Unit/HttpBuilderTest.php b/tests/Unit/HttpBuilderTest.php index fde7bfd..31d729d 100644 --- a/tests/Unit/HttpBuilderTest.php +++ b/tests/Unit/HttpBuilderTest.php @@ -39,11 +39,10 @@ public function testWithTransportReturnNewBuilderAndOriginalIsUnchanged(): void client: new class implements ClientInterface { public function sendRequest(RequestInterface $request): ResponseInterface { - return (new Psr17Factory())->createResponse(200); + return new Psr17Factory()->createResponse(200); } }, - streamFactory: $factory, - requestFactory: $factory + factory: $factory ); /** @When calling withTransport */ diff --git a/tests/Unit/HttpTest.php b/tests/Unit/HttpTest.php index b46cff7..aca3a5d 100644 --- a/tests/Unit/HttpTest.php +++ b/tests/Unit/HttpTest.php @@ -112,14 +112,13 @@ public function testNetworkExceptionMapsToHttpNetworkFailed(): void NetworkExceptionInterface { public function getRequest(): RequestInterface { - return (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + return new Psr17Factory()->createRequest('GET', 'https://api.example.com'); } }; $transport = NetworkTransport::with( client: $this->buildFailingClient(exception: $networkException), - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); $http = Http::create() @@ -140,14 +139,13 @@ public function testRequestExceptionMapsToHttpRequestInvalid(): void $requestException = new class ('bad request') extends RuntimeException implements RequestExceptionInterface { public function getRequest(): RequestInterface { - return (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + return new Psr17Factory()->createRequest('GET', 'https://api.example.com'); } }; $transport = NetworkTransport::with( client: $this->buildFailingClient(exception: $requestException), - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); $http = Http::create() @@ -170,8 +168,7 @@ public function testGenericClientExceptionMapsToHttpRequestFailed(): void $transport = NetworkTransport::with( client: $this->buildFailingClient(exception: $clientException), - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); $http = Http::create() @@ -243,14 +240,13 @@ public function testNetworkExceptionPreservesPreviousChain(): void $networkException = new class ('timeout') extends RuntimeException implements NetworkExceptionInterface { public function getRequest(): RequestInterface { - return (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + return new Psr17Factory()->createRequest('GET', 'https://api.example.com'); } }; $transport = NetworkTransport::with( client: $this->buildFailingClient(exception: $networkException), - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); $http = Http::create() @@ -271,8 +267,8 @@ private function buildTransport(int $statusCode): NetworkTransport { $response = $this->factory->createResponse($statusCode); - $client = new class ($response) implements ClientInterface { - public function __construct(private readonly ResponseInterface $response) + $client = new readonly class ($response) implements ClientInterface { + public function __construct(private ResponseInterface $response) { } @@ -284,15 +280,14 @@ public function sendRequest(RequestInterface $request): ResponseInterface return NetworkTransport::with( client: $client, - streamFactory: $this->factory, - requestFactory: $this->factory + factory: $this->factory ); } private function buildFailingClient(Throwable $exception): ClientInterface { - return new class ($exception) implements ClientInterface { - public function __construct(private readonly Throwable $exception) + return new readonly class ($exception) implements ClientInterface { + public function __construct(private Throwable $exception) { } From 4d578198c4c540858c0b47cb1bdb5c196defc2a1 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 15 May 2026 01:20:30 -0300 Subject: [PATCH 12/27] feat: add UserAgent value object with empty-version normalization Co-Authored-By: Claude Sonnet 4.6 --- src/UserAgent.php | 49 +++++++++++++++++++++++++ tests/Unit/UserAgentTest.php | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/UserAgent.php create mode 100644 tests/Unit/UserAgentTest.php diff --git a/src/UserAgent.php b/src/UserAgent.php new file mode 100644 index 0000000..33d5f66 --- /dev/null +++ b/src/UserAgent.php @@ -0,0 +1,49 @@ +version)) { + return ['User-Agent' => $this->product]; + } + + return ['User-Agent' => sprintf('%s/%s', $this->product, $this->version)]; + } +} diff --git a/tests/Unit/UserAgentTest.php b/tests/Unit/UserAgentTest.php new file mode 100644 index 0000000..bd0e27c --- /dev/null +++ b/tests/Unit/UserAgentTest.php @@ -0,0 +1,71 @@ +toArray(); + + /** @Then the header contains only the product token */ + self::assertSame(['User-Agent' => 'MyApp'], $header); + } + + public function testFromWithEmptyVersionIsEquivalentToProductOnly(): void + { + /** @Given a product token with an explicitly empty version */ + $userAgent = UserAgent::from(product: 'MyApp', version: ''); + + /** @When reading the header array */ + $header = $userAgent->toArray(); + + /** @Then the header carries only the product token */ + self::assertSame(['User-Agent' => 'MyApp'], $header); + } + + public function testFromWithProductAndVersionRendersProductSlashVersion(): void + { + /** @Given a product token and a version */ + $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); + + /** @When reading the header array */ + $header = $userAgent->toArray(); + + /** @Then the header contains the product and version combined */ + self::assertSame(['User-Agent' => 'MyApp/1.2.3'], $header); + } + + public function testToArrayIsPureAndReturnsSameValueOnRepeatedCalls(): void + { + /** @Given a UserAgent value object */ + $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); + + /** @When calling toArray multiple times */ + $first = $userAgent->toArray(); + $second = $userAgent->toArray(); + + /** @Then both calls return identical arrays */ + self::assertSame($first, $second); + } + + public function testFromWithEmptyProductThrowsInvalidArgumentException(): void + { + /** @Then an exception is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('User-Agent product must not be empty.'); + + /** @When constructing with an empty product token */ + UserAgent::from(product: ''); + } +} From 03404e206dd6fd25e148bbb3ab774b03a98b30e1 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 15 May 2026 01:22:27 -0300 Subject: [PATCH 13/27] docs: update NetworkTransport examples to new factory signature, add UserAgent section Co-Authored-By: Claude Sonnet 4.6 --- README.md | 165 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 86 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index e8b5c3a..826badd 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,21 @@ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) -PSR-7/PSR-15 HTTP primitives for PHP with a fluent server-side response builder and a hexagonal client built on PSR-18. - * [Overview](#overview) * [Installation](#installation) * [How to use](#how-to-use) - * [Server](#server) + + [Server](#server) * [Decoding a request](#decoding-a-request) * [Creating a response](#creating-a-response) * [Setting cookies](#setting-cookies) * [Status code](#status-code) - * [Client](#client) + + [Client](#client) * [Building Http with a PSR-18 client and PSR-17 factories](#building-http-with-a-psr-18-client-and-psr-17-factories) * [Making a request](#making-a-request) * [Reading the response](#reading-the-response) * [Query parameters](#query-parameters) * [Custom headers and content type](#custom-headers-and-content-type) + * [Setting the User-Agent](#setting-the-user-agent) * [Error handling](#error-handling) * [Configuring timeouts](#configuring-timeouts) * [Testing with InMemoryTransport](#testing-with-inmemorytransport) @@ -26,42 +25,37 @@ PSR-7/PSR-15 HTTP primitives for PHP with a fluent server-side response builder * [License](#license) * [Contributing](#contributing) -
- ## Overview The library covers both sides of an HTTP exchange: -- **Server side** (`TinyBlocks\Http\Server`) — decodes a PSR-7 `ServerRequestInterface` into typed accessors and builds outgoing `ResponseInterface` instances with cookies, cache-control, and status codes. -- **Client side** (`TinyBlocks\Http\Client`) — composes outbound requests, sends them through a `Transport` port backed by any PSR-18 client, and exposes responses with typed body and header access. +- **Server side** (`TinyBlocks\Http\Server`) — decodes a PSR-7 `ServerRequestInterface` into typed accessors and builds + outgoing `ResponseInterface` instances with cookies, cache-control, and status codes. +- **Client side** (`TinyBlocks\Http\Client`) — composes outbound requests, sends them through a `Transport` port backed + by any PSR-18 client, and exposes responses with typed body and header access. -Shared primitives (`Method`, `Code`, `Headers`, `Headerable`, `ContentType`, `Cookie`, `CacheControl`) live at the root namespace. +Shared primitives (`Method`, `Code`, `Headers`, `Headerable`, `ContentType`, `Cookie`, `CacheControl`) live at the root +namespace. Design choices: - PSR alignment at every boundary (PSR-7, PSR-15, PSR-17, PSR-18). No proprietary HTTP client wrapper. -- Hexagonal architecture on the client: one `Transport` port with `NetworkTransport` (PSR-18 adapter) and `InMemoryTransport` (in-process test adapter). Decorators (retry, logging, circuit breakers) plug in by implementing `Transport`. +- Hexagonal architecture on the client: one `Transport` port with `NetworkTransport` (PSR-18 adapter) and + `InMemoryTransport` (in-process test adapter). Decorators (retry, logging, circuit breakers) plug in by implementing + `Transport`. - Total immutability for every public type. - Typed access over raw arrays: `Code` enum, `Method` enum, `Headers` value object, typed body fields via `Attribute`. -
- ## Installation ```bash composer require tiny-blocks/http ``` -
- ## How to use -
- ### Server -
- #### Decoding a request Wrap a PSR-7 `ServerRequestInterface` and read typed fields from the body, route parameters, and query string. @@ -89,8 +83,6 @@ use TinyBlocks\Http\Server\Request; $method = Request::from(request: $psrRequest)->method(); ``` -
- #### Creating a response Each helper returns a PSR-7 `ResponseInterface` and defaults to `application/json`: @@ -129,8 +121,6 @@ Response::ok(body: ['ok' => true], $cacheControl, ContentType::applicationJson() ->withHeader(name: 'X-Trace-Id', value: 'abc-123'); ``` -
- #### Setting cookies `Cookie` implements `Headerable` and composes naturally with `Response`: @@ -166,8 +156,6 @@ $expired = Cookie::expire(name: 'session') Response::noContent($expired); ``` -
- #### Status code The `Code` enum carries the full RFC HTTP status set with typed helpers: @@ -185,12 +173,8 @@ Code::isErrorCode(code: 500); // true Code::isSuccessCode(code: 200); // true ``` -
- ### Client -
- #### Building Http with a PSR-18 client and PSR-17 factories Assemble the façade with any PSR-18 client and PSR-17 factories. @@ -205,13 +189,7 @@ $factory = new HttpFactory(); $client = new Client(['timeout' => 30, 'connect_timeout' => 5]); $http = Http::create() - ->withTransport( - transport: NetworkTransport::with( - client: $client, - streamFactory: $factory, - requestFactory: $factory - ) - ) + ->withTransport(transport: NetworkTransport::with(client: $client, factory: $factory)) ->withBaseUrl(url: 'https://api.example.com') ->build(); ``` @@ -230,14 +208,11 @@ $http = Http::with( baseUrl: 'https://api.example.com', transport: NetworkTransport::with( client: new Client(['timeout' => 30, 'connect_timeout' => 5]), - streamFactory: $factory, - requestFactory: $factory + factory: $factory ) ); ``` -
- #### Making a request `Request::create(...)` accepts only `url` as required. Everything else has sensible defaults. @@ -263,8 +238,6 @@ use TinyBlocks\Http\Client\Request; $response = $http->send(request: Request::create(url: '/v1/charges/abc123')); ``` -
- #### Reading the response ```php @@ -285,8 +258,6 @@ $contentType = $response->headers()->get(name: 'content-type'); // "application/ $hasTrace = $response->headers()->has(name: 'X-Trace-Id'); // true ``` -
- #### Query parameters Pass the query as a named parameter — the library encodes it in RFC3986 form. @@ -302,8 +273,6 @@ $response = $http->send( ); ``` -
- #### Custom headers and content type Any `Headerable` composes via varargs: @@ -339,7 +308,53 @@ $response = $http->send( Custom headers always win over the library's JSON defaults. -
+
+ +#### Setting the User-Agent + +The `UserAgent` value object implements `Headerable` and renders the standard +`User-Agent` header. Empty version is normalized to "no version" — the rendered +header carries only the product token in that case, so configuration with an +optional version flows in directly. + +```php +use TinyBlocks\Http\Client\Request; +use TinyBlocks\Http\UserAgent; + +$userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); + +$response = $http->send( + request: Request::create(url: '/v1/charges', $userAgent) +); +``` + +When the version is unknown: + +```php +use TinyBlocks\Http\UserAgent; + +$userAgent = UserAgent::from(product: 'MyApp'); +// renders as: User-Agent: MyApp +``` + +`UserAgent` composes naturally with any other `Headerable`: + +```php +use TinyBlocks\Http\Client\Request; +use TinyBlocks\Http\ContentType; +use TinyBlocks\Http\Method; +use TinyBlocks\Http\UserAgent; + +$response = $http->send( + request: Request::create( + url: '/v1/charges', + body: ['amount' => 1000], + method: Method::POST, + UserAgent::from(product: 'MyApp', version: '1.2.3'), + ContentType::applicationJson() + ) +); +``` #### Error handling @@ -364,17 +379,15 @@ try { } ``` -| Exception | Cause | -|---|---| -| `HttpRequestFailed` | Generic PSR-18 `ClientExceptionInterface`. Base class for the others below. | -| `HttpNetworkFailed` | PSR-18 `NetworkExceptionInterface` — DNS, timeout, connection refused. | -| `HttpRequestInvalid` | PSR-18 `RequestExceptionInterface` — request malformed before transport. | -| `MalformedPath` | Path attempts to escape the base URL (scheme, protocol-relative, control characters). | -| `NoMoreResponses` | `InMemoryTransport` exhausted. | -| `HttpConfigurationInvalid` | Builder called without `withBaseUrl` or `withTransport`. | -| `SynthesizedResponseHasNoRaw` | `Response::raw()` called on a response created via `Response::with(...)`. | - -
+| Exception | Cause | +|-------------------------------|---------------------------------------------------------------------------------------| +| `HttpRequestFailed` | Generic PSR-18 `ClientExceptionInterface`. Base class for the others below. | +| `HttpNetworkFailed` | PSR-18 `NetworkExceptionInterface` — DNS, timeout, connection refused. | +| `HttpRequestInvalid` | PSR-18 `RequestExceptionInterface` — request malformed before transport. | +| `MalformedPath` | Path attempts to escape the base URL (scheme, protocol-relative, control characters). | +| `NoMoreResponses` | `InMemoryTransport` exhausted. | +| `HttpConfigurationInvalid` | Builder called without `withBaseUrl` or `withTransport`. | +| `SynthesizedResponseHasNoRaw` | `Response::raw()` called on a response created via `Response::with(...)`. | #### Configuring timeouts @@ -397,8 +410,6 @@ use Symfony\Component\HttpClient\Psr18Client; $client = new Psr18Client(client: HttpClient::create(['timeout' => 30])); ``` -
- #### Testing with InMemoryTransport Pre-program responses with `Response::with(...)` and feed them to `InMemoryTransport`: @@ -424,11 +435,10 @@ $http = Http::create() Calls consume responses in FIFO order. Exhaustion raises `NoMoreResponses`. -
- #### Extending with custom transports -Implement `Transport` to add retry, logging, circuit breaker, or any other cross-cutting concern. The decorator wraps any inner `Transport`. +Implement `Transport` to add retry, logging, circuit breaker, or any other cross-cutting concern. The decorator wraps +any inner `Transport`. ```php use TinyBlocks\Http\Client\Request; @@ -469,11 +479,7 @@ Compose it into the façade: $http = Http::create() ->withTransport( transport: new RetryingTransport( - inner: NetworkTransport::with( - client: $client, - streamFactory: $factory, - requestFactory: $factory - ), + inner: NetworkTransport::with(client: $client, factory: $factory), maxAttempts: 3 ) ) @@ -481,38 +487,39 @@ $http = Http::create() ->build(); ``` -
- ## FAQ ### 01. Why is there a `Headerable` interface and a `Headers` value object? -`Headerable` is the contract implemented by classes that emit one or more header lines — `ContentType`, `Cookie`, `CacheControl`, and any custom header type. `Headers` is the value object that carries the consolidated header set of an HTTP request or response, with case-insensitive lookup and merging. +`Headerable` is the contract implemented by classes that emit one or more header lines — `ContentType`, `Cookie`, +`CacheControl`, and any custom header type. `Headers` is the value object that carries the consolidated header set of an +HTTP request or response, with case-insensitive lookup and merging. ### 02. Why are timeouts not part of the public API? -PSR-18 does not standardize timeouts. Exposing them in the façade would require a transport-specific contract that leaks the underlying client. Configure timeouts on the PSR-18 client before injecting it. +PSR-18 does not standardize timeouts. Exposing them in the façade would require a transport-specific contract that leaks +the underlying client. Configure timeouts on the PSR-18 client before injecting it. ### 03. Why does `Response::raw()` throw on a synthesized response? -A response created via `Response::with(...)` has no PSR-7 backing — it exists only for in-process scenarios (tests, `InMemoryTransport`). Calling `raw()` in that mode is a programmer error and raises `SynthesizedResponseHasNoRaw`. +A response created via `Response::with(...)` has no PSR-7 backing — it exists only for in-process scenarios (tests, +`InMemoryTransport`). Calling `raw()` in that mode is a programmer error and raises `SynthesizedResponseHasNoRaw`. ### 04. Why is path validation enforced at the resolver? -To protect the configured base URL from being hijacked by paths that contain a scheme, are protocol-relative, or carry control characters. Such inputs raise `MalformedPath` before the transport is invoked. +To protect the configured base URL from being hijacked by paths that contain a scheme, are protocol-relative, or carry +control characters. Such inputs raise `MalformedPath` before the transport is invoked. ### 05. What happens to status codes outside the `Code` enum? -`Response::from()` requires a code present in the enum, which covers every RFC code in use. Non-RFC status codes are reachable through `Response::raw()->getStatusCode()`. - -
+`Response::from()` requires a code present in the enum, which covers every RFC code in use. Non-RFC status codes are +reachable through `Response::raw()->getStatusCode()`. ## License Http is licensed under [MIT](LICENSE). -
- ## Contributing -Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to contribute to the project. +Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to +contribute to the project. From 40339adfec6edc1e762ad4ff292f87c3ea54ba48 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 15 May 2026 02:04:54 -0300 Subject: [PATCH 14/27] chore: tighten validation gates and fix README PHP syntax - phpstan: drop ignoreErrors, include tests/, keep level 9. - phpcs: introduce phpcs.xml and run against src/ and tests/. - infection: remove ProtectedVisibility override so the default mutator set is enforced. - composer: declare psr/http-server-handler and psr/http-server-middleware used by the PSR-15 test drivers. - README: fix code blocks that mixed named with trailing positional arguments (fatal under PHP 8.5) and drop the fictional keyed argument on CacheControl::fromResponseDirectives' variadic. Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/php-library-code-style.md | 23 ++++++++++++++--- README.md | 34 ++++++++++--------------- composer.json | 4 ++- infection.json.dist | 4 --- phpcs.xml | 7 +++++ phpstan.neon.dist | 8 +----- 6 files changed, 45 insertions(+), 35 deletions(-) create mode 100644 phpcs.xml diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md index e7981b7..e7c8ead 100644 --- a/.claude/rules/php-library-code-style.md +++ b/.claude/rules/php-library-code-style.md @@ -33,8 +33,13 @@ Verify every item before producing any PHP code. If any item fails, revise befor fluent API (`Collection`, `Collectible`) when data is `Collectible`. Use `createLazyFrom` when elements are consumed once. Raw arrays are acceptable only for primitive configuration data, variadic pass-through, and interop at system boundaries. See "Collection usage" below for the full rule and example. -10. No private methods exist except private constructors for factory patterns. Inline trivial logic at the call site - or extract it to a collaborator or value object. +10. No private methods exist except: + - Private constructors for factory patterns. + - Methods inside `src/Internal/` (implementation detail by definition; the + namespace is the abstraction boundary, not the class). + - `setUp` / `tearDown` overrides in PHPUnit test classes. + Outside these cases, inline trivial logic at the call site or extract it + to a collaborator or value object. 11. Members are ordered: constants first, then constructor, then static methods, then instance methods. Within each group, order by body size ascending (number of lines between `{` and `}`). Constants and enum cases, which have no body, are ordered by name length ascending. @@ -106,6 +111,17 @@ Verify every item before producing any PHP code. If any item fails, revise befor - Collections are always plural: `$orders`, `$lines`. - Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`. +## Inheritance + +- Inheritance between concrete classes is prohibited. Every concrete class is `final`. +- Polymorphism uses interfaces + composition, never extension of concrete types. +- The only allowed `extends` is against framework or SPL base classes that the + language requires (e.g., extending `RuntimeException`, `LogicException`, + `PHPUnit\Framework\TestCase`). +- Constructors of `final` classes are `private` when paired with named factories, + `public` otherwise. `protected` constructors are prohibited (no subclasses + exist to call them). + ## Comparisons 1. Null checks: use `is_null($variable)`, never `$variable === null`. @@ -136,7 +152,8 @@ All identifiers, enum values, comments, and error codes use American English spe - Private and protected methods. - Public methods of concrete classes whose contract is already documented on an implemented interface — the interface carries the docblock. - - Anything inside `src/Internal/`. + - Anything inside `src/Internal/`. Internal types are implementation detail; + they must not carry PHPDoc — the namespace itself is the boundary. ## Collection usage diff --git a/README.md b/README.md index 826badd..f9a7609 100644 --- a/README.md +++ b/README.md @@ -37,15 +37,6 @@ The library covers both sides of an HTTP exchange: Shared primitives (`Method`, `Code`, `Headers`, `Headerable`, `ContentType`, `Cookie`, `CacheControl`) live at the root namespace. -Design choices: - -- PSR alignment at every boundary (PSR-7, PSR-15, PSR-17, PSR-18). No proprietary HTTP client wrapper. -- Hexagonal architecture on the client: one `Transport` port with `NetworkTransport` (PSR-18 adapter) and - `InMemoryTransport` (in-process test adapter). Decorators (retry, logging, circuit breakers) plug in by implementing - `Transport`. -- Total immutability for every public type. -- Typed access over raw arrays: `Code` enum, `Method` enum, `Headers` value object, typed body fields via `Attribute`. - ## Installation ```bash @@ -114,11 +105,11 @@ use TinyBlocks\Http\ResponseCacheDirectives; use TinyBlocks\Http\Server\Response; $cacheControl = CacheControl::fromResponseDirectives( - maxAge: ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000) + ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000) ); -Response::ok(body: ['ok' => true], $cacheControl, ContentType::applicationJson()) - ->withHeader(name: 'X-Trace-Id', value: 'abc-123'); +Response::ok(['ok' => true], $cacheControl, ContentType::applicationJson()) + ->withHeader('X-Trace-Id', 'abc-123'); ``` #### Setting cookies @@ -137,7 +128,7 @@ $session = Cookie::create(name: 'session', value: $token) ->withPath(path: '/v1/sessions') ->withMaxAge(seconds: 604800); -Response::ok(body: ['ok' => true], $session); +Response::ok(['ok' => true], $session); ``` To expire a cookie, use `Cookie::expire(...)` with the same `Path` and `Domain` used at creation. @@ -297,9 +288,10 @@ final readonly class IdempotencyKey implements Headerable $response = $http->send( request: Request::create( - url: '/v1/charges', - body: ['amount' => 1000], - method: Method::POST, + '/v1/charges', + ['amount' => 1000], + null, + Method::POST, ContentType::applicationJson(), new IdempotencyKey(value: $key) ) @@ -319,12 +311,13 @@ optional version flows in directly. ```php use TinyBlocks\Http\Client\Request; +use TinyBlocks\Http\Method; use TinyBlocks\Http\UserAgent; $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); $response = $http->send( - request: Request::create(url: '/v1/charges', $userAgent) + request: Request::create('/v1/charges', null, null, Method::GET, $userAgent) ); ``` @@ -347,9 +340,10 @@ use TinyBlocks\Http\UserAgent; $response = $http->send( request: Request::create( - url: '/v1/charges', - body: ['amount' => 1000], - method: Method::POST, + '/v1/charges', + ['amount' => 1000], + null, + Method::POST, UserAgent::from(product: 'MyApp', version: '1.2.3'), ContentType::applicationJson() ) diff --git a/composer.json b/composer.json index 29f9d7d..ab49091 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,8 @@ "nyholm/psr7": "^1.8", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^13.1", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", "slim/psr7": "^1.8", "slim/slim": "^4.15", "squizlabs/php_codesniffer": "^4.0" @@ -54,7 +56,7 @@ }, "scripts": { "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", - "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src", + "phpcs": "php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests", "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress", "review": [ "@phpcs", diff --git a/infection.json.dist b/infection.json.dist index ee435dd..ec34860 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -15,10 +15,6 @@ "configDir": "", "customPath": "./vendor/bin/phpunit" }, - "mutators": { - "@default": true, - "ProtectedVisibility": false - }, "minCoveredMsi": 100, "testFramework": "phpunit" } diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..440b463 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,7 @@ + + + Code style for tiny-blocks/http. + + src + tests + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b06e4e1..08440b6 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,12 +1,6 @@ parameters: paths: - src + - tests level: 9 tmpDir: report/phpstan - ignoreErrors: - - '#expects#' - - '#should return#' - - '#mixed to string#' - - '#does not accept#' - - '#type specified in iterable type#' - reportUnmatchedIgnoredErrors: false From 8ea0d8411b6400141809adbeced5743434050169 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 15 May 2026 02:08:42 -0300 Subject: [PATCH 15/27] refactor(exceptions): flatten hierarchy and unify HttpException contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every public exception is now final, extends RuntimeException or LogicException, and implements HttpException directly. None extend another library exception, eliminating the protected constructor problem at the root. - HttpException carries url(), reason(), method() — all documented. - Every message lives in a class constant (REASON / REASON_TEMPLATE) and is built via positional sprintf when interpolation is needed. - Internal Server exceptions stop using named arguments on the native exception parent and centralize their messages in constants. - NetworkTransport keeps the catch order NetworkExceptionInterface → RequestExceptionInterface → ClientExceptionInterface and now folds the JSON depth literal at the json_encode call site. - SynthesizedResponseHasNoRaw exposes a private constructor and a ::create() factory; Client\Response uses it. Co-Authored-By: Claude Sonnet 4.6 --- src/Client/Response.php | 18 +++---- src/Client/Transports/NetworkTransport.php | 9 +--- src/Exceptions/HttpConfigurationInvalid.php | 30 ++++++++++-- src/Exceptions/HttpException.php | 18 +++++-- src/Exceptions/HttpNetworkFailed.php | 49 ++++++++++++++++--- src/Exceptions/HttpRequestFailed.php | 34 ++++++++----- src/Exceptions/HttpRequestInvalid.php | 49 ++++++++++++++++--- src/Exceptions/MalformedPath.php | 29 ++++++++++- src/Exceptions/NoMoreResponses.php | 32 +++++++++++- .../SynthesizedResponseHasNoRaw.php | 32 +++++++++++- src/Headers.php | 8 ++- src/Http.php | 5 -- .../ConflictingLifetimeAttributes.php | 10 ++-- .../Server/Exceptions/CookieNameIsInvalid.php | 10 ++-- .../Exceptions/CookieValueIsInvalid.php | 11 ++--- .../Server/Exceptions/InvalidResource.php | 4 +- .../Exceptions/MissingResourceStream.php | 4 +- .../Server/Exceptions/NonReadableStream.php | 4 +- .../Server/Exceptions/NonSeekableStream.php | 4 +- .../Server/Exceptions/NonWritableStream.php | 4 +- .../Exceptions/SameSiteNoneRequiresSecure.php | 10 ++-- 21 files changed, 274 insertions(+), 100 deletions(-) diff --git a/src/Client/Response.php b/src/Client/Response.php index 5507dbb..cfa2158 100644 --- a/src/Client/Response.php +++ b/src/Client/Response.php @@ -40,6 +40,15 @@ public static function with(Code $code, ?array $body = null, array $headers = [] ); } + public function raw(): ResponseInterface + { + if (is_null($this->psr)) { + throw SynthesizedResponseHasNoRaw::create(); + } + + return $this->psr; + } + public function code(): Code { return $this->code; @@ -64,13 +73,4 @@ public function isSuccess(): bool { return $this->code->isSuccess(); } - - public function raw(): ResponseInterface - { - if (is_null($this->psr)) { - throw new SynthesizedResponseHasNoRaw(); - } - - return $this->psr; - } } diff --git a/src/Client/Transports/NetworkTransport.php b/src/Client/Transports/NetworkTransport.php index 32fca41..129c4c6 100644 --- a/src/Client/Transports/NetworkTransport.php +++ b/src/Client/Transports/NetworkTransport.php @@ -20,7 +20,6 @@ final readonly class NetworkTransport implements Transport { private const int JSON_FLAGS = JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE; - private const int MAX_JSON_DEPTH = 64; private function __construct( private ClientInterface $client, @@ -37,15 +36,11 @@ public static function with( public function send(Request $request): Response { - $psrRequest = $this->factory->createRequest( - method: $request->method->value, - uri: $request->url - ); - + $psrRequest = $this->factory->createRequest(method: $request->method->value, uri: $request->url); $psrRequest = $request->headers->applyTo(message: $psrRequest); if (!is_null($request->body)) { - $encoded = json_encode($request->body, self::JSON_FLAGS, self::MAX_JSON_DEPTH); + $encoded = json_encode($request->body, self::JSON_FLAGS, 64); $psrRequest = $psrRequest->withBody(body: $this->factory->createStream(content: $encoded)); } diff --git a/src/Exceptions/HttpConfigurationInvalid.php b/src/Exceptions/HttpConfigurationInvalid.php index 387cdc3..3aa7ac2 100644 --- a/src/Exceptions/HttpConfigurationInvalid.php +++ b/src/Exceptions/HttpConfigurationInvalid.php @@ -5,19 +5,43 @@ namespace TinyBlocks\Http\Exceptions; use LogicException; +use TinyBlocks\Http\Method; -final class HttpConfigurationInvalid extends LogicException +final class HttpConfigurationInvalid extends LogicException implements HttpException { private const string MISSING_BASE_URL_REASON = 'Base URL is required to build Http.'; private const string MISSING_TRANSPORT_REASON = 'Transport is required to build Http.'; + private function __construct( + private readonly string $url, + private readonly Method $method, + private readonly string $reason + ) { + parent::__construct($reason); + } + public static function missingBaseUrl(): HttpConfigurationInvalid { - return new self(self::MISSING_BASE_URL_REASON); + return new HttpConfigurationInvalid(url: '', method: Method::GET, reason: self::MISSING_BASE_URL_REASON); } public static function missingTransport(): HttpConfigurationInvalid { - return new self(self::MISSING_TRANSPORT_REASON); + return new HttpConfigurationInvalid(url: '', method: Method::GET, reason: self::MISSING_TRANSPORT_REASON); + } + + public function url(): string + { + return $this->url; + } + + public function reason(): string + { + return $this->reason; + } + + public function method(): Method + { + return $this->method; } } diff --git a/src/Exceptions/HttpException.php b/src/Exceptions/HttpException.php index fdb2777..545fcde 100644 --- a/src/Exceptions/HttpException.php +++ b/src/Exceptions/HttpException.php @@ -10,17 +10,29 @@ interface HttpException extends Throwable { /** - * @return string The URL of the failed request. + * The fully composed URL the failed request targeted. + * + * @return string The absolute URL. + * + * @complexity O(1) time and space. */ public function url(): string; /** - * @return string The reason for the failure. + * The transport-level or domain-level reason for the failure. + * + * @return string The human-readable reason already formatted into the message. + * + * @complexity O(1) time and space. */ public function reason(): string; /** - * @return Method The method of the failed request. + * The HTTP method used in the failed request. + * + * @return Method The verb of the failed request. + * + * @complexity O(1) time and space. */ public function method(): Method; } diff --git a/src/Exceptions/HttpNetworkFailed.php b/src/Exceptions/HttpNetworkFailed.php index 800bd94..ab1f9fc 100644 --- a/src/Exceptions/HttpNetworkFailed.php +++ b/src/Exceptions/HttpNetworkFailed.php @@ -4,25 +4,58 @@ namespace TinyBlocks\Http\Exceptions; -use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\NetworkExceptionInterface; +use RuntimeException; use Throwable; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Method; -final class HttpNetworkFailed extends HttpRequestFailed +final class HttpNetworkFailed extends RuntimeException implements HttpException { - public static function from(string $url, Method $method, string $reason, ?Throwable $previous = null): static - { - return new self(url: $url, method: $method, reason: $reason, previous: $previous); + private const string REASON_TEMPLATE = 'Network failure for %s %s: %s'; + + private function __construct( + private readonly string $url, + private readonly Method $method, + private readonly string $reason, + ?Throwable $previous = null + ) { + parent::__construct(sprintf(self::REASON_TEMPLATE, $method->value, $url, $reason), 0, $previous); } - public static function fromClientException(Request $request, ClientExceptionInterface $exception): static - { - return new self( + public static function from( + string $url, + Method $method, + string $reason, + ?Throwable $previous = null + ): HttpNetworkFailed { + return new HttpNetworkFailed(url: $url, method: $method, reason: $reason, previous: $previous); + } + + public static function fromClientException( + Request $request, + NetworkExceptionInterface $exception + ): HttpNetworkFailed { + return self::from( url: $request->url, method: $request->method, reason: $exception->getMessage(), previous: $exception ); } + + public function url(): string + { + return $this->url; + } + + public function reason(): string + { + return $this->reason; + } + + public function method(): Method + { + return $this->method; + } } diff --git a/src/Exceptions/HttpRequestFailed.php b/src/Exceptions/HttpRequestFailed.php index 7e639a2..444de43 100644 --- a/src/Exceptions/HttpRequestFailed.php +++ b/src/Exceptions/HttpRequestFailed.php @@ -10,25 +10,33 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Method; -class HttpRequestFailed extends RuntimeException implements HttpException +final class HttpRequestFailed extends RuntimeException implements HttpException { - protected function __construct( + private const string REASON_TEMPLATE = 'PSR-18 client failed for %s %s: %s'; + + private function __construct( private readonly string $url, private readonly Method $method, private readonly string $reason, ?Throwable $previous = null ) { - parent::__construct($reason, 0, $previous); + parent::__construct(sprintf(self::REASON_TEMPLATE, $method->value, $url, $reason), 0, $previous); } - public static function from(string $url, Method $method, string $reason, ?Throwable $previous = null): self - { - return new self(url: $url, method: $method, reason: $reason, previous: $previous); + public static function from( + string $url, + Method $method, + string $reason, + ?Throwable $previous = null + ): HttpRequestFailed { + return new HttpRequestFailed(url: $url, method: $method, reason: $reason, previous: $previous); } - public static function fromClientException(Request $request, ClientExceptionInterface $exception): self - { - return new self( + public static function fromClientException( + Request $request, + ClientExceptionInterface $exception + ): HttpRequestFailed { + return self::from( url: $request->url, method: $request->method, reason: $exception->getMessage(), @@ -36,9 +44,9 @@ public static function fromClientException(Request $request, ClientExceptionInte ); } - public function method(): Method + public function url(): string { - return $this->method; + return $this->url; } public function reason(): string @@ -46,8 +54,8 @@ public function reason(): string return $this->reason; } - public function url(): string + public function method(): Method { - return $this->url; + return $this->method; } } diff --git a/src/Exceptions/HttpRequestInvalid.php b/src/Exceptions/HttpRequestInvalid.php index dcd0433..5e4718e 100644 --- a/src/Exceptions/HttpRequestInvalid.php +++ b/src/Exceptions/HttpRequestInvalid.php @@ -4,25 +4,58 @@ namespace TinyBlocks\Http\Exceptions; -use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\RequestExceptionInterface; +use RuntimeException; use Throwable; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Method; -final class HttpRequestInvalid extends HttpRequestFailed +final class HttpRequestInvalid extends RuntimeException implements HttpException { - public static function from(string $url, Method $method, string $reason, ?Throwable $previous = null): static - { - return new self(url: $url, method: $method, reason: $reason, previous: $previous); + private const string REASON_TEMPLATE = 'Request is invalid for %s %s: %s'; + + private function __construct( + private readonly string $url, + private readonly Method $method, + private readonly string $reason, + ?Throwable $previous = null + ) { + parent::__construct(sprintf(self::REASON_TEMPLATE, $method->value, $url, $reason), 0, $previous); } - public static function fromClientException(Request $request, ClientExceptionInterface $exception): static - { - return new self( + public static function from( + string $url, + Method $method, + string $reason, + ?Throwable $previous = null + ): HttpRequestInvalid { + return new HttpRequestInvalid(url: $url, method: $method, reason: $reason, previous: $previous); + } + + public static function fromClientException( + Request $request, + RequestExceptionInterface $exception + ): HttpRequestInvalid { + return self::from( url: $request->url, method: $request->method, reason: $exception->getMessage(), previous: $exception ); } + + public function url(): string + { + return $this->url; + } + + public function reason(): string + { + return $this->reason; + } + + public function method(): Method + { + return $this->method; + } } diff --git a/src/Exceptions/MalformedPath.php b/src/Exceptions/MalformedPath.php index 5970d1f..34ee76f 100644 --- a/src/Exceptions/MalformedPath.php +++ b/src/Exceptions/MalformedPath.php @@ -4,18 +4,43 @@ namespace TinyBlocks\Http\Exceptions; +use RuntimeException; use TinyBlocks\Http\Client\Request; +use TinyBlocks\Http\Method; -final class MalformedPath extends HttpRequestFailed +final class MalformedPath extends RuntimeException implements HttpException { private const string REASON_TEMPLATE = 'Path "%s" is malformed and cannot be composed safely against a base URL.'; + private function __construct( + private readonly string $url, + private readonly Method $method, + private readonly string $reason + ) { + parent::__construct($reason); + } + public static function fromRequest(Request $request): MalformedPath { - return new self( + return new MalformedPath( url: $request->url, method: $request->method, reason: sprintf(self::REASON_TEMPLATE, $request->url) ); } + + public function url(): string + { + return $this->url; + } + + public function reason(): string + { + return $this->reason; + } + + public function method(): Method + { + return $this->method; + } } diff --git a/src/Exceptions/NoMoreResponses.php b/src/Exceptions/NoMoreResponses.php index 5ab6093..405b116 100644 --- a/src/Exceptions/NoMoreResponses.php +++ b/src/Exceptions/NoMoreResponses.php @@ -5,13 +5,41 @@ namespace TinyBlocks\Http\Exceptions; use LogicException; +use TinyBlocks\Http\Method; -final class NoMoreResponses extends LogicException +final class NoMoreResponses extends LogicException implements HttpException { private const string REASON_TEMPLATE = 'InMemoryTransport has no response queued at index %d.'; + private function __construct( + private readonly string $url, + private readonly Method $method, + private readonly string $reason + ) { + parent::__construct($reason); + } + public static function atIndex(int $index): NoMoreResponses { - return new self(sprintf(self::REASON_TEMPLATE, $index)); + return new NoMoreResponses( + url: '', + method: Method::GET, + reason: sprintf(self::REASON_TEMPLATE, $index) + ); + } + + public function url(): string + { + return $this->url; + } + + public function reason(): string + { + return $this->reason; + } + + public function method(): Method + { + return $this->method; } } diff --git a/src/Exceptions/SynthesizedResponseHasNoRaw.php b/src/Exceptions/SynthesizedResponseHasNoRaw.php index 9849df4..c83755c 100644 --- a/src/Exceptions/SynthesizedResponseHasNoRaw.php +++ b/src/Exceptions/SynthesizedResponseHasNoRaw.php @@ -5,7 +5,37 @@ namespace TinyBlocks\Http\Exceptions; use LogicException; +use TinyBlocks\Http\Method; -final class SynthesizedResponseHasNoRaw extends LogicException +final class SynthesizedResponseHasNoRaw extends LogicException implements HttpException { + private const string REASON = 'Response was synthesized via Response::with(...) and has no underlying PSR-7 raw response.'; + + private function __construct( + private readonly string $url, + private readonly Method $method, + private readonly string $reason + ) { + parent::__construct($reason); + } + + public static function create(): SynthesizedResponseHasNoRaw + { + return new SynthesizedResponseHasNoRaw(url: '', method: Method::GET, reason: self::REASON); + } + + public function url(): string + { + return $this->url; + } + + public function reason(): string + { + return $this->reason; + } + + public function method(): Method + { + return $this->method; + } } diff --git a/src/Headers.php b/src/Headers.php index bbcc35e..6b6b4ec 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -30,11 +30,9 @@ public static function fromArray(array $entries): Headers public static function fromMessage(MessageInterface $message): Headers { - $entries = []; - - foreach ($message->getHeaders() as $name => $values) { - $entries[$name] = implode(', ', $values); - } + $entries = array_map(function ($values) { + return implode(', ', $values); + }, $message->getHeaders()); return new Headers(entries: $entries); } diff --git a/src/Http.php b/src/Http.php index d2a97e8..c5342a4 100644 --- a/src/Http.php +++ b/src/Http.php @@ -58,11 +58,6 @@ public static function with(string $baseUrl, Transport $transport): Http * @param Request $request The outbound request to send. * @return Response The response returned by the transport. * @throws HttpException When request resolution or the transport fails. - * - * @complexity O(H + B + R) CPU, where H is the header count, B is the - * encoded body size in bytes, and R is the response body size - * in bytes. Network I/O is not accounted for; it depends on - * the underlying PSR-18 client. */ public function send(Request $request): Response { diff --git a/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php b/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php index 8ba8f24..7fc5aa7 100644 --- a/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php +++ b/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php @@ -8,14 +8,10 @@ final class ConflictingLifetimeAttributes extends DomainException { + private const string REASON = 'Cookie lifetime attributes are conflicting. A cookie must declare its lifetime via either Max-Age or Expires, not both. Choose one and reset the other with a new Cookie instance.'; + public function __construct() { - $message = sprintf( - '%s%s', - 'Cookie lifetime attributes are conflicting. A cookie must declare its lifetime via either ', - 'Max-Age or Expires, not both. Choose one and reset the other with a new Cookie instance.' - ); - - parent::__construct($message); + parent::__construct(self::REASON); } } diff --git a/src/Internal/Server/Exceptions/CookieNameIsInvalid.php b/src/Internal/Server/Exceptions/CookieNameIsInvalid.php index a6ab5c4..122fd38 100644 --- a/src/Internal/Server/Exceptions/CookieNameIsInvalid.php +++ b/src/Internal/Server/Exceptions/CookieNameIsInvalid.php @@ -8,14 +8,10 @@ final class CookieNameIsInvalid extends InvalidArgumentException { + private const string REASON_TEMPLATE = 'Cookie name <%s> is invalid. A name must not be empty and must not contain control characters, whitespace, or any of the following separators: ( ) < > @ , ; : \\ " / [ ] ? = { }.'; + public function __construct(string $name) { - $template = sprintf( - '%s%s', - 'Cookie name <%s> is invalid. A name must not be empty and must not contain control ', - 'characters, whitespace, or any of the following separators: ( ) < > @ , ; : \\ " / [ ] ? = { }.' - ); - - parent::__construct(sprintf($template, $name)); + parent::__construct(sprintf(self::REASON_TEMPLATE, $name)); } } diff --git a/src/Internal/Server/Exceptions/CookieValueIsInvalid.php b/src/Internal/Server/Exceptions/CookieValueIsInvalid.php index 1b200bb..007dd8f 100644 --- a/src/Internal/Server/Exceptions/CookieValueIsInvalid.php +++ b/src/Internal/Server/Exceptions/CookieValueIsInvalid.php @@ -8,15 +8,10 @@ final class CookieValueIsInvalid extends InvalidArgumentException { + private const string REASON_TEMPLATE = 'Cookie value <%s> is invalid. A value must not contain control characters, whitespace, double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or Base64) before passing it.'; + public function __construct(string $value) { - $template = sprintf( - '%s%s%s', - 'Cookie value <%s> is invalid. A value must not contain control characters, whitespace, ', - 'double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or ', - 'Base64) before passing it.' - ); - - parent::__construct(sprintf($template, $value)); + parent::__construct(sprintf(self::REASON_TEMPLATE, $value)); } } diff --git a/src/Internal/Server/Exceptions/InvalidResource.php b/src/Internal/Server/Exceptions/InvalidResource.php index 719c3ec..d9017b3 100644 --- a/src/Internal/Server/Exceptions/InvalidResource.php +++ b/src/Internal/Server/Exceptions/InvalidResource.php @@ -8,8 +8,10 @@ final class InvalidResource extends RuntimeException { + private const string REASON = 'The provided value is not a valid resource.'; + public function __construct() { - parent::__construct(message: 'The provided value is not a valid resource.'); + parent::__construct(self::REASON); } } diff --git a/src/Internal/Server/Exceptions/MissingResourceStream.php b/src/Internal/Server/Exceptions/MissingResourceStream.php index b335c32..78e810f 100644 --- a/src/Internal/Server/Exceptions/MissingResourceStream.php +++ b/src/Internal/Server/Exceptions/MissingResourceStream.php @@ -8,8 +8,10 @@ final class MissingResourceStream extends RuntimeException { + private const string REASON = 'No resource available.'; + public function __construct() { - parent::__construct(message: 'No resource available.'); + parent::__construct(self::REASON); } } diff --git a/src/Internal/Server/Exceptions/NonReadableStream.php b/src/Internal/Server/Exceptions/NonReadableStream.php index f633908..9023847 100644 --- a/src/Internal/Server/Exceptions/NonReadableStream.php +++ b/src/Internal/Server/Exceptions/NonReadableStream.php @@ -8,8 +8,10 @@ final class NonReadableStream extends RuntimeException { + private const string REASON = 'Stream is not readable.'; + public function __construct() { - parent::__construct(message: 'Stream is not readable.'); + parent::__construct(self::REASON); } } diff --git a/src/Internal/Server/Exceptions/NonSeekableStream.php b/src/Internal/Server/Exceptions/NonSeekableStream.php index 436a5f1..7627558 100644 --- a/src/Internal/Server/Exceptions/NonSeekableStream.php +++ b/src/Internal/Server/Exceptions/NonSeekableStream.php @@ -8,8 +8,10 @@ final class NonSeekableStream extends RuntimeException { + private const string REASON = 'Stream is not seekable.'; + public function __construct() { - parent::__construct(message: 'Stream is not seekable.'); + parent::__construct(self::REASON); } } diff --git a/src/Internal/Server/Exceptions/NonWritableStream.php b/src/Internal/Server/Exceptions/NonWritableStream.php index 67360c9..5c1b325 100644 --- a/src/Internal/Server/Exceptions/NonWritableStream.php +++ b/src/Internal/Server/Exceptions/NonWritableStream.php @@ -8,8 +8,10 @@ final class NonWritableStream extends RuntimeException { + private const string REASON = 'Stream is not writable.'; + public function __construct() { - parent::__construct(message: 'Stream is not writable.'); + parent::__construct(self::REASON); } } diff --git a/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php b/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php index be8f880..bf8d3fb 100644 --- a/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php +++ b/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php @@ -8,14 +8,10 @@ final class SameSiteNoneRequiresSecure extends DomainException { + private const string REASON = 'Cookies with SameSite=None require the Secure flag to be set; modern browsers reject such cookies otherwise. Call secure() on the Cookie instance.'; + public function __construct() { - $message = sprintf( - '%s%s', - 'Cookies with SameSite=None require the Secure flag to be set; modern browsers reject ', - 'such cookies otherwise. Call secure() on the Cookie instance.' - ); - - parent::__construct($message); + parent::__construct(self::REASON); } } From 6047912ecd4d63648106524b79bcc6b128079b4e Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 15 May 2026 02:10:32 -0300 Subject: [PATCH 16/27] refactor: align public surface with v2 contract - Headers: get() keeps nullable return; mergedWith() now takes Headers not arrays; fromArray() removed (callers build Headers directly). - Client\Response: with() accepts ?Headers $headers = null; raw() moves after the single-line accessors so members ascend by body size. - Client\Request: query parameter is nullable with a null default; the variadic is renamed from headerables to headers throughout. - Internal\Client\Url::compose() returns the composed string directly; the Url value object disappears. RequestResolver consumes the string. - Server\Response delegates to InternalResponse using named arguments end-to-end. Co-Authored-By: Claude Sonnet 4.6 --- src/Client/Request.php | 14 ++++---- src/Client/Response.php | 30 ++++++++--------- src/Headers.php | 45 +++++++++++-------------- src/Internal/Client/RequestResolver.php | 13 +++---- src/Internal/Client/Url.php | 17 +++------- src/Server/Response.php | 24 ++++++------- 6 files changed, 63 insertions(+), 80 deletions(-) diff --git a/src/Client/Request.php b/src/Client/Request.php index e2e75d8..b1aae1a 100644 --- a/src/Client/Request.php +++ b/src/Client/Request.php @@ -13,7 +13,7 @@ public function __construct( public string $url, public ?array $body, - public array $query, + public ?array $query, public Method $method, public Headers $headers ) { @@ -22,16 +22,16 @@ public function __construct( public static function create( string $url, ?array $body = null, - array $query = [], + ?array $query = null, Method $method = Method::GET, - Headerable ...$headerables + Headerable ...$headers ): Request { return new Request( url: $url, body: $body, query: $query, method: $method, - headers: Headers::from(...$headerables) + headers: Headers::from(...$headers) ); } @@ -46,7 +46,7 @@ public function withUrl(string $url): Request ); } - public function withQuery(array $query): Request + public function withQuery(?array $query): Request { return new Request( url: $this->url, @@ -57,14 +57,14 @@ public function withQuery(array $query): Request ); } - public function withMergedHeaders(array $defaults): Request + public function withMergedHeaders(Headers $defaults): Request { return new Request( url: $this->url, body: $this->body, query: $this->query, method: $this->method, - headers: $this->headers->mergedWith(defaults: $defaults) + headers: $this->headers->mergedWith(other: $defaults) ); } } diff --git a/src/Client/Response.php b/src/Client/Response.php index cfa2158..d416090 100644 --- a/src/Client/Response.php +++ b/src/Client/Response.php @@ -30,25 +30,16 @@ public static function from(ResponseInterface $response): Response ); } - public static function with(Code $code, ?array $body = null, array $headers = []): Response + public static function with(Code $code, ?array $body = null, ?Headers $headers = null): Response { return new Response( psr: null, body: Body::fromArray(data: $body ?? []), code: $code, - headers: Headers::fromArray(entries: $headers) + headers: $headers ?? new Headers(entries: []) ); } - public function raw(): ResponseInterface - { - if (is_null($this->psr)) { - throw SynthesizedResponseHasNoRaw::create(); - } - - return $this->psr; - } - public function code(): Code { return $this->code; @@ -59,18 +50,27 @@ public function body(): Body return $this->body; } - public function isError(): bool + public function headers(): Headers { - return $this->code->isError(); + return $this->headers; } - public function headers(): Headers + public function isError(): bool { - return $this->headers; + return $this->code->isError(); } public function isSuccess(): bool { return $this->code->isSuccess(); } + + public function raw(): ResponseInterface + { + if (is_null($this->psr)) { + throw SynthesizedResponseHasNoRaw::create(); + } + + return $this->psr; + } } diff --git a/src/Headers.php b/src/Headers.php index 6b6b4ec..24ec507 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -8,9 +8,12 @@ final readonly class Headers { + /** @var array */ private array $entries; + /** @var array */ private array $lowerIndex; + /** @param array $entries */ public function __construct(array $entries) { $lowerIndex = []; @@ -23,11 +26,6 @@ public function __construct(array $entries) $this->lowerIndex = $lowerIndex; } - public static function fromArray(array $entries): Headers - { - return new Headers(entries: $entries); - } - public static function fromMessage(MessageInterface $message): Headers { $entries = array_map(function ($values) { @@ -37,12 +35,12 @@ public static function fromMessage(MessageInterface $message): Headers return new Headers(entries: $entries); } - public static function from(Headerable ...$headerables): Headers + public static function from(Headerable ...$headers): Headers { $entries = []; - foreach ($headerables as $headerable) { - foreach ($headerable->toArray() as $name => $value) { + foreach ($headers as $header) { + foreach ($header->toArray() as $name => $value) { $entries[$name] = is_array($value) ? implode(', ', $value) : $value; } } @@ -55,38 +53,39 @@ public function has(string $name): bool return isset($this->lowerIndex[strtolower($name)]); } + /** @return array */ public function toArray(): array { return $this->entries; } - public function applyTo(MessageInterface $message): MessageInterface + public function get(string $name): ?string { - $applied = $message; + $key = strtolower($name); - foreach ($this->entries as $name => $value) { - $applied = $applied->withHeader($name, $value); + if (!isset($this->lowerIndex[$key])) { + return null; } - return $applied; + return $this->entries[$this->lowerIndex[$key]]; } - public function get(string $name): ?string + public function applyTo(MessageInterface $message): MessageInterface { - $key = strtolower($name); + $applied = $message; - if (!isset($this->lowerIndex[$key])) { - return null; + foreach ($this->entries as $name => $value) { + $applied = $applied->withHeader($name, $value); } - return $this->entries[$this->lowerIndex[$key]]; + return $applied; } - public function mergedWith(array $defaults): Headers + public function mergedWith(Headers $other): Headers { - $merged = []; + $merged = $this->entries; - foreach ($defaults as $name => $value) { + foreach ($other->entries as $name => $value) { if (isset($this->lowerIndex[strtolower($name)])) { continue; } @@ -94,10 +93,6 @@ public function mergedWith(array $defaults): Headers $merged[$name] = $value; } - foreach ($this->entries as $name => $value) { - $merged[$name] = $value; - } - return new Headers(entries: $merged); } } diff --git a/src/Internal/Client/RequestResolver.php b/src/Internal/Client/RequestResolver.php index 7a2dbcb..ddd85cf 100644 --- a/src/Internal/Client/RequestResolver.php +++ b/src/Internal/Client/RequestResolver.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Exceptions\MalformedPath; +use TinyBlocks\Http\Headers; final readonly class RequestResolver { @@ -27,18 +28,14 @@ public static function withBaseUrl(string $baseUrl): RequestResolver public function resolve(Request $request): Request { try { - $url = Url::compose( - path: $request->url, - query: $request->query, - baseUrl: $this->baseUrl - ); + $url = Url::compose(path: $request->url, query: $request->query, baseUrl: $this->baseUrl); } catch (InvalidArgumentException) { throw MalformedPath::fromRequest(request: $request); } return $request - ->withUrl(url: $url->toString()) - ->withQuery(query: []) - ->withMergedHeaders(defaults: self::JSON_DEFAULTS); + ->withUrl(url: $url) + ->withQuery(query: null) + ->withMergedHeaders(defaults: new Headers(entries: self::JSON_DEFAULTS)); } } diff --git a/src/Internal/Client/Url.php b/src/Internal/Client/Url.php index 33e70b8..300fa1c 100644 --- a/src/Internal/Client/Url.php +++ b/src/Internal/Client/Url.php @@ -13,11 +13,7 @@ private const string SCHEME_REASON_TEMPLATE = 'Path "%s" must not contain a scheme or be protocol-relative.'; private const string SCHEME_OR_PROTOCOL_RELATIVE_PATTERN = '#^(?://|\\\\\\\\|[a-z][a-z0-9+.-]*:)#i'; - private function __construct(public string $value) - { - } - - public static function compose(string $path, array $query, string $baseUrl): Url + public static function compose(string $path, ?array $query, string $baseUrl): string { if (preg_match(self::SCHEME_OR_PROTOCOL_RELATIVE_PATTERN, $path) === 1) { throw new InvalidArgumentException(sprintf(self::SCHEME_REASON_TEMPLATE, $path)); @@ -31,15 +27,10 @@ public static function compose(string $path, array $query, string $baseUrl): Url ? $path : sprintf('%s/%s', rtrim($baseUrl, '/'), ltrim($path, '/')); - if ($query === []) { - return new Url($absolute); + if (is_null($query) || $query === []) { + return $absolute; } - return new Url(sprintf('%s?%s', $absolute, http_build_query($query, '', '&', PHP_QUERY_RFC3986))); - } - - public function toString(): string - { - return $this->value; + return sprintf('%s?%s', $absolute, http_build_query($query, '', '&', PHP_QUERY_RFC3986)); } } diff --git a/src/Server/Response.php b/src/Server/Response.php index 7210ce6..b972cb9 100644 --- a/src/Server/Response.php +++ b/src/Server/Response.php @@ -13,61 +13,61 @@ { public static function from(mixed $body, Code $code, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, $code, ...$headers); + return InternalResponse::createWithBody(body: $body, code: $code, ...$headers); } public static function ok(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::OK, ...$headers); + return InternalResponse::createWithBody(body: $body, code: Code::OK, ...$headers); } public static function created(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::CREATED, ...$headers); + return InternalResponse::createWithBody(body: $body, code: Code::CREATED, ...$headers); } public static function accepted(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::ACCEPTED, ...$headers); + return InternalResponse::createWithBody(body: $body, code: Code::ACCEPTED, ...$headers); } public static function noContent(Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithoutBody(Code::NO_CONTENT, ...$headers); + return InternalResponse::createWithoutBody(code: Code::NO_CONTENT, ...$headers); } public static function badRequest(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::BAD_REQUEST, ...$headers); + return InternalResponse::createWithBody(body: $body, code: Code::BAD_REQUEST, ...$headers); } public static function unauthorized(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::UNAUTHORIZED, ...$headers); + return InternalResponse::createWithBody(body: $body, code: Code::UNAUTHORIZED, ...$headers); } public static function forbidden(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::FORBIDDEN, ...$headers); + return InternalResponse::createWithBody(body: $body, code: Code::FORBIDDEN, ...$headers); } public static function notFound(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::NOT_FOUND, ...$headers); + return InternalResponse::createWithBody(body: $body, code: Code::NOT_FOUND, ...$headers); } public static function conflict(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::CONFLICT, ...$headers); + return InternalResponse::createWithBody(body: $body, code: Code::CONFLICT, ...$headers); } public static function unprocessableEntity(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::UNPROCESSABLE_ENTITY, ...$headers); + return InternalResponse::createWithBody(body: $body, code: Code::UNPROCESSABLE_ENTITY, ...$headers); } public static function internalServerError(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody($body, Code::INTERNAL_SERVER_ERROR, ...$headers); + return InternalResponse::createWithBody(body: $body, code: Code::INTERNAL_SERVER_ERROR, ...$headers); } } From ff54d54a3879390140abaf042186dd57af9d9bd8 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 15 May 2026 02:14:16 -0300 Subject: [PATCH 17/27] chore(code-style): close remaining audit findings in src/ - Cookie/Stream/Uri constructor parameter lists ascend by name length with alphabetical tiebreak. - Code enum carries PHPDoc on the isError/isSuccess instance methods. - Internal namespace is fully PHPDoc-free: Uri, RouteParameterResolver, Directives, and the inline @var hint in Stream are gone. - Stream is rewritten so resource state is narrowed by inline is_resource() checks; PHPStan level 9 no longer needs ignoreErrors. Co-Authored-By: Claude Sonnet 4.6 --- src/Code.php | 14 ++++ src/Cookie.php | 24 +++---- .../Server/CacheControl/Directives.php | 3 - .../Server/Request/RouteParameterResolver.php | 9 --- src/Internal/Server/Request/Uri.php | 51 ++----------- src/Internal/Server/Stream/Stream.php | 71 +++++++++++-------- 6 files changed, 72 insertions(+), 100 deletions(-) diff --git a/src/Code.php b/src/Code.php index 43b665a..59d5f3f 100644 --- a/src/Code.php +++ b/src/Code.php @@ -89,11 +89,25 @@ enum Code: int case NOT_EXTENDED = 510; case NETWORK_AUTHENTICATION_REQUIRED = 511; + /** + * Indicates whether the status code falls in the 4xx or 5xx range. + * + * @return bool True when the code represents an error response. + * + * @complexity O(1) time and space. + */ public function isError(): bool { return self::isErrorCode(code: $this->value); } + /** + * Indicates whether the status code falls in the 2xx range. + * + * @return bool True when the code represents a successful response. + * + * @complexity O(1) time and space. + */ public function isSuccess(): bool { return self::isSuccessCode(code: $this->value); diff --git a/src/Cookie.php b/src/Cookie.php index 14130a2..4df0434 100644 --- a/src/Cookie.php +++ b/src/Cookie.php @@ -24,8 +24,8 @@ private function __construct( private ?int $maxAge, private bool $secure, private ?DateTimeImmutable $expires, - private ?SameSite $sameSite, private bool $httpOnly, + private ?SameSite $sameSite, private bool $partitioned ) { } @@ -40,8 +40,8 @@ public static function create(string $name, string $value): Cookie maxAge: null, secure: false, expires: null, - sameSite: null, httpOnly: false, + sameSite: null, partitioned: false ); } @@ -56,8 +56,8 @@ public static function expire(string $name): Cookie maxAge: 0, secure: false, expires: null, - sameSite: null, httpOnly: false, + sameSite: null, partitioned: false ); } @@ -72,8 +72,8 @@ public function secure(): Cookie maxAge: $this->maxAge, secure: true, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } @@ -88,8 +88,8 @@ public function httpOnly(): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: true, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } @@ -104,8 +104,8 @@ public function partitioned(): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: true ); } @@ -120,8 +120,8 @@ public function withPath(string $path): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } @@ -136,8 +136,8 @@ public function withValue(string $value): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } @@ -152,8 +152,8 @@ public function withDomain(string $domain): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } @@ -168,8 +168,8 @@ public function withMaxAge(int $seconds): Cookie maxAge: $seconds, secure: $this->secure, expires: $this->expires, - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } @@ -184,8 +184,8 @@ public function withExpires(DateTimeInterface $expires): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: DateTimeImmutable::createFromInterface($expires)->setTimezone(new DateTimeZone('UTC')), - sameSite: $this->sameSite, httpOnly: $this->httpOnly, + sameSite: $this->sameSite, partitioned: $this->partitioned ); } @@ -200,8 +200,8 @@ public function withSameSite(SameSite $sameSite): Cookie maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, - sameSite: $sameSite, httpOnly: $this->httpOnly, + sameSite: $sameSite, partitioned: $this->partitioned ); } diff --git a/src/Internal/Server/CacheControl/Directives.php b/src/Internal/Server/CacheControl/Directives.php index 8bd8536..e1844c1 100644 --- a/src/Internal/Server/CacheControl/Directives.php +++ b/src/Internal/Server/CacheControl/Directives.php @@ -4,9 +4,6 @@ namespace TinyBlocks\Http\Internal\Server\CacheControl; -/** - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#cache_directives - */ enum Directives: string { case MAX_AGE = 'max-age'; diff --git a/src/Internal/Server/Request/RouteParameterResolver.php b/src/Internal/Server/Request/RouteParameterResolver.php index 21edd6e..e69c801 100644 --- a/src/Internal/Server/Request/RouteParameterResolver.php +++ b/src/Internal/Server/Request/RouteParameterResolver.php @@ -6,15 +6,6 @@ use Psr\Http\Message\ServerRequestInterface; -/** - * Resolves route parameters from a PSR-7 ServerRequestInterface in a framework-agnostic way. - * - * Supports multiple attribute formats used by popular frameworks: - * - Plain arrays (e.g., Symfony's `_route_params`) - * - Objects with accessor methods (e.g., Slim's `getArguments()`, Mezzio's `getMatchedParams()`) - * - Objects with public properties (e.g., `arguments`, `params`, `vars`) - * - Direct attributes on the request (e.g., Laravel's `getAttribute('id')`) - */ final readonly class RouteParameterResolver { private const array KNOWN_ATTRIBUTE_KEYS = [ diff --git a/src/Internal/Server/Request/Uri.php b/src/Internal/Server/Request/Uri.php index 3a0a952..8516688 100644 --- a/src/Internal/Server/Request/Uri.php +++ b/src/Internal/Server/Request/Uri.php @@ -7,22 +7,14 @@ use Psr\Http\Message\ServerRequestInterface; use TinyBlocks\Http\Internal\Shared\Attribute; -/** - * Provides access to URI components and route parameters extracted from a PSR-7 ServerRequestInterface. - * - * The route parameters are resolved in the following priority: - * 1. The explicitly specified attribute name (default: `__route__`). - * 2. A scan of all known framework attribute keys. - * 3. Direct attribute lookup on the request (for frameworks like Laravel). - */ final readonly class Uri { private const string ROUTE = '__route__'; private function __construct( private ServerRequestInterface $request, - private string $routeAttributeName, - private RouteParameterResolver $resolver + private RouteParameterResolver $resolver, + private string $routeAttributeName ) { } @@ -30,61 +22,30 @@ public static function from(ServerRequestInterface $request): Uri { return new Uri( request: $request, - routeAttributeName: self::ROUTE, - resolver: RouteParameterResolver::from(request: $request) + resolver: RouteParameterResolver::from(request: $request), + routeAttributeName: self::ROUTE ); } - /** - * Returns the full URI of the request as a string. - * - * Delegates to the PSR-7 UriInterface's string representation, - * which includes scheme, host, path, query string, and fragment. - * - * @return string The complete URI string (e.g., "https://api.example.com/v1/dragons?sort=name"). - */ public function toString(): string { return $this->request->getUri()->__toString(); } - /** - * Returns a typed wrapper around the query string parameters. - * - * @return QueryParameters Provides typed access to individual query parameters via get(). - */ public function queryParameters(): QueryParameters { return QueryParameters::from(request: $this->request); } - /** - * Returns a new Uri instance configured to read route parameters from the given attribute name. - * - * @param string $name The request attribute name where route params are stored. - * @return Uri A new instance targeting the specified attribute. - */ public function route(string $name = self::ROUTE): Uri { return new Uri( request: $this->request, - routeAttributeName: $name, - resolver: $this->resolver + resolver: $this->resolver, + routeAttributeName: $name ); } - /** - * Retrieves a single route parameter by key. - * - * Resolution order: - * 1. Look up the configured attribute name and extract the key from it. - * 2. If not found, scan all known framework attribute keys. - * 3. If still not found, try a direct `getAttribute($key)` on the request. - * 4. Falls back to `Attribute::from(null)` which provides safe defaults. - * - * @param string $key The route parameter name. - * @return Attribute A typed wrapper around the resolved value. - */ public function get(string $key): Attribute { $value = $this->resolveValue(key: $key); diff --git a/src/Internal/Server/Stream/Stream.php b/src/Internal/Server/Stream/Stream.php index f2a52a5..119fabd 100644 --- a/src/Internal/Server/Stream/Stream.php +++ b/src/Internal/Server/Stream/Stream.php @@ -15,11 +15,7 @@ final class Stream implements StreamInterface { private const int OFFSET_ZERO = 0; - /** - * @param resource|null $resource - * @param StreamMetaData $metaData - */ - private function __construct(private mixed $resource, private readonly StreamMetaData $metaData) + private function __construct(private readonly StreamMetaData $metaData, private mixed $resource) { } @@ -31,17 +27,17 @@ public static function from(mixed $resource): Stream $metaData = StreamMetaData::from(data: stream_get_meta_data($resource)); - return new Stream(resource: $resource, metaData: $metaData); + return new Stream(metaData: $metaData, resource: $resource); } public function close(): void { - if ($this->noResource()) { + if (!is_resource($this->resource)) { return; } - /** @var resource $resource */ - $resource = $this->detach(); + $resource = $this->resource; + $this->resource = null; fclose($resource); } @@ -56,7 +52,7 @@ public function detach() public function getSize(): ?int { - if ($this->noResource()) { + if (!is_resource($this->resource)) { return null; } @@ -67,7 +63,7 @@ public function getSize(): ?int public function tell(): int { - if ($this->noResource()) { + if (!is_resource($this->resource)) { throw new MissingResourceStream(); } @@ -76,12 +72,16 @@ public function tell(): int public function eof(): bool { - return $this->resource && feof($this->resource); + return is_resource($this->resource) && feof($this->resource); } public function seek(int $offset, int $whence = SEEK_SET): void { - if (!$this->isSeekable()) { + if (!is_resource($this->resource)) { + throw new NonSeekableStream(); + } + + if (!$this->metaData->isSeekable()) { throw new NonSeekableStream(); } @@ -95,7 +95,11 @@ public function rewind(): void public function read(int $length): string { - if (!$this->isReadable()) { + if (!is_resource($this->resource)) { + throw new NonReadableStream(); + } + + if (!$this->modeAllowsReading()) { throw new NonReadableStream(); } @@ -104,7 +108,11 @@ public function read(int $length): string public function write(string $string): int { - if (!$this->isWritable()) { + if (!is_resource($this->resource)) { + throw new NonWritableStream(); + } + + if (!$this->modeAllowsWriting()) { throw new NonWritableStream(); } @@ -113,32 +121,26 @@ public function write(string $string): int public function isReadable(): bool { - if ($this->noResource()) { - return false; - } - - $mode = $this->metaData->getMode(); - - return str_contains($mode, 'r') || str_contains($mode, '+'); + return is_resource($this->resource) && $this->modeAllowsReading(); } public function isWritable(): bool { - if ($this->noResource()) { - return false; - } - - return strpbrk($this->metaData->getMode(), 'xwca+') !== false; + return is_resource($this->resource) && $this->modeAllowsWriting(); } public function isSeekable(): bool { - return !$this->noResource() && $this->metaData->isSeekable(); + return is_resource($this->resource) && $this->metaData->isSeekable(); } public function getContents(): string { - if (!$this->isReadable()) { + if (!is_resource($this->resource)) { + throw new NonReadableStream(); + } + + if (!$this->modeAllowsReading()) { throw new NonReadableStream(); } @@ -165,8 +167,15 @@ public function __toString(): string return $this->getContents(); } - private function noResource(): bool + private function modeAllowsReading(): bool { - return !is_resource($this->resource); + $mode = $this->metaData->getMode(); + + return str_contains($mode, 'r') || str_contains($mode, '+'); + } + + private function modeAllowsWriting(): bool + { + return strpbrk($this->metaData->getMode(), 'xwca+') !== false; } } From e9947bf59411c3894410307035c2e51a905041ac Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 15 May 2026 02:29:59 -0300 Subject: [PATCH 18/27] test: align tests with the v2 contract - Rename every test method to testWhenThen. - Move the PSR-18 client stubs out of HttpTest and NetworkTransportTest into reusable tests/Fixtures/Client/{CapturingClient,ThrowingClient}. - Replace PHPUnit createStub(ServerRequestInterface/StreamInterface) with real nyholm/psr7 ServerRequest instances wherever the test exercises the public boundary. - Switch tests to Headers::from()/new Headers() (no fromArray); the RequestResolver now passes a Headers instance, so withMergedHeaders test wiring is updated accordingly. - Update exception tests to the flat hierarchy (each exception is a direct HttpException implementor) and assert against the templated message via assertStringContainsString. - Internal\Client\UrlTest asserts on the returned string from Url::compose() now that the Url value object is gone. - Bring driver tests (Laminas, Slim) onto real ServerRequest instances and stop using the fictional `noCache:` named argument on CacheControl::fromResponseDirectives' variadic. Co-Authored-By: Claude Sonnet 4.6 --- tests/Drivers/Laminas/LaminasTest.php | 42 +- tests/Drivers/Slim/SlimTest.php | 42 +- tests/Fixtures/Client/CapturingClient.php | 31 + tests/Fixtures/Client/ThrowingClient.php | 27 + tests/Unit/Client/RequestTest.php | 42 +- tests/Unit/Client/ResponseTest.php | 39 +- .../Transports/InMemoryTransportTest.php | 10 +- .../Transports/NetworkTransportTest.php | 137 ++-- tests/Unit/CodeTest.php | 54 +- tests/Unit/CookieTest.php | 72 +-- .../HttpConfigurationInvalidTest.php | 14 +- .../Unit/Exceptions/HttpNetworkFailedTest.php | 26 +- .../Unit/Exceptions/HttpRequestFailedTest.php | 25 +- .../Exceptions/HttpRequestInvalidTest.php | 26 +- tests/Unit/Exceptions/MalformedPathTest.php | 12 +- tests/Unit/Exceptions/NoMoreResponsesTest.php | 12 +- tests/Unit/HeadersTest.php | 66 +- tests/Unit/HttpBuilderTest.php | 37 +- tests/Unit/HttpTest.php | 151 ++--- tests/Unit/Internal/Client/CursorTest.php | 6 +- .../Internal/Client/RequestResolverTest.php | 14 +- tests/Unit/Internal/Client/UrlTest.php | 68 +- .../Request/RouteParameterResolverTest.php | 136 ++-- .../Server/Stream/StreamFactoryTest.php | 37 +- .../Internal/Server/Stream/StreamTest.php | 87 +-- tests/Unit/SameSiteTest.php | 6 +- tests/Unit/Server/HeadersTest.php | 183 +++--- tests/Unit/Server/ProtocolVersionTest.php | 6 +- tests/Unit/Server/RequestTest.php | 599 +++++------------- tests/Unit/Server/ResponseTest.php | 304 ++------- tests/Unit/Server/ResponseWithCookiesTest.php | 16 +- tests/Unit/UserAgentTest.php | 10 +- 32 files changed, 846 insertions(+), 1491 deletions(-) create mode 100644 tests/Fixtures/Client/CapturingClient.php create mode 100644 tests/Fixtures/Client/ThrowingClient.php diff --git a/tests/Drivers/Laminas/LaminasTest.php b/tests/Drivers/Laminas/LaminasTest.php index ff750cc..cbcacd5 100644 --- a/tests/Drivers/Laminas/LaminasTest.php +++ b/tests/Drivers/Laminas/LaminasTest.php @@ -6,9 +6,8 @@ use DateTimeInterface; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; -use PHPUnit\Framework\MockObject\Exception; +use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ServerRequestInterface; use Test\TinyBlocks\Http\Drivers\Endpoint; use Test\TinyBlocks\Http\Drivers\Middleware; use TinyBlocks\Http\CacheControl; @@ -29,45 +28,32 @@ protected function setUp(): void $this->middleware = new Middleware(); } - /** - * @throws Exception - */ - public function testResponseProcessedWithLaminas(): void + public function testProcessWhenLaminasMiddlewareInvokedThenReturnsConfiguredResponse(): void { /** @Given a valid request */ - $request = $this->createStub(ServerRequestInterface::class); + $request = new ServerRequest(method: 'GET', uri: 'https://api.example.com/'); - /** @And the Content-Type for the response is set to application/json with UTF-8 charset */ + /** @And the Content-Type and Cache-Control headers are set */ $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()); - /** @And a Cache-Control header is set with no-cache directive */ - $cacheControl = CacheControl::fromResponseDirectives(noCache: ResponseCacheDirectives::noCache()); - - /** @And an HTTP response is created with a 200 OK status and a body containing the creation timestamp */ + /** @And an HTTP response is created with a 200 OK status and a JSON body */ $response = Response::ok(['createdAt' => date(DateTimeInterface::ATOM)], $contentType, $cacheControl); /** @When the request is processed by the handler */ $actual = $this->middleware->process(request: $request, handler: new Endpoint(response: $response)); - /** @Then the response status should indicate success */ + /** @Then the response is returned through the middleware unchanged */ self::assertSame(Code::OK->value, $actual->getStatusCode()); - - /** @And the response body should match the expected body */ self::assertSame($response->getBody()->__toString(), $actual->getBody()->__toString()); - - /** @And the response headers should match the expected headers */ self::assertSame($response->getHeaders(), $actual->getHeaders()); } - public function testResponseEmissionWithLaminas(): void + public function testEmitWhenLaminasEmitterUsedThenWritesBodyToOutputBuffer(): void { - /** @Given the Content-Type for the response is set to application/json with UTF-8 charset */ + /** @Given a response with Content-Type, Cache-Control, and a custom header */ $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - - /** @And a Cache-Control header is set with no-cache directive */ - $cacheControl = CacheControl::fromResponseDirectives(noCache: ResponseCacheDirectives::noCache()); - - /** @And an HTTP response is created with a 200 OK status and a body containing the creation timestamp */ + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()); $response = Response::ok( ['createdAt' => date(DateTimeInterface::ATOM)], $contentType, @@ -79,16 +65,10 @@ public function testResponseEmissionWithLaminas(): void $this->emitter->emit($response); $actual = ob_get_clean(); - /** @Then the emitted response content should match the response body */ + /** @Then the emitted body matches the response body */ self::assertSame($response->getBody()->__toString(), $actual); - - /** @And the response status code should be 200 */ self::assertSame(200, $response->getStatusCode()); - - /** @And the reason phrase should be 'OK' */ self::assertSame('OK', $response->getReasonPhrase()); - - /** @And the response should contain the X-Request-ID header */ self::assertSame('123456', $response->getHeaderLine(name: 'X-Request-ID')); } } diff --git a/tests/Drivers/Slim/SlimTest.php b/tests/Drivers/Slim/SlimTest.php index 2b83ffd..181dfa5 100644 --- a/tests/Drivers/Slim/SlimTest.php +++ b/tests/Drivers/Slim/SlimTest.php @@ -5,9 +5,8 @@ namespace Test\TinyBlocks\Http\Drivers\Slim; use DateTimeInterface; -use PHPUnit\Framework\MockObject\Exception; +use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ServerRequestInterface; use Slim\ResponseEmitter; use Test\TinyBlocks\Http\Drivers\Endpoint; use Test\TinyBlocks\Http\Drivers\Middleware; @@ -29,45 +28,32 @@ protected function setUp(): void $this->middleware = new Middleware(); } - /** - * @throws Exception - */ - public function testResponseProcessedWithSlim(): void + public function testProcessWhenSlimMiddlewareInvokedThenReturnsConfiguredResponse(): void { /** @Given a valid request */ - $request = $this->createStub(ServerRequestInterface::class); + $request = new ServerRequest(method: 'GET', uri: 'https://api.example.com/'); - /** @And the Content-Type for the response is set to application/json with UTF-8 charset */ + /** @And the Content-Type and Cache-Control headers are set */ $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()); - /** @And a Cache-Control header is set with no-cache directive */ - $cacheControl = CacheControl::fromResponseDirectives(noCache: ResponseCacheDirectives::noCache()); - - /** @And an HTTP response is created with a 200 OK status and a body containing the creation timestamp */ + /** @And an HTTP response is created with a 200 OK status and a JSON body */ $response = Response::ok(['createdAt' => date(DateTimeInterface::ATOM)], $contentType, $cacheControl); /** @When the request is processed by the handler */ $actual = $this->middleware->process(request: $request, handler: new Endpoint(response: $response)); - /** @Then the response status should indicate success */ + /** @Then the response is returned through the middleware unchanged */ self::assertSame(Code::OK->value, $actual->getStatusCode()); - - /** @And the response body should match the expected body */ self::assertSame($response->getBody()->__toString(), $actual->getBody()->__toString()); - - /** @And the response headers should match the expected headers */ self::assertSame($response->getHeaders(), $actual->getHeaders()); } - public function testResponseEmissionWithSlim(): void + public function testEmitWhenSlimEmitterUsedThenWritesBodyToOutputBuffer(): void { - /** @Given the Content-Type for the response is set to application/json with UTF-8 charset */ + /** @Given a response with Content-Type, Cache-Control, and a custom header */ $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - - /** @And a Cache-Control header is set with no-cache directive */ - $cacheControl = CacheControl::fromResponseDirectives(noCache: ResponseCacheDirectives::noCache()); - - /** @And an HTTP response is created with a 200 OK status and a body containing the creation timestamp */ + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()); $response = Response::ok( ['createdAt' => date(DateTimeInterface::ATOM)], $contentType, @@ -79,16 +65,10 @@ public function testResponseEmissionWithSlim(): void $this->emitter->emit($response); $actual = ob_get_clean(); - /** @Then the emitted response content should match the response body */ + /** @Then the emitted body matches the response body */ self::assertSame($response->getBody()->__toString(), $actual); - - /** @And the response status code should be 200 */ self::assertSame(200, $response->getStatusCode()); - - /** @And the reason phrase should be 'OK' */ self::assertSame('OK', $response->getReasonPhrase()); - - /** @And the response should contain the X-Request-ID header */ self::assertSame('123456', $response->getHeaderLine(name: 'X-Request-ID')); } } diff --git a/tests/Fixtures/Client/CapturingClient.php b/tests/Fixtures/Client/CapturingClient.php new file mode 100644 index 0000000..a6ee4ac --- /dev/null +++ b/tests/Fixtures/Client/CapturingClient.php @@ -0,0 +1,31 @@ +createResponse($statusCode)); + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->captured = $request; + + return $this->response; + } +} diff --git a/tests/Fixtures/Client/ThrowingClient.php b/tests/Fixtures/Client/ThrowingClient.php new file mode 100644 index 0000000..883bd55 --- /dev/null +++ b/tests/Fixtures/Client/ThrowingClient.php @@ -0,0 +1,27 @@ +exception; + } +} diff --git a/tests/Unit/Client/RequestTest.php b/tests/Unit/Client/RequestTest.php index be7735d..fb284ec 100644 --- a/tests/Unit/Client/RequestTest.php +++ b/tests/Unit/Client/RequestTest.php @@ -15,7 +15,7 @@ final class RequestTest extends TestCase { - public function testCreateWithMinimalParametersDefaultsToGet(): void + public function testCreateWhenMinimalParametersGivenThenDefaultsToGet(): void { /** @When creating a request with only a URL */ $request = Request::create(url: 'https://api.example.com/dragons'); @@ -24,11 +24,11 @@ public function testCreateWithMinimalParametersDefaultsToGet(): void self::assertSame('https://api.example.com/dragons', $request->url); self::assertSame(Method::GET, $request->method); self::assertNull($request->body); - self::assertSame([], $request->query); + self::assertNull($request->query); self::assertSame([], $request->headers->toArray()); } - public function testCreateWithNullBodyCarriesNoBody(): void + public function testCreateWhenNullBodyGivenThenCarriesNoBody(): void { /** @When creating a request with an explicit null body */ $request = Request::create(url: '/dragons'); @@ -37,33 +37,33 @@ public function testCreateWithNullBodyCarriesNoBody(): void self::assertNull($request->body); } - public function testCreateMergesMultipleHeaders(): void + public function testCreateWhenMultipleHeadersGivenThenMergesEntries(): void { /** @Given two distinct headers */ $contentType = ContentType::applicationJson(charset: Charset::UTF_8); $accept = ContentType::applicationJson(); - /** @When creating a request with both headers positionally (multiple variadics) */ - $request = Request::create('/dragons', null, [], Method::POST, $contentType, $accept); + /** @When creating a request with both headers via the variadic */ + $request = Request::create('/dragons', null, null, Method::POST, $contentType, $accept); - /** @Then the merged headers contain entries from both */ + /** @Then the merged headers contain Content-Type */ self::assertTrue($request->headers->has('Content-Type')); } - public function testCreatePreservesLastHeaderWhenSameNameProvided(): void + public function testCreateWhenSameHeaderProvidedTwiceThenLastOneWins(): void { /** @Given two Content-Type headers with different values */ $first = ContentType::applicationJson(charset: Charset::UTF_8); $second = ContentType::applicationJson(); /** @When creating the request with both (last one wins) */ - $request = Request::create('/dragons', null, [], Method::POST, $first, $second); + $request = Request::create('/dragons', null, null, Method::POST, $first, $second); /** @Then the last one wins for the Content-Type key */ self::assertSame('application/json', $request->headers->get('Content-Type')); } - public function testCreatePreservesQueryArray(): void + public function testCreateWhenQueryGivenThenPreservesArrayInProperty(): void { /** @Given query parameters */ $query = ['sort' => 'name', 'order' => 'asc']; @@ -75,7 +75,7 @@ public function testCreatePreservesQueryArray(): void self::assertSame($query, $request->query); } - public function testWithUrlReturnsNewInstanceWithReplacedUrl(): void + public function testWithUrlWhenInvokedThenReturnsNewInstanceWithReplacedUrl(): void { /** @Given a request with an original URL */ $request = Request::create(url: '/dragons'); @@ -89,7 +89,7 @@ public function testWithUrlReturnsNewInstanceWithReplacedUrl(): void self::assertSame('/dragons', $request->url); } - public function testWithQueryReturnsNewInstanceWithReplacedQuery(): void + public function testWithQueryWhenInvokedThenReturnsNewInstanceWithReplacedQuery(): void { /** @Given a request with an original query */ $request = Request::create(url: '/dragons', query: ['sort' => 'name']); @@ -103,30 +103,30 @@ public function testWithQueryReturnsNewInstanceWithReplacedQuery(): void self::assertSame(['sort' => 'name'], $request->query); } - public function testCreateWithDistinctKeyHeadersProducesBothEntries(): void + public function testCreateWhenDistinctKeyHeadersGivenThenBothPresent(): void { /** @Given two headers with distinct keys */ $contentType = ContentType::applicationJson(); $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::mustRevalidate()); /** @When creating a request with both headers */ - $request = Request::create('/dragons', null, [], Method::GET, $contentType, $cacheControl); + $request = Request::create('/dragons', null, null, Method::GET, $contentType, $cacheControl); /** @Then both header keys are present in the merged result */ self::assertCount(2, $request->headers->toArray()); } - public function testWithMergedHeadersCustomHeadersWinOverDefaults(): void + public function testWithMergedHeadersWhenCustomConflictsWithDefaultThenCustomWins(): void { /** @Given a request with a custom Content-Type header */ $request = Request::create( url: '/dragons', method: Method::POST, - headerables: ContentType::applicationJson(charset: Charset::UTF_8) + headers: ContentType::applicationJson(charset: Charset::UTF_8) ); /** @When merging defaults that include the same header */ - $defaults = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; + $defaults = new Headers(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); $resolved = $request->withMergedHeaders(defaults: $defaults); /** @Then the custom header wins over the default */ @@ -134,12 +134,12 @@ public function testWithMergedHeadersCustomHeadersWinOverDefaults(): void self::assertSame('application/json', $resolved->headers->get('Accept')); } - public function testHeadersAreCaseInsensitiveLookup(): void + public function testHeadersWhenMixedCaseGivenThenLookupIsCaseInsensitive(): void { /** @Given a request with a Content-Type header */ $request = Request::create( url: '/dragons', - headerables: ContentType::applicationJson() + headers: ContentType::applicationJson() ); /** @When looking up the header with different casing */ @@ -148,7 +148,7 @@ public function testHeadersAreCaseInsensitiveLookup(): void self::assertSame('application/json', $request->headers->get('CONTENT-TYPE')); } - public function testHeadersReturnNullForMissingKey(): void + public function testHeadersGetWhenMissingKeyGivenThenReturnsNull(): void { /** @Given a request with no headers */ $request = Request::create(url: '/dragons'); @@ -158,7 +158,7 @@ public function testHeadersReturnNullForMissingKey(): void self::assertNull($request->headers->get('X-Missing')); } - public function testHeadersInstanceIsReturnedOnRequest(): void + public function testHeadersWhenRequestCreatedThenExposesHeadersInstance(): void { /** @Given a request */ $request = Request::create(url: '/dragons'); diff --git a/tests/Unit/Client/ResponseTest.php b/tests/Unit/Client/ResponseTest.php index f1d08b9..1048fbf 100644 --- a/tests/Unit/Client/ResponseTest.php +++ b/tests/Unit/Client/ResponseTest.php @@ -9,6 +9,7 @@ use TinyBlocks\Http\Client\Response; use TinyBlocks\Http\Code; use TinyBlocks\Http\Exceptions\SynthesizedResponseHasNoRaw; +use TinyBlocks\Http\Headers; final class ResponseTest extends TestCase { @@ -19,7 +20,7 @@ protected function setUp(): void $this->factory = new Psr17Factory(); } - public function testResponseWith200AndJsonBodyAllowsTypedAccess(): void + public function testFromWhen200JsonResponseGivenThenExposesTypedBody(): void { /** @Given a 200 response with a JSON body */ $psrResponse = $this->factory->createResponse(200) @@ -34,7 +35,7 @@ public function testResponseWith200AndJsonBodyAllowsTypedAccess(): void self::assertSame(Code::OK, $response->code()); } - public function testResponseWith204AndNoBodyReturnsEmptyArray(): void + public function testFromWhen204ResponseGivenThenBodyIsEmptyArray(): void { /** @Given a 204 response with no body */ $psrResponse = $this->factory->createResponse(204); @@ -47,7 +48,7 @@ public function testResponseWith204AndNoBodyReturnsEmptyArray(): void self::assertSame(Code::NO_CONTENT, $response->code()); } - public function testResponseWithNonJsonBodyReturnsSafeEmptyArray(): void + public function testFromWhenNonJsonBodyGivenThenReturnsSafeEmptyArray(): void { /** @Given a 200 response with a non-JSON body */ $psrResponse = $this->factory->createResponse(200) @@ -60,7 +61,7 @@ public function testResponseWithNonJsonBodyReturnsSafeEmptyArray(): void self::assertSame([], $response->body()->toArray()); } - public function testResponseWith200IsSuccessAndNotError(): void + public function testFromWhen200ResponseGivenThenIsSuccessAndNotError(): void { /** @Given a 200 response */ $psrResponse = $this->factory->createResponse(200); @@ -73,7 +74,7 @@ public function testResponseWith200IsSuccessAndNotError(): void self::assertFalse($response->isError()); } - public function testResponseWith500IsErrorAndNotSuccess(): void + public function testFromWhen500ResponseGivenThenIsErrorAndNotSuccess(): void { /** @Given a 500 response */ $psrResponse = $this->factory->createResponse(500); @@ -86,7 +87,7 @@ public function testResponseWith500IsErrorAndNotSuccess(): void self::assertFalse($response->isSuccess()); } - public function testResponseHeadersAreFlattenedToStrings(): void + public function testHeadersWhenPsrResponseGivenThenAccessibleViaHeadersValueObject(): void { /** @Given a response with two distinct headers */ $psrResponse = $this->factory->createResponse(200) @@ -101,7 +102,7 @@ public function testResponseHeadersAreFlattenedToStrings(): void self::assertSame('123', $response->headers()->get('X-Request-ID')); } - public function testRawReturnsUnderlyingPsrResponse(): void + public function testRawWhenPsrResponseWrappedThenReturnsUnderlyingInstance(): void { /** @Given a PSR response */ $psrResponse = $this->factory->createResponse(200); @@ -113,7 +114,7 @@ public function testRawReturnsUnderlyingPsrResponse(): void self::assertSame($psrResponse, $response->raw()); } - public function testSynthesizedResponseWithCodeAndBodyWorks(): void + public function testWithWhenCodeAndBodyGivenThenSynthesizesAccessibleResponse(): void { /** @Given code and body data */ /** @When synthesizing a response via with() */ @@ -126,7 +127,7 @@ public function testSynthesizedResponseWithCodeAndBodyWorks(): void self::assertFalse($response->isError()); } - public function testSynthesizedResponseRawThrowsSynthesizedResponseHasNoRaw(): void + public function testRawWhenSynthesizedResponseGivenThenThrowsSynthesizedResponseHasNoRaw(): void { /** @Given a synthesized response */ $response = Response::with(code: Code::OK); @@ -138,7 +139,7 @@ public function testSynthesizedResponseRawThrowsSynthesizedResponseHasNoRaw(): v $response->raw(); } - public function testSynthesizedResponseWithNullBodyReturnsEmptyArray(): void + public function testWithWhenNullBodyGivenThenReturnsEmptyArray(): void { /** @Given a synthesized response with null body */ /** @When creating the response */ @@ -148,7 +149,19 @@ public function testSynthesizedResponseWithNullBodyReturnsEmptyArray(): void self::assertSame([], $response->body()->toArray()); } - public function testResponseWithSeekableStreamCanBeConsumedAgainViaRaw(): void + public function testWithWhenHeadersGivenThenExposesViaHeadersAccessor(): void + { + /** @Given a Headers instance with one entry */ + $headers = new Headers(entries: ['X-Trace' => 'abc']); + + /** @When synthesizing a response with the headers */ + $response = Response::with(code: Code::OK, headers: $headers); + + /** @Then headers() returns the same value object */ + self::assertSame('abc', $response->headers()->get('X-Trace')); + } + + public function testFromWhenSeekableStreamGivenThenRawIsStillReadable(): void { /** @Given a 200 response with a JSON body in a seekable stream */ $psrResponse = $this->factory->createResponse(200) @@ -166,7 +179,7 @@ public function testResponseWithSeekableStreamCanBeConsumedAgainViaRaw(): void self::assertSame('{"name":"Hydra"}', $raw->getContents()); } - public function testResponseFromAdvancedSeekableStreamParsesBodyFromStart(): void + public function testFromWhenAdvancedSeekableStreamGivenThenParsesBodyFromStart(): void { /** @Given a seekable stream advanced past its start */ $stream = $this->factory->createStream('{"name":"Hydra"}'); @@ -185,7 +198,7 @@ public function testResponseFromAdvancedSeekableStreamParsesBodyFromStart(): voi self::assertSame('{"name":"Hydra"}', $response->raw()->getBody()->getContents()); } - public function testResponseWithDeeplyNestedJsonBeyondDepthDegradesToEmpty(): void + public function testFromWhenDeeplyNestedJsonGivenThenDegradesToEmptyArray(): void { /** @Given a JSON string nested deeper than 64 levels */ $json = str_repeat('{"a":', 65) . '1' . str_repeat('}', 65); diff --git a/tests/Unit/Client/Transports/InMemoryTransportTest.php b/tests/Unit/Client/Transports/InMemoryTransportTest.php index bd9ab38..d0c92cf 100644 --- a/tests/Unit/Client/Transports/InMemoryTransportTest.php +++ b/tests/Unit/Client/Transports/InMemoryTransportTest.php @@ -13,7 +13,7 @@ final class InMemoryTransportTest extends TestCase { - public function testResponsesAreReturnedInFifoOrder(): void + public function testSendWhenMultipleResponsesQueuedThenServesInFifoOrder(): void { /** @Given a transport seeded with two responses */ $first = Response::with(code: Code::OK); @@ -30,9 +30,9 @@ public function testResponsesAreReturnedInFifoOrder(): void self::assertSame(Code::CREATED, $responseTwo->code()); } - public function testExhaustedTransportThrowsNoMoreResponses(): void + public function testSendWhenQueueExhaustedThenThrowsNoMoreResponses(): void { - /** @Given a transport seeded with one response */ + /** @Given a transport seeded with one response that is already consumed */ $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); $request = Request::create(url: '/dragons'); $transport->send(request: $request); @@ -44,7 +44,7 @@ public function testExhaustedTransportThrowsNoMoreResponses(): void $transport->send(request: $request); } - public function testEmptyTransportThrowsNoMoreResponsesImmediately(): void + public function testSendWhenQueueEmptyThenThrowsNoMoreResponsesImmediately(): void { /** @Given a transport seeded with zero responses */ $transport = InMemoryTransport::with(responses: []); @@ -56,7 +56,7 @@ public function testEmptyTransportThrowsNoMoreResponsesImmediately(): void $transport->send(request: Request::create(url: '/dragons')); } - public function testSingleResponseTransportReturnsCorrectResponse(): void + public function testSendWhenSingleResponseQueuedThenReturnsIt(): void { /** @Given a transport seeded with one response */ $transport = InMemoryTransport::with(responses: [Response::with(code: Code::CREATED)]); diff --git a/tests/Unit/Client/Transports/NetworkTransportTest.php b/tests/Unit/Client/Transports/NetworkTransportTest.php index 7ebd33f..be4f8b8 100644 --- a/tests/Unit/Client/Transports/NetworkTransportTest.php +++ b/tests/Unit/Client/Transports/NetworkTransportTest.php @@ -7,19 +7,19 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientExceptionInterface; -use Psr\Http\Client\ClientInterface; use Psr\Http\Client\NetworkExceptionInterface; use Psr\Http\Client\RequestExceptionInterface; use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; use RuntimeException; -use Throwable; +use Test\TinyBlocks\Http\Fixtures\Client\CapturingClient; +use Test\TinyBlocks\Http\Fixtures\Client\ThrowingClient; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Client\Transports\NetworkTransport; use TinyBlocks\Http\Code; use TinyBlocks\Http\Exceptions\HttpNetworkFailed; use TinyBlocks\Http\Exceptions\HttpRequestFailed; use TinyBlocks\Http\Exceptions\HttpRequestInvalid; +use TinyBlocks\Http\Headers; use TinyBlocks\Http\Method; final class NetworkTransportTest extends TestCase @@ -31,102 +31,93 @@ protected function setUp(): void $this->factory = new Psr17Factory(); } - public function testRequestWithBodySendsJsonEncodedBodyAndContentTypeHeader(): void + public function testSendWhenBodyGivenThenForwardsJsonAndContentTypeHeader(): void { - /** @Given a client that captures the PSR-7 request */ - $captured = null; - $client = $this->buildCapturingClient(captured: $captured, statusCode: 201); - $transport = NetworkTransport::with( - client: $client, - factory: $this->factory - ); + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 201); + $transport = NetworkTransport::with(client: $client, factory: $this->factory); - /** @When sending a request with a JSON body */ + /** @When sending a request with a JSON body and a Content-Type default */ $transport->send( request: Request::create( url: 'https://api.example.com/dragons', body: ['name' => 'Hydra'], method: Method::POST - )->withMergedHeaders(defaults: ['Content-Type' => 'application/json']) + )->withMergedHeaders(defaults: new Headers(entries: ['Content-Type' => 'application/json'])) ); /** @Then the PSR-7 request carries JSON and the Content-Type header */ - self::assertSame('{"name":"Hydra"}', (string)$captured->getBody()); - self::assertSame('application/json', $captured->getHeaderLine('Content-Type')); + self::assertNotNull($client->captured); + self::assertSame('{"name":"Hydra"}', (string)$client->captured->getBody()); + self::assertSame('application/json', $client->captured->getHeaderLine('Content-Type')); } - public function testRequestWithoutBodySendsNoBody(): void + public function testSendWhenNoBodyGivenThenForwardsEmptyBody(): void { - /** @Given a client that captures the PSR-7 request */ - $captured = null; - $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); - $transport = NetworkTransport::with( - client: $client, - factory: $this->factory - ); + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + $transport = NetworkTransport::with(client: $client, factory: $this->factory); /** @When sending a request without body */ $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); /** @Then the PSR-7 request body is empty */ - self::assertSame('', (string)$captured->getBody()); + self::assertNotNull($client->captured); + self::assertSame('', (string)$client->captured->getBody()); } - public function testCustomHeadersAreForwardedToPsrRequest(): void + public function testSendWhenCustomHeaderMergedThenForwardsToPsrRequest(): void { - /** @Given a client that captures the PSR-7 request */ - $captured = null; - $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); - $transport = NetworkTransport::with( - client: $client, - factory: $this->factory - ); + /** @Given a capturing client */ + $client = CapturingClient::returningStatus(statusCode: 200); + $transport = NetworkTransport::with(client: $client, factory: $this->factory); - /** @When sending a request with a custom header */ + /** @When sending a request with a custom header merged in */ $transport->send( request: Request::create(url: 'https://api.example.com/dragons') - ->withMergedHeaders(defaults: ['X-Correlation-ID' => 'abc-123']) + ->withMergedHeaders(defaults: new Headers(entries: ['X-Correlation-ID' => 'abc-123'])) ); /** @Then the PSR-7 request carries the custom header */ - self::assertSame('abc-123', $captured->getHeaderLine('X-Correlation-ID')); + self::assertNotNull($client->captured); + self::assertSame('abc-123', $client->captured->getHeaderLine('X-Correlation-ID')); } - public function testNetworkExceptionMapsToHttpNetworkFailed(): void + public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFailed(): void { /** @Given a PSR-18 client that throws NetworkExceptionInterface */ $networkException = new class ('connection refused') extends RuntimeException implements NetworkExceptionInterface { public function getRequest(): RequestInterface { - return (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + return new Psr17Factory()->createRequest('GET', 'https://api.example.com'); } }; $transport = NetworkTransport::with( - client: $this->buildThrowingClient(exception: $networkException), + client: ThrowingClient::throwing(exception: $networkException), factory: $this->factory ); - /** @Then HttpNetworkFailed is thrown with previous set */ + /** @Then HttpNetworkFailed is thrown */ $this->expectException(HttpNetworkFailed::class); /** @When sending the request */ $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); } - public function testRequestExceptionMapsToHttpRequestInvalid(): void + public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInvalid(): void { /** @Given a PSR-18 client that throws RequestExceptionInterface */ $requestException = new class ('bad request') extends RuntimeException implements RequestExceptionInterface { public function getRequest(): RequestInterface { - return (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + return new Psr17Factory()->createRequest('GET', 'https://api.example.com'); } }; $transport = NetworkTransport::with( - client: $this->buildThrowingClient(exception: $requestException), + client: ThrowingClient::throwing(exception: $requestException), factory: $this->factory ); @@ -137,14 +128,14 @@ public function getRequest(): RequestInterface $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); } - public function testGenericClientExceptionMapsToHttpRequestFailed(): void + public function testSendWhenClientRaisesGenericClientExceptionThenThrowsHttpRequestFailed(): void { /** @Given a PSR-18 client that throws a generic ClientExceptionInterface */ $clientException = new class ('generic failure') extends RuntimeException implements ClientExceptionInterface { }; $transport = NetworkTransport::with( - client: $this->buildThrowingClient(exception: $clientException), + client: ThrowingClient::throwing(exception: $clientException), factory: $this->factory ); @@ -155,15 +146,11 @@ public function testGenericClientExceptionMapsToHttpRequestFailed(): void $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); } - public function testSuccessfulResponseIsWrappedInClientResponse(): void + public function testSendWhenSuccessfulPsrResponseGivenThenWrapsInClientResponse(): void { /** @Given a client that returns a 200 response */ - $captured = null; - $client = $this->buildCapturingClient(captured: $captured, statusCode: 200); - $transport = NetworkTransport::with( - client: $client, - factory: $this->factory - ); + $client = CapturingClient::returningStatus(statusCode: 200); + $transport = NetworkTransport::with(client: $client, factory: $this->factory); /** @When sending a request */ $response = $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); @@ -172,14 +159,11 @@ public function testSuccessfulResponseIsWrappedInClientResponse(): void self::assertSame(Code::OK, $response->code()); } - public function testBodyWithInvalidUtf8IsSubstitutedAndRequestSendsNormally(): void + public function testSendWhenBodyHasInvalidUtf8ThenSubstitutesAndStillSends(): void { /** @Given a transport configured with a capturing client */ - $captured = null; - $transport = NetworkTransport::with( - client: $this->buildCapturingClient(captured: $captured, statusCode: 200), - factory: $this->factory - ); + $client = CapturingClient::returningStatus(statusCode: 200); + $transport = NetworkTransport::with(client: $client, factory: $this->factory); /** @When sending a request whose body contains a non-UTF-8 byte sequence */ $transport->send( @@ -190,41 +174,8 @@ public function testBodyWithInvalidUtf8IsSubstitutedAndRequestSendsNormally(): v ) ); - /** @Then the PSR-7 request body carries the JSON-escaped replacement character and no exception is thrown */ - self::assertStringContainsString('\ufffd', (string)$captured->getBody()); - } - - private function buildCapturingClient(?RequestInterface &$captured, int $statusCode): ClientInterface - { - $response = $this->factory->createResponse($statusCode); - - return new class ($response, $captured) implements ClientInterface { - public function __construct( - private readonly ResponseInterface $response, - private ?RequestInterface &$captured - ) { - } - - public function sendRequest(RequestInterface $request): ResponseInterface - { - $this->captured = $request; - - return $this->response; - } - }; - } - - private function buildThrowingClient(Throwable $exception): ClientInterface - { - return new class ($exception) implements ClientInterface { - public function __construct(private readonly Throwable $exception) - { - } - - public function sendRequest(RequestInterface $request): ResponseInterface - { - throw $this->exception; - } - }; + /** @Then the PSR-7 request body carries the JSON-escaped replacement character */ + self::assertNotNull($client->captured); + self::assertStringContainsString('\ufffd', (string)$client->captured->getBody()); } } diff --git a/tests/Unit/CodeTest.php b/tests/Unit/CodeTest.php index e2d7692..4f05a2e 100644 --- a/tests/Unit/CodeTest.php +++ b/tests/Unit/CodeTest.php @@ -11,62 +11,62 @@ final class CodeTest extends TestCase { #[DataProvider('messagesDataProvider')] - public function testMessage(Code $code, string $expected): void + public function testMessageWhenKnownCodeGivenThenReturnsRfcDescription(Code $code, string $expected): void { /** @Given a Code instance */ /** @When retrieving the message for the Code */ $actual = $code->message(); - /** @Then the message should match the expected string */ + /** @Then the message matches the expected string */ self::assertSame($expected, $actual); } #[DataProvider('codesDataProvider')] - public function testIsHttpCode(int $code, bool $expected): void + public function testIsValidCodeWhenIntegerGivenThenReturnsExpected(int $code, bool $expected): void { /** @Given an integer representing an HTTP code */ /** @When checking if it is a valid HTTP code */ $actual = Code::isValidCode(code: $code); - /** @Then the result should match the expected boolean */ + /** @Then the result matches the expected boolean */ self::assertSame($expected, $actual); } #[DataProvider('errorCodesDataProvider')] - public function testIsErrorCode(int $code, bool $expected): void + public function testIsErrorCodeWhenIntegerGivenThenReturnsExpected(int $code, bool $expected): void { /** @Given an HTTP status code */ /** @When checking if it is an error code (4xx or 5xx) */ $actual = Code::isErrorCode(code: $code); - /** @Then the result should match the expected boolean */ + /** @Then the result matches the expected boolean */ self::assertSame($expected, $actual); } #[DataProvider('successCodesDataProvider')] - public function testIsSuccessCode(int $code, bool $expected): void + public function testIsSuccessCodeWhenIntegerGivenThenReturnsExpected(int $code, bool $expected): void { /** @Given an HTTP status code */ /** @When checking if it is a success code (2xx) */ $actual = Code::isSuccessCode(code: $code); - /** @Then the result should match the expected boolean */ + /** @Then the result matches the expected boolean */ self::assertSame($expected, $actual); } - public function testCodeOkIsSuccessAndNotError(): void + public function testIsSuccessWhenCodeOkGivenThenReturnsTrueAndIsErrorFalse(): void { /** @Given Code::OK */ - /** @When checking instance methods */ + /** @When checking the instance methods */ /** @Then isSuccess is true and isError is false */ self::assertTrue(Code::OK->isSuccess()); self::assertFalse(Code::OK->isError()); } - public function testCodeInternalServerErrorIsErrorAndNotSuccess(): void + public function testIsErrorWhenCodeInternalServerErrorGivenThenReturnsTrueAndIsSuccessFalse(): void { /** @Given Code::INTERNAL_SERVER_ERROR */ - /** @When checking instance methods */ + /** @When checking the instance methods */ /** @Then isError is true and isSuccess is false */ self::assertTrue(Code::INTERNAL_SERVER_ERROR->isError()); self::assertFalse(Code::INTERNAL_SERVER_ERROR->isSuccess()); @@ -121,30 +121,12 @@ public static function messagesDataProvider(): array public static function codesDataProvider(): array { return [ - 'Invalid code 0' => [ - 'code' => 0, - 'expected' => false - ], - 'Invalid code -1' => [ - 'code' => -1, - 'expected' => false - ], - 'Invalid code 1054' => [ - 'code' => 1054, - 'expected' => false - ], - 'Valid code 200 OK' => [ - 'code' => Code::OK->value, - 'expected' => true - ], - 'Valid code 100 Continue' => [ - 'code' => Code::CONTINUE->value, - 'expected' => true - ], - 'Valid code 500 Internal Server Error' => [ - 'code' => Code::INTERNAL_SERVER_ERROR->value, - 'expected' => true - ] + 'Invalid code 0' => ['code' => 0, 'expected' => false], + 'Invalid code -1' => ['code' => -1, 'expected' => false], + 'Invalid code 1054' => ['code' => 1054, 'expected' => false], + 'Valid code 200 OK' => ['code' => Code::OK->value, 'expected' => true], + 'Valid code 100 Continue' => ['code' => Code::CONTINUE->value, 'expected' => true], + 'Valid code 500 Internal Server Error' => ['code' => Code::INTERNAL_SERVER_ERROR->value, 'expected' => true] ]; } diff --git a/tests/Unit/CookieTest.php b/tests/Unit/CookieTest.php index 17e133d..acdd1e7 100644 --- a/tests/Unit/CookieTest.php +++ b/tests/Unit/CookieTest.php @@ -17,7 +17,7 @@ final class CookieTest extends TestCase { - public function testCreateCookieWithNameAndValue(): void + public function testCreateWhenNameAndValueGivenThenSerializesNameValuePair(): void { /** @Given a cookie name and value */ $cookie = Cookie::create(name: 'session', value: 'abc'); @@ -25,11 +25,11 @@ public function testCreateCookieWithNameAndValue(): void /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the header should contain only the name and value */ + /** @Then the header contains only the name and value */ self::assertSame(['Set-Cookie' => ['session=abc']], $actual); } - public function testCreateCookieWithAllAttributes(): void + public function testCreateWhenAllAttributesAppliedThenSerializesInCanonicalOrder(): void { /** @Given a cookie composed with every supported attribute */ $cookie = Cookie::create(name: 'refresh_token', value: 'opaque-value') @@ -44,13 +44,13 @@ public function testCreateCookieWithAllAttributes(): void /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the header should include every attribute in the canonical order */ + /** @Then the header includes every attribute in the canonical order */ $expected = 'refresh_token=opaque-value; Max-Age=604800; Path=/v1/sessions; ' . 'Domain=api.example.com; Secure; HttpOnly; SameSite=Strict; Partitioned'; self::assertSame(['Set-Cookie' => [$expected]], $actual); } - public function testExpireCookieEmitsEmptyValueAndMaxAgeZero(): void + public function testExpireWhenInvokedThenEmitsEmptyValueAndMaxAgeZero(): void { /** @Given a cookie deletion for an existing name */ /** @And the same path used when the cookie was issued */ @@ -59,11 +59,11 @@ public function testExpireCookieEmitsEmptyValueAndMaxAgeZero(): void /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the header should instruct the browser to discard the cookie */ + /** @Then the header instructs the browser to discard the cookie */ self::assertSame(['Set-Cookie' => ['refresh_token=; Max-Age=0; Path=/v1/sessions']], $actual); } - public function testWithValueReturnsNewInstanceWithReplacedValue(): void + public function testWithValueWhenInvokedThenReturnsNewInstanceAndOriginalIsUntouched(): void { /** @Given a cookie with an initial value */ $original = Cookie::create(name: 'session', value: 'initial'); @@ -77,7 +77,7 @@ public function testWithValueReturnsNewInstanceWithReplacedValue(): void self::assertSame(['Set-Cookie' => ['session=rotated']], $rotated->toArray()); } - public function testWithExpiresRendersTheDateInRfcFormatInUtc(): void + public function testWithExpiresWhenNonUtcDateGivenThenRendersInUtcRfcFormat(): void { /** @Given an expiration in a non-UTC timezone */ $cookie = Cookie::create(name: 'session', value: 'abc')->withExpires( @@ -87,14 +87,14 @@ public function testWithExpiresRendersTheDateInRfcFormatInUtc(): void /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the Expires attribute should be converted to UTC and formatted per RFC 7231 */ + /** @Then the Expires attribute is converted to UTC and formatted per RFC 7231 */ self::assertSame( ['Set-Cookie' => ['session=abc; Expires=Tue, 15 Jan 2030 15:00:00 GMT']], $actual ); } - public function testBuilderMethodsReturnNewInstanceWithoutMutatingOriginal(): void + public function testSecureWhenInvokedThenReturnsNewInstanceWithFlag(): void { /** @Given a base cookie without the secure flag */ $base = Cookie::create(name: 'session', value: 'abc'); @@ -108,22 +108,19 @@ public function testBuilderMethodsReturnNewInstanceWithoutMutatingOriginal(): vo self::assertSame(['Set-Cookie' => ['session=abc; Secure']], $secured->toArray()); } - public function testSameSiteNoneWithoutSecureThrows(): void + public function testToArrayWhenSameSiteNoneWithoutSecureGivenThenThrows(): void { /** @Given a cookie set to SameSite=None without the Secure flag */ $cookie = Cookie::create(name: 'session', value: 'abc')->withSameSite(sameSite: SameSite::NONE); - /** @Then an exception indicating the missing Secure flag should be thrown */ + /** @Then an exception indicating the missing Secure flag is thrown */ $this->expectException(SameSiteNoneRequiresSecure::class); - $this->expectExceptionMessage( - 'Cookies with SameSite=None require the Secure flag to be set; modern browsers reject such cookies otherwise. Call secure() on the Cookie instance.' - ); /** @When the header is serialized */ $cookie->toArray(); } - public function testSameSiteNoneWithSecureIsAllowed(): void + public function testToArrayWhenSameSiteNoneWithSecureGivenThenSerializesBothAttributes(): void { /** @Given a cookie with SameSite=None combined with Secure */ $cookie = Cookie::create(name: 'session', value: 'abc') @@ -133,28 +130,25 @@ public function testSameSiteNoneWithSecureIsAllowed(): void /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then both attributes should be present */ + /** @Then both attributes are present */ self::assertSame(['Set-Cookie' => ['session=abc; Secure; SameSite=None']], $actual); } - public function testMaxAgeAndExpiresTogetherThrows(): void + public function testToArrayWhenBothMaxAgeAndExpiresGivenThenThrows(): void { /** @Given a cookie with both Max-Age and Expires assigned */ $cookie = Cookie::create(name: 'session', value: 'abc') ->withMaxAge(seconds: 3600) ->withExpires(expires: new DateTimeImmutable('2030-01-15 12:00:00 UTC')); - /** @Then an exception indicating conflicting lifetime attributes should be thrown */ + /** @Then an exception indicating conflicting lifetime attributes is thrown */ $this->expectException(ConflictingLifetimeAttributes::class); - $this->expectExceptionMessage( - 'Cookie lifetime attributes are conflicting. A cookie must declare its lifetime via either Max-Age or Expires, not both. Choose one and reset the other with a new Cookie instance.' - ); /** @When the header is serialized */ $cookie->toArray(); } - public function testEmptyValueIsAcceptedAsValid(): void + public function testCreateWhenEmptyValueGivenThenRendersEmpty(): void { /** @Given an empty value */ $cookie = Cookie::create(name: 'session', value: ''); @@ -162,50 +156,44 @@ public function testEmptyValueIsAcceptedAsValid(): void /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the value should be rendered as empty */ + /** @Then the value is rendered as empty */ self::assertSame(['Set-Cookie' => ['session=']], $actual); } - public function testWithValueRejectsInvalidReplacement(): void + public function testWithValueWhenForbiddenCharacterGivenThenThrows(): void { /** @Given a valid cookie */ $cookie = Cookie::create(name: 'session', value: 'abc'); - /** @Then an exception indicating the value is invalid should be thrown */ + /** @Then an exception indicating the value is invalid is thrown */ $this->expectException(CookieValueIsInvalid::class); /** @When the value is replaced with one containing forbidden characters */ $cookie->withValue(value: 'has;semicolon'); } - public function testExpireValidatesTheName(): void + public function testExpireWhenInvalidNameGivenThenThrows(): void { - /** @Then an exception indicating the name is invalid should be thrown */ + /** @Then an exception indicating the name is invalid is thrown */ $this->expectException(CookieNameIsInvalid::class); - $this->expectExceptionMessage( - 'Cookie name is invalid. A name must not be empty and must not contain control characters, whitespace, or any of the following separators: ( ) < > @ , ; : \\ " / [ ] ? = { }.' - ); /** @When expiring a cookie with an invalid name */ Cookie::expire(name: 'bad name'); } - public function testCreateExposesInvalidValueMessage(): void + public function testCreateWhenForbiddenCharacterInValueGivenThenThrows(): void { - /** @Then an exception indicating the value is invalid should be thrown */ + /** @Then an exception indicating the value is invalid is thrown */ $this->expectException(CookieValueIsInvalid::class); - $this->expectExceptionMessage( - 'Cookie value is invalid. A value must not contain control characters, whitespace, double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or Base64) before passing it.' - ); /** @When creating a cookie with the invalid value */ Cookie::create(name: 'session', value: 'abc;def'); } #[DataProvider('invalidNameProvider')] - public function testCreateCookieRejectsInvalidName(string $name): void + public function testCreateWhenInvalidNameGivenThenThrows(string $name): void { - /** @Then an exception indicating the name is invalid should be thrown */ + /** @Then an exception indicating the name is invalid is thrown */ $this->expectException(CookieNameIsInvalid::class); /** @When creating a cookie with the invalid name */ @@ -213,9 +201,9 @@ public function testCreateCookieRejectsInvalidName(string $name): void } #[DataProvider('invalidValueProvider')] - public function testCreateCookieRejectsInvalidValue(string $value): void + public function testCreateWhenInvalidValueGivenThenThrows(string $value): void { - /** @Then an exception indicating the value is invalid should be thrown */ + /** @Then an exception indicating the value is invalid is thrown */ $this->expectException(CookieValueIsInvalid::class); /** @When creating a cookie with the invalid value */ @@ -232,7 +220,7 @@ public static function invalidNameProvider(): array 'Name with control character' => ["session\x00"], 'Name with comma' => ['session,id'], 'Name with double quote' => ['session"'], - 'Name with brackets' => ['session[]'], + 'Name with brackets' => ['session[]'] ]; } @@ -245,7 +233,7 @@ public static function invalidValueProvider(): array 'Value with comma' => ['abc,def'], 'Value with double quote' => ['abc"def'], 'Value with backslash' => ['abc\\def'], - 'Value with control character' => ["abc\x00def"], + 'Value with control character' => ["abc\x00def"] ]; } } diff --git a/tests/Unit/Exceptions/HttpConfigurationInvalidTest.php b/tests/Unit/Exceptions/HttpConfigurationInvalidTest.php index 82b3cf0..919136d 100644 --- a/tests/Unit/Exceptions/HttpConfigurationInvalidTest.php +++ b/tests/Unit/Exceptions/HttpConfigurationInvalidTest.php @@ -4,29 +4,29 @@ namespace Test\TinyBlocks\Http\Unit\Exceptions; -use LogicException; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\Exceptions\HttpConfigurationInvalid; +use TinyBlocks\Http\Exceptions\HttpException; final class HttpConfigurationInvalidTest extends TestCase { - public function testMissingTransportCreatesExceptionWithCorrectMessage(): void + public function testMissingTransportWhenInvokedThenMessageDescribesMissingTransport(): void { /** @When creating the exception for missing transport */ $exception = HttpConfigurationInvalid::missingTransport(); - /** @Then the message describes the missing transport */ + /** @Then the message describes the missing transport and is recognized as HttpException */ self::assertSame('Transport is required to build Http.', $exception->getMessage()); - self::assertInstanceOf(LogicException::class, $exception); + self::assertInstanceOf(HttpException::class, $exception); } - public function testMissingBaseUrlCreatesExceptionWithCorrectMessage(): void + public function testMissingBaseUrlWhenInvokedThenMessageDescribesMissingBaseUrl(): void { /** @When creating the exception for missing base URL */ $exception = HttpConfigurationInvalid::missingBaseUrl(); - /** @Then the message describes the missing base URL */ + /** @Then the message describes the missing base URL and is recognized as HttpException */ self::assertSame('Base URL is required to build Http.', $exception->getMessage()); - self::assertInstanceOf(LogicException::class, $exception); + self::assertInstanceOf(HttpException::class, $exception); } } diff --git a/tests/Unit/Exceptions/HttpNetworkFailedTest.php b/tests/Unit/Exceptions/HttpNetworkFailedTest.php index 8685c11..6d6db4f 100644 --- a/tests/Unit/Exceptions/HttpNetworkFailedTest.php +++ b/tests/Unit/Exceptions/HttpNetworkFailedTest.php @@ -12,12 +12,11 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Exceptions\HttpException; use TinyBlocks\Http\Exceptions\HttpNetworkFailed; -use TinyBlocks\Http\Exceptions\HttpRequestFailed; use TinyBlocks\Http\Method; final class HttpNetworkFailedTest extends TestCase { - public function testFromBuildsExceptionWithAllFields(): void + public function testFromWhenAllFieldsGivenThenExposesEveryAccessor(): void { /** @Given a URL, method, and reason */ $url = 'https://api.example.com/dragons'; @@ -27,15 +26,15 @@ public function testFromBuildsExceptionWithAllFields(): void /** @When constructing the exception */ $exception = HttpNetworkFailed::from(url: $url, method: $method, reason: $reason); - /** @Then it carries the correct fields */ - self::assertInstanceOf(HttpRequestFailed::class, $exception); + /** @Then it carries the correct fields and is recognized as HttpException */ + self::assertInstanceOf(HttpException::class, $exception); self::assertSame($url, $exception->url()); self::assertSame($method, $exception->method()); self::assertSame($reason, $exception->reason()); - self::assertSame($reason, $exception->getMessage()); + self::assertStringContainsString($reason, $exception->getMessage()); } - public function testFromChainsPreviousThrowable(): void + public function testFromWhenPreviousGivenThenPreservesChain(): void { /** @Given a previous throwable */ $previous = new RuntimeException('socket error'); @@ -52,7 +51,7 @@ public function testFromChainsPreviousThrowable(): void self::assertSame($previous, $exception->getPrevious()); } - public function testFromClientExceptionBuildsFromNetworkException(): void + public function testFromClientExceptionWhenNetworkExceptionGivenThenWrapsOriginal(): void { /** @Given a request and a network exception */ $request = Request::create(url: 'https://api.example.com/dragons'); @@ -71,17 +70,4 @@ public function getRequest(): RequestInterface self::assertSame($networkException, $exception->getPrevious()); self::assertInstanceOf(HttpException::class, $exception); } - - public function testExceptionIsCatchableAsHttpRequestFailed(): void - { - /** @Given an HttpNetworkFailed exception */ - $exception = HttpNetworkFailed::from( - url: 'https://api.example.com', - method: Method::GET, - reason: 'Failure.' - ); - - /** @Then it is catchable as HttpRequestFailed */ - self::assertInstanceOf(HttpRequestFailed::class, $exception); - } } diff --git a/tests/Unit/Exceptions/HttpRequestFailedTest.php b/tests/Unit/Exceptions/HttpRequestFailedTest.php index 36d2894..eba7714 100644 --- a/tests/Unit/Exceptions/HttpRequestFailedTest.php +++ b/tests/Unit/Exceptions/HttpRequestFailedTest.php @@ -14,7 +14,7 @@ final class HttpRequestFailedTest extends TestCase { - public function testFromBuildsExceptionWithAllFields(): void + public function testFromWhenAllFieldsGivenThenExposesEveryAccessor(): void { /** @Given a URL, method, and reason */ $url = 'https://api.example.com/dragons'; @@ -24,15 +24,15 @@ public function testFromBuildsExceptionWithAllFields(): void /** @When constructing the exception */ $exception = HttpRequestFailed::from(url: $url, method: $method, reason: $reason); - /** @Then methods and message are correct */ + /** @Then methods, reason, and message are correct */ self::assertSame($url, $exception->url()); self::assertSame($method, $exception->method()); self::assertSame($reason, $exception->reason()); - self::assertSame($reason, $exception->getMessage()); + self::assertStringContainsString($reason, $exception->getMessage()); self::assertNull($exception->getPrevious()); } - public function testFromChainsPreviousThrowable(): void + public function testFromWhenPreviousGivenThenPreservesChain(): void { /** @Given a previous throwable */ $previous = new RuntimeException('root cause'); @@ -49,7 +49,7 @@ public function testFromChainsPreviousThrowable(): void self::assertSame($previous, $exception->getPrevious()); } - public function testFromClientExceptionBuildsExceptionFromRequest(): void + public function testFromClientExceptionWhenRequestGivenThenWrapsOriginal(): void { /** @Given a request and a client exception */ $request = Request::create(url: 'https://api.example.com/dragons', method: Method::DELETE); @@ -66,20 +66,7 @@ public function testFromClientExceptionBuildsExceptionFromRequest(): void self::assertInstanceOf(HttpException::class, $exception); } - public function testExceptionIsInstanceOfHttpException(): void - { - /** @When building an HttpRequestFailed */ - $exception = HttpRequestFailed::from( - url: 'https://api.example.com', - method: Method::GET, - reason: 'Failure.' - ); - - /** @Then it implements HttpException */ - self::assertInstanceOf(HttpException::class, $exception); - } - - public function testExceptionCodeIsZero(): void + public function testGetCodeWhenExceptionBuiltThenReturnsZero(): void { /** @When building an HttpRequestFailed */ $exception = HttpRequestFailed::from( diff --git a/tests/Unit/Exceptions/HttpRequestInvalidTest.php b/tests/Unit/Exceptions/HttpRequestInvalidTest.php index 31c46c4..46219db 100644 --- a/tests/Unit/Exceptions/HttpRequestInvalidTest.php +++ b/tests/Unit/Exceptions/HttpRequestInvalidTest.php @@ -11,13 +11,12 @@ use RuntimeException; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Exceptions\HttpException; -use TinyBlocks\Http\Exceptions\HttpRequestFailed; use TinyBlocks\Http\Exceptions\HttpRequestInvalid; use TinyBlocks\Http\Method; final class HttpRequestInvalidTest extends TestCase { - public function testFromBuildsExceptionWithAllFields(): void + public function testFromWhenAllFieldsGivenThenExposesEveryAccessor(): void { /** @Given a URL, method, and reason */ $url = 'https://api.example.com/dragons'; @@ -27,15 +26,15 @@ public function testFromBuildsExceptionWithAllFields(): void /** @When constructing the exception */ $exception = HttpRequestInvalid::from(url: $url, method: $method, reason: $reason); - /** @Then it is an instance of HttpRequestFailed and carries the correct fields */ - self::assertInstanceOf(HttpRequestFailed::class, $exception); + /** @Then it implements HttpException and carries the correct fields */ + self::assertInstanceOf(HttpException::class, $exception); self::assertSame($url, $exception->url()); self::assertSame($method, $exception->method()); self::assertSame($reason, $exception->reason()); - self::assertSame($reason, $exception->getMessage()); + self::assertStringContainsString($reason, $exception->getMessage()); } - public function testFromChainsPreviousThrowable(): void + public function testFromWhenPreviousGivenThenPreservesChain(): void { /** @Given a previous throwable */ $previous = new RuntimeException('bad request object'); @@ -52,7 +51,7 @@ public function testFromChainsPreviousThrowable(): void self::assertSame($previous, $exception->getPrevious()); } - public function testFromClientExceptionBuildsFromRequestException(): void + public function testFromClientExceptionWhenRequestExceptionGivenThenWrapsOriginal(): void { /** @Given a request and a request exception */ $request = Request::create(url: 'https://api.example.com/dragons', method: Method::POST); @@ -71,17 +70,4 @@ public function getRequest(): RequestInterface self::assertSame($requestException, $exception->getPrevious()); self::assertInstanceOf(HttpException::class, $exception); } - - public function testExceptionIsCatchableAsHttpRequestFailed(): void - { - /** @Given an HttpRequestInvalid exception */ - $exception = HttpRequestInvalid::from( - url: 'https://api.example.com', - method: Method::GET, - reason: 'Invalid.' - ); - - /** @Then it is catchable as HttpRequestFailed */ - self::assertInstanceOf(HttpRequestFailed::class, $exception); - } } diff --git a/tests/Unit/Exceptions/MalformedPathTest.php b/tests/Unit/Exceptions/MalformedPathTest.php index 8e44edf..70d0993 100644 --- a/tests/Unit/Exceptions/MalformedPathTest.php +++ b/tests/Unit/Exceptions/MalformedPathTest.php @@ -7,13 +7,12 @@ use PHPUnit\Framework\TestCase; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Exceptions\HttpException; -use TinyBlocks\Http\Exceptions\HttpRequestFailed; use TinyBlocks\Http\Exceptions\MalformedPath; use TinyBlocks\Http\Method; final class MalformedPathTest extends TestCase { - public function testFromRequestBuildsExceptionWithPathInUrl(): void + public function testFromRequestWhenMalformedPathGivenThenExposesPath(): void { /** @Given a request with a malformed path */ $request = Request::create(url: '//evil.example.com/attack', method: Method::GET); @@ -27,19 +26,18 @@ public function testFromRequestBuildsExceptionWithPathInUrl(): void self::assertStringContainsString('//evil.example.com/attack', $exception->reason()); } - public function testMalformedPathIsCatchableAsHttpRequestFailed(): void + public function testFromRequestWhenAnyMalformedPathGivenThenImplementsHttpException(): void { - /** @Given a MalformedPath exception */ + /** @Given a MalformedPath exception built from a scheme-containing path */ $exception = MalformedPath::fromRequest( request: Request::create(url: 'javascript:alert(1)') ); - /** @Then it is catchable as HttpRequestFailed and HttpException */ - self::assertInstanceOf(HttpRequestFailed::class, $exception); + /** @Then it is catchable as HttpException */ self::assertInstanceOf(HttpException::class, $exception); } - public function testMalformedPathReasonDescribesThePath(): void + public function testFromRequestWhenSchemePathGivenThenReasonDescribesPath(): void { /** @Given a request with a scheme-containing path */ $request = Request::create(url: 'https://attacker.com/steal'); diff --git a/tests/Unit/Exceptions/NoMoreResponsesTest.php b/tests/Unit/Exceptions/NoMoreResponsesTest.php index 285e1e5..a307fed 100644 --- a/tests/Unit/Exceptions/NoMoreResponsesTest.php +++ b/tests/Unit/Exceptions/NoMoreResponsesTest.php @@ -4,28 +4,28 @@ namespace Test\TinyBlocks\Http\Unit\Exceptions; -use LogicException; use PHPUnit\Framework\TestCase; +use TinyBlocks\Http\Exceptions\HttpException; use TinyBlocks\Http\Exceptions\NoMoreResponses; final class NoMoreResponsesTest extends TestCase { - public function testAtIndexCreatesExceptionWithCorrectMessage(): void + public function testAtIndexWhenIndexThreeGivenThenMessageReferencesIndex(): void { /** @When creating the exception at index 3 */ $exception = NoMoreResponses::atIndex(index: 3); - /** @Then the message references the index */ + /** @Then the message references the index and the exception is an HttpException */ self::assertStringContainsString('3', $exception->getMessage()); - self::assertInstanceOf(LogicException::class, $exception); + self::assertInstanceOf(HttpException::class, $exception); } - public function testAtIndexZeroCreatesExceptionWithCorrectMessage(): void + public function testAtIndexWhenIndexZeroGivenThenMessageReferencesIndexAndTransport(): void { /** @When creating the exception at index 0 */ $exception = NoMoreResponses::atIndex(index: 0); - /** @Then the message references index 0 */ + /** @Then the message references index 0 and the InMemoryTransport */ self::assertStringContainsString('0', $exception->getMessage()); self::assertStringContainsString('InMemoryTransport', $exception->getMessage()); } diff --git a/tests/Unit/HeadersTest.php b/tests/Unit/HeadersTest.php index 8a583fa..ee4c562 100644 --- a/tests/Unit/HeadersTest.php +++ b/tests/Unit/HeadersTest.php @@ -13,18 +13,18 @@ final class HeadersTest extends TestCase { - public function testFromArrayCreatesHeadersWithEntries(): void + public function testConstructorWhenEntriesGivenThenExposesEachEntry(): void { /** @Given an array of headers */ - /** @When creating Headers from an array */ - $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); + /** @When creating Headers from a constructor */ + $headers = new Headers(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); /** @Then the entries are accessible */ self::assertSame('application/json', $headers->get('Content-Type')); self::assertSame('application/json', $headers->get('Accept')); } - public function testFromMergesMultipleHeaderables(): void + public function testFromWhenMultipleHeaderablesGivenThenMergesEntries(): void { /** @Given two headerable instances */ $contentType = ContentType::applicationJson(charset: Charset::UTF_8); @@ -38,7 +38,7 @@ public function testFromMergesMultipleHeaderables(): void self::assertTrue($headers->has('Set-Cookie')); } - public function testFromWithNoArgumentsReturnsEmptyHeaders(): void + public function testFromWhenNoArgumentsGivenThenReturnsEmptyHeaders(): void { /** @When creating Headers with no headerable arguments */ $headers = Headers::from(); @@ -47,7 +47,7 @@ public function testFromWithNoArgumentsReturnsEmptyHeaders(): void self::assertSame([], $headers->toArray()); } - public function testFromMessageWithEmptyHeadersReturnsEmptyHeaders(): void + public function testFromMessageWhenEmptyHeadersGivenThenReturnsEmptyHeaders(): void { /** @Given a PSR-7 response with no headers */ $psrResponse = (new Psr17Factory())->createResponse(200); @@ -59,7 +59,7 @@ public function testFromMessageWithEmptyHeadersReturnsEmptyHeaders(): void self::assertSame([], $headers->toArray()); } - public function testFromMessageFoldsMultiValueHeadersWithCommaSeparator(): void + public function testFromMessageWhenMultiValueHeaderGivenThenFoldsWithComma(): void { /** @Given a PSR-7 response with a header that carries multiple values */ $psrResponse = (new Psr17Factory())->createResponse(200) @@ -73,10 +73,10 @@ public function testFromMessageFoldsMultiValueHeadersWithCommaSeparator(): void self::assertSame('application/json, text/html', $headers->get('Accept')); } - public function testApplyToOnEmptyHeadersReturnsOriginalMessageUnchanged(): void + public function testApplyToWhenEmptyHeadersGivenThenReturnsMessageUnchanged(): void { /** @Given an empty Headers instance */ - $headers = Headers::fromArray(entries: []); + $headers = new Headers(entries: []); /** @And a PSR-7 request */ $psrRequest = (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); @@ -88,10 +88,10 @@ public function testApplyToOnEmptyHeadersReturnsOriginalMessageUnchanged(): void self::assertSame($psrRequest, $applied); } - public function testApplyToAttachesEntriesAndLeavesOriginalUnchanged(): void + public function testApplyToWhenEntriesGivenThenAttachesAndLeavesOriginalUnchanged(): void { /** @Given a Headers instance with one entry */ - $headers = Headers::fromArray(entries: ['X-Trace' => 'abc']); + $headers = new Headers(entries: ['X-Trace' => 'abc']); /** @And a PSR-7 request */ $psrRequest = (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); @@ -106,10 +106,10 @@ public function testApplyToAttachesEntriesAndLeavesOriginalUnchanged(): void self::assertSame('', $psrRequest->getHeaderLine('X-Trace')); } - public function testGetIsCaseInsensitive(): void + public function testGetWhenMixedCaseKeyGivenThenLookupIsCaseInsensitive(): void { /** @Given headers with a mixed-case key */ - $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); + $headers = new Headers(entries: ['Content-Type' => 'application/json']); /** @When looking up with different casing */ /** @Then the lookup succeeds */ @@ -118,20 +118,20 @@ public function testGetIsCaseInsensitive(): void self::assertSame('application/json', $headers->get('Content-Type')); } - public function testGetReturnsNullForMissingKey(): void + public function testGetWhenMissingKeyGivenThenReturnsNull(): void { /** @Given headers with one entry */ - $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); + $headers = new Headers(entries: ['Content-Type' => 'application/json']); /** @When looking up a non-existent header */ /** @Then null is returned */ self::assertNull($headers->get('X-Missing')); } - public function testHasIsCaseInsensitive(): void + public function testHasWhenMixedCaseKeyGivenThenIsCaseInsensitive(): void { /** @Given headers with a mixed-case key */ - $headers = Headers::fromArray(entries: ['X-Trace' => 'abc']); + $headers = new Headers(entries: ['X-Trace' => 'abc']); /** @When checking existence with different casing */ /** @Then has() returns true regardless of case */ @@ -140,36 +140,36 @@ public function testHasIsCaseInsensitive(): void self::assertTrue($headers->has('X-Trace')); } - public function testHasReturnsFalseForMissingKey(): void + public function testHasWhenMissingKeyGivenThenReturnsFalse(): void { /** @Given empty headers */ - $headers = Headers::fromArray(entries: []); + $headers = new Headers(entries: []); /** @When checking for a non-existent header */ /** @Then has() returns false */ self::assertFalse($headers->has('Content-Type')); } - public function testMergedWithDefaultAppearsWhenNoConflict(): void + public function testMergedWithWhenOtherHasNewEntriesThenBothAppearInResult(): void { /** @Given headers with one entry */ - $headers = Headers::fromArray(entries: ['Accept' => 'application/json']); + $headers = new Headers(entries: ['Accept' => 'application/json']); - /** @When merging with a default that does not conflict */ - $merged = $headers->mergedWith(defaults: ['Content-Type' => 'application/json']); + /** @When merging with a Headers carrying a default that does not conflict */ + $merged = $headers->mergedWith(other: new Headers(entries: ['Content-Type' => 'application/json'])); /** @Then both entries are present */ self::assertSame('application/json', $merged->get('Accept')); self::assertSame('application/json', $merged->get('Content-Type')); } - public function testMergedWithExistingHeaderWinsOverDefault(): void + public function testMergedWithWhenOtherCollidesThenExistingEntryWins(): void { /** @Given headers with a Content-Type entry */ - $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json; charset=utf-8']); + $headers = new Headers(entries: ['Content-Type' => 'application/json; charset=utf-8']); - /** @When merging with a default Content-Type */ - $merged = $headers->mergedWith(defaults: ['Content-Type' => 'application/json']); + /** @When merging with a Headers carrying a default Content-Type */ + $merged = $headers->mergedWith(other: new Headers(entries: ['Content-Type' => 'application/json'])); /** @Then the existing header wins */ self::assertSame('application/json; charset=utf-8', $merged->get('Content-Type')); @@ -178,13 +178,13 @@ public function testMergedWithExistingHeaderWinsOverDefault(): void self::assertCount(1, $merged->toArray()); } - public function testMergedWithIsCaseInsensitiveWhenCheckingConflicts(): void + public function testMergedWithWhenCasingDiffersThenStillTreatsAsCollision(): void { /** @Given headers with a lowercase key */ - $headers = Headers::fromArray(entries: ['content-type' => 'application/json; charset=utf-8']); + $headers = new Headers(entries: ['content-type' => 'application/json; charset=utf-8']); - /** @When merging with a default that uses mixed casing */ - $merged = $headers->mergedWith(defaults: ['Content-Type' => 'application/json']); + /** @When merging with a Headers using mixed casing */ + $merged = $headers->mergedWith(other: new Headers(entries: ['Content-Type' => 'application/json'])); /** @Then the existing header wins despite different casing */ self::assertSame('application/json; charset=utf-8', $merged->get('content-type')); @@ -193,10 +193,10 @@ public function testMergedWithIsCaseInsensitiveWhenCheckingConflicts(): void self::assertCount(1, $merged->toArray()); } - public function testToArrayReturnsAllEntries(): void + public function testToArrayWhenMultipleEntriesGivenThenReturnsAll(): void { /** @Given headers with two entries */ - $headers = Headers::fromArray(entries: ['X-Trace' => 'abc', 'X-Request-ID' => '123']); + $headers = new Headers(entries: ['X-Trace' => 'abc', 'X-Request-ID' => '123']); /** @When converting to array */ $array = $headers->toArray(); diff --git a/tests/Unit/HttpBuilderTest.php b/tests/Unit/HttpBuilderTest.php index 31d729d..a166a3b 100644 --- a/tests/Unit/HttpBuilderTest.php +++ b/tests/Unit/HttpBuilderTest.php @@ -6,9 +6,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; +use Test\TinyBlocks\Http\Fixtures\Client\CapturingClient; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Client\Response; use TinyBlocks\Http\Client\Transports\InMemoryTransport; @@ -20,7 +18,7 @@ final class HttpBuilderTest extends TestCase { - public function testCreateReturnsEmptyBuilder(): void + public function testCreateWhenInvokedThenReturnsEmptyBuilder(): void { /** @When calling Http::create() */ $builder = Http::create(); @@ -29,20 +27,15 @@ public function testCreateReturnsEmptyBuilder(): void self::assertInstanceOf(HttpBuilder::class, $builder); } - public function testWithTransportReturnNewBuilderAndOriginalIsUnchanged(): void + public function testWithTransportWhenInvokedThenReturnsNewBuilderAndOriginalIsUntouched(): void { /** @Given an empty builder */ $original = Http::create(); - $factory = new Psr17Factory(); + /** @And a fresh transport */ $transport = NetworkTransport::with( - client: new class implements ClientInterface { - public function sendRequest(RequestInterface $request): ResponseInterface - { - return new Psr17Factory()->createResponse(200); - } - }, - factory: $factory + client: CapturingClient::returningStatus(statusCode: 200), + factory: new Psr17Factory() ); /** @When calling withTransport */ @@ -54,7 +47,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface $original->build(); } - public function testWithBaseUrlReturnsNewBuilderAndOriginalIsUnchanged(): void + public function testWithBaseUrlWhenInvokedThenReturnsNewBuilderAndOriginalIsUntouched(): void { /** @Given an empty builder */ $original = Http::create(); @@ -68,7 +61,7 @@ public function testWithBaseUrlReturnsNewBuilderAndOriginalIsUnchanged(): void $original->build(); } - public function testBuildWithoutTransportThrowsHttpConfigurationInvalid(): void + public function testBuildWhenTransportMissingThenThrowsHttpConfigurationInvalid(): void { /** @Given a builder with no transport */ $builder = Http::create()->withBaseUrl(url: 'https://api.example.com'); @@ -81,7 +74,7 @@ public function testBuildWithoutTransportThrowsHttpConfigurationInvalid(): void $builder->build(); } - public function testBuildWithoutBaseUrlThrowsHttpConfigurationInvalid(): void + public function testBuildWhenBaseUrlMissingThenThrowsHttpConfigurationInvalid(): void { /** @Given a builder with no base URL */ $builder = Http::create()->withTransport( @@ -96,12 +89,10 @@ public function testBuildWithoutBaseUrlThrowsHttpConfigurationInvalid(): void $builder->build(); } - public function testFullyConfiguredBuilderProducesWorkingHttp(): void + public function testBuildWhenFullyConfiguredThenProducesWorkingHttp(): void { /** @Given a fully configured builder */ - $transport = InMemoryTransport::with( - responses: [Response::with(code: Code::OK)] - ); + $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') @@ -115,12 +106,10 @@ public function testFullyConfiguredBuilderProducesWorkingHttp(): void self::assertSame(Code::OK, $response->code()); } - public function testWithReturnsWorkingHttpInstance(): void + public function testWithWhenInvokedDirectlyThenReturnsWorkingHttp(): void { /** @Given a transport seeded with one response */ - $transport = InMemoryTransport::with( - responses: [Response::with(code: Code::OK)] - ); + $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); /** @When constructing Http directly via Http::with */ $http = Http::with(baseUrl: 'https://api.example.com', transport: $transport); diff --git a/tests/Unit/HttpTest.php b/tests/Unit/HttpTest.php index aca3a5d..6e93b6e 100644 --- a/tests/Unit/HttpTest.php +++ b/tests/Unit/HttpTest.php @@ -7,13 +7,12 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientExceptionInterface; -use Psr\Http\Client\ClientInterface; use Psr\Http\Client\NetworkExceptionInterface; use Psr\Http\Client\RequestExceptionInterface; use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; use RuntimeException; -use Throwable; +use Test\TinyBlocks\Http\Fixtures\Client\CapturingClient; +use Test\TinyBlocks\Http\Fixtures\Client\ThrowingClient; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Client\Transports\NetworkTransport; use TinyBlocks\Http\Code; @@ -33,14 +32,15 @@ protected function setUp(): void $this->factory = new Psr17Factory(); } - public function testSendReturnsResponseWithCorrectCode(): void + public function testSendWhenTransportRespondsThenReturnsResponseWithMatchingCode(): void { /** @Given a transport seeded with a 200 response */ - $transport = $this->buildTransport(statusCode: 200); - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') - ->withTransport(transport: $transport) + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) ->build(); /** @When sending a valid request */ @@ -50,14 +50,15 @@ public function testSendReturnsResponseWithCorrectCode(): void self::assertSame(Code::OK, $response->code()); } - public function testBaseUrlWithTrailingSlashAndPathWithLeadingSlashProducesNoDoubleSlash(): void + public function testSendWhenBaseUrlEndsWithSlashAndPathLeadsWithSlashThenNoDoubleSlash(): void { /** @Given a transport seeded with a 200 response and a base URL ending in slash */ - $transport = $this->buildTransport(statusCode: 200); - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com/') - ->withTransport(transport: $transport) + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) ->build(); /** @When sending a request whose path starts with a slash */ @@ -67,14 +68,15 @@ public function testBaseUrlWithTrailingSlashAndPathWithLeadingSlashProducesNoDou self::assertSame(Code::OK, $response->code()); } - public function testQueryParametersAreAppendedAsRfc3986(): void + public function testSendWhenQueryGivenThenAppendsAsRfc3986(): void { /** @Given a transport seeded with a 200 response */ - $transport = $this->buildTransport(statusCode: 200); - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') - ->withTransport(transport: $transport) + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) ->build(); /** @When sending a request with query parameters */ @@ -86,14 +88,15 @@ public function testQueryParametersAreAppendedAsRfc3986(): void self::assertSame(Code::OK, $response->code()); } - public function testRequestWithBodySendsJsonPayload(): void + public function testSendWhenBodyGivenThenSendsJsonPayload(): void { /** @Given a transport seeded with a 201 response */ - $transport = $this->buildTransport(statusCode: 201); - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') - ->withTransport(transport: $transport) + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 201), + factory: $this->factory + )) ->build(); /** @When sending a request with a JSON body */ @@ -105,7 +108,7 @@ public function testRequestWithBodySendsJsonPayload(): void self::assertSame(Code::CREATED, $response->code()); } - public function testNetworkExceptionMapsToHttpNetworkFailed(): void + public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFailed(): void { /** @Given a PSR-18 client that throws NetworkExceptionInterface */ $networkException = new class ('connection refused') extends RuntimeException implements @@ -116,14 +119,12 @@ public function getRequest(): RequestInterface } }; - $transport = NetworkTransport::with( - client: $this->buildFailingClient(exception: $networkException), - factory: $this->factory - ); - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') - ->withTransport(transport: $transport) + ->withTransport(transport: NetworkTransport::with( + client: ThrowingClient::throwing(exception: $networkException), + factory: $this->factory + )) ->build(); /** @Then HttpNetworkFailed is thrown */ @@ -133,7 +134,7 @@ public function getRequest(): RequestInterface $http->send(request: Request::create(url: '/dragons')); } - public function testRequestExceptionMapsToHttpRequestInvalid(): void + public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInvalid(): void { /** @Given a PSR-18 client that throws RequestExceptionInterface */ $requestException = new class ('bad request') extends RuntimeException implements RequestExceptionInterface { @@ -143,14 +144,12 @@ public function getRequest(): RequestInterface } }; - $transport = NetworkTransport::with( - client: $this->buildFailingClient(exception: $requestException), - factory: $this->factory - ); - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') - ->withTransport(transport: $transport) + ->withTransport(transport: NetworkTransport::with( + client: ThrowingClient::throwing(exception: $requestException), + factory: $this->factory + )) ->build(); /** @Then HttpRequestInvalid is thrown */ @@ -160,20 +159,18 @@ public function getRequest(): RequestInterface $http->send(request: Request::create(url: '/dragons')); } - public function testGenericClientExceptionMapsToHttpRequestFailed(): void + public function testSendWhenGenericClientExceptionRaisedThenThrowsHttpRequestFailed(): void { /** @Given a PSR-18 client that throws a generic ClientExceptionInterface */ $clientException = new class ('generic failure') extends RuntimeException implements ClientExceptionInterface { }; - $transport = NetworkTransport::with( - client: $this->buildFailingClient(exception: $clientException), - factory: $this->factory - ); - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') - ->withTransport(transport: $transport) + ->withTransport(transport: NetworkTransport::with( + client: ThrowingClient::throwing(exception: $clientException), + factory: $this->factory + )) ->build(); /** @Then HttpRequestFailed is thrown */ @@ -183,14 +180,15 @@ public function testGenericClientExceptionMapsToHttpRequestFailed(): void $http->send(request: Request::create(url: '/dragons')); } - public function testMalformedPathWithProtocolRelativeThrowsMalformedPath(): void + public function testSendWhenProtocolRelativePathGivenThenThrowsMalformedPath(): void { /** @Given an Http instance with a base URL */ - $transport = $this->buildTransport(statusCode: 200); - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') - ->withTransport(transport: $transport) + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) ->build(); /** @Then MalformedPath is thrown without invoking the transport */ @@ -200,14 +198,15 @@ public function testMalformedPathWithProtocolRelativeThrowsMalformedPath(): void $http->send(request: Request::create(url: '//evil.example.com/attack')); } - public function testMalformedPathWithSchemeThrowsMalformedPath(): void + public function testSendWhenSchemePathGivenThenThrowsMalformedPath(): void { /** @Given an Http instance with a base URL */ - $transport = $this->buildTransport(statusCode: 200); - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') - ->withTransport(transport: $transport) + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) ->build(); /** @Then MalformedPath is thrown */ @@ -217,14 +216,15 @@ public function testMalformedPathWithSchemeThrowsMalformedPath(): void $http->send(request: Request::create(url: 'javascript:alert(1)')); } - public function testMalformedPathWithControlCharactersThrowsMalformedPath(): void + public function testSendWhenControlCharsInPathGivenThenThrowsMalformedPath(): void { /** @Given an Http instance with a base URL */ - $transport = $this->buildTransport(statusCode: 200); - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') - ->withTransport(transport: $transport) + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) ->build(); /** @Then MalformedPath is thrown */ @@ -234,7 +234,7 @@ public function testMalformedPathWithControlCharactersThrowsMalformedPath(): voi $http->send(request: Request::create(url: "/dragons\x00/evil")); } - public function testNetworkExceptionPreservesPreviousChain(): void + public function testSendWhenNetworkExceptionRaisedThenPreservesPreviousChain(): void { /** @Given a network exception */ $networkException = new class ('timeout') extends RuntimeException implements NetworkExceptionInterface { @@ -244,57 +244,22 @@ public function getRequest(): RequestInterface } }; - $transport = NetworkTransport::with( - client: $this->buildFailingClient(exception: $networkException), - factory: $this->factory - ); - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') - ->withTransport(transport: $transport) + ->withTransport(transport: NetworkTransport::with( + client: ThrowingClient::throwing(exception: $networkException), + factory: $this->factory + )) ->build(); /** @When sending the request */ try { $http->send(request: Request::create(url: '/dragons')); + self::fail('HttpNetworkFailed was expected.'); } catch (HttpNetworkFailed $exception) { /** @Then the previous exception is preserved in the chain */ self::assertSame($networkException, $exception->getPrevious()); } } - private function buildTransport(int $statusCode): NetworkTransport - { - $response = $this->factory->createResponse($statusCode); - - $client = new readonly class ($response) implements ClientInterface { - public function __construct(private ResponseInterface $response) - { - } - - public function sendRequest(RequestInterface $request): ResponseInterface - { - return $this->response; - } - }; - - return NetworkTransport::with( - client: $client, - factory: $this->factory - ); - } - - private function buildFailingClient(Throwable $exception): ClientInterface - { - return new readonly class ($exception) implements ClientInterface { - public function __construct(private Throwable $exception) - { - } - - public function sendRequest(RequestInterface $request): ResponseInterface - { - throw $this->exception; - } - }; - } } diff --git a/tests/Unit/Internal/Client/CursorTest.php b/tests/Unit/Internal/Client/CursorTest.php index a4ccee4..63d9b84 100644 --- a/tests/Unit/Internal/Client/CursorTest.php +++ b/tests/Unit/Internal/Client/CursorTest.php @@ -9,7 +9,7 @@ final class CursorTest extends TestCase { - public function testFirstAdvanceReturnsZero(): void + public function testAdvanceWhenInvokedFirstTimeThenReturnsZero(): void { /** @Given a new cursor */ $cursor = new Cursor(); @@ -21,7 +21,7 @@ public function testFirstAdvanceReturnsZero(): void self::assertSame(0, $position); } - public function testSecondAdvanceReturnsOne(): void + public function testAdvanceWhenInvokedTwiceThenReturnsOne(): void { /** @Given a cursor that has been advanced once */ $cursor = new Cursor(); @@ -34,7 +34,7 @@ public function testSecondAdvanceReturnsOne(): void self::assertSame(1, $position); } - public function testThirdAdvanceReturnsTwo(): void + public function testAdvanceWhenInvokedThreeTimesThenReturnsTwo(): void { /** @Given a cursor that has been advanced twice */ $cursor = new Cursor(); diff --git a/tests/Unit/Internal/Client/RequestResolverTest.php b/tests/Unit/Internal/Client/RequestResolverTest.php index eeff83d..69fcca5 100644 --- a/tests/Unit/Internal/Client/RequestResolverTest.php +++ b/tests/Unit/Internal/Client/RequestResolverTest.php @@ -14,7 +14,7 @@ final class RequestResolverTest extends TestCase { - public function testRequestWithoutExplicitHeadersReceivesJsonDefaults(): void + public function testResolveWhenNoExplicitHeadersThenAppliesJsonDefaults(): void { /** @Given a resolver with a base URL and a request with no headers */ $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); @@ -28,14 +28,14 @@ public function testRequestWithoutExplicitHeadersReceivesJsonDefaults(): void self::assertSame('application/json', $resolved->headers->get('Accept')); } - public function testExplicitContentTypeWinsOverDefault(): void + public function testResolveWhenExplicitContentTypeGivenThenWinsOverDefault(): void { /** @Given a resolver and a request with an explicit Content-Type */ $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); $request = Request::create( url: '/dragons', method: Method::POST, - headerables: ContentType::applicationJson(charset: Charset::UTF_8) + headers: ContentType::applicationJson(charset: Charset::UTF_8) ); /** @When resolving the request */ @@ -45,7 +45,7 @@ public function testExplicitContentTypeWinsOverDefault(): void self::assertSame('application/json; charset=utf-8', $resolved->headers->get('Content-Type')); } - public function testRelativeUrlIsComposedWithBaseUrl(): void + public function testResolveWhenRelativeUrlGivenThenComposesAgainstBaseUrl(): void { /** @Given a resolver with a base URL and a request with a relative path */ $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); @@ -58,7 +58,7 @@ public function testRelativeUrlIsComposedWithBaseUrl(): void self::assertSame('https://api.example.com/dragons', $resolved->url); } - public function testQueryIsEmbeddedInUrlAndClearedFromRequest(): void + public function testResolveWhenQueryGivenThenEmbedsInUrlAndClearsRequestQuery(): void { /** @Given a resolver and a request with query parameters */ $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); @@ -70,10 +70,10 @@ public function testQueryIsEmbeddedInUrlAndClearedFromRequest(): void /** @Then the query is embedded in the URL and cleared from the request object */ self::assertStringContainsString('sort=name', $resolved->url); self::assertStringContainsString('order=asc', $resolved->url); - self::assertSame([], $resolved->query); + self::assertNull($resolved->query); } - public function testMalformedPathThrowsMalformedPath(): void + public function testResolveWhenMalformedPathGivenThenThrowsMalformedPath(): void { /** @Given a resolver and a request with a malformed path */ $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); diff --git a/tests/Unit/Internal/Client/UrlTest.php b/tests/Unit/Internal/Client/UrlTest.php index eb0d297..b756120 100644 --- a/tests/Unit/Internal/Client/UrlTest.php +++ b/tests/Unit/Internal/Client/UrlTest.php @@ -10,34 +10,34 @@ final class UrlTest extends TestCase { - public function testBaseUrlWithTrailingSlashAndPathWithLeadingSlashProducesNoDoubleSlash(): void + public function testComposeWhenBaseUrlEndsWithSlashAndPathStartsWithSlashThenNoDoubleSlash(): void { /** @When composing a URL with trailing base slash and leading path slash */ - $url = Url::compose(path: '/dragons', query: [], baseUrl: 'https://api.example.com/'); + $url = Url::compose(path: '/dragons', query: null, baseUrl: 'https://api.example.com/'); /** @Then the result has exactly one slash between host and path */ - self::assertSame('https://api.example.com/dragons', $url->value); + self::assertSame('https://api.example.com/dragons', $url); } - public function testBaseUrlWithoutTrailingSlashAndPathWithLeadingSlashProducesCorrectUrl(): void + public function testComposeWhenBaseUrlHasNoTrailingSlashThenStillComposesCorrectly(): void { /** @When composing a URL without trailing slash and with leading path slash */ - $url = Url::compose(path: '/dragons', query: [], baseUrl: 'https://api.example.com'); + $url = Url::compose(path: '/dragons', query: null, baseUrl: 'https://api.example.com'); /** @Then the result is correct without double slash */ - self::assertSame('https://api.example.com/dragons', $url->value); + self::assertSame('https://api.example.com/dragons', $url); } - public function testEmptyBaseUrlUsesRelativePathAsIs(): void + public function testComposeWhenBaseUrlEmptyThenReturnsPathUnchanged(): void { /** @When composing with no base URL and a relative path */ - $url = Url::compose(path: '/dragons', query: [], baseUrl: ''); + $url = Url::compose(path: '/dragons', query: null, baseUrl: ''); /** @Then the relative path is used as-is */ - self::assertSame('/dragons', $url->value); + self::assertSame('/dragons', $url); } - public function testQueryParametersAreAppendedAsRfc3986(): void + public function testComposeWhenQueryGivenThenAppendsAsRfc3986(): void { /** @Given a path and query parameters */ /** @When composing with query parameters */ @@ -48,78 +48,78 @@ public function testQueryParametersAreAppendedAsRfc3986(): void ); /** @Then the query is appended with RFC 3986 encoding */ - self::assertStringContainsString('?sort=name&order=asc', $url->value); + self::assertStringContainsString('?sort=name&order=asc', $url); } - public function testEmptyQueryProducesNoTrailingQuestionMark(): void + public function testComposeWhenQueryEmptyThenNoTrailingQuestionMark(): void { /** @When composing with an empty query array */ $url = Url::compose(path: '/dragons', query: [], baseUrl: 'https://api.example.com'); /** @Then the URL has no trailing question mark */ - self::assertStringNotContainsString('?', $url->value); + self::assertStringNotContainsString('?', $url); + } + + public function testComposeWhenQueryNullThenNoTrailingQuestionMark(): void + { + /** @When composing with a null query */ + $url = Url::compose(path: '/dragons', query: null, baseUrl: 'https://api.example.com'); + + /** @Then the URL has no trailing question mark */ + self::assertStringNotContainsString('?', $url); } - public function testProtocolRelativePathThrowsInvalidArgumentException(): void + public function testComposeWhenProtocolRelativePathGivenThenThrowsInvalidArgument(): void { /** @Then InvalidArgumentException is thrown */ $this->expectException(InvalidArgumentException::class); /** @When composing a path starting with // */ - Url::compose(path: '//evil.example.com/attack', query: [], baseUrl: 'https://api.example.com'); + Url::compose(path: '//evil.example.com/attack', query: null, baseUrl: 'https://api.example.com'); } - public function testProtocolRelativePathThrowsEvenWithEmptyBaseUrl(): void + public function testComposeWhenProtocolRelativePathGivenWithEmptyBaseThenStillThrows(): void { /** @Then InvalidArgumentException is thrown */ $this->expectException(InvalidArgumentException::class); /** @When composing a protocol-relative path with an empty base URL */ - Url::compose(path: '//evil.example.com/attack', query: [], baseUrl: ''); + Url::compose(path: '//evil.example.com/attack', query: null, baseUrl: ''); } - public function testPathWithSchemeThrowsInvalidArgumentException(): void + public function testComposeWhenSchemePathGivenThenThrowsInvalidArgument(): void { /** @Then InvalidArgumentException is thrown */ $this->expectException(InvalidArgumentException::class); /** @When composing a path with https:// scheme */ - Url::compose(path: 'https://attacker.com/steal', query: [], baseUrl: 'https://api.example.com'); + Url::compose(path: 'https://attacker.com/steal', query: null, baseUrl: 'https://api.example.com'); } - public function testPathWithSchemeThrowsEvenWithEmptyBaseUrl(): void + public function testComposeWhenSchemePathGivenWithEmptyBaseThenStillThrows(): void { /** @Then InvalidArgumentException is thrown */ $this->expectException(InvalidArgumentException::class); /** @When composing a path with a scheme and empty base URL */ - Url::compose(path: 'https://attacker.com/steal', query: [], baseUrl: ''); + Url::compose(path: 'https://attacker.com/steal', query: null, baseUrl: ''); } - public function testJavascriptSchemePathThrowsInvalidArgumentException(): void + public function testComposeWhenJavascriptSchemePathGivenThenThrowsInvalidArgument(): void { /** @Then InvalidArgumentException is thrown */ $this->expectException(InvalidArgumentException::class); /** @When composing a path with javascript: scheme */ - Url::compose(path: 'javascript:alert(1)', query: [], baseUrl: 'https://api.example.com'); + Url::compose(path: 'javascript:alert(1)', query: null, baseUrl: 'https://api.example.com'); } - public function testPathWithControlCharactersThrowsInvalidArgumentException(): void + public function testComposeWhenControlCharactersGivenThenThrowsInvalidArgument(): void { /** @Then InvalidArgumentException is thrown */ $this->expectException(InvalidArgumentException::class); /** @When composing a path containing a null byte */ - Url::compose(path: "/dragons\x00/evil", query: [], baseUrl: 'https://api.example.com'); - } - - public function testToStringReturnsSameValueAsPublicProperty(): void - { - /** @Given a composed URL */ - $url = Url::compose(path: '/dragons', query: [], baseUrl: 'https://api.example.com'); - - /** @Then toString() returns the same value as the value property */ - self::assertSame($url->value, $url->toString()); + Url::compose(path: "/dragons\x00/evil", query: null, baseUrl: 'https://api.example.com'); } } diff --git a/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php b/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php index 562eb66..a6abf49 100644 --- a/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php +++ b/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php @@ -4,32 +4,27 @@ namespace Test\TinyBlocks\Http\Unit\Internal\Server\Request; +use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ServerRequestInterface; use TinyBlocks\Http\Internal\Server\Request\RouteParameterResolver; final class RouteParameterResolverTest extends TestCase { - public function testResolveWithArrayAttribute(): void + public function testResolveWhenArrayAttributeGivenThenReturnsItDirectly(): void { /** @Given a request with an array attribute under __route__ */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => ['id' => '42', 'slug' => 'test'], - default => null - }); + $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) + ->withAttribute('__route__', ['id' => '42', 'slug' => 'test']); /** @When resolving parameters */ $resolver = RouteParameterResolver::from(request: $serverRequest); $params = $resolver->resolve(attributeName: '__route__'); - /** @Then the array should be returned directly */ + /** @Then the array is returned directly */ self::assertSame(['id' => '42', 'slug' => 'test'], $params); } - public function testResolveWithObjectUsingGetArguments(): void + public function testResolveWhenObjectExposesGetArgumentsThenUsesThatMethod(): void { /** @Given a Slim-style route object */ $routeObject = new class { @@ -39,23 +34,17 @@ public function getArguments(): array } }; - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $routeObject, - default => null - }); + $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) + ->withAttribute('__route__', $routeObject); /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: '__route__'); + $params = RouteParameterResolver::from(request: $serverRequest)->resolve(attributeName: '__route__'); - /** @Then getArguments() result should be returned */ + /** @Then getArguments() result is returned */ self::assertSame(['id' => '1', 'name' => 'dragon'], $params); } - public function testResolveWithObjectUsingGetMatchedParams(): void + public function testResolveWhenObjectExposesGetMatchedParamsThenUsesThatMethod(): void { /** @Given a Mezzio-style route result object */ $routeResult = new class { @@ -65,62 +54,46 @@ public function getMatchedParams(): array } }; - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - 'routeResult' => $routeResult, - default => null - }); + $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) + ->withAttribute('routeResult', $routeResult); /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: 'routeResult'); + $params = RouteParameterResolver::from(request: $serverRequest)->resolve(attributeName: 'routeResult'); - /** @Then getMatchedParams() result should be returned */ + /** @Then getMatchedParams() result is returned */ self::assertSame(['id' => '99', 'action' => 'view'], $params); } - public function testResolveWithObjectUsingPublicProperty(): void + public function testResolveWhenObjectExposesPublicPropertyThenReadsIt(): void { /** @Given a route object with a public arguments property */ $routeObject = new class { public array $arguments = ['key' => 'value']; }; - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $routeObject, - default => null - }); + $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) + ->withAttribute('__route__', $routeObject); /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: '__route__'); + $params = RouteParameterResolver::from(request: $serverRequest)->resolve(attributeName: '__route__'); - /** @Then the public property value should be returned */ + /** @Then the public property value is returned */ self::assertSame(['key' => 'value'], $params); } - public function testResolveReturnsEmptyArrayWhenAttributeIsNull(): void + public function testResolveWhenAttributeAbsentThenReturnsEmptyArray(): void { /** @Given a request with no matching attribute */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturn(null); + $serverRequest = new ServerRequest(method: 'GET', uri: '/'); /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: '__route__'); + $params = RouteParameterResolver::from(request: $serverRequest)->resolve(attributeName: '__route__'); - /** @Then an empty array should be returned */ + /** @Then an empty array is returned */ self::assertSame([], $params); } - public function testResolveReturnsEmptyArrayForUnextractableObject(): void + public function testResolveWhenObjectExposesNoKnownAccessorThenReturnsEmptyArray(): void { /** @Given a route object without known methods or properties */ $routeObject = new class { @@ -130,61 +103,44 @@ public function unknownMethod(): string } }; - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $routeObject, - default => null - }); + $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) + ->withAttribute('__route__', $routeObject); /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: '__route__'); + $params = RouteParameterResolver::from(request: $serverRequest)->resolve(attributeName: '__route__'); - /** @Then an empty array should be returned */ + /** @Then an empty array is returned */ self::assertSame([], $params); } - public function testResolveFromKnownAttributesScansMultipleKeys(): void + public function testResolveFromKnownAttributesWhenSymfonyKeyGivenThenScanFindsIt(): void { /** @Given params stored under _route_params (Symfony-style) */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '_route_params' => ['controller' => 'DragonController', 'id' => '5'], - default => null - }); + $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) + ->withAttribute('_route_params', ['controller' => 'DragonController', 'id' => '5']); /** @When scanning known attributes */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolveFromKnownAttributes(); + $params = RouteParameterResolver::from(request: $serverRequest)->resolveFromKnownAttributes(); - /** @Then the Symfony-style params should be found */ + /** @Then the Symfony-style params are found */ self::assertSame(['controller' => 'DragonController', 'id' => '5'], $params); } - public function testResolveDirectAttribute(): void + public function testResolveDirectAttributeWhenKeyPresentThenReturnsValue(): void { /** @Given a request with direct attributes (Laravel-style) */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - 'id' => '123', - default => null - }); + $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) + ->withAttribute('id', '123'); /** @When resolving a direct attribute */ $resolver = RouteParameterResolver::from(request: $serverRequest); - /** @Then the direct value should be returned */ + /** @Then the direct value is returned */ self::assertSame('123', $resolver->resolveDirectAttribute(key: 'id')); self::assertNull($resolver->resolveDirectAttribute(key: 'nonexistent')); } - public function testResolveWithObjectMethodPriorityOverProperty(): void + public function testResolveWhenObjectHasBothMethodAndPropertyThenMethodWins(): void { /** @Given an object that has both a method and a property */ $routeObject = new class { @@ -196,19 +152,13 @@ public function getArguments(): array } }; - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $routeObject, - default => null - }); + $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) + ->withAttribute('__route__', $routeObject); /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: '__route__'); + $params = RouteParameterResolver::from(request: $serverRequest)->resolve(attributeName: '__route__'); - /** @Then the method result should take priority */ + /** @Then the method result takes priority */ self::assertSame(['source' => 'method'], $params); } } diff --git a/tests/Unit/Internal/Server/Stream/StreamFactoryTest.php b/tests/Unit/Internal/Server/Stream/StreamFactoryTest.php index de99e2b..7e05b5f 100644 --- a/tests/Unit/Internal/Server/Stream/StreamFactoryTest.php +++ b/tests/Unit/Internal/Server/Stream/StreamFactoryTest.php @@ -4,13 +4,14 @@ namespace Test\TinyBlocks\Http\Unit\Internal\Server\Stream; +use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; use Psr\Http\Message\StreamInterface; use TinyBlocks\Http\Internal\Server\Stream\StreamFactory; final class StreamFactoryTest extends TestCase { - public function testWriteShouldRewindStream(): void + public function testWriteWhenBodyGivenThenStreamCarriesContent(): void { /** @Given an HTTP response body */ $body = 'This is a test body'; @@ -21,27 +22,23 @@ public function testWriteShouldRewindStream(): void /** @When the body is written to the stream */ $stream = $streamFactory->write(); - /** @Then the stream should contain the written content */ + /** @Then the stream contains the written content */ self::assertSame($body, $stream->getContents()); } - public function testFromStreamShouldRewindBeforeAndAfterReadingWhenSeekable(): void + public function testFromStreamWhenSeekableThenRewindsBeforeAndAfterReading(): void { - /** @Given a seekable stream */ + /** @Given a seekable stream observed via a stub */ $stream = $this->createStub(StreamInterface::class); $stream->method('isSeekable')->willReturn(true); /** @And rewind call counter */ $rewindCalls = 0; - - /** @And rewind increments the counter */ $stream->method('rewind')->willReturnCallback( static function () use (&$rewindCalls): void { $rewindCalls++; } ); - - /** @And getContents must be called after the first rewind */ $stream->method('getContents')->willReturnCallback( static function () use (&$rewindCalls): string { self::assertSame(1, $rewindCalls); @@ -52,27 +49,23 @@ static function () use (&$rewindCalls): string { /** @When a StreamFactory is created from the stream */ StreamFactory::fromStream(stream: $stream); - /** @Then it must rewind twice (before and after reading) */ + /** @Then it rewinds twice (before and after reading) */ self::assertSame(2, $rewindCalls); } - public function testFromStreamShouldNotRewindWhenNotSeekable(): void + public function testFromStreamWhenNotSeekableThenDoesNotRewind(): void { - /** @Given a non-seekable stream */ + /** @Given a non-seekable stream observed via a stub */ $stream = $this->createStub(StreamInterface::class); $stream->method('isSeekable')->willReturn(false); /** @And rewind call counter */ $rewindCalls = 0; - - /** @And rewind increments the counter */ $stream->method('rewind')->willReturnCallback( static function () use (&$rewindCalls): void { $rewindCalls++; } ); - - /** @And getContents must be called without any rewind */ $stream->method('getContents')->willReturnCallback( static function () use (&$rewindCalls): string { self::assertSame(0, $rewindCalls); @@ -83,7 +76,19 @@ static function () use (&$rewindCalls): string { /** @When a StreamFactory is created from the stream */ StreamFactory::fromStream(stream: $stream); - /** @Then it must not rewind */ + /** @Then it does not rewind */ self::assertSame(0, $rewindCalls); } + + public function testFromBodyWhenStringGivenThenCarriesBodyVerbatim(): void + { + /** @Given a real seekable stream */ + $stream = new Psr17Factory()->createStream('payload'); + + /** @When wrapping it through StreamFactory::fromStream */ + $factory = StreamFactory::fromStream(stream: $stream); + + /** @Then the factory's content matches the stream */ + self::assertSame('payload', $factory->content()); + } } diff --git a/tests/Unit/Internal/Server/Stream/StreamTest.php b/tests/Unit/Internal/Server/Stream/StreamTest.php index 010a61e..c6ff5ab 100644 --- a/tests/Unit/Internal/Server/Stream/StreamTest.php +++ b/tests/Unit/Internal/Server/Stream/StreamTest.php @@ -32,7 +32,7 @@ protected function tearDown(): void } } - public function testGetMetadata(): void + public function testGetMetadataWhenInvokedThenReturnsResourceMetadata(): void { /** @Given a stream */ $stream = Stream::from(resource: $this->resource); @@ -40,7 +40,7 @@ public function testGetMetadata(): void /** @When retrieving metadata */ $actual = $stream->getMetadata(); - /** @Then the metadata should match the expected values */ + /** @Then the metadata matches the underlying resource's metadata */ $expected = StreamMetaData::from(data: stream_get_meta_data($this->resource))->toArray(); self::assertSame($expected['uri'], $actual['uri']); @@ -49,7 +49,7 @@ public function testGetMetadata(): void self::assertSame($expected['streamType'], $actual['streamType']); } - public function testCloseWithoutResource(): void + public function testCloseWhenAlreadyClosedThenIsNoOp(): void { /** @Given a stream that has already been closed */ $stream = Stream::from(resource: $this->resource); @@ -58,14 +58,14 @@ public function testCloseWithoutResource(): void /** @When closing the stream again */ $stream->close(); - /** @Then the stream should remain closed and detached */ + /** @Then the stream remains detached */ self::assertFalse($stream->isReadable()); self::assertFalse($stream->isWritable()); self::assertFalse($stream->isSeekable()); self::assertFalse(is_resource($this->resource)); } - public function testCloseDetachesResource(): void + public function testCloseWhenInvokedThenDetachesResource(): void { /** @Given a stream resource */ $stream = Stream::from(resource: $this->resource); @@ -73,14 +73,14 @@ public function testCloseDetachesResource(): void /** @When the stream is closed */ $stream->close(); - /** @Then the stream should be detached and no longer readable, writable, or seekable */ + /** @Then the resource is detached */ self::assertFalse($stream->isReadable()); self::assertFalse($stream->isWritable()); self::assertFalse($stream->isSeekable()); self::assertFalse(is_resource($this->resource)); } - public function testSeekMovesCursorPosition(): void + public function testSeekWhenInvokedThenMovesCursorPosition(): void { /** @Given a stream with data */ $stream = Stream::from(resource: $this->resource); @@ -91,14 +91,14 @@ public function testSeekMovesCursorPosition(): void $tellAfterFirstSeek = $stream->tell(); $stream->seek(offset: 0, whence: SEEK_END); - /** @Then the cursor position should be updated correctly */ + /** @Then the cursor moves correctly */ self::assertTrue($stream->isWritable()); self::assertTrue($stream->isSeekable()); self::assertSame(7, $tellAfterFirstSeek); self::assertSame(13, $stream->tell()); } - public function testGetSizeReturnsCorrectSize(): void + public function testGetSizeWhenWritesPerformedThenReflectsContentLength(): void { /** @Given a stream */ $stream = Stream::from(resource: $this->resource); @@ -107,12 +107,12 @@ public function testGetSizeReturnsCorrectSize(): void $sizeBeforeWrite = $stream->getSize(); $stream->write(string: 'Hello, world!'); - /** @Then the size should be updated correctly */ + /** @Then the size reflects the bytes written */ self::assertSame(0, $sizeBeforeWrite); self::assertSame(13, $stream->getSize()); } - public function testIsWritableForCreateMode(): void + public function testIsWritableWhenCreateModeGivenThenReturnsTrue(): void { /** @Given a file that does not exist */ unlink($this->temporary); @@ -120,21 +120,21 @@ public function testIsWritableForCreateMode(): void /** @When opening the stream in create mode ('x') */ $stream = Stream::from(resource: fopen($this->temporary, 'x')); - /** @Then the stream should be writable */ + /** @Then the stream is writable */ self::assertTrue($stream->isWritable()); } #[DataProvider('modesDataProvider')] - public function testIsWritableForVariousModes(string $mode, bool $expected): void + public function testIsWritableWhenModeGivenThenMatchesExpectation(string $mode, bool $expected): void { /** @Given a stream opened in a specific mode */ $stream = Stream::from(resource: fopen('php://memory', $mode)); - /** @Then check if the stream is writable based on the mode */ + /** @Then the writable flag matches the expectation */ self::assertSame($expected, $stream->isWritable()); } - public function testRewindResetsCursorPosition(): void + public function testRewindWhenInvokedThenResetsCursorPosition(): void { /** @Given a stream with data */ $stream = Stream::from(resource: $this->resource); @@ -144,27 +144,27 @@ public function testRewindResetsCursorPosition(): void $stream->seek(offset: 7); $stream->rewind(); - /** @Then the cursor position should be reset to the beginning */ + /** @Then the cursor returns to the beginning */ self::assertSame(0, $stream->tell()); } - public function testEofReturnsTrueAtEndOfStream(): void + public function testEofWhenEndReachedThenReturnsTrue(): void { /** @Given a stream with data */ $stream = Stream::from(resource: $this->resource); $stream->write(string: 'Hello'); - /** @When reaching the end of the stream */ + /** @When reading every byte */ $eofBeforeRead = $stream->eof(); $stream->read(length: 5); - /** @Then EOF should return true */ + /** @Then EOF reports true at the end */ self::assertTrue($stream->eof()); self::assertTrue($stream->isReadable()); self::assertFalse($eofBeforeRead); } - public function testGetMetadataWhenKeyIsUnknown(): void + public function testGetMetadataWhenUnknownKeyGivenThenReturnsNull(): void { /** @Given a stream */ $stream = Stream::from(resource: $this->resource); @@ -172,11 +172,11 @@ public function testGetMetadataWhenKeyIsUnknown(): void /** @When retrieving metadata for an unknown key */ $actual = $stream->getMetadata(key: 'UNKNOWN'); - /** @Then the result should be null */ + /** @Then the result is null */ self::assertNull($actual); } - public function testToStringRewindsStreamIfNotSeekable(): void + public function testToStringWhenInvokedThenReturnsFullContent(): void { /** @Given a stream */ $stream = Stream::from(resource: $this->resource); @@ -184,21 +184,21 @@ public function testToStringRewindsStreamIfNotSeekable(): void /** @When writing and converting the stream to string */ $stream->write(string: 'Hello, world!'); - /** @Then the content should match the written data */ + /** @Then the content matches the written data */ self::assertSame('Hello, world!', (string)$stream); } - public function testGetSizeReturnsNullWhenWithoutResource(): void + public function testGetSizeWhenStreamClosedThenReturnsNull(): void { /** @Given a stream that has been closed */ $stream = Stream::from(resource: $this->resource); $stream->close(); - /** @Then getSize should return null */ + /** @Then getSize returns null */ self::assertNull($stream->getSize()); } - public function testIsSeekableReturnsFalseWhenUnderlyingResourceIsClosedExternally(): void + public function testIsSeekableWhenResourceClosedExternallyThenReturnsFalse(): void { /** @Given a stream whose underlying resource was closed outside the stream API */ $resource = fopen('php://memory', 'w+'); @@ -208,82 +208,87 @@ public function testIsSeekableReturnsFalseWhenUnderlyingResourceIsClosedExternal /** @When checking if the stream is seekable */ $actual = $stream->isSeekable(); - /** @Then it should return false because the resource is no longer valid */ + /** @Then it returns false because the resource is no longer valid */ self::assertFalse($actual); } - public function testExceptionWhenNonSeekableStream(): void + public function testSeekWhenStreamClosedThenThrowsNonSeekableStream(): void { /** @Given a stream */ $stream = Stream::from(resource: $this->resource); - /** @When attempting to seek on a closed stream */ + /** @Then NonSeekableStream is thrown */ self::expectException(NonSeekableStream::class); self::expectExceptionMessage('Stream is not seekable.'); + /** @When attempting to seek on a closed stream */ $stream->close(); $stream->seek(offset: 1); } - public function testExceptionWhenNonWritableStream(): void + public function testWriteWhenStreamReadOnlyThenThrowsNonWritableStream(): void { /** @Given a read-only stream */ $stream = Stream::from(resource: fopen($this->temporary, 'r')); - /** @When attempting to write to the stream */ + /** @Then NonWritableStream is thrown */ self::expectException(NonWritableStream::class); self::expectExceptionMessage('Stream is not writable.'); + /** @When attempting to write to the stream */ $stream->write(string: 'Hello, world!'); } - public function testExceptionWhenNonReadableStreamOnRead(): void + public function testReadWhenStreamWriteOnlyThenThrowsNonReadableStream(): void { /** @Given a write-only stream */ $stream = Stream::from(resource: fopen($this->temporary, 'w')); - /** @When attempting to read from the stream */ + /** @Then NonReadableStream is thrown */ self::expectException(NonReadableStream::class); self::expectExceptionMessage('Stream is not readable.'); + /** @When attempting to read from the stream */ $stream->read(length: 13); } - public function testExceptionWhenInvalidResourceProvided(): void + public function testFromWhenInvalidResourceGivenThenThrowsInvalidResource(): void { - /** @Given an invalid resource (e.g., a string) */ + /** @Given an invalid resource */ $resource = 'not_a_resource'; - /** @Then an InvalidResource exception should be thrown */ + /** @Then InvalidResource is thrown */ $this->expectException(InvalidResource::class); $this->expectExceptionMessage('The provided value is not a valid resource.'); - /** @When calling from method with an invalid resource */ + /** @When calling from() with an invalid resource */ Stream::from(resource: $resource); } - public function testExceptionWhenMissingResourceStreamOnTell(): void + public function testTellWhenStreamClosedThenThrowsMissingResourceStream(): void { /** @Given a stream */ $stream = Stream::from(resource: $this->resource); - /** @When attempting to call tell on a closed stream */ + /** @Then MissingResourceStream is thrown */ self::expectException(MissingResourceStream::class); self::expectExceptionMessage('No resource available.'); + /** @When attempting to call tell on a closed stream */ $stream->close(); $stream->tell(); } - public function testExceptionWhenNonReadableStreamOnGetContents(): void + public function testGetContentsWhenStreamWriteOnlyThenThrowsNonReadableStream(): void { /** @Given a write-only stream */ $stream = Stream::from(resource: fopen($this->temporary, 'w')); - /** @When attempting to get contents of the stream */ + /** @Then NonReadableStream is thrown */ self::expectException(NonReadableStream::class); self::expectExceptionMessage('Stream is not readable.'); + /** @When attempting to get contents of the stream */ $stream->getContents(); } diff --git a/tests/Unit/SameSiteTest.php b/tests/Unit/SameSiteTest.php index 4aaadc0..fad2078 100644 --- a/tests/Unit/SameSiteTest.php +++ b/tests/Unit/SameSiteTest.php @@ -11,13 +11,13 @@ final class SameSiteTest extends TestCase { #[DataProvider('sameSiteValueProvider')] - public function testBackedValueMatchesHeaderSpelling(SameSite $sameSite, string $expected): void + public function testValueWhenEnumCaseGivenThenMatchesHeaderSpelling(SameSite $sameSite, string $expected): void { /** @Given a SameSite enum case */ /** @When the backed value is read */ $actual = $sameSite->value; - /** @Then the value should match the casing expected by the Set-Cookie header */ + /** @Then the value matches the casing expected by the Set-Cookie header */ self::assertSame($expected, $actual); } @@ -26,7 +26,7 @@ public static function sameSiteValueProvider(): array return [ 'Lax strategy' => [SameSite::LAX, 'Lax'], 'None strategy' => [SameSite::NONE, 'None'], - 'Strict strategy' => [SameSite::STRICT, 'Strict'], + 'Strict strategy' => [SameSite::STRICT, 'Strict'] ]; } } diff --git a/tests/Unit/Server/HeadersTest.php b/tests/Unit/Server/HeadersTest.php index e222550..511d695 100644 --- a/tests/Unit/Server/HeadersTest.php +++ b/tests/Unit/Server/HeadersTest.php @@ -12,7 +12,7 @@ final class HeadersTest extends TestCase { - public function testResponseWithCustomHeaders(): void + public function testWithHeaderWhenInvokedThenAddsCustomHeadersAlongsideDefaultContentType(): void { /** @Given an HTTP response */ $response = Response::noContent(); @@ -25,7 +25,7 @@ public function testResponseWithCustomHeaders(): void ->withHeader(name: 'X-ID', value: 100) ->withHeader(name: 'X-NAME', value: 'Xpto'); - /** @Then the response should contain the correct headers */ + /** @Then the response contains the correct headers */ self::assertSame( ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => [100], 'X-NAME' => ['Xpto']], $actual->getHeaders() @@ -34,7 +34,7 @@ public function testResponseWithCustomHeaders(): void /** @And when we update the 'X-ID' header with a new value */ $actual = $actual->withHeader(name: 'X-ID', value: 200); - /** @Then the response should contain the updated 'X-ID' header value */ + /** @Then the response contains the updated 'X-ID' header value */ self::assertSame('200', $actual->withAddedHeader(name: 'X-ID', value: 200)->getHeaderLine(name: 'X-ID')); self::assertSame( ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => [200], 'X-NAME' => ['Xpto']], @@ -44,16 +44,16 @@ public function testResponseWithCustomHeaders(): void /** @And when we remove the 'X-NAME' header */ $actual = $actual->withoutHeader(name: 'X-NAME'); - /** @Then the response should contain only the 'X-ID' header and the default 'Content-Type' header */ + /** @Then the response contains only the 'X-ID' header and the default 'Content-Type' header */ self::assertSame( ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => [200]], $actual->getHeaders() ); } - public function testResponseWithDuplicatedHeader(): void + public function testWithHeaderWhenSameHeaderSetTwiceThenLastValueWins(): void { - /** @Given an HTTP response with a 'Content-Type' header set to 'application/json; charset=utf-8' */ + /** @Given an HTTP response with a default Content-Type */ $response = Response::noContent(); /** @When we add the 'Content-Type' header twice with different values */ @@ -61,26 +61,26 @@ public function testResponseWithDuplicatedHeader(): void ->withHeader(name: 'Content-Type', value: 'application/json; charset=utf-8') ->withHeader(name: 'Content-Type', value: 'application/json; charset=ISO-8859-1'); - /** @Then the response should contain the latest 'Content-Type' value */ + /** @Then the response carries the latest 'Content-Type' value */ self::assertSame('application/json; charset=ISO-8859-1', $actual->getHeaderLine(name: 'Content-Type')); - /** @And the headers should only contain the last 'Content-Type' value */ + /** @And only one Content-Type entry exists */ self::assertSame(['Content-Type' => ['application/json; charset=ISO-8859-1']], $actual->getHeaders()); } - public function testResponseHeadersWithNoCustomHeader(): void + public function testGetHeaderWhenHeaderMissingThenReturnsEmptyArray(): void { /** @Given an HTTP response with no custom headers */ $response = Response::noContent(); - /** @When we retrieve the header that doesn't exist */ + /** @When we retrieve a missing header */ $actual = $response->getHeader(name: 'Non-Existent-Header'); - /** @Then the header should return an empty array */ + /** @Then the header is returned as an empty array */ self::assertSame([], $actual); } - public function testAddHeaderAppendsDistinctValuesToExistingHeader(): void + public function testWithAddedHeaderWhenDistinctValueGivenThenAppendsToExistingHeader(): void { /** @Given an HTTP response with a custom header */ $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'first'); @@ -88,20 +88,20 @@ public function testAddHeaderAppendsDistinctValuesToExistingHeader(): void /** @When a distinct value is added to the same header */ $actual = $response->withAddedHeader(name: 'X-Trace', value: 'second'); - /** @Then both values should be preserved in the original order */ + /** @Then both values are preserved in the original order */ self::assertSame('first, second', $actual->getHeaderLine(name: 'X-Trace')); self::assertSame(['first', 'second'], $actual->getHeader(name: 'X-Trace')); } - public function testAddHeaderCreatesHeaderWhenAbsent(): void + public function testWithAddedHeaderWhenHeaderAbsentThenCreatesItWithGivenValue(): void { - /** @Given an HTTP response without a custom header */ + /** @Given an HTTP response without the target header */ $response = Response::noContent(); /** @When a value is added for the absent header */ $actual = $response->withAddedHeader(name: 'X-Trace', value: 'only-value'); - /** @Then the header should be created carrying the given value */ + /** @Then the header is created carrying the given value */ self::assertSame(['only-value'], $actual->getHeader(name: 'X-Trace')); self::assertSame( ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['only-value']], @@ -109,15 +109,15 @@ public function testAddHeaderCreatesHeaderWhenAbsent(): void ); } - public function testAddHeaderIsCaseInsensitiveWhenMatchingExistingHeader(): void + public function testWithAddedHeaderWhenCaseMismatchedThenMatchesExistingHeader(): void { /** @Given an HTTP response with a custom header */ $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'first'); - /** @When a value is added using a differently cased header name */ + /** @When a value is added using a differently cased name */ $actual = $response->withAddedHeader(name: 'x-trace', value: 'second'); - /** @Then the value should be appended preserving the original case of the header name */ + /** @Then the value is appended preserving the original case of the header name */ self::assertSame(['first', 'second'], $actual->getHeader(name: 'X-Trace')); self::assertSame( ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['first', 'second']], @@ -125,7 +125,7 @@ public function testAddHeaderIsCaseInsensitiveWhenMatchingExistingHeader(): void ); } - public function testWithoutHeaderIsCaseInsensitive(): void + public function testWithoutHeaderWhenCaseMismatchedThenStillRemovesHeader(): void { /** @Given an HTTP response with a custom header */ $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'value'); @@ -133,12 +133,12 @@ public function testWithoutHeaderIsCaseInsensitive(): void /** @When the header is removed using a differently cased name */ $actual = $response->withoutHeader(name: 'x-trace'); - /** @Then the header should no longer be present */ + /** @Then the header is no longer present */ self::assertFalse($actual->hasHeader(name: 'X-Trace')); self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testWithoutHeaderIsNoOpWhenHeaderIsAbsent(): void + public function testWithoutHeaderWhenAbsentThenIsNoOp(): void { /** @Given an HTTP response without the target header */ $response = Response::noContent(); @@ -146,11 +146,11 @@ public function testWithoutHeaderIsNoOpWhenHeaderIsAbsent(): void /** @When the missing header is requested to be removed */ $actual = $response->withoutHeader(name: 'X-Trace'); - /** @Then the headers should remain unchanged */ + /** @Then the headers remain unchanged */ self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testReplaceHeaderCreatesHeaderWhenAbsent(): void + public function testWithHeaderWhenHeaderAbsentThenCreatesIt(): void { /** @Given an HTTP response without the target header */ $response = Response::noContent(); @@ -158,11 +158,11 @@ public function testReplaceHeaderCreatesHeaderWhenAbsent(): void /** @When the header is replaced (i.e., set) */ $actual = $response->withHeader(name: 'X-Trace', value: 'value'); - /** @Then the header should be created with the given value */ + /** @Then the header is created with the given value */ self::assertSame(['value'], $actual->getHeader(name: 'X-Trace')); } - public function testReplaceHeaderIsCaseInsensitiveOnExistingHeader(): void + public function testWithHeaderWhenCaseMismatchedThenReplacesExistingHeader(): void { /** @Given an HTTP response with a custom header */ $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'first'); @@ -170,7 +170,7 @@ public function testReplaceHeaderIsCaseInsensitiveOnExistingHeader(): void /** @When the header is replaced using a differently cased name */ $actual = $response->withHeader(name: 'x-trace', value: 'second'); - /** @Then the original casing of the header name should be preserved and the value replaced */ + /** @Then the original casing is preserved and the value replaced */ self::assertSame(['second'], $actual->getHeader(name: 'X-Trace')); self::assertSame( ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['second']], @@ -178,40 +178,40 @@ public function testReplaceHeaderIsCaseInsensitiveOnExistingHeader(): void ); } - public function testMergingMultipleHeadersCombinesEntries(): void + public function testNoContentWhenMultipleHeaderablesGivenThenCombinesEntries(): void { /** @Given a Cache-Control and a Content-Type header */ - $cacheControl = CacheControl::fromResponseDirectives(noStore: ResponseCacheDirectives::noStore()); + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noStore()); $contentType = ContentType::textPlain(); /** @When a response is created with both */ $actual = Response::noContent($cacheControl, $contentType); - /** @Then both headers should be present */ + /** @Then both headers are present */ self::assertSame(['no-store'], $actual->getHeader(name: 'Cache-Control')); self::assertSame(['text/plain'], $actual->getHeader(name: 'Content-Type')); } - public function testResponseWithCacheControl(): void + public function testNoContentWhenCacheControlWithEveryDirectiveGivenThenHeaderRendersAll(): void { /** @Given a Cache-Control header with multiple directives */ $cacheControl = CacheControl::fromResponseDirectives( - maxAge: ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000), - noCache: ResponseCacheDirectives::noCache(), - noStore: ResponseCacheDirectives::noStore(), - noTransform: ResponseCacheDirectives::noTransform(), - staleIfError: ResponseCacheDirectives::staleIfError(), - mustRevalidate: ResponseCacheDirectives::mustRevalidate(), - proxyRevalidate: ResponseCacheDirectives::proxyRevalidate() + ResponseCacheDirectives::maxAge(maxAgeInWholeSeconds: 10000), + ResponseCacheDirectives::noCache(), + ResponseCacheDirectives::noStore(), + ResponseCacheDirectives::noTransform(), + ResponseCacheDirectives::staleIfError(), + ResponseCacheDirectives::mustRevalidate(), + ResponseCacheDirectives::proxyRevalidate() ); - /** @When we create an HTTP response with no content, using the provided Cache-Control header */ + /** @When we create an HTTP response with no content, using the Cache-Control header */ $actual = Response::noContent($cacheControl); - /** @And the response should include a Cache-Control header */ + /** @And the response includes the Cache-Control header */ self::assertTrue($actual->hasHeader(name: 'Cache-Control')); - /** @And the Cache-Control header should match the provided directives */ + /** @And the Cache-Control header lists every directive */ $expected = 'max-age=10000, no-cache, no-store, no-transform, stale-if-error, must-revalidate, proxy-revalidate'; self::assertSame($expected, $actual->getHeaderLine(name: 'Cache-Control')); @@ -219,117 +219,76 @@ public function testResponseWithCacheControl(): void self::assertSame($cacheControl->toArray(), $actual->getHeaders()); } - public function testResponseWithContentTypePDF(): void + public function testNoContentWhenContentTypeIsPdfThenHeaderReflectsIt(): void { - /** @Given the Content-Type header is set to application/pdf */ + /** @Given the Content-Type header set to application/pdf */ $contentType = ContentType::applicationPdf(); - /** @When we create an HTTP response with no content, using the provided Content-Type */ + /** @When the response is created with the Content-Type */ $actual = Response::noContent($contentType); - /** @Then the response should include a Content-Type header */ + /** @Then the response carries Content-Type: application/pdf */ self::assertTrue($actual->hasHeader(name: 'Content-Type')); - - /** @And the Content-Type header should be set to application/pdf */ - $expected = 'application/pdf'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); - self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); - self::assertSame(['Content-Type' => [$expected]], $actual->getHeaders()); + self::assertSame('application/pdf', $actual->getHeaderLine(name: 'Content-Type')); } - public function testResponseWithContentTypeHTML(): void + public function testNoContentWhenContentTypeIsHtmlThenHeaderReflectsIt(): void { - /** @Given the Content-Type header is set to text/html */ + /** @Given the Content-Type header set to text/html */ $contentType = ContentType::textHtml(); - /** @When we create an HTTP response with no content, using the provided Content-Type */ + /** @When the response is created with the Content-Type */ $actual = Response::noContent($contentType); - /** @Then the response should include a Content-Type header */ - self::assertTrue($actual->hasHeader(name: 'Content-Type')); - - /** @And the Content-Type header should be set to text/html */ - $expected = 'text/html'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); - self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); - self::assertSame(['Content-Type' => [$expected]], $actual->getHeaders()); + /** @Then the response carries Content-Type: text/html */ + self::assertSame('text/html', $actual->getHeaderLine(name: 'Content-Type')); } - public function testResponseWithContentTypeJSON(): void + public function testNoContentWhenContentTypeIsJsonThenHeaderReflectsIt(): void { - /** @Given the Content-Type header is set to application/json */ + /** @Given the Content-Type header set to application/json */ $contentType = ContentType::applicationJson(); - /** @When we create an HTTP response with no content, using the provided Content-Type */ + /** @When the response is created with the Content-Type */ $actual = Response::noContent($contentType); - /** @Then the response should include a Content-Type header */ - self::assertTrue($actual->hasHeader(name: 'Content-Type')); - - /** @And the Content-Type header should be set to application/json */ - $expected = 'application/json'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); - self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); - self::assertSame(['Content-Type' => [$expected]], $actual->getHeaders()); + /** @Then the response carries Content-Type: application/json */ + self::assertSame('application/json', $actual->getHeaderLine(name: 'Content-Type')); } - public function testResponseWithContentTypePlainText(): void + public function testNoContentWhenContentTypeIsPlainTextThenHeaderReflectsIt(): void { - /** @Given the Content-Type header is set to text/plain */ + /** @Given the Content-Type header set to text/plain */ $contentType = ContentType::textPlain(); - /** @When we create an HTTP response with no content, using the provided Content-Type */ + /** @When the response is created with the Content-Type */ $actual = Response::noContent($contentType); - /** @Then the response should include a Content-Type header */ - self::assertTrue($actual->hasHeader(name: 'Content-Type')); - - /** @And the Content-Type header should be set to text/plain */ - $expected = 'text/plain'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); - self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); - self::assertSame(['Content-Type' => [$expected]], $actual->getHeaders()); + /** @Then the response carries Content-Type: text/plain */ + self::assertSame('text/plain', $actual->getHeaderLine(name: 'Content-Type')); } - public function testResponseWithContentTypeOctetStream(): void + public function testNoContentWhenContentTypeIsOctetStreamThenHeaderReflectsIt(): void { - /** @Given the Content-Type header is set to application/octet-stream */ + /** @Given the Content-Type header set to application/octet-stream */ $contentType = ContentType::applicationOctetStream(); - /** @When we create an HTTP response with no content, using the provided Content-Type */ + /** @When the response is created with the Content-Type */ $actual = Response::noContent($contentType); - /** @Then the response should include a Content-Type header */ - self::assertTrue($actual->hasHeader(name: 'Content-Type')); - - /** @And the Content-Type header should be set to application/octet-stream */ - $expected = 'application/octet-stream'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); - self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); - self::assertSame(['Content-Type' => [$expected]], $actual->getHeaders()); + /** @Then the response carries Content-Type: application/octet-stream */ + self::assertSame('application/octet-stream', $actual->getHeaderLine(name: 'Content-Type')); } - public function testResponseWithContentTypeFormUrlencoded(): void + public function testNoContentWhenContentTypeIsFormUrlEncodedThenHeaderReflectsIt(): void { - /** @Given the Content-Type header is set to application/x-www-form-urlencoded */ + /** @Given the Content-Type header set to application/x-www-form-urlencoded */ $contentType = ContentType::applicationFormUrlencoded(); - /** @When we create an HTTP response with no content, using the provided Content-Type */ + /** @When the response is created with the Content-Type */ $actual = Response::noContent($contentType); - /** @Then the response should include a Content-Type header */ - self::assertTrue($actual->hasHeader(name: 'Content-Type')); - - /** @And the Content-Type header should be set to application/x-www-form-urlencoded */ - $expected = 'application/x-www-form-urlencoded'; - - self::assertSame($expected, $actual->getHeaderLine(name: 'Content-Type')); - self::assertSame([$expected], $actual->getHeader(name: 'Content-Type')); - self::assertSame(['Content-Type' => [$expected]], $actual->getHeaders()); + /** @Then the response carries Content-Type: application/x-www-form-urlencoded */ + self::assertSame('application/x-www-form-urlencoded', $actual->getHeaderLine(name: 'Content-Type')); } } diff --git a/tests/Unit/Server/ProtocolVersionTest.php b/tests/Unit/Server/ProtocolVersionTest.php index 98dd163..f2f4e19 100644 --- a/tests/Unit/Server/ProtocolVersionTest.php +++ b/tests/Unit/Server/ProtocolVersionTest.php @@ -9,7 +9,7 @@ final class ProtocolVersionTest extends TestCase { - public function testProtocolVersion(): void + public function testWithProtocolVersionWhenInvokedThenReturnsResponseWithUpdatedProtocol(): void { /** @Given an HTTP response */ $response = Response::noContent(); @@ -20,7 +20,7 @@ public function testProtocolVersion(): void /** @When the protocol version is updated to HTTP/3 */ $actual = $response->withProtocolVersion(version: '3'); - /** @Then the response should use the updated protocol version 3 */ + /** @Then the response uses the updated protocol version 3 */ self::assertSame('3', $actual->getProtocolVersion()); } -} \ No newline at end of file +} diff --git a/tests/Unit/Server/RequestTest.php b/tests/Unit/Server/RequestTest.php index 2b05b51..2a5d283 100644 --- a/tests/Unit/Server/RequestTest.php +++ b/tests/Unit/Server/RequestTest.php @@ -5,17 +5,22 @@ namespace Test\TinyBlocks\Http\Unit\Server; use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Message\UriInterface; use TinyBlocks\Http\Method; use TinyBlocks\Http\Server\Request; final class RequestTest extends TestCase { - public function testRequestDecodingWithPayload(): void + private Psr17Factory $factory; + + protected function setUp(): void + { + $this->factory = new Psr17Factory(); + } + + public function testDecodeWhenBodyGivenThenExposesTypedAccessors(): void { /** @Given a payload to send */ $payload = [ @@ -27,27 +32,17 @@ public function testRequestDecodingWithPayload(): void 'is_legendary' => true ]; - /** @And this payload is used to create a ServerRequestInterface */ - $stream = $this->createStub(StreamInterface::class); - $stream - ->method('getContents') - ->willReturn(json_encode($payload, JSON_PRESERVE_ZERO_FRACTION)); + /** @And a real PSR-7 server request with that JSON body */ + $serverRequest = new ServerRequest( + method: 'POST', + uri: 'https://api.example.com/dragons', + body: $this->factory->createStream(json_encode($payload, JSON_PRESERVE_ZERO_FRACTION)) + ); - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('POST'); - $serverRequest - ->method('getBody') - ->willReturn($stream); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we decode the body of the HTTP Request */ - $actual = $request->decode()->body(); + /** @When decoding the request body */ + $actual = Request::from(request: $serverRequest)->decode()->body(); - /** @Then the decoded body should match the original payload */ + /** @Then every typed accessor matches the original payload */ self::assertSame($payload, $actual->toArray()); self::assertSame($payload['id'], $actual->get(key: 'id')->toInteger()); self::assertSame($payload['name'], $actual->get(key: 'name')->toString()); @@ -57,127 +52,68 @@ public function testRequestDecodingWithPayload(): void self::assertSame($payload['is_legendary'], $actual->get(key: 'is_legendary')->toBoolean()); } - public function testRequestDecodingWithRouteWithSingleAttribute(): void + public function testDecodeWhenRouteHasSingleAttributeThenExposesIt(): void { - /** @Given a route name to be retrieved */ - $routeName = '/v1/dragons/{id}'; - - /** @And an id to be retrieved from the route attribute */ - $attribute = 'dragon-id'; - - /** @And a ServerRequestInterface with this route attribute */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => ['name' => $routeName, 'id' => $attribute], - default => null - }); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we decode the route attribute of the HTTP Request */ - $actual = $request->decode()->uri()->route()->get(key: 'id'); - - self::assertSame($attribute, $actual->toString()); + /** @Given a route attribute carrying a single id */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com/dragons/dragon-id')) + ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', 'id' => 'dragon-id']); + + /** @When decoding the route attribute */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->get(key: 'id'); + + /** @Then the value is returned as a string */ + self::assertSame('dragon-id', $actual->toString()); } - public function testRequestDecodingWithRouteWithMultipleAttributes(): void + public function testDecodeWhenRouteHasMultipleAttributesThenExposesEach(): void { - /** @Given a route name to be retrieved */ - $routeName = '/v1/dragons/{id}/skills/{skill}'; - - /** @And an id and skill to be retrieved from the route attribute */ - $attributes = [ - 'id' => 'dragon-id', - 'skill' => 'dragon-skill', - 'weight' => 6000.00 - ]; + /** @Given a route attribute carrying id, skill, and weight */ + $attributes = ['id' => 'dragon-id', 'skill' => 'dragon-skill', 'weight' => 6000.00]; - /** @And a ServerRequestInterface with this route attribute */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => ['name' => $routeName, ...$attributes], - default => null - }); + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('__route__', ['name' => '/v1/dragons/{id}/skills/{skill}', ...$attributes]); - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we decode the route attribute of the HTTP Request */ - $route = $request->decode()->uri()->route(); + /** @When decoding each attribute */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + /** @Then each typed accessor matches */ self::assertSame($attributes['id'], $route->get(key: 'id')->toString()); self::assertSame($attributes['skill'], $route->get(key: 'skill')->toString()); self::assertSame($attributes['weight'], $route->get(key: 'weight')->toFloat()); } #[DataProvider('attributeConversionsProvider')] - public function testRequestWhenAttributeConversions( + public function testDecodeWhenAttributeTypedConversionRequestedThenReturnsExpectedValue( string $key, mixed $value, string $method, mixed $expected ): void { - /** @Given a ServerRequestInterface with a route attribute */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => ['name' => '/v1/dragons/{id}', $key => $value], - default => null - }); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we decode the route attribute of the HTTP Request and convert it to the expected type */ - $actual = $request->decode()->uri()->route()->get(key: $key)->$method(); - - /** @Then the converted value should match the expected value */ + /** @Given a route attribute with the provided value */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', $key => $value]); + + /** @When converting through the typed accessor */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->get(key: $key)->$method(); + + /** @Then the converted value matches the expected one */ self::assertSame($expected, $actual); } - public function testRequestDecodingWithRouteAttributeAsScalar(): void + public function testDecodeWhenRouteAttributeIsScalarThenExposesIt(): void { /** @Given a scalar route attribute value */ - $attribute = 'dragon-id'; - - /** @And a ServerRequestInterface with this route attribute as scalar */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $attribute, - default => null - }); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we decode the route attribute of the HTTP Request */ - $actual = $request->decode()->uri()->route()->get(key: 'id'); - - /** @Then the decoded attribute should match the original scalar value */ - self::assertSame($attribute, $actual->toString()); + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('__route__', 'dragon-id'); + + /** @When decoding the route attribute */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->route()->get(key: 'id'); + + /** @Then the value is returned */ + self::assertSame('dragon-id', $actual->toString()); } - public function testRequestDecodingWithSlimStyleRouteObject(): void + public function testDecodeWhenSlimStyleRouteObjectGivenThenResolvesArguments(): void { /** @Given a Slim-style route object that stores params in getArguments() */ $routeObject = new class { @@ -187,191 +123,122 @@ public function getArguments(): array } }; - /** @And a ServerRequestInterface with this route object under __route__ */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $routeObject, - default => null - }); - - /** @When we create the HTTP Request and decode route params */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('__route__', $routeObject); + + /** @When decoding the route */ $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - /** @Then the params should be correctly resolved from the object */ + /** @Then the params resolve from the object */ self::assertSame('42', $route->get(key: 'id')->toString()); self::assertSame(42, $route->get(key: 'id')->toInteger()); self::assertSame('dragon@fire.com', $route->get(key: 'email')->toString()); } - public function testRequestDecodingWithMezzioStyleRouteResult(): void + public function testDecodeWhenMezzioStyleRouteResultGivenThenResolvesMatchedParams(): void { /** @Given a Mezzio-style route result object that uses getMatchedParams() */ $routeResult = new class { - /** @noinspection PhpUnused */ public function getMatchedParams(): array { return ['id' => '99', 'slug' => 'fire-dragon']; } }; - /** @And a ServerRequestInterface with this route result under routeResult */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - 'routeResult' => $routeResult, - default => null - }); - - /** @When we create the HTTP Request and decode using known attribute scan */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('routeResult', $routeResult); + + /** @When decoding using the known-attribute scan */ $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - /** @Then the params should be correctly resolved from the Mezzio object */ + /** @Then the params resolve correctly */ self::assertSame('99', $route->get(key: 'id')->toString()); self::assertSame('fire-dragon', $route->get(key: 'slug')->toString()); } - public function testRequestDecodingWithSymfonyStyleRouteParams(): void + public function testDecodeWhenSymfonyStyleRouteParamsGivenThenResolvesWithExplicitName(): void { - /** @Given Symfony stores route params as an array under _route_params */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '_route_params' => ['id' => '7', 'category' => 'legendary'], - default => null - }); - - /** @When we use the custom route attribute name */ - $route = Request::from(request: $serverRequest) - ->decode() - ->uri() - ->route(name: '_route_params'); - - /** @Then the params should be correctly resolved */ + /** @Given Symfony stores route params under _route_params */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('_route_params', ['id' => '7', 'category' => 'legendary']); + + /** @When decoding with the custom route attribute name */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(name: '_route_params'); + + /** @Then the params resolve correctly */ self::assertSame('7', $route->get(key: 'id')->toString()); self::assertSame('legendary', $route->get(key: 'category')->toString()); } - public function testRequestDecodingWithSymfonyStyleFallbackScan(): void + public function testDecodeWhenSymfonyAttributePresentThenFallbackScanFindsIt(): void { - /** @Given Symfony stores route params under _route_params and default __route__ is null */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '_route_params' => ['id' => '55'], - default => null - }); - - /** @When we use the default route() without specifying a name */ + /** @Given Symfony stores params under _route_params and default __route__ is absent */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('_route_params', ['id' => '55']); + + /** @When decoding with the default route() */ $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - /** @Then the fallback scan should find params under _route_params */ + /** @Then the fallback scan finds params under _route_params */ self::assertSame('55', $route->get(key: 'id')->toString()); } - public function testRequestDecodingWithDirectAttributes(): void + public function testDecodeWhenDirectAttributesPresentThenFallbackResolves(): void { - /** @Given a framework like Laravel stores route params as direct request attributes */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - 'id' => '123', - 'email' => 'user@example.com', - default => null - }); - - /** @When we decode route params using the default route */ + /** @Given a request that stores route params as direct attributes */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('id', '123') + ->withAttribute('email', 'user@example.com'); + + /** @When decoding with the default route() */ $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - /** @Then direct attributes should be resolved as fallback */ + /** @Then direct attributes resolve as fallback */ self::assertSame('123', $route->get(key: 'id')->toString()); self::assertSame('user@example.com', $route->get(key: 'email')->toString()); } - public function testRequestDecodingWithManualWithAttribute(): void + public function testDecodeWhenManualAttributesInjectedThenExposesValues(): void { - /** @Given a user manually injects route params via withAttribute() */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('POST'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => ['id' => 'manually-injected', 'status' => 'active'], - default => null - }); - - /** @When we decode route params */ + /** @Given a request manually injecting route params via withAttribute() */ + $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) + ->withAttribute('__route__', ['id' => 'manually-injected', 'status' => 'active']); + + /** @When decoding */ $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - /** @Then the manually injected values should be returned */ + /** @Then the injected values are returned */ self::assertSame('manually-injected', $route->get(key: 'id')->toString()); self::assertSame('active', $route->get(key: 'status')->toString()); } - public function testRequestDecodingWithObjectHavingPublicProperty(): void + public function testDecodeWhenRouteObjectExposesPublicPropertyThenResolvesIt(): void { - /** @Given an object that exposes route params via a public property */ + /** @Given a route object exposing public properties */ $routeObject = new class { public array $arguments = ['id' => '10', 'name' => 'Hydra']; }; - /** @And a ServerRequestInterface with this object under __route__ */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturnCallback(static fn(string $name) => match ($name) { - '__route__' => $routeObject, - default => null - }); - - /** @When we decode route params */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('__route__', $routeObject); + + /** @When decoding */ $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - /** @Then public property values should be resolved */ + /** @Then public property values resolve */ self::assertSame('10', $route->get(key: 'id')->toString()); self::assertSame('Hydra', $route->get(key: 'name')->toString()); } - public function testRequestDecodingReturnsDefaultsWhenNoRouteParams(): void + public function testDecodeWhenNoRouteAttributesGivenThenSafeDefaultsAreReturned(): void { - /** @Given a ServerRequestInterface with no route attributes at all */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getAttribute') - ->willReturn(null); - - /** @When we try to decode route params */ + /** @Given a server request with no route attributes at all */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com'); + + /** @When decoding any route attribute */ $route = Request::from(request: $serverRequest)->decode()->uri()->route(); - /** @Then safe defaults should be returned */ + /** @Then safe defaults are returned */ self::assertSame(0, $route->get(key: 'id')->toInteger()); self::assertSame('', $route->get(key: 'name')->toString()); self::assertSame(0.00, $route->get(key: 'weight')->toFloat()); @@ -379,7 +246,7 @@ public function testRequestDecodingReturnsDefaultsWhenNoRouteParams(): void self::assertSame([], $route->get(key: 'tags')->toArray()); } - public function testRequestDecodingWithParsedBody(): void + public function testDecodeWhenParsedBodyPresentAndStreamEmptyThenUsesParsedBody(): void { /** @Given a payload already parsed by the framework */ $payload = [ @@ -391,103 +258,45 @@ public function testRequestDecodingWithParsedBody(): void 'is_legendary' => true ]; - /** @And a ServerRequestInterface with an empty stream but a parsed body */ - $stream = $this->createStub(StreamInterface::class); - $stream - ->method('getContents') - ->willReturn(''); - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('POST'); - $serverRequest - ->method('getBody') - ->willReturn($stream); - $serverRequest - ->method('getParsedBody') - ->willReturn($payload); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we decode the body of the HTTP Request */ - $actual = $request->decode()->body(); - - /** @Then the decoded body should match the parsed payload */ + $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) + ->withBody($this->factory->createStream('')) + ->withParsedBody($payload); + + /** @When decoding the body */ + $actual = Request::from(request: $serverRequest)->decode()->body(); + + /** @Then the parsed body is exposed */ self::assertSame($payload, $actual->toArray()); self::assertSame($payload['id'], $actual->get(key: 'id')->toInteger()); - self::assertSame($payload['name'], $actual->get(key: 'name')->toString()); - self::assertSame($payload['type'], $actual->get(key: 'type')->toString()); self::assertSame($payload['weight'], $actual->get(key: 'weight')->toFloat()); - self::assertSame($payload['skills'], $actual->get(key: 'skills')->toArray()); self::assertSame($payload['is_legendary'], $actual->get(key: 'is_legendary')->toBoolean()); } - public function testRequestDecodingWithFullUri(): void + public function testDecodeWhenUriGivenThenExposesAsString(): void { - /** @Given a full URI string */ + /** @Given a full URI on the server request */ $expectedUri = 'https://api.example.com/v1/dragons?sort=name&order=asc'; + $serverRequest = new ServerRequest(method: 'GET', uri: $expectedUri); - /** @And a PSR-7 UriInterface mock that returns this URI */ - $uri = $this->createStub(UriInterface::class); - $uri - ->method('__toString') - ->willReturn($expectedUri); - - /** @And a ServerRequestInterface that returns this UriInterface */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getUri') - ->willReturn($uri); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we retrieve the full URI string from the decoded request */ - $actual = $request->decode()->uri()->toString(); + /** @When decoding the URI */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->toString(); - /** @Then the URI string should match the expected full URI */ + /** @Then the URI string matches */ self::assertSame($expectedUri, $actual); } - public function testRequestDecodingWithQueryParameters(): void + public function testDecodeWhenQueryParamsPresentThenExposesTypedAccessors(): void { - /** @Given query parameters present in the request URI */ - $queryParams = [ - 'sort' => 'name', - 'order' => 'asc', - 'limit' => '50', - 'active' => 'true' - ]; + /** @Given query parameters present on the request URI */ + $queryParams = ['sort' => 'name', 'order' => 'asc', 'limit' => '50', 'active' => 'true']; + + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withQueryParams($queryParams); + + /** @When decoding the query parameters */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->queryParameters(); - /** @And a ServerRequestInterface that returns these query parameters */ - $stream = $this->createStub(StreamInterface::class); - $stream - ->method('getContents') - ->willReturn(''); - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getQueryParams') - ->willReturn($queryParams); - $serverRequest - ->method('getBody') - ->willReturn($stream); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we retrieve the query parameters from the decoded request */ - $actual = $request->decode()->uri()->queryParameters(); - - /** @Then the query parameters should match the original values */ + /** @Then every accessor matches */ self::assertSame($queryParams, $actual->toArray()); self::assertSame($queryParams['sort'], $actual->get(key: 'sort')->toString()); self::assertSame($queryParams['order'], $actual->get(key: 'order')->toString()); @@ -495,32 +304,15 @@ public function testRequestDecodingWithQueryParameters(): void self::assertTrue($actual->get(key: 'active')->toBoolean()); } - public function testRequestDecodingWithQueryParametersReturnsDefaultsWhenEmpty(): void + public function testDecodeWhenQueryParamsAbsentThenSafeDefaultsReturned(): void { - /** @Given a ServerRequestInterface with no query parameters */ - $stream = $this->createStub(StreamInterface::class); - $stream - ->method('getContents') - ->willReturn(''); - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('GET'); - $serverRequest - ->method('getQueryParams') - ->willReturn([]); - $serverRequest - ->method('getBody') - ->willReturn($stream); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); - - /** @And we try to access query parameters that do not exist */ - $actual = $request->decode()->uri()->queryParameters(); - - /** @Then safe defaults should be returned */ + /** @Given a server request with no query parameters */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com'); + + /** @When decoding the query parameters */ + $actual = Request::from(request: $serverRequest)->decode()->uri()->queryParameters(); + + /** @Then safe defaults are returned */ self::assertSame([], $actual->toArray()); self::assertSame('', $actual->get(key: 'sort')->toString()); self::assertSame(0, $actual->get(key: 'page')->toInteger()); @@ -528,41 +320,30 @@ public function testRequestDecodingWithQueryParametersReturnsDefaultsWhenEmpty() self::assertFalse($actual->get(key: 'active')->toBoolean()); } - public function testRequestWithMethod(): void + public function testMethodWhenPostRequestGivenThenReturnsPostEnum(): void { - /** @Given a ServerRequestInterface with POST method */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn('POST'); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); + /** @Given a POST server request */ + $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com'); - /** @And we retrieve the HTTP method */ - $actual = $request->method(); + /** @When asking for the typed method */ + $actual = Request::from(request: $serverRequest)->method(); - /** @Then the method should match the expected enum value */ + /** @Then the Method enum is returned */ self::assertSame(Method::POST, $actual); - self::assertSame('POST', $actual->value); } #[DataProvider('httpMethodsProvider')] - public function testRequestWithDifferentHttpMethods(string $methodString, Method $expectedMethod): void - { - /** @Given a ServerRequestInterface with the specified HTTP method */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest - ->method('getMethod') - ->willReturn($methodString); - - /** @When we create the HTTP Request with this ServerRequestInterface */ - $request = Request::from(request: $serverRequest); + public function testMethodWhenAnyHttpVerbGivenThenReturnsMatchingEnum( + string $methodString, + Method $expectedMethod + ): void { + /** @Given a server request with the specified HTTP verb */ + $serverRequest = new ServerRequest(method: $methodString, uri: 'https://api.example.com'); - /** @And we retrieve the HTTP method */ - $actual = $request->method(); + /** @When asking for the typed method */ + $actual = Request::from(request: $serverRequest)->method(); - /** @Then the method should match the expected enum value */ + /** @Then the Method enum matches */ self::assertSame($expectedMethod, $actual); self::assertSame($methodString, $actual->value); } @@ -615,77 +396,47 @@ public static function attributeConversionsProvider(): array ]; } - public function testRequestDecodingWithSeekableStreamAndJsonBody(): void - { - /** @Given a seekable stream with a JSON body */ - $stream = $this->createStub(StreamInterface::class); - $stream->method('isSeekable')->willReturn(true); - $stream->method('getContents')->willReturn('{"name":"Hydra"}'); - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest->method('getMethod')->willReturn('POST'); - $serverRequest->method('getBody')->willReturn($stream); - - /** @When decoding the request */ - $decoded = Request::from(request: $serverRequest)->decode(); - - /** @Then the body is parsed correctly from the seekable stream */ - self::assertSame('Hydra', $decoded->body()->get(key: 'name')->toString()); - } - - public function testRequestDecodingWithInvalidJsonBodyReturnsEmpty(): void + public function testDecodeWhenInvalidJsonBodyGivenThenReturnsEmptyArray(): void { - /** @Given a stream with an invalid JSON body */ - $stream = $this->createStub(StreamInterface::class); - $stream->method('isSeekable')->willReturn(false); - $stream->method('getContents')->willReturn('{not valid json]'); - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest->method('getMethod')->willReturn('POST'); - $serverRequest->method('getBody')->willReturn($stream); + /** @Given a non-JSON body */ + $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) + ->withBody($this->factory->createStream('{not valid json]')); - /** @When decoding the request */ + /** @When decoding */ $decoded = Request::from(request: $serverRequest)->decode(); /** @Then the body gracefully returns an empty array */ self::assertSame([], $decoded->body()->toArray()); } - public function testRequestDecodingWithSeekableStreamAtNonZeroPositionParsesFromStart(): void + public function testDecodeWhenStreamAdvancedThenStillParsesFromStart(): void { /** @Given a seekable stream advanced past its start */ - $factory = new Psr17Factory(); - $stream = $factory->createStream('{"name":"Hydra"}'); + $stream = $this->factory->createStream('{"name":"Hydra"}'); $stream->getContents(); /** @And a server request using that stream */ - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest->method('getMethod')->willReturn('POST'); - $serverRequest->method('getBody')->willReturn($stream); + $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) + ->withBody($stream); /** @When decoding the request body */ $decoded = Request::from(request: $serverRequest)->decode()->body(); - /** @Then the body is parsed correctly despite the advanced stream position */ + /** @Then the body parses correctly despite the stream position */ self::assertSame('Hydra', $decoded->get(key: 'name')->toString()); - /** @And the stream is at position zero after parsing so it can be re-read without a manual rewind */ + /** @And the stream is rewound so it can be re-read */ self::assertSame('{"name":"Hydra"}', $stream->getContents()); } - public function testRequestDecodingWithEmptyStreamAndNonArrayParsedBodyReturnsEmpty(): void + public function testDecodeWhenEmptyStreamAndNonArrayParsedBodyThenReturnsEmpty(): void { - /** @Given a stream with no body and a non-array parsed body */ - $stream = $this->createStub(StreamInterface::class); - $stream->method('isSeekable')->willReturn(false); - $stream->method('getContents')->willReturn(''); - - $serverRequest = $this->createStub(ServerRequestInterface::class); - $serverRequest->method('getMethod')->willReturn('POST'); - $serverRequest->method('getBody')->willReturn($stream); - $serverRequest->method('getParsedBody')->willReturn('not-an-array'); + /** @Given an empty stream and a non-array parsed body */ + $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) + ->withBody($this->factory->createStream('')) + ->withParsedBody('not-an-array'); - /** @When decoding the request */ + /** @When decoding */ $decoded = Request::from(request: $serverRequest)->decode(); /** @Then the body gracefully returns an empty array */ diff --git a/tests/Unit/Server/ResponseTest.php b/tests/Unit/Server/ResponseTest.php index f6b6bf9..5b9a687 100644 --- a/tests/Unit/Server/ResponseTest.php +++ b/tests/Unit/Server/ResponseTest.php @@ -22,345 +22,179 @@ final class ResponseTest extends TestCase { #[DataProvider('responseFromProvider')] - public function testResponseFrom(Code $code, mixed $body, string $expectedBody): void - { + public function testFromWhenCodeAndBodyGivenThenRendersBodyWithMatchingStatus( + Code $code, + mixed $body, + string $expectedBody + ): void { /** @Given a specific status code and body */ - /** @When we create the HTTP response using the generic from method */ + /** @When creating the HTTP response via the generic from method */ $actual = Response::from(body: $body, code: $code); - /** @Then the protocol version should be "1.1" */ + /** @Then the protocol version is "1.1" */ self::assertSame('1.1', $actual->getProtocolVersion()); - /** @And the body of the response should match the expected output */ + /** @And the body of the response matches the expected output */ self::assertSame($expectedBody, $actual->getBody()->__toString()); - /** @And the status code should match the provided code */ + /** @And the status code matches the provided code */ self::assertSame($code->value, $actual->getStatusCode()); self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); - /** @And the reason phrase should match the provided code message */ + /** @And the reason phrase matches the code message */ self::assertSame($code->message(), $actual->getReasonPhrase()); - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ + /** @And the default Content-Type is application/json; charset=utf-8 */ self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testResponseOk(): void + public function testOkWhenBodyGivenThenReturnsResponseWithStatus200(): void { /** @Given a body with data */ - $body = [ - 'id' => PHP_INT_MAX, - 'name' => 'Drakengard Firestorm', - 'type' => 'Dragon', - 'weight' => 6000.00 - ]; + $body = ['id' => PHP_INT_MAX, 'name' => 'Drakengard Firestorm', 'type' => 'Dragon', 'weight' => 6000.00]; - /** @When we create the HTTP response with this body */ + /** @When the response is created with the body */ $actual = Response::ok(body: $body); - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ + /** @Then the response carries the body encoded as JSON and a 200 status */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 200 */ self::assertSame(Code::OK->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "OK" */ self::assertSame(Code::OK->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testResponseCreated(): void + public function testCreatedWhenBodyGivenThenReturnsResponseWithStatus201(): void { /** @Given a body with data */ - $body = [ - 'id' => 1, - 'name' => 'New Resource', - 'type' => 'Item', - 'weight' => 100.00 - ]; + $body = ['id' => 1, 'name' => 'New Resource', 'type' => 'Item', 'weight' => 100.00]; - /** @When we create the HTTP response with this body */ + /** @When the response is created with the body */ $actual = Response::created(body: $body); - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ + /** @Then the response carries the body and a 201 status */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 201 */ self::assertSame(Code::CREATED->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Created" */ self::assertSame(Code::CREATED->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testResponseAccepted(): void + public function testAcceptedWhenBodyGivenThenReturnsResponseWithStatus202(): void { /** @Given a body with data */ - $body = [ - 'id' => 1, - 'status' => 'Processing' - ]; + $body = ['id' => 1, 'status' => 'Processing']; - /** @When we create the HTTP response with this body */ + /** @When the response is created with the body */ $actual = Response::accepted(body: $body); - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ + /** @Then the response carries the body and a 202 status */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 202 */ self::assertSame(Code::ACCEPTED->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Accepted" */ self::assertSame(Code::ACCEPTED->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testResponseNoContent(): void + public function testNoContentWhenInvokedThenReturnsEmptyBodyWithStatus204(): void { - /** @Given I have no data for the body */ - /** @When we create the HTTP response without body */ + /** @When the response is created without body */ $actual = Response::noContent(); - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should be empty */ + /** @Then the body is empty and the status is 204 */ self::assertEmpty($actual->getBody()->__toString()); - - /** @And the status code should be 204 */ self::assertSame(Code::NO_CONTENT->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); self::assertTrue(Code::isSuccessCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "No Content" */ self::assertSame(Code::NO_CONTENT->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testResponseBadRequest(): void + public function testBadRequestWhenBodyGivenThenReturnsResponseWithStatus400(): void { /** @Given a body with error details */ - $body = [ - 'error' => 'Invalid request', - 'message' => 'The request body is malformed.' - ]; + $body = ['error' => 'Invalid request', 'message' => 'The request body is malformed.']; - /** @When we create the HTTP response with this body */ + /** @When the response is created with the body */ $actual = Response::badRequest(body: $body); - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 400 */ + /** @Then the status is 400 */ self::assertSame(Code::BAD_REQUEST->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Bad Request" */ - self::assertSame(Code::BAD_REQUEST->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testResponseUnauthorized(): void + public function testUnauthorizedWhenBodyGivenThenReturnsResponseWithStatus401(): void { /** @Given a body with error details */ - $body = [ - 'error' => 'Unauthorized', - 'message' => 'Authentication is required to access this resource.' - ]; + $body = ['error' => 'Unauthorized', 'message' => 'Authentication is required.']; - /** @When we create the HTTP response with this body */ + /** @When the response is created with the body */ $actual = Response::unauthorized(body: $body); - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 401 */ + /** @Then the status is 401 */ self::assertSame(Code::UNAUTHORIZED->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Unauthorized" */ - self::assertSame(Code::UNAUTHORIZED->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testResponseForbidden(): void + public function testForbiddenWhenBodyGivenThenReturnsResponseWithStatus403(): void { /** @Given a body with error details */ - $body = [ - 'error' => 'Forbidden', - 'message' => 'You do not have permission to access this resource.' - ]; + $body = ['error' => 'Forbidden', 'message' => 'You do not have permission to access this resource.']; - /** @When we create the HTTP response with this body */ + /** @When the response is created with the body */ $actual = Response::forbidden(body: $body); - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 403 */ + /** @Then the status is 403 */ self::assertSame(Code::FORBIDDEN->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Forbidden" */ - self::assertSame(Code::FORBIDDEN->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testResponseNotFound(): void + public function testNotFoundWhenBodyGivenThenReturnsResponseWithStatus404(): void { /** @Given a body with error details */ - $body = [ - 'error' => 'Not found', - 'message' => 'The requested resource could not be found.' - ]; + $body = ['error' => 'Not found', 'message' => 'The requested resource could not be found.']; - /** @When we create the HTTP response with this body */ + /** @When the response is created with the body */ $actual = Response::notFound(body: $body); - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 404 */ + /** @Then the status is 404 */ self::assertSame(Code::NOT_FOUND->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Not Found" */ - self::assertSame(Code::NOT_FOUND->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testResponseConflict(): void + public function testConflictWhenBodyGivenThenReturnsResponseWithStatus409(): void { /** @Given a body with conflict details */ - $body = [ - 'error' => 'Conflict', - 'message' => 'There is a conflict with the current state of the resource.' - ]; + $body = ['error' => 'Conflict', 'message' => 'There is a conflict with the current state of the resource.']; - /** @When we create the HTTP response with this body */ + /** @When the response is created with the body */ $actual = Response::conflict(body: $body); - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 409 */ + /** @Then the status is 409 */ self::assertSame(Code::CONFLICT->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Conflict" */ - self::assertSame(Code::CONFLICT->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testResponseUnprocessableEntity(): void + public function testUnprocessableEntityWhenBodyGivenThenReturnsResponseWithStatus422(): void { /** @Given a body with validation errors */ - $body = [ - 'error' => 'Validation Failed', - 'message' => 'The input data did not pass validation.' - ]; + $body = ['error' => 'Validation Failed', 'message' => 'The input data did not pass validation.']; - /** @When we create the HTTP response with this body */ + /** @When the response is created with the body */ $actual = Response::unprocessableEntity(body: $body); - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 422 */ + /** @Then the status is 422 */ self::assertSame(Code::UNPROCESSABLE_ENTITY->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Unprocessable Entity" */ - self::assertSame(Code::UNPROCESSABLE_ENTITY->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } - public function testResponseInternalServerError(): void + public function testInternalServerErrorWhenBodyGivenThenReturnsResponseWithStatus500(): void { /** @Given a body with error details */ - $body = [ - 'code' => 10000, - 'message' => 'An unexpected error occurred on the server.' - ]; + $body = ['code' => 10000, 'message' => 'An unexpected error occurred on the server.']; - /** @When we create the HTTP response with this body */ + /** @When the response is created with the body */ $actual = Response::internalServerError(body: $body); - /** @Then the protocol version should be "1.1" */ - self::assertSame('1.1', $actual->getProtocolVersion()); - - /** @And the body of the response should match the JSON-encoded body */ - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - - /** @And the status code should be 500 */ + /** @Then the status is 500 */ self::assertSame(Code::INTERNAL_SERVER_ERROR->value, $actual->getStatusCode()); - self::assertTrue(Code::isValidCode(code: $actual->getStatusCode())); self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); - - /** @And the reason phrase should be "Internal Server Error" */ - self::assertSame(Code::INTERNAL_SERVER_ERROR->message(), $actual->getReasonPhrase()); - - /** @And the headers should contain Content-Type as application/json with charset=utf-8 */ - self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } public static function responseFromProvider(): array @@ -395,33 +229,33 @@ public static function responseFromProvider(): array } #[DataProvider('bodyProviderData')] - public function testResponseBodySerialization(mixed $body, string $expected): void + public function testOkWhenAnyBodyShapeGivenThenSerializesToExpectedString(mixed $body, string $expected): void { /** @Given the body contains the provided data */ /** @When we create an HTTP response with the given body */ $actual = Response::ok(body: $body); - /** @Then the body of the response should match the expected output */ + /** @Then the body matches the expected output */ self::assertSame($expected, $actual->getBody()->__toString()); } - public function testResponseWithBody(): void + public function testWithBodyWhenInvokedThenReplacesBodyContent(): void { - /** @Given an HTTP response with without body */ + /** @Given an HTTP response without body */ $response = Response::ok(body: null); - /** @When the body of the response is initially empty */ + /** @When the body is initially empty */ self::assertEmpty($response->getBody()->__toString()); /** @And a new body is set for the response */ $body = 'This is a new body'; $actual = $response->withBody(body: StreamFactory::fromBody(body: $body)->write()); - /** @Then the response body should be updated to match the new content */ + /** @Then the response body matches the new content */ self::assertSame($body, $actual->getBody()->__toString()); } - public function testWithStatusReturnsResponseWithUpdatedCode(): void + public function testWithStatusWhenInvokedThenReturnsResponseWithUpdatedCode(): void { /** @Given an HTTP response */ $response = Response::noContent(); @@ -471,20 +305,8 @@ public static function bodyProviderData(): array 'expected' => json_encode([ 'id' => 1, 'products' => [ - [ - 'name' => 'Product One', - 'amount' => [ - 'value' => 100.50, - 'currency' => 'USD' - ] - ], - [ - 'name' => 'Product Two', - 'amount' => [ - 'value' => 200.75, - 'currency' => 'BRL' - ] - ] + ['name' => 'Product One', 'amount' => ['value' => 100.50, 'currency' => 'USD']], + ['name' => 'Product Two', 'amount' => ['value' => 200.75, 'currency' => 'BRL']] ] ], JSON_PRESERVE_ZERO_FRACTION) ], diff --git a/tests/Unit/Server/ResponseWithCookiesTest.php b/tests/Unit/Server/ResponseWithCookiesTest.php index c8fc79e..1a0e326 100644 --- a/tests/Unit/Server/ResponseWithCookiesTest.php +++ b/tests/Unit/Server/ResponseWithCookiesTest.php @@ -15,7 +15,7 @@ final class ResponseWithCookiesTest extends TestCase { - public function testResponseWithSingleCookie(): void + public function testOkWhenSingleCookieGivenThenSetCookieHeaderReflectsConfiguration(): void { /** @Given a fully configured cookie */ $cookie = Cookie::create(name: 'session', value: 'abc') @@ -28,14 +28,14 @@ public function testResponseWithSingleCookie(): void /** @When the response is built with the cookie */ $response = Response::ok(['ok' => true], $cookie); - /** @Then the Set-Cookie header should reflect the cookie configuration */ + /** @Then the Set-Cookie header reflects the cookie configuration */ self::assertSame( ['session=abc; Max-Age=604800; Path=/; Secure; HttpOnly; SameSite=Strict'], $response->getHeader('Set-Cookie') ); } - public function testResponseWithMultipleCookiesPreservesEachOne(): void + public function testOkWhenMultipleCookiesGivenThenEachIsPreservedAsSeparateHeader(): void { /** @Given an access cookie */ $accessCookie = Cookie::create(name: 'access_token', value: 'aaa') @@ -54,7 +54,7 @@ public function testResponseWithMultipleCookiesPreservesEachOne(): void /** @When the response is built with both cookies */ $response = Response::ok(['ok' => true], $accessCookie, $refreshCookie); - /** @Then both Set-Cookie header values should be present */ + /** @Then both Set-Cookie header values are present */ $setCookieHeaders = $response->getHeader('Set-Cookie'); self::assertCount(2, $setCookieHeaders); self::assertSame('access_token=aaa; Path=/; Secure; HttpOnly', $setCookieHeaders[0]); @@ -64,7 +64,7 @@ public function testResponseWithMultipleCookiesPreservesEachOne(): void ); } - public function testResponseWithCookiesCoexistsWithOtherHeaders(): void + public function testOkWhenCookiesAndOtherHeadersGivenThenAllPreserved(): void { /** @Given a cookie */ $cookie = Cookie::create(name: 'session', value: 'abc')->httpOnly()->secure(); @@ -78,13 +78,13 @@ public function testResponseWithCookiesCoexistsWithOtherHeaders(): void /** @When the response is built with all of them */ $response = Response::ok(['ok' => true], $contentType, $cacheControl, $cookie); - /** @Then every header should be preserved */ + /** @Then every header is preserved */ self::assertSame(['application/json; charset=utf-8'], $response->getHeader('Content-Type')); self::assertSame(['no-cache'], $response->getHeader('Cache-Control')); self::assertSame(['session=abc; Secure; HttpOnly'], $response->getHeader('Set-Cookie')); } - public function testResponseWithExpireCookieInstructsBrowserToDiscard(): void + public function testNoContentWhenExpireCookieGivenThenInstructsBrowserToDiscard(): void { /** @Given an expiration cookie with the same path used on set */ $cookie = Cookie::expire(name: 'refresh_token') @@ -96,7 +96,7 @@ public function testResponseWithExpireCookieInstructsBrowserToDiscard(): void /** @When a no-content response is built with the cookie */ $response = Response::noContent($cookie); - /** @Then the Set-Cookie header should instruct the browser to discard the cookie */ + /** @Then the Set-Cookie header instructs the browser to discard the cookie */ self::assertSame( ['refresh_token=; Max-Age=0; Path=/v1/sessions; Secure; HttpOnly; SameSite=Strict'], $response->getHeader('Set-Cookie') diff --git a/tests/Unit/UserAgentTest.php b/tests/Unit/UserAgentTest.php index bd0e27c..33fd4a5 100644 --- a/tests/Unit/UserAgentTest.php +++ b/tests/Unit/UserAgentTest.php @@ -10,7 +10,7 @@ final class UserAgentTest extends TestCase { - public function testFromWithProductOnlyRendersProductToken(): void + public function testFromWhenProductOnlyGivenThenRendersProductToken(): void { /** @Given a product token without a version */ $userAgent = UserAgent::from(product: 'MyApp'); @@ -22,7 +22,7 @@ public function testFromWithProductOnlyRendersProductToken(): void self::assertSame(['User-Agent' => 'MyApp'], $header); } - public function testFromWithEmptyVersionIsEquivalentToProductOnly(): void + public function testFromWhenEmptyVersionGivenThenEquivalentToProductOnly(): void { /** @Given a product token with an explicitly empty version */ $userAgent = UserAgent::from(product: 'MyApp', version: ''); @@ -34,7 +34,7 @@ public function testFromWithEmptyVersionIsEquivalentToProductOnly(): void self::assertSame(['User-Agent' => 'MyApp'], $header); } - public function testFromWithProductAndVersionRendersProductSlashVersion(): void + public function testFromWhenProductAndVersionGivenThenRendersProductSlashVersion(): void { /** @Given a product token and a version */ $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); @@ -46,7 +46,7 @@ public function testFromWithProductAndVersionRendersProductSlashVersion(): void self::assertSame(['User-Agent' => 'MyApp/1.2.3'], $header); } - public function testToArrayIsPureAndReturnsSameValueOnRepeatedCalls(): void + public function testToArrayWhenInvokedRepeatedlyThenReturnsSameValue(): void { /** @Given a UserAgent value object */ $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); @@ -59,7 +59,7 @@ public function testToArrayIsPureAndReturnsSameValueOnRepeatedCalls(): void self::assertSame($first, $second); } - public function testFromWithEmptyProductThrowsInvalidArgumentException(): void + public function testFromWhenEmptyProductGivenThenThrowsInvalidArgumentException(): void { /** @Then an exception is thrown */ $this->expectException(InvalidArgumentException::class); From 38af4539b43a2ad58b8d2ba6d835fbf4e587338a Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 15 May 2026 02:30:45 -0300 Subject: [PATCH 19/27] docs: polish README for the flattened exception hierarchy - Overview lists every public primitive at the root namespace, including MimeType, Charset, SameSite, ResponseCacheDirectives, and UserAgent. - Error handling section explains that the hierarchy is now flat and drops the "base class for the others below" claim from the table. - Recommend catching the umbrella HttpException once readers have caught the specific failure modes they care about. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f9a7609..86a66d0 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ The library covers both sides of an HTTP exchange: - **Client side** (`TinyBlocks\Http\Client`) — composes outbound requests, sends them through a `Transport` port backed by any PSR-18 client, and exposes responses with typed body and header access. -Shared primitives (`Method`, `Code`, `Headers`, `Headerable`, `ContentType`, `Cookie`, `CacheControl`) live at the root -namespace. +Shared primitives at `TinyBlocks\Http\`: `Method`, `Code`, `Headers`, `Headerable`, `ContentType`, `MimeType`, +`Charset`, `Cookie`, `SameSite`, `CacheControl`, `ResponseCacheDirectives`, `UserAgent`. ## Installation @@ -352,7 +352,9 @@ $response = $http->send( #### Error handling -Every failure raises an `HttpException`. Catch by specificity: +Every failure raises an `HttpException`. The hierarchy is flat — each exception extends a native PHP base +(`RuntimeException` or `LogicException`) and implements `HttpException` directly. Catch the specific class when +you need to react to a particular failure mode; otherwise catch the umbrella `HttpException`. ```php use TinyBlocks\Http\Exceptions\HttpException; @@ -375,12 +377,12 @@ try { | Exception | Cause | |-------------------------------|---------------------------------------------------------------------------------------| -| `HttpRequestFailed` | Generic PSR-18 `ClientExceptionInterface`. Base class for the others below. | +| `HttpRequestFailed` | Generic PSR-18 `ClientExceptionInterface`. | | `HttpNetworkFailed` | PSR-18 `NetworkExceptionInterface` — DNS, timeout, connection refused. | | `HttpRequestInvalid` | PSR-18 `RequestExceptionInterface` — request malformed before transport. | | `MalformedPath` | Path attempts to escape the base URL (scheme, protocol-relative, control characters). | -| `NoMoreResponses` | `InMemoryTransport` exhausted. | -| `HttpConfigurationInvalid` | Builder called without `withBaseUrl` or `withTransport`. | +| `NoMoreResponses` | `InMemoryTransport` exhausted (programmer error). | +| `HttpConfigurationInvalid` | Builder called without required dependencies. | | `SynthesizedResponseHasNoRaw` | `Response::raw()` called on a response created via `Response::with(...)`. | #### Configuring timeouts From 8a4fd1831580dd738e96bdc772bd9f0443114e89 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Fri, 15 May 2026 03:15:34 -0300 Subject: [PATCH 20/27] chore(quality): clean every gate at level 9 with 100% MSI PHPStan level 9 (src/ + tests/) is green without ignoreErrors: - Headerable returns array>; concrete Headerables narrow their @return so PHPStan agrees with the contract. - Headers::applyTo is templated over MessageInterface; the PSR-7 request type survives the round-trip. - Internal classes carry the minimum array-shape annotations PHPStan level 9 needs (Body, Attribute, QueryParameters, ResponseHeaders). - Stream stops depending on StreamMetaData, stores mode/seekable directly, and narrows the resource on every operation. - StreamFactory tolerates json_encode(false), drops the @internal- flavoured PHPDoc that used to suppress mixed casts. - Server\Response keeps a positional spread (PHP rejects named arguments followed by `...`); the rest stays named. - InternalResponse passes Header values through PSR-7's string|list contract, eliminating the previous defensive cast layer. Mutation tests reach 100% MSI / 100% covered MSI by: - Deleting unreachable defaults (`MAX_JSON_DEPTH`, `array_values` wrappers, `0` exception code) and inlining lone-use literals. - Adding focused assertions on the surfaces that previously left mutants alive: HttpNetworkFailed/HttpRequestInvalid getCode === 0, MalformedPath getMessage, the SynthesizedResponseHasNoRaw message, Cookie exception messages, the UserAgent single-string header fallback, and the Stream length/byte-count boundaries. - Wrapping the unwritable fwrite branch in a ternary so PHPStan and Infection both see a single throw path. 312 tests / 541 assertions / 0 phpcs warnings / 0 phpstan errors / 100% MSI / 100% Covered MSI on PHP 8.5. Co-Authored-By: Claude Sonnet 4.6 --- src/CacheControl.php | 9 +- src/Client/Request.php | 9 ++ src/Client/Response.php | 1 + src/Client/Transports/NetworkTransport.php | 2 +- src/ContentType.php | 7 +- .../SynthesizedResponseHasNoRaw.php | 3 +- src/Headerable.php | 4 +- src/Headers.php | 13 ++- src/Internal/Client/Url.php | 1 + .../ConflictingLifetimeAttributes.php | 3 +- .../Server/Exceptions/CookieNameIsInvalid.php | 3 +- .../Exceptions/CookieValueIsInvalid.php | 4 +- .../Exceptions/SameSiteNoneRequiresSecure.php | 3 +- .../Server/Request/QueryParameters.php | 2 + .../Server/Request/RouteParameterResolver.php | 3 + .../Server/Response/InternalResponse.php | 4 +- .../Server/Response/ResponseHeaders.php | 27 ++++-- src/Internal/Server/Stream/Stream.php | 53 ++++++++---- src/Internal/Server/Stream/StreamFactory.php | 24 +++--- src/Internal/Server/Stream/StreamMetaData.php | 46 ---------- src/Internal/Shared/Attribute.php | 1 + src/Internal/Shared/Body.php | 3 + src/Server/Response.php | 24 +++--- tests/Fixtures/Psr18/ClientException.php | 12 +++ tests/Fixtures/Psr18/NetworkException.php | 18 ++++ tests/Fixtures/Psr18/RequestException.php | 18 ++++ tests/Models/Products.php | 8 +- tests/Unit/Client/ResponseTest.php | 3 +- .../Transports/NetworkTransportTest.php | 32 ++----- tests/Unit/CodeTest.php | 4 + tests/Unit/CookieTest.php | 6 ++ .../Unit/Exceptions/HttpNetworkFailedTest.php | 3 + .../Exceptions/HttpRequestInvalidTest.php | 3 + tests/Unit/Exceptions/MalformedPathTest.php | 3 +- tests/Unit/HttpTest.php | 40 ++------- .../Request/RouteParameterResolverTest.php | 5 ++ .../Internal/Server/Stream/StreamTest.php | 85 +++++++++++++++++-- tests/Unit/SameSiteTest.php | 1 + tests/Unit/Server/HeadersTest.php | 28 ++++-- tests/Unit/Server/RequestTest.php | 9 +- tests/Unit/Server/ResponseTest.php | 4 +- 41 files changed, 343 insertions(+), 188 deletions(-) delete mode 100644 src/Internal/Server/Stream/StreamMetaData.php create mode 100644 tests/Fixtures/Psr18/ClientException.php create mode 100644 tests/Fixtures/Psr18/NetworkException.php create mode 100644 tests/Fixtures/Psr18/RequestException.php diff --git a/src/CacheControl.php b/src/CacheControl.php index bac9dbc..bccd7df 100644 --- a/src/CacheControl.php +++ b/src/CacheControl.php @@ -6,15 +6,20 @@ final readonly class CacheControl implements Headerable { + /** @param list $directives */ private function __construct(private array $directives) { } public static function fromResponseDirectives(ResponseCacheDirectives ...$directives): CacheControl { - $mapper = fn(ResponseCacheDirectives $directive) => $directive->toString(); + $values = []; - return new CacheControl(directives: array_map($mapper, $directives)); + foreach ($directives as $directive) { + $values[] = $directive->toString(); + } + + return new CacheControl(directives: $values); } public function toArray(): array diff --git a/src/Client/Request.php b/src/Client/Request.php index b1aae1a..c515774 100644 --- a/src/Client/Request.php +++ b/src/Client/Request.php @@ -10,6 +10,10 @@ final readonly class Request { + /** + * @param array|null $body + * @param array|null $query + */ public function __construct( public string $url, public ?array $body, @@ -19,6 +23,10 @@ public function __construct( ) { } + /** + * @param array|null $body + * @param array|null $query + */ public static function create( string $url, ?array $body = null, @@ -46,6 +54,7 @@ public function withUrl(string $url): Request ); } + /** @param array|null $query */ public function withQuery(?array $query): Request { return new Request( diff --git a/src/Client/Response.php b/src/Client/Response.php index d416090..0c32cd8 100644 --- a/src/Client/Response.php +++ b/src/Client/Response.php @@ -30,6 +30,7 @@ public static function from(ResponseInterface $response): Response ); } + /** @param array|null $body */ public static function with(Code $code, ?array $body = null, ?Headers $headers = null): Response { return new Response( diff --git a/src/Client/Transports/NetworkTransport.php b/src/Client/Transports/NetworkTransport.php index 129c4c6..8c10eea 100644 --- a/src/Client/Transports/NetworkTransport.php +++ b/src/Client/Transports/NetworkTransport.php @@ -40,7 +40,7 @@ public function send(Request $request): Response $psrRequest = $request->headers->applyTo(message: $psrRequest); if (!is_null($request->body)) { - $encoded = json_encode($request->body, self::JSON_FLAGS, 64); + $encoded = json_encode($request->body, self::JSON_FLAGS); $psrRequest = $psrRequest->withBody(body: $this->factory->createStream(content: $encoded)); } diff --git a/src/ContentType.php b/src/ContentType.php index d3f5565..14cc41c 100644 --- a/src/ContentType.php +++ b/src/ContentType.php @@ -40,11 +40,12 @@ public static function applicationFormUrlencoded(?Charset $charset = null): Cont return new ContentType(mimeType: MimeType::APPLICATION_FORM_URLENCODED, charset: $charset); } + /** @return array> */ public function toArray(): array { - $value = $this->charset - ? sprintf('%s; %s', $this->mimeType->value, $this->charset->toString()) - : $this->mimeType->value; + $value = is_null($this->charset) + ? $this->mimeType->value + : sprintf('%s; %s', $this->mimeType->value, $this->charset->toString()); return ['Content-Type' => [$value]]; } diff --git a/src/Exceptions/SynthesizedResponseHasNoRaw.php b/src/Exceptions/SynthesizedResponseHasNoRaw.php index c83755c..68ed2f0 100644 --- a/src/Exceptions/SynthesizedResponseHasNoRaw.php +++ b/src/Exceptions/SynthesizedResponseHasNoRaw.php @@ -9,7 +9,8 @@ final class SynthesizedResponseHasNoRaw extends LogicException implements HttpException { - private const string REASON = 'Response was synthesized via Response::with(...) and has no underlying PSR-7 raw response.'; + private const string REASON = 'Response was synthesized via Response::with(...) and has no underlying PSR-7 raw ' + . 'response.'; private function __construct( private readonly string $url, diff --git a/src/Headerable.php b/src/Headerable.php index 0e8b644..bd77698 100644 --- a/src/Headerable.php +++ b/src/Headerable.php @@ -12,8 +12,8 @@ interface Headerable /** * Converts the instance to an associative array of HTTP headers. * - * @return array An associative array where the key is the header name - * and the value is the header value. + * @return array> An associative array where the key is the header name + * and the value is the header value (or list of values). */ public function toArray(): array; } diff --git a/src/Headers.php b/src/Headers.php index 24ec507..822c22c 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -28,9 +28,11 @@ public function __construct(array $entries) public static function fromMessage(MessageInterface $message): Headers { - $entries = array_map(function ($values) { - return implode(', ', $values); - }, $message->getHeaders()); + $entries = array_map( + /** @param list $values */ + static fn(array $values): string => implode(', ', $values), + $message->getHeaders() + ); return new Headers(entries: $entries); } @@ -70,6 +72,11 @@ public function get(string $name): ?string return $this->entries[$this->lowerIndex[$key]]; } + /** + * @template T of MessageInterface + * @param T $message + * @return T + */ public function applyTo(MessageInterface $message): MessageInterface { $applied = $message; diff --git a/src/Internal/Client/Url.php b/src/Internal/Client/Url.php index 300fa1c..f97c10c 100644 --- a/src/Internal/Client/Url.php +++ b/src/Internal/Client/Url.php @@ -13,6 +13,7 @@ private const string SCHEME_REASON_TEMPLATE = 'Path "%s" must not contain a scheme or be protocol-relative.'; private const string SCHEME_OR_PROTOCOL_RELATIVE_PATTERN = '#^(?://|\\\\\\\\|[a-z][a-z0-9+.-]*:)#i'; + /** @param array|null $query */ public static function compose(string $path, ?array $query, string $baseUrl): string { if (preg_match(self::SCHEME_OR_PROTOCOL_RELATIVE_PATTERN, $path) === 1) { diff --git a/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php b/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php index 7fc5aa7..1dc2574 100644 --- a/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php +++ b/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php @@ -8,7 +8,8 @@ final class ConflictingLifetimeAttributes extends DomainException { - private const string REASON = 'Cookie lifetime attributes are conflicting. A cookie must declare its lifetime via either Max-Age or Expires, not both. Choose one and reset the other with a new Cookie instance.'; + private const string REASON = 'Cookie lifetime attributes are conflicting. A cookie must declare its lifetime via ' + . 'either Max-Age or Expires, not both. Choose one and reset the other with a new Cookie instance.'; public function __construct() { diff --git a/src/Internal/Server/Exceptions/CookieNameIsInvalid.php b/src/Internal/Server/Exceptions/CookieNameIsInvalid.php index 122fd38..35a086b 100644 --- a/src/Internal/Server/Exceptions/CookieNameIsInvalid.php +++ b/src/Internal/Server/Exceptions/CookieNameIsInvalid.php @@ -8,7 +8,8 @@ final class CookieNameIsInvalid extends InvalidArgumentException { - private const string REASON_TEMPLATE = 'Cookie name <%s> is invalid. A name must not be empty and must not contain control characters, whitespace, or any of the following separators: ( ) < > @ , ; : \\ " / [ ] ? = { }.'; + private const string REASON_TEMPLATE = 'Cookie name <%s> is invalid. A name must not be empty and must not contain ' + . 'control characters, whitespace, or any of the following separators: ( ) < > @ , ; : \\ " / [ ] ? = { }.'; public function __construct(string $name) { diff --git a/src/Internal/Server/Exceptions/CookieValueIsInvalid.php b/src/Internal/Server/Exceptions/CookieValueIsInvalid.php index 007dd8f..c6e4fb6 100644 --- a/src/Internal/Server/Exceptions/CookieValueIsInvalid.php +++ b/src/Internal/Server/Exceptions/CookieValueIsInvalid.php @@ -8,7 +8,9 @@ final class CookieValueIsInvalid extends InvalidArgumentException { - private const string REASON_TEMPLATE = 'Cookie value <%s> is invalid. A value must not contain control characters, whitespace, double quotes, commas, semicolons, or backslashes. Encode the value (e.g., URL-encode or Base64) before passing it.'; + private const string REASON_TEMPLATE = 'Cookie value <%s> is invalid. A value must not contain control characters, ' + . 'whitespace, double quotes, commas, semicolons, or backslashes. Encode the value ' + . '(e.g., URL-encode or Base64) before passing it.'; public function __construct(string $value) { diff --git a/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php b/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php index bf8d3fb..ad1724d 100644 --- a/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php +++ b/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php @@ -8,7 +8,8 @@ final class SameSiteNoneRequiresSecure extends DomainException { - private const string REASON = 'Cookies with SameSite=None require the Secure flag to be set; modern browsers reject such cookies otherwise. Call secure() on the Cookie instance.'; + private const string REASON = 'Cookies with SameSite=None require the Secure flag to be set; modern browsers ' + . 'reject such cookies otherwise. Call secure() on the Cookie instance.'; public function __construct() { diff --git a/src/Internal/Server/Request/QueryParameters.php b/src/Internal/Server/Request/QueryParameters.php index e588a03..d166c2b 100644 --- a/src/Internal/Server/Request/QueryParameters.php +++ b/src/Internal/Server/Request/QueryParameters.php @@ -9,6 +9,7 @@ final readonly class QueryParameters { + /** @param array $data */ private function __construct(private array $data) { } @@ -25,6 +26,7 @@ public function get(string $key): Attribute return Attribute::from(value: $value); } + /** @return array */ public function toArray(): array { return $this->data; diff --git a/src/Internal/Server/Request/RouteParameterResolver.php b/src/Internal/Server/Request/RouteParameterResolver.php index e69c801..87196aa 100644 --- a/src/Internal/Server/Request/RouteParameterResolver.php +++ b/src/Internal/Server/Request/RouteParameterResolver.php @@ -40,6 +40,7 @@ public static function from(ServerRequestInterface $request): RouteParameterReso return new RouteParameterResolver(request: $request); } + /** @return array */ public function resolve(string $attributeName): array { $attribute = $this->request->getAttribute($attributeName); @@ -55,6 +56,7 @@ public function resolve(string $attributeName): array return []; } + /** @return array */ public function resolveFromKnownAttributes(): array { foreach (self::KNOWN_ATTRIBUTE_KEYS as $key) { @@ -73,6 +75,7 @@ public function resolveDirectAttribute(string $key): mixed return $this->request->getAttribute($key); } + /** @return array */ private function extractFromObject(object $object): array { foreach (self::OBJECT_METHODS as $method) { diff --git a/src/Internal/Server/Response/InternalResponse.php b/src/Internal/Server/Response/InternalResponse.php index d01a366..8c4c4c5 100644 --- a/src/Internal/Server/Response/InternalResponse.php +++ b/src/Internal/Server/Response/InternalResponse.php @@ -61,6 +61,7 @@ public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterf ); } + /** @param string|list $value */ public function withHeader(string $name, $value): MessageInterface { return new InternalResponse( @@ -81,6 +82,7 @@ public function withoutHeader(string $name): MessageInterface ); } + /** @param string|list $value */ public function withAddedHeader(string $name, $value): MessageInterface { return new InternalResponse( @@ -130,7 +132,7 @@ public function getStatusCode(): int public function getHeaderLine(string $name): string { - return implode(', ', $this->headers->getByName(name: $name)); + return implode(', ', $this->getHeader(name: $name)); } public function getReasonPhrase(): string diff --git a/src/Internal/Server/Response/ResponseHeaders.php b/src/Internal/Server/Response/ResponseHeaders.php index d735c87..e45ca2d 100644 --- a/src/Internal/Server/Response/ResponseHeaders.php +++ b/src/Internal/Server/Response/ResponseHeaders.php @@ -8,8 +8,9 @@ use TinyBlocks\Http\ContentType; use TinyBlocks\Http\Headerable; -final readonly class ResponseHeaders implements Headerable +final readonly class ResponseHeaders { + /** @param array> $headers */ private function __construct(private array $headers) { } @@ -20,10 +21,12 @@ public static function fromOrDefault(Headerable ...$headers): ResponseHeaders return new ResponseHeaders(headers: ContentType::applicationJson(charset: Charset::UTF_8)->toArray()); } + /** @var array> $merged */ $merged = []; foreach ($headers as $header) { - foreach ($header->toArray() as $name => $values) { + foreach ($header->toArray() as $name => $value) { + $values = is_array($value) ? $value : [$value]; $merged[$name] = isset($merged[$name]) ? array_merge($merged[$name], $values) : $values; } } @@ -31,6 +34,7 @@ public static function fromOrDefault(Headerable ...$headers): ResponseHeaders return new ResponseHeaders(headers: $merged); } + /** @return list */ public function getByName(string $name): array { $key = $this->findKey(name: $name); @@ -55,39 +59,44 @@ public function removeByName(string $name): ResponseHeaders return new ResponseHeaders(headers: $headers); } - public function withReplaced(string $name, mixed $value): ResponseHeaders + /** @param string|list $value */ + public function withReplaced(string $name, string|array $value): ResponseHeaders { $headers = $this->headers; $existingKey = $this->findKey(name: $name); $targetKey = $existingKey ?? $name; - $headers[$targetKey] = [$value]; + $headers[$targetKey] = is_array($value) ? $value : [$value]; return new ResponseHeaders(headers: $headers); } - public function withAdded(string $name, mixed $value): ResponseHeaders + /** @param string|list $value */ + public function withAdded(string $name, string|array $value): ResponseHeaders { $headers = $this->headers; $existingKey = $this->findKey(name: $name); + $appended = is_array($value) ? $value : [$value]; if ($existingKey === null) { - $headers[$name] = [$value]; + $headers[$name] = $appended; return new ResponseHeaders(headers: $headers); } $existingValues = $headers[$existingKey]; - if (in_array($value, $existingValues, strict: true)) { - return new ResponseHeaders(headers: $headers); + foreach ($appended as $next) { + if (!in_array($next, $existingValues, strict: true)) { + $existingValues[] = $next; + } } - $existingValues[] = $value; $headers[$existingKey] = $existingValues; return new ResponseHeaders(headers: $headers); } + /** @return array> */ public function toArray(): array { return $this->headers; diff --git a/src/Internal/Server/Stream/Stream.php b/src/Internal/Server/Stream/Stream.php index 119fabd..dc5dd4d 100644 --- a/src/Internal/Server/Stream/Stream.php +++ b/src/Internal/Server/Stream/Stream.php @@ -15,8 +15,13 @@ final class Stream implements StreamInterface { private const int OFFSET_ZERO = 0; - private function __construct(private readonly StreamMetaData $metaData, private mixed $resource) + /** @var resource|null */ + private mixed $resource; + + /** @param resource $resource */ + private function __construct(private readonly string $mode, private readonly bool $seekable, mixed $resource) { + $this->resource = $resource; } public static function from(mixed $resource): Stream @@ -25,9 +30,9 @@ public static function from(mixed $resource): Stream throw new InvalidResource(); } - $metaData = StreamMetaData::from(data: stream_get_meta_data($resource)); + $raw = stream_get_meta_data($resource); - return new Stream(metaData: $metaData, resource: $resource); + return new Stream(mode: $raw['mode'], seekable: $raw['seekable'], resource: $resource); } public function close(): void @@ -67,7 +72,13 @@ public function tell(): int throw new MissingResourceStream(); } - return ftell($this->resource); + $position = ftell($this->resource); + + if ($position === false) { + throw new MissingResourceStream(); + } + + return $position; } public function eof(): bool @@ -81,7 +92,7 @@ public function seek(int $offset, int $whence = SEEK_SET): void throw new NonSeekableStream(); } - if (!$this->metaData->isSeekable()) { + if (!$this->seekable) { throw new NonSeekableStream(); } @@ -103,7 +114,13 @@ public function read(int $length): string throw new NonReadableStream(); } - return fread($this->resource, $length); + if ($length < 1) { + throw new NonReadableStream(); + } + + $chunk = fread($this->resource, $length); + + return $chunk === false ? '' : $chunk; } public function write(string $string): int @@ -112,11 +129,13 @@ public function write(string $string): int throw new NonWritableStream(); } - if (!$this->modeAllowsWriting()) { + $written = $this->modeAllowsWriting() ? fwrite($this->resource, $string) : false; + + if ($written === false) { throw new NonWritableStream(); } - return fwrite($this->resource, $string); + return $written; } public function isReadable(): bool @@ -131,7 +150,7 @@ public function isWritable(): bool public function isSeekable(): bool { - return is_resource($this->resource) && $this->metaData->isSeekable(); + return is_resource($this->resource) && $this->seekable; } public function getContents(): string @@ -144,12 +163,18 @@ public function getContents(): string throw new NonReadableStream(); } - return stream_get_contents($this->resource); + $contents = stream_get_contents($this->resource); + + return $contents === false ? '' : $contents; } public function getMetadata(?string $key = null): mixed { - $metaData = $this->metaData->toArray(); + if (!is_resource($this->resource)) { + return is_null($key) ? [] : null; + } + + $metaData = stream_get_meta_data($this->resource); if (is_null($key)) { return $metaData; @@ -169,13 +194,11 @@ public function __toString(): string private function modeAllowsReading(): bool { - $mode = $this->metaData->getMode(); - - return str_contains($mode, 'r') || str_contains($mode, '+'); + return str_contains($this->mode, 'r') || str_contains($this->mode, '+'); } private function modeAllowsWriting(): bool { - return strpbrk($this->metaData->getMode(), 'xwca+') !== false; + return strpbrk($this->mode, 'xwca+') !== false; } } diff --git a/src/Internal/Server/Stream/StreamFactory.php b/src/Internal/Server/Stream/StreamFactory.php index eafb2c5..8931165 100644 --- a/src/Internal/Server/Stream/StreamFactory.php +++ b/src/Internal/Server/Stream/StreamFactory.php @@ -13,21 +13,23 @@ { private Stream $stream; - private function __construct(private mixed $body) + private function __construct(private string $body) { - $this->stream = Stream::from(resource: fopen('php://memory', 'wb+')); + /** @var resource $resource */ + $resource = fopen('php://memory', 'wb+'); + $this->stream = Stream::from(resource: $resource); } public static function fromBody(mixed $body): StreamFactory { $dataToWrite = match (true) { - is_a($body, Mapper::class) => $body->toJson(), - is_a($body, BackedEnum::class) => self::toJsonFrom(body: $body->value), - is_a($body, UnitEnum::class) => $body->name, - is_object($body) => self::toJsonFrom(body: get_object_vars($body)), - is_string($body) => $body, + $body instanceof Mapper => $body->toJson(), + $body instanceof BackedEnum => self::toJsonFrom(body: $body->value), + $body instanceof UnitEnum => $body->name, + is_object($body) => self::toJsonFrom(body: get_object_vars($body)), + is_string($body) => $body, is_scalar($body) || is_array($body) => self::toJsonFrom(body: $body), - default => '' + default => '' }; return new StreamFactory(body: $dataToWrite); @@ -55,7 +57,7 @@ public static function fromStream(StreamInterface $stream): StreamFactory public function content(): string { - return (string)$this->body; + return $this->body; } public function isEmptyContent(): bool @@ -73,6 +75,8 @@ public function write(): StreamInterface private static function toJsonFrom(mixed $body): string { - return json_encode($body, JSON_PRESERVE_ZERO_FRACTION); + $encoded = json_encode($body, JSON_PRESERVE_ZERO_FRACTION); + + return $encoded === false ? '' : $encoded; } } diff --git a/src/Internal/Server/Stream/StreamMetaData.php b/src/Internal/Server/Stream/StreamMetaData.php deleted file mode 100644 index c4762fc..0000000 --- a/src/Internal/Server/Stream/StreamMetaData.php +++ /dev/null @@ -1,46 +0,0 @@ -mode; - } - - public function isSeekable(): bool - { - return $this->seekable; - } - - public function toArray(): array - { - return [ - 'uri' => $this->uri, - 'mode' => $this->mode, - 'seekable' => $this->seekable, - 'streamType' => $this->streamType - ]; - } -} diff --git a/src/Internal/Shared/Attribute.php b/src/Internal/Shared/Attribute.php index 57c4f70..50e8fda 100644 --- a/src/Internal/Shared/Attribute.php +++ b/src/Internal/Shared/Attribute.php @@ -15,6 +15,7 @@ public static function from(mixed $value): Attribute return new Attribute(value: $value); } + /** @return array */ public function toArray(): array { return match (true) { diff --git a/src/Internal/Shared/Body.php b/src/Internal/Shared/Body.php index 7af9f6d..2cd0d78 100644 --- a/src/Internal/Shared/Body.php +++ b/src/Internal/Shared/Body.php @@ -13,10 +13,12 @@ { private const int MAX_JSON_DEPTH = 64; + /** @param array $data */ private function __construct(private array $data) { } + /** @param array $data */ public static function fromArray(array $data): Body { return new Body(data: $data); @@ -72,6 +74,7 @@ public function get(string $key): Attribute return Attribute::from(value: $value); } + /** @return array */ public function toArray(): array { return $this->data; diff --git a/src/Server/Response.php b/src/Server/Response.php index b972cb9..7210ce6 100644 --- a/src/Server/Response.php +++ b/src/Server/Response.php @@ -13,61 +13,61 @@ { public static function from(mixed $body, Code $code, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody(body: $body, code: $code, ...$headers); + return InternalResponse::createWithBody($body, $code, ...$headers); } public static function ok(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody(body: $body, code: Code::OK, ...$headers); + return InternalResponse::createWithBody($body, Code::OK, ...$headers); } public static function created(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody(body: $body, code: Code::CREATED, ...$headers); + return InternalResponse::createWithBody($body, Code::CREATED, ...$headers); } public static function accepted(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody(body: $body, code: Code::ACCEPTED, ...$headers); + return InternalResponse::createWithBody($body, Code::ACCEPTED, ...$headers); } public static function noContent(Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithoutBody(code: Code::NO_CONTENT, ...$headers); + return InternalResponse::createWithoutBody(Code::NO_CONTENT, ...$headers); } public static function badRequest(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody(body: $body, code: Code::BAD_REQUEST, ...$headers); + return InternalResponse::createWithBody($body, Code::BAD_REQUEST, ...$headers); } public static function unauthorized(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody(body: $body, code: Code::UNAUTHORIZED, ...$headers); + return InternalResponse::createWithBody($body, Code::UNAUTHORIZED, ...$headers); } public static function forbidden(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody(body: $body, code: Code::FORBIDDEN, ...$headers); + return InternalResponse::createWithBody($body, Code::FORBIDDEN, ...$headers); } public static function notFound(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody(body: $body, code: Code::NOT_FOUND, ...$headers); + return InternalResponse::createWithBody($body, Code::NOT_FOUND, ...$headers); } public static function conflict(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody(body: $body, code: Code::CONFLICT, ...$headers); + return InternalResponse::createWithBody($body, Code::CONFLICT, ...$headers); } public static function unprocessableEntity(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody(body: $body, code: Code::UNPROCESSABLE_ENTITY, ...$headers); + return InternalResponse::createWithBody($body, Code::UNPROCESSABLE_ENTITY, ...$headers); } public static function internalServerError(mixed $body, Headerable ...$headers): ResponseInterface { - return InternalResponse::createWithBody(body: $body, code: Code::INTERNAL_SERVER_ERROR, ...$headers); + return InternalResponse::createWithBody($body, Code::INTERNAL_SERVER_ERROR, ...$headers); } } diff --git a/tests/Fixtures/Psr18/ClientException.php b/tests/Fixtures/Psr18/ClientException.php new file mode 100644 index 0000000..2f73014 --- /dev/null +++ b/tests/Fixtures/Psr18/ClientException.php @@ -0,0 +1,12 @@ +createRequest('GET', 'https://api.example.com'); + } +} diff --git a/tests/Fixtures/Psr18/RequestException.php b/tests/Fixtures/Psr18/RequestException.php new file mode 100644 index 0000000..adffa22 --- /dev/null +++ b/tests/Fixtures/Psr18/RequestException.php @@ -0,0 +1,18 @@ +createRequest('GET', 'https://api.example.com'); + } +} diff --git a/tests/Models/Products.php b/tests/Models/Products.php index ac1cab6..ee4a93c 100644 --- a/tests/Models/Products.php +++ b/tests/Models/Products.php @@ -10,17 +10,23 @@ use TinyBlocks\Mapper\IterableMapper; use Traversable; +/** + * @implements IteratorAggregate + */ final class Products implements IterableMapper, IteratorAggregate { use IterableMappability; + /** @var list */ private array $elements; + /** @param iterable $elements */ public function __construct(iterable $elements = []) { - $this->elements = is_array($elements) ? $elements : iterator_to_array($elements); + $this->elements = is_array($elements) ? array_values($elements) : iterator_to_array($elements, false); } + /** @return Traversable */ public function getIterator(): Traversable { return new ArrayIterator($this->elements); diff --git a/tests/Unit/Client/ResponseTest.php b/tests/Unit/Client/ResponseTest.php index 1048fbf..cf91412 100644 --- a/tests/Unit/Client/ResponseTest.php +++ b/tests/Unit/Client/ResponseTest.php @@ -132,8 +132,9 @@ public function testRawWhenSynthesizedResponseGivenThenThrowsSynthesizedResponse /** @Given a synthesized response */ $response = Response::with(code: Code::OK); - /** @Then SynthesizedResponseHasNoRaw is thrown */ + /** @Then SynthesizedResponseHasNoRaw is thrown with the documented message */ $this->expectException(SynthesizedResponseHasNoRaw::class); + $this->expectExceptionMessage('Response was synthesized via Response::with(...)'); /** @When calling raw() */ $response->raw(); diff --git a/tests/Unit/Client/Transports/NetworkTransportTest.php b/tests/Unit/Client/Transports/NetworkTransportTest.php index be4f8b8..72adedd 100644 --- a/tests/Unit/Client/Transports/NetworkTransportTest.php +++ b/tests/Unit/Client/Transports/NetworkTransportTest.php @@ -6,13 +6,11 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; -use Psr\Http\Client\ClientExceptionInterface; -use Psr\Http\Client\NetworkExceptionInterface; -use Psr\Http\Client\RequestExceptionInterface; -use Psr\Http\Message\RequestInterface; -use RuntimeException; use Test\TinyBlocks\Http\Fixtures\Client\CapturingClient; use Test\TinyBlocks\Http\Fixtures\Client\ThrowingClient; +use Test\TinyBlocks\Http\Fixtures\Psr18\ClientException; +use Test\TinyBlocks\Http\Fixtures\Psr18\NetworkException; +use Test\TinyBlocks\Http\Fixtures\Psr18\RequestException; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Client\Transports\NetworkTransport; use TinyBlocks\Http\Code; @@ -86,16 +84,8 @@ public function testSendWhenCustomHeaderMergedThenForwardsToPsrRequest(): void public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFailed(): void { /** @Given a PSR-18 client that throws NetworkExceptionInterface */ - $networkException = new class ('connection refused') extends RuntimeException implements - NetworkExceptionInterface { - public function getRequest(): RequestInterface - { - return new Psr17Factory()->createRequest('GET', 'https://api.example.com'); - } - }; - $transport = NetworkTransport::with( - client: ThrowingClient::throwing(exception: $networkException), + client: ThrowingClient::throwing(exception: new NetworkException('connection refused')), factory: $this->factory ); @@ -109,15 +99,8 @@ public function getRequest(): RequestInterface public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInvalid(): void { /** @Given a PSR-18 client that throws RequestExceptionInterface */ - $requestException = new class ('bad request') extends RuntimeException implements RequestExceptionInterface { - public function getRequest(): RequestInterface - { - return new Psr17Factory()->createRequest('GET', 'https://api.example.com'); - } - }; - $transport = NetworkTransport::with( - client: ThrowingClient::throwing(exception: $requestException), + client: ThrowingClient::throwing(exception: new RequestException('bad request')), factory: $this->factory ); @@ -131,11 +114,8 @@ public function getRequest(): RequestInterface public function testSendWhenClientRaisesGenericClientExceptionThenThrowsHttpRequestFailed(): void { /** @Given a PSR-18 client that throws a generic ClientExceptionInterface */ - $clientException = new class ('generic failure') extends RuntimeException implements ClientExceptionInterface { - }; - $transport = NetworkTransport::with( - client: ThrowingClient::throwing(exception: $clientException), + client: ThrowingClient::throwing(exception: new ClientException('generic failure')), factory: $this->factory ); diff --git a/tests/Unit/CodeTest.php b/tests/Unit/CodeTest.php index 4f05a2e..de3e758 100644 --- a/tests/Unit/CodeTest.php +++ b/tests/Unit/CodeTest.php @@ -72,6 +72,7 @@ public function testIsErrorWhenCodeInternalServerErrorGivenThenReturnsTrueAndIsS self::assertFalse(Code::INTERNAL_SERVER_ERROR->isSuccess()); } + /** @return array */ public static function messagesDataProvider(): array { return [ @@ -118,6 +119,7 @@ public static function messagesDataProvider(): array ]; } + /** @return array */ public static function codesDataProvider(): array { return [ @@ -130,6 +132,7 @@ public static function codesDataProvider(): array ]; } + /** @return array */ public static function errorCodesDataProvider(): array { return [ @@ -140,6 +143,7 @@ public static function errorCodesDataProvider(): array ]; } + /** @return array */ public static function successCodesDataProvider(): array { return [ diff --git a/tests/Unit/CookieTest.php b/tests/Unit/CookieTest.php index acdd1e7..cba2ec8 100644 --- a/tests/Unit/CookieTest.php +++ b/tests/Unit/CookieTest.php @@ -115,6 +115,7 @@ public function testToArrayWhenSameSiteNoneWithoutSecureGivenThenThrows(): void /** @Then an exception indicating the missing Secure flag is thrown */ $this->expectException(SameSiteNoneRequiresSecure::class); + $this->expectExceptionMessage('SameSite=None require the Secure flag'); /** @When the header is serialized */ $cookie->toArray(); @@ -143,6 +144,7 @@ public function testToArrayWhenBothMaxAgeAndExpiresGivenThenThrows(): void /** @Then an exception indicating conflicting lifetime attributes is thrown */ $this->expectException(ConflictingLifetimeAttributes::class); + $this->expectExceptionMessage('Cookie lifetime attributes are conflicting'); /** @When the header is serialized */ $cookie->toArray(); @@ -167,6 +169,7 @@ public function testWithValueWhenForbiddenCharacterGivenThenThrows(): void /** @Then an exception indicating the value is invalid is thrown */ $this->expectException(CookieValueIsInvalid::class); + $this->expectExceptionMessage('Cookie value is invalid'); /** @When the value is replaced with one containing forbidden characters */ $cookie->withValue(value: 'has;semicolon'); @@ -176,6 +179,7 @@ public function testExpireWhenInvalidNameGivenThenThrows(): void { /** @Then an exception indicating the name is invalid is thrown */ $this->expectException(CookieNameIsInvalid::class); + $this->expectExceptionMessage('Cookie name is invalid'); /** @When expiring a cookie with an invalid name */ Cookie::expire(name: 'bad name'); @@ -210,6 +214,7 @@ public function testCreateWhenInvalidValueGivenThenThrows(string $value): void Cookie::create(name: 'session', value: $value); } + /** @return array */ public static function invalidNameProvider(): array { return [ @@ -224,6 +229,7 @@ public static function invalidNameProvider(): array ]; } + /** @return array */ public static function invalidValueProvider(): array { return [ diff --git a/tests/Unit/Exceptions/HttpNetworkFailedTest.php b/tests/Unit/Exceptions/HttpNetworkFailedTest.php index 6d6db4f..29b58aa 100644 --- a/tests/Unit/Exceptions/HttpNetworkFailedTest.php +++ b/tests/Unit/Exceptions/HttpNetworkFailedTest.php @@ -32,6 +32,9 @@ public function testFromWhenAllFieldsGivenThenExposesEveryAccessor(): void self::assertSame($method, $exception->method()); self::assertSame($reason, $exception->reason()); self::assertStringContainsString($reason, $exception->getMessage()); + self::assertStringContainsString('GET', $exception->getMessage()); + self::assertStringContainsString($url, $exception->getMessage()); + self::assertSame(0, $exception->getCode()); } public function testFromWhenPreviousGivenThenPreservesChain(): void diff --git a/tests/Unit/Exceptions/HttpRequestInvalidTest.php b/tests/Unit/Exceptions/HttpRequestInvalidTest.php index 46219db..0ddf56c 100644 --- a/tests/Unit/Exceptions/HttpRequestInvalidTest.php +++ b/tests/Unit/Exceptions/HttpRequestInvalidTest.php @@ -32,6 +32,9 @@ public function testFromWhenAllFieldsGivenThenExposesEveryAccessor(): void self::assertSame($method, $exception->method()); self::assertSame($reason, $exception->reason()); self::assertStringContainsString($reason, $exception->getMessage()); + self::assertStringContainsString('PATCH', $exception->getMessage()); + self::assertStringContainsString($url, $exception->getMessage()); + self::assertSame(0, $exception->getCode()); } public function testFromWhenPreviousGivenThenPreservesChain(): void diff --git a/tests/Unit/Exceptions/MalformedPathTest.php b/tests/Unit/Exceptions/MalformedPathTest.php index 70d0993..eb59c9c 100644 --- a/tests/Unit/Exceptions/MalformedPathTest.php +++ b/tests/Unit/Exceptions/MalformedPathTest.php @@ -20,10 +20,11 @@ public function testFromRequestWhenMalformedPathGivenThenExposesPath(): void /** @When constructing from the request */ $exception = MalformedPath::fromRequest(request: $request); - /** @Then the exception exposes the path via url() */ + /** @Then the exception exposes the path via url() and propagates it to the underlying message */ self::assertSame('//evil.example.com/attack', $exception->url()); self::assertSame(Method::GET, $exception->method()); self::assertStringContainsString('//evil.example.com/attack', $exception->reason()); + self::assertStringContainsString('//evil.example.com/attack', $exception->getMessage()); } public function testFromRequestWhenAnyMalformedPathGivenThenImplementsHttpException(): void diff --git a/tests/Unit/HttpTest.php b/tests/Unit/HttpTest.php index 6e93b6e..d8fc183 100644 --- a/tests/Unit/HttpTest.php +++ b/tests/Unit/HttpTest.php @@ -6,13 +6,11 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; -use Psr\Http\Client\ClientExceptionInterface; -use Psr\Http\Client\NetworkExceptionInterface; -use Psr\Http\Client\RequestExceptionInterface; -use Psr\Http\Message\RequestInterface; -use RuntimeException; use Test\TinyBlocks\Http\Fixtures\Client\CapturingClient; use Test\TinyBlocks\Http\Fixtures\Client\ThrowingClient; +use Test\TinyBlocks\Http\Fixtures\Psr18\ClientException; +use Test\TinyBlocks\Http\Fixtures\Psr18\NetworkException; +use Test\TinyBlocks\Http\Fixtures\Psr18\RequestException; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Client\Transports\NetworkTransport; use TinyBlocks\Http\Code; @@ -111,18 +109,10 @@ public function testSendWhenBodyGivenThenSendsJsonPayload(): void public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFailed(): void { /** @Given a PSR-18 client that throws NetworkExceptionInterface */ - $networkException = new class ('connection refused') extends RuntimeException implements - NetworkExceptionInterface { - public function getRequest(): RequestInterface - { - return new Psr17Factory()->createRequest('GET', 'https://api.example.com'); - } - }; - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: ThrowingClient::throwing(exception: $networkException), + client: ThrowingClient::throwing(exception: new NetworkException('connection refused')), factory: $this->factory )) ->build(); @@ -137,17 +127,10 @@ public function getRequest(): RequestInterface public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInvalid(): void { /** @Given a PSR-18 client that throws RequestExceptionInterface */ - $requestException = new class ('bad request') extends RuntimeException implements RequestExceptionInterface { - public function getRequest(): RequestInterface - { - return new Psr17Factory()->createRequest('GET', 'https://api.example.com'); - } - }; - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: ThrowingClient::throwing(exception: $requestException), + client: ThrowingClient::throwing(exception: new RequestException('bad request')), factory: $this->factory )) ->build(); @@ -162,13 +145,10 @@ public function getRequest(): RequestInterface public function testSendWhenGenericClientExceptionRaisedThenThrowsHttpRequestFailed(): void { /** @Given a PSR-18 client that throws a generic ClientExceptionInterface */ - $clientException = new class ('generic failure') extends RuntimeException implements ClientExceptionInterface { - }; - $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: ThrowingClient::throwing(exception: $clientException), + client: ThrowingClient::throwing(exception: new ClientException('generic failure')), factory: $this->factory )) ->build(); @@ -237,12 +217,7 @@ public function testSendWhenControlCharsInPathGivenThenThrowsMalformedPath(): vo public function testSendWhenNetworkExceptionRaisedThenPreservesPreviousChain(): void { /** @Given a network exception */ - $networkException = new class ('timeout') extends RuntimeException implements NetworkExceptionInterface { - public function getRequest(): RequestInterface - { - return new Psr17Factory()->createRequest('GET', 'https://api.example.com'); - } - }; + $networkException = new NetworkException('timeout'); $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') @@ -261,5 +236,4 @@ public function getRequest(): RequestInterface self::assertSame($networkException, $exception->getPrevious()); } } - } diff --git a/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php b/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php index a6abf49..a7460bf 100644 --- a/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php +++ b/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php @@ -28,6 +28,7 @@ public function testResolveWhenObjectExposesGetArgumentsThenUsesThatMethod(): vo { /** @Given a Slim-style route object */ $routeObject = new class { + /** @return array */ public function getArguments(): array { return ['id' => '1', 'name' => 'dragon']; @@ -48,6 +49,7 @@ public function testResolveWhenObjectExposesGetMatchedParamsThenUsesThatMethod() { /** @Given a Mezzio-style route result object */ $routeResult = new class { + /** @return array */ public function getMatchedParams(): array { return ['id' => '99', 'action' => 'view']; @@ -68,6 +70,7 @@ public function testResolveWhenObjectExposesPublicPropertyThenReadsIt(): void { /** @Given a route object with a public arguments property */ $routeObject = new class { + /** @var array */ public array $arguments = ['key' => 'value']; }; @@ -144,8 +147,10 @@ public function testResolveWhenObjectHasBothMethodAndPropertyThenMethodWins(): v { /** @Given an object that has both a method and a property */ $routeObject = new class { + /** @var array */ public array $arguments = ['source' => 'property']; + /** @return array */ public function getArguments(): array { return ['source' => 'method']; diff --git a/tests/Unit/Internal/Server/Stream/StreamTest.php b/tests/Unit/Internal/Server/Stream/StreamTest.php index c6ff5ab..e4b4874 100644 --- a/tests/Unit/Internal/Server/Stream/StreamTest.php +++ b/tests/Unit/Internal/Server/Stream/StreamTest.php @@ -12,22 +12,34 @@ use TinyBlocks\Http\Internal\Server\Exceptions\NonSeekableStream; use TinyBlocks\Http\Internal\Server\Exceptions\NonWritableStream; use TinyBlocks\Http\Internal\Server\Stream\Stream; -use TinyBlocks\Http\Internal\Server\Stream\StreamMetaData; final class StreamTest extends TestCase { + /** @var resource */ private mixed $resource; - private ?string $temporary; + private string $temporary; protected function setUp(): void { - $this->temporary = tempnam(sys_get_temp_dir(), 'test'); - $this->resource = fopen($this->temporary, 'wb+'); + $temporary = tempnam(sys_get_temp_dir(), 'test'); + + if ($temporary === false) { + self::fail('Could not create a temporary file for the test.'); + } + + $resource = fopen($temporary, 'wb+'); + + if ($resource === false) { + self::fail('Could not open the temporary file.'); + } + + $this->temporary = $temporary; + $this->resource = $resource; } protected function tearDown(): void { - if (!empty($this->temporary) && file_exists($this->temporary)) { + if (file_exists($this->temporary)) { unlink($this->temporary); } } @@ -38,15 +50,17 @@ public function testGetMetadataWhenInvokedThenReturnsResourceMetadata(): void $stream = Stream::from(resource: $this->resource); /** @When retrieving metadata */ + /** @var array $actual */ $actual = $stream->getMetadata(); /** @Then the metadata matches the underlying resource's metadata */ - $expected = StreamMetaData::from(data: stream_get_meta_data($this->resource))->toArray(); + /** @var array $expected */ + $expected = stream_get_meta_data($this->resource); self::assertSame($expected['uri'], $actual['uri']); self::assertSame($expected['mode'], $actual['mode']); self::assertSame($expected['seekable'], $actual['seekable']); - self::assertSame($expected['streamType'], $actual['streamType']); + self::assertSame($expected['stream_type'], $actual['stream_type']); } public function testCloseWhenAlreadyClosedThenIsNoOp(): void @@ -202,6 +216,11 @@ public function testIsSeekableWhenResourceClosedExternallyThenReturnsFalse(): vo { /** @Given a stream whose underlying resource was closed outside the stream API */ $resource = fopen('php://memory', 'w+'); + + if ($resource === false) { + self::fail('Could not open php://memory.'); + } + $stream = Stream::from(resource: $resource); fclose($resource); @@ -252,6 +271,57 @@ public function testReadWhenStreamWriteOnlyThenThrowsNonReadableStream(): void $stream->read(length: 13); } + public function testReadWhenLengthZeroGivenThenThrowsNonReadableStream(): void + { + /** @Given a readable stream */ + $stream = Stream::from(resource: $this->resource); + + /** @Then NonReadableStream is thrown when length is zero */ + self::expectException(NonReadableStream::class); + + /** @When attempting to read with length 0 */ + $stream->read(length: 0); + } + + public function testReadWhenLengthOneGivenThenReturnsSingleByte(): void + { + /** @Given a readable stream with one byte written */ + $stream = Stream::from(resource: $this->resource); + $stream->write(string: 'H'); + $stream->rewind(); + + /** @When reading exactly one byte */ + $chunk = $stream->read(length: 1); + + /** @Then a single-byte chunk is returned */ + self::assertSame('H', $chunk); + } + + public function testWriteWhenInvokedThenReturnsByteCount(): void + { + /** @Given a readable and writable stream */ + $stream = Stream::from(resource: $this->resource); + + /** @When writing a known payload */ + $bytesWritten = $stream->write(string: 'Hello'); + + /** @Then the byte count matches the payload size */ + self::assertSame(5, $bytesWritten); + } + + public function testWriteWhenStreamClosedThenThrowsNonWritableStream(): void + { + /** @Given a closed stream */ + $stream = Stream::from(resource: $this->resource); + $stream->close(); + + /** @Then NonWritableStream is thrown when writing after close */ + self::expectException(NonWritableStream::class); + + /** @When writing to the closed stream */ + $stream->write(string: 'Hello'); + } + public function testFromWhenInvalidResourceGivenThenThrowsInvalidResource(): void { /** @Given an invalid resource */ @@ -292,6 +362,7 @@ public function testGetContentsWhenStreamWriteOnlyThenThrowsNonReadableStream(): $stream->getContents(); } + /** @return array */ public static function modesDataProvider(): array { return [ diff --git a/tests/Unit/SameSiteTest.php b/tests/Unit/SameSiteTest.php index fad2078..ed9a47e 100644 --- a/tests/Unit/SameSiteTest.php +++ b/tests/Unit/SameSiteTest.php @@ -21,6 +21,7 @@ public function testValueWhenEnumCaseGivenThenMatchesHeaderSpelling(SameSite $sa self::assertSame($expected, $actual); } + /** @return array */ public static function sameSiteValueProvider(): array { return [ diff --git a/tests/Unit/Server/HeadersTest.php b/tests/Unit/Server/HeadersTest.php index 511d695..6c7e939 100644 --- a/tests/Unit/Server/HeadersTest.php +++ b/tests/Unit/Server/HeadersTest.php @@ -9,6 +9,7 @@ use TinyBlocks\Http\ContentType; use TinyBlocks\Http\ResponseCacheDirectives; use TinyBlocks\Http\Server\Response; +use TinyBlocks\Http\UserAgent; final class HeadersTest extends TestCase { @@ -22,22 +23,22 @@ public function testWithHeaderWhenInvokedThenAddsCustomHeadersAlongsideDefaultCo /** @When we add custom headers to the response */ $actual = $response - ->withHeader(name: 'X-ID', value: 100) + ->withHeader(name: 'X-ID', value: '100') ->withHeader(name: 'X-NAME', value: 'Xpto'); /** @Then the response contains the correct headers */ self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => [100], 'X-NAME' => ['Xpto']], + ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => ['100'], 'X-NAME' => ['Xpto']], $actual->getHeaders() ); /** @And when we update the 'X-ID' header with a new value */ - $actual = $actual->withHeader(name: 'X-ID', value: 200); + $actual = $actual->withHeader(name: 'X-ID', value: '200'); /** @Then the response contains the updated 'X-ID' header value */ - self::assertSame('200', $actual->withAddedHeader(name: 'X-ID', value: 200)->getHeaderLine(name: 'X-ID')); + self::assertSame('200', $actual->withAddedHeader(name: 'X-ID', value: '200')->getHeaderLine(name: 'X-ID')); self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => [200], 'X-NAME' => ['Xpto']], + ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => ['200'], 'X-NAME' => ['Xpto']], $actual->getHeaders() ); @@ -46,7 +47,7 @@ public function testWithHeaderWhenInvokedThenAddsCustomHeadersAlongsideDefaultCo /** @Then the response contains only the 'X-ID' header and the default 'Content-Type' header */ self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => [200]], + ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => ['200']], $actual->getHeaders() ); } @@ -212,7 +213,8 @@ public function testNoContentWhenCacheControlWithEveryDirectiveGivenThenHeaderRe self::assertTrue($actual->hasHeader(name: 'Cache-Control')); /** @And the Cache-Control header lists every directive */ - $expected = 'max-age=10000, no-cache, no-store, no-transform, stale-if-error, must-revalidate, proxy-revalidate'; + $expected = 'max-age=10000, no-cache, no-store, no-transform, stale-if-error, ' + . 'must-revalidate, proxy-revalidate'; self::assertSame($expected, $actual->getHeaderLine(name: 'Cache-Control')); self::assertSame([$expected], $actual->getHeader(name: 'Cache-Control')); @@ -280,6 +282,18 @@ public function testNoContentWhenContentTypeIsOctetStreamThenHeaderReflectsIt(): self::assertSame('application/octet-stream', $actual->getHeaderLine(name: 'Content-Type')); } + public function testNoContentWhenHeaderableEmitsStringValueThenWrapsItInList(): void + { + /** @Given a Headerable whose toArray() emits a string value (not a list) */ + $userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3'); + + /** @When a response is created with that header */ + $actual = Response::noContent($userAgent); + + /** @Then the header is preserved as a single-entry list */ + self::assertSame(['MyApp/1.2.3'], $actual->getHeader(name: 'User-Agent')); + } + public function testNoContentWhenContentTypeIsFormUrlEncodedThenHeaderReflectsIt(): void { /** @Given the Content-Type header set to application/x-www-form-urlencoded */ diff --git a/tests/Unit/Server/RequestTest.php b/tests/Unit/Server/RequestTest.php index 2a5d283..aa5bcfb 100644 --- a/tests/Unit/Server/RequestTest.php +++ b/tests/Unit/Server/RequestTest.php @@ -36,7 +36,7 @@ public function testDecodeWhenBodyGivenThenExposesTypedAccessors(): void $serverRequest = new ServerRequest( method: 'POST', uri: 'https://api.example.com/dragons', - body: $this->factory->createStream(json_encode($payload, JSON_PRESERVE_ZERO_FRACTION)) + body: $this->factory->createStream(json_encode($payload, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)) ); /** @When decoding the request body */ @@ -117,6 +117,7 @@ public function testDecodeWhenSlimStyleRouteObjectGivenThenResolvesArguments(): { /** @Given a Slim-style route object that stores params in getArguments() */ $routeObject = new class { + /** @return array */ public function getArguments(): array { return ['id' => '42', 'email' => 'dragon@fire.com']; @@ -139,6 +140,7 @@ public function testDecodeWhenMezzioStyleRouteResultGivenThenResolvesMatchedPara { /** @Given a Mezzio-style route result object that uses getMatchedParams() */ $routeResult = new class { + /** @return array */ public function getMatchedParams(): array { return ['id' => '99', 'slug' => 'fire-dragon']; @@ -216,6 +218,7 @@ public function testDecodeWhenRouteObjectExposesPublicPropertyThenResolvesIt(): { /** @Given a route object exposing public properties */ $routeObject = new class { + /** @var array */ public array $arguments = ['id' => '10', 'name' => 'Hydra']; }; @@ -348,6 +351,7 @@ public function testMethodWhenAnyHttpVerbGivenThenReturnsMatchingEnum( self::assertSame($methodString, $actual->value); } + /** @return array */ public static function httpMethodsProvider(): array { return [ @@ -363,6 +367,7 @@ public static function httpMethodsProvider(): array ]; } + /** @return array */ public static function attributeConversionsProvider(): array { return [ @@ -434,7 +439,7 @@ public function testDecodeWhenEmptyStreamAndNonArrayParsedBodyThenReturnsEmpty() /** @Given an empty stream and a non-array parsed body */ $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) ->withBody($this->factory->createStream('')) - ->withParsedBody('not-an-array'); + ->withParsedBody(null); /** @When decoding */ $decoded = Request::from(request: $serverRequest)->decode(); diff --git a/tests/Unit/Server/ResponseTest.php b/tests/Unit/Server/ResponseTest.php index 5b9a687..6eaac6d 100644 --- a/tests/Unit/Server/ResponseTest.php +++ b/tests/Unit/Server/ResponseTest.php @@ -197,6 +197,7 @@ public function testInternalServerErrorWhenBodyGivenThenReturnsResponseWithStatu self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); } + /** @return array */ public static function responseFromProvider(): array { return [ @@ -267,6 +268,7 @@ public function testWithStatusWhenInvokedThenReturnsResponseWithUpdatedCode(): v self::assertSame(Code::OK->value, $updated->getStatusCode()); } + /** @return array */ public static function bodyProviderData(): array { return [ @@ -308,7 +310,7 @@ public static function bodyProviderData(): array ['name' => 'Product One', 'amount' => ['value' => 100.50, 'currency' => 'USD']], ['name' => 'Product Two', 'amount' => ['value' => 200.75, 'currency' => 'BRL']] ] - ], JSON_PRESERVE_ZERO_FRACTION) + ], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) ], 'Boolean true value' => [ 'body' => true, From 45d5de1b37ac6397cb7e4722e2ab6ffd057bcc9a Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 16 May 2026 09:11:19 -0300 Subject: [PATCH 21/27] chore: Update Claude rule files and Copilot instructions. --- .claude/CLAUDE.md | 48 +- .claude/rules/github-workflows.md | 78 -- .claude/rules/php-library-architecture.md | 145 ++++ .claude/rules/php-library-code-style.md | 664 ++++++++++++++---- .claude/rules/php-library-commits.md | 111 +++ .claude/rules/php-library-documentation.md | 325 ++++++++- .claude/rules/php-library-github-workflows.md | 287 ++++++++ .claude/rules/php-library-modeling.md | 315 ++++++--- .claude/rules/php-library-testing.md | 335 +++++++-- .claude/rules/php-library-tooling.md | 464 ++++++++++++ .github/copilot-instructions.md | 9 +- 11 files changed, 2348 insertions(+), 433 deletions(-) delete mode 100644 .claude/rules/github-workflows.md create mode 100644 .claude/rules/php-library-architecture.md create mode 100644 .claude/rules/php-library-commits.md create mode 100644 .claude/rules/php-library-github-workflows.md create mode 100644 .claude/rules/php-library-tooling.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 11885b0..a561aa6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,32 +1,16 @@ -# Project - -PHP library (tiny-blocks ecosystem). Self-contained package: immutable models, zero infrastructure -dependencies in core, small public surface area. Public API at `src/` root; implementation details -under `src/Internal/`. - -## Rules - -All coding standards, architecture, naming, testing, and documentation conventions -are defined in `rules/`. Read the applicable rule files before generating any code or documentation. - -## Commands - -- `make test` — run tests with coverage. -- `make mutation-test` — run mutation testing (Infection). -- `make review` — run lint. -- `make help` — list all available commands. - -## Post-change validation - -After any code change, run `make review`, `make test`, and `make mutation-test`. -If any fails, iterate on the fix while respecting all project rules until all pass. -Never deliver code that breaks lint, tests, or leaves surviving mutants. - -## File formatting - -Every file produced or modified must: - -- Use **LF** line endings. Never CRLF. -- Have no trailing whitespace on any line. -- End with a single trailing newline. -- Have no consecutive blank lines (max one blank line between blocks). +# CLAUDE.md + +This is a PHP library in the tiny-blocks ecosystem. Detailed rules live in `.claude/rules/`. +Each file is scoped via its `paths` frontmatter. Read the relevant file before producing or +editing content under its scope. + +## Rule files + +- `php-library-architecture.md` — folder structure, public API boundary, `Internal/` semantics. +- `php-library-code-style.md` — semantic code rules for `.php` files in `src/` and `tests/`. +- `php-library-commits.md` — Conventional Commits format. Applied only when generating commit messages. +- `php-library-documentation.md` — README and Markdown documentation standards. +- `php-library-github-workflows.md` — CI workflow structure and action pinning. +- `php-library-modeling.md` — nomenclature, value objects, exceptions, enums, complexity. +- `php-library-testing.md` — BDD Given/When/Then, PHPUnit conventions, coverage discipline. +- `php-library-tooling.md` — canonical config files (`composer.json`, `phpcs.xml`, etc). diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md deleted file mode 100644 index a369ba4..0000000 --- a/.claude/rules/github-workflows.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -description: Naming, ordering, inputs, security, and structural rules for all GitHub Actions workflow files. -paths: - - ".github/workflows/**/*.yml" - - ".github/workflows/**/*.yaml" ---- - -# Workflows - -Structural and stylistic rules for GitHub Actions workflow files. Refer to `shell-scripts.md` for Bash conventions used -inside `run:` steps, and to `terraforms.md` for Terraform conventions used in `terraform/`. - -## Pre-output checklist - -Verify every item before producing any workflow YAML. If any item fails, revise before outputting. - -1. File name follows the convention: `ci-.yml` for reusable CI, `cd-.yml` for dispatch CD. -2. `name` field follows the pattern `CI — ` or `CD — `, using sentence case after the dash - (e.g., `CD — Run migration`, not `CD — Run Migration`). -3. Reusable workflows use `workflow_call` trigger. CD workflows use `workflow_dispatch` trigger. -4. Each workflow has a single responsibility. CI tests code. CD deploys it. Never combine both. -5. Every input has a `description` field. Descriptions use American English and end with a period. -6. Input names use `kebab-case`: `service-name`, `dry-run`, `skip-build`. -7. Inputs are ordered: required first, then optional. Each group by **name length ascending**. -8. Choice input options are in **alphabetical order**. -9. `env`, `outputs`, and `with` entries are ordered by **key length ascending**. -10. `permissions` keys are ordered by **key length ascending** (`contents` before `id-token`). -11. Top-level workflow keys follow canonical order: `name`, `on`, `concurrency`, `permissions`, `env`, `jobs`. -12. Job-level properties follow canonical order: `if`, `name`, `needs`, `uses`, `with`, `runs-on`, - `environment`, `timeout-minutes`, `strategy`, `outputs`, `permissions`, `env`, `steps`. -13. All other YAML property names within a block are ordered by **name length ascending**. -14. Jobs follow execution order: `load-config` → `lint` → `test` → `build` → `deploy`. -15. Step names start with a verb and use sentence case: `Setup PHP`, `Run lint`, `Resolve image tag`. -16. Runtime versions are resolved from the service repo's native dependency file (`composer.json`, `go.mod`, - `package.json`). No version is hardcoded in any workflow. -17. Service-specific overrides live in a pipeline config file (e.g., `.pipeline.yml`) in the service repo, - not in the workflows repository. -18. The `load-config` job reads the pipeline config file at runtime with safe fallback to defaults when absent. -19. Top-level `permissions` defaults to read-only (`contents: read`). Jobs escalate only the permissions they - need. -20. AWS authentication uses OIDC federation exclusively. Static access keys are forbidden. -21. Secrets are passed via `secrets: inherit` from callers. No secret is hardcoded. -22. Sensitive values fetched from SSM are masked with `::add-mask::` before assignment. -23. Third-party actions are pinned to the latest available full commit SHA with a version comment: - `uses: aws-actions/configure-aws-credentials@ # v4.0.2`. Always verify the latest - version before generating a workflow. -24. First-party actions (`actions/*`) are pinned to the latest major version tag available: - `actions/checkout@v4`. Always check for the most recent major version before generating a workflow. -25. Production deployments require GitHub Environments protection rules (manual approval). -26. Every job sets `timeout-minutes` to prevent indefinite hangs. CI jobs: 10–15 minutes. CD jobs: 20–30 - minutes. Adjust only with justification in a comment. -27. CI workflows set `concurrency` with `group` scoped to the PR and `cancel-in-progress: true` to avoid - redundant runs. -28. CD workflows set `concurrency` with `group` scoped to the environment and `cancel-in-progress: false` to - prevent interrupted deployments. -29. CD workflows use `if: ${{ !cancelled() }}` to allow to deploy after optional build steps. -30. Inline logic longer than 3 lines is extracted to a script in `scripts/ci/` or `scripts/cd/`. - -## Style - -- All text (workflow names, step names, input descriptions, comments) uses American English with correct - spelling and punctuation. Sentences and descriptions end with a period. - -## Callers - -- Callers trigger on `pull_request` targeting `main` only. No `push` trigger. -- Callers in service repos are static (~10 lines) and pass only `service-name` or `app-name`. -- Callers reference workflows with `@main` during development. Pin to a tag or SHA for production. - -## Image tagging - -- CD deploy builds: `-sha-` + `latest`. - -## Migrations - -- Migrations run **before** service deployment (schema first, code second). -- `cd-migrate.yml` supports `dry-run` mode (`flyway validate`) for pre-flight checks. -- Database credentials are fetched from SSM at runtime, never stored in workflow files. diff --git a/.claude/rules/php-library-architecture.md b/.claude/rules/php-library-architecture.md new file mode 100644 index 0000000..7e4be10 --- /dev/null +++ b/.claude/rules/php-library-architecture.md @@ -0,0 +1,145 @@ +--- +description: Folder structure, public API boundary, and Internal/ semantics for PHP libraries. +paths: + - "src/**/*.php" +--- + +# Architecture + +Covers the physical layout of the library. Folder structure, the boundary between public API and +implementation detail, and where each type of class lives. Semantic rules (value objects, +exceptions, enums, complexity, nomenclature) live in `php-library-modeling.md`. Code style lives +in `php-library-code-style.md`. + +## Pre-output checklist + +Verify every item before producing or relocating any file. If any item fails, revise before +outputting. + +1. None of the following folder names exist in `src/`: `Models/`, `Entities/`, `ValueObjects/`, + `Enums/`, `Domain/`. They carry no semantic content and conflate technical role with domain + meaning. +2. The `src/` root contains only interfaces, extension points, public enums, thin orchestration + classes, and primary implementations or façades. Substantial logic (algorithms, state machines, + I/O) lives in `src/Internal/`, never at the root. +3. `src/Internal/` is implementation detail and not part of the public API. Breaking changes + inside `src/Internal/` are not semver-breaking. +4. Consumers must not reference, extend, or depend on any type inside `src/Internal/`. The + namespace itself is the boundary. +5. Public exception classes live in `src/Exceptions/`. +6. Internal exception classes live in `src/Internal/Exceptions/`. +7. Public enums live at the `src/` root or inside a public `/` folder. Enums used + only by internals live in `src/Internal/`. +8. Public interfaces live at the `src/` root or inside a public `/` folder. +9. A `/` folder at the `src/` root groups related public types under a shared + concept. Each group has its own namespace and is part of the public API. +10. `/` is optional. Use it only when the library exposes several coherent groups of + types (for example, aggregates and events) rather than a flat set of types around a single + concept. +11. Test fixtures representing domain concepts live in `tests/Models/`. Test doubles for system + boundaries live at the root of `tests/Unit/` or `tests/Integration/`. No dedicated `Mocks/` + or `Doubles/` subdirectory exists. `tests/Drivers//` is permitted when the library + exposes a port exercised against multiple third-party implementations (PSR adapters, + framework integrations). Each `/` subdir holds tests against one specific + implementation. +12. The `tests/Integration/` folder exists only when the library interacts with external + infrastructure (filesystem, database, network). Otherwise, the folder is absent. + +## Folder structure + +Canonical layout for a PHP library in the tiny-blocks ecosystem. + +``` +src/ +├── .php # public contract at root +├── .php # main implementation or extension point at root +├── .php # public enum at root +├── / # public folder grouping related public types under a shared concept +│ ├── .php +│ └── ... +├── Internal/ # implementation details, not part of the public API +│ ├── .php +│ └── Exceptions/ # internal exception classes +└── Exceptions/ # public exception classes + +tests/ +├── Models/ # domain fixtures reused across tests +├── Unit/ # unit tests targeting the public API +│ ├── .php # test doubles at root of Unit/ +│ └── .php +└── Integration/ # only present when the library interacts with infrastructure + └── .php # test doubles at root of Integration/ when needed +``` + +Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. They +carry no semantic content and describe technical role instead of domain meaning. + +## Public API boundary + +The `src/` root is the contract. Everything at the root, plus everything inside public +`/` folders and the public `Exceptions/` folder, is what consumers depend on. Changes +to these types follow semver rules. + +`src/Internal/` is implementation detail. The namespace itself signals the boundary. Consumers +must not depend on any type inside `src/Internal/`. Breaking changes inside `src/Internal/` are +not semver-breaking for the library. + +### What lives at the public boundary + +- Interfaces that define contracts for consumers. +- Extension points designed to be subclassed or composed by consumers. +- Public enums and value objects consumers manipulate directly. +- Thin orchestration classes that wire collaborators together without containing substantial logic. +- Public exception classes consumers may catch. + +### What lives in `src/Internal/` + +- Algorithms, state machines, and complex transformations. +- Adapters for I/O (filesystem, network, database). +- Collaborators that exist purely to break a public class into testable units. +- Implementation details that may change between minor or patch releases. +- Internal exception classes raised by collaborators. + +## Reference examples + +### Small library with flat root + +``` +src/ +├── Timezone.php # public value object +├── Timezones.php # public collection +├── Clock.php # public interface +└── Internal/ + ├── SystemClock.php # default Clock implementation + └── Exceptions/ + └── InvalidTimezone.php +``` + +Everything lives at the root or inside `Internal/`. No `/` folders. Suitable when +the library exposes a small, cohesive set of types around a single concept. + +### Library with public concept groups + +``` +src/ +├── ValueObject.php # public extension point at root +├── Aggregate/ # public namespace grouping aggregate types +│ ├── AggregateRoot.php +│ ├── EventualAggregateRoot.php +│ └── ModelVersion.php +├── Event/ # public namespace grouping event types +│ ├── EventRecord.php +│ ├── EventRecords.php +│ └── SequenceNumber.php +├── Internal/ +│ ├── DefaultModelVersionResolver.php +│ └── Exceptions/ +│ └── InvalidSequenceNumber.php +└── Exceptions/ + └── EventRecordingFailure.php +``` + +`Aggregate/` and `Event/` are public folders at the root, each grouping a coherent set of public +types under one shared concept. Consumers import directly, for example +`TinyBlocks\\Aggregate\AggregateRoot`. Suitable when the library exposes several distinct +concept areas, each with its own set of related types. diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md index e7c8ead..465c32c 100644 --- a/.claude/rules/php-library-code-style.md +++ b/.claude/rules/php-library-code-style.md @@ -1,5 +1,5 @@ --- -description: Pre-output checklist, naming, typing, complexity, and PHPDoc rules for all PHP files in libraries. +description: Semantic code rules for all PHP files in libraries. paths: - "src/**/*.php" - "tests/**/*.php" @@ -7,162 +7,417 @@ paths: # Code style -Semantic code rules for all PHP files. Formatting rules (PSR-1, PSR-4, PSR-12, line length) are enforced by `phpcs.xml` -and are not repeated here. Refer to `php-library-modeling.md` for library modeling rules. +Semantic rules for all PHP files in libraries. Formatting rules covered by `PSR-12` are enforced +by `phpcs.xml`. Two formatting rules outside `PSR-12` (no vertical alignment, no trailing comma in +multi-line lists) are documented at the end of this file under "Formatting overrides". Complexity +rules live in `php-library-modeling.md`. Folder structure, public API boundary, and the semantics +of `Internal/` live in `php-library-architecture.md`. ## Pre-output checklist Verify every item before producing any PHP code. If any item fails, revise before outputting. 1. `declare(strict_types=1)` is present. -2. All classes are `final readonly` by default. Use `class` (without `final` or `readonly`) only when the class is - designed as an extension point for consumers (e.g., `Collection`, `ValueObject`). Use `final class` without - `readonly` only when the parent class is not readonly (e.g., extending a third-party abstract class). -3. All parameters, return types, and properties have explicit types. -4. Constructor property promotion is used. -5. Named arguments are used at call sites for own code, tests, and third-party library methods (e.g., tiny-blocks). - Never use named arguments on native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, - `iterator_to_array`, `sprintf`, `implode`, etc.) or PHPUnit assertions (`assertEquals`, `assertSame`, - `assertTrue`, `expectException`, etc.). -6. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. -7. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of `$acc`. -8. No generic identifiers exist. Use domain-specific names instead: - `$data` → `$payload`, `$value` → `$totalAmount`, `$item` → `$element`, - `$info` → `$currencyDetails`, `$result` → `$conversionOutcome`. -9. No raw arrays exist where a typed collection or value object is available. Use the `tiny-blocks/collection` - fluent API (`Collection`, `Collectible`) when data is `Collectible`. Use `createLazyFrom` when elements are - consumed once. Raw arrays are acceptable only for primitive configuration data, variadic pass-through, and - interop at system boundaries. See "Collection usage" below for the full rule and example. -10. No private methods exist except: - - Private constructors for factory patterns. - - Methods inside `src/Internal/` (implementation detail by definition; the - namespace is the abstraction boundary, not the class). - - `setUp` / `tearDown` overrides in PHPUnit test classes. - Outside these cases, inline trivial logic at the call site or extract it - to a collaborator or value object. -11. Members are ordered: constants first, then constructor, then static methods, then instance methods. Within each - group, order by body size ascending (number of lines between `{` and `}`). Constants and enum cases, which have - no body, are ordered by name length ascending. -12. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type), - except when parameters have an implicit semantic order (e.g., `$start/$end`, `$from/$to`, `$startAt/$endAt`), - which takes precedence. Parameters with default values go last, regardless of name length. The same rule - applies to named arguments at call sites. - Example: `$id` (2) → `$value` (5) → `$status` (6) → `$precision` (9). -13. Time and space complexity are first-class design concerns. - - No `O(N²)` or worse time complexity exists unless the problem inherently requires it and the cost is - documented in PHPDoc on the interface method. - - Space complexity is kept minimal: prefer lazy/streaming pipelines (`createLazyFrom`) over materializing - intermediate collections. - - Never re-iterate the same source; fuse stages when possible. - - Public interface methods document time and space complexity in Big O form (see "PHPDoc" section). +2. All parameters, return types, and properties have explicit types. +3. Constructor property promotion is used. +4. Named arguments are used at call sites for own code, tests, and third-party library methods + (for example, tiny-blocks). Never use named arguments on: + - Native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, + `iterator_to_array`, `sprintf`, `implode`, and similar). + - Native PHP enum methods (`from`, `tryFrom`, `cases`). + - PHPUnit assertions and expectations (`assertEquals`, `assertSame`, `assertTrue`, + `expectException`, and similar). + - Interfaces from PHP-FIG PSR standards (PSR-7 `withHeader`, PSR-18 `sendRequest`, etc.). + The PSR contract does not include parameter names. Implementations may rename parameters. + - Calls that include variadic spread (`...$args`). PHP rejects positional argument unpacking + after named arguments. When the caller passes through a `...$variadic`, all arguments are + positional. New own-code APIs should prefer a typed collection parameter over a variadic + so named-argument call sites remain possible. + + Native PHP **class constructors** (`parent::__construct` calls to `\Exception`, + `\RuntimeException`, `\InvalidArgumentException`, `\LogicException`, and similar) are not + in the list above. They accept named arguments, and rule 8 requires using them whenever + the positional call would pass an argument whose value equals the parameter's default. + Example: `parent::__construct(message: sprintf(...), previous: $previous)` instead of + `parent::__construct(sprintf(...), 0, $previous)`. The exclusion above covers native + functions and enum methods, not native class instantiation. +5. Classes follow the rules in "Inheritance and constructors". `final readonly` is the default, + with documented exceptions for extension points and for parents that are not `readonly`. +6. Members are ordered constants first, then constructor, then static methods, then instance + methods. Within each group, order by body size ascending (number of lines between `{` and `}`). + Constants and enum cases, which have no body, are ordered by name length ascending. This + ordering may be overridden only when the alternative carries explicit documentation value: + grouping by domain class with section markers (HTTP status codes by 1xx/2xx/3xx/etc), + mirroring the order of an implemented interface, or similar evident structure. The override + must be obvious at first reading. + + **At call sites** (chained method calls in production code, tests, or documentation + examples), consecutive method invocations on the same receiver are ordered by the **visible + width** of each call expression ascending. The body is not visible at the call site, so the + visible width is the practical proxy for body size. Boolean toggles such as `->secure()` and + `->httpOnly()` come before parameterized `with*` builders for the same reason. When two + calls have equal width, order them alphabetically by method name. + + **Terminal methods that change the receiver type** stay at the end of the chain regardless + of width. A `build()` that returns the built value, a `commit()` that finalizes a unit of + work, a `send()` that flushes a request, are terminal: the chain ends with them. The + ordering rule applies only to consecutive calls on the same receiver type; calls that + transition to a different type are not reorderable. The same applies in reverse to the + factory or accessor that starts the chain (`Cookie::create(...)`, `$repository`) — it stays + at its position. +7. Constructor parameters are ordered by parameter name length ascending (count the name only, + without `$` or type), except when parameters have an implicit semantic order (for example, + `$start/$end`, `$from/$to`, `$startAt/$endAt`), which takes precedence. Parameters with default + values go last, regardless of name length. The same rule applies to named arguments at call + sites. Example order: `$id` (2), `$value` (5), `$status` (6), `$precision` (9). +8. Never pass an argument whose value equals the parameter's default. Omit the argument entirely. + Example with `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`. The call + `$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` becomes + `$collection->toArray()`. Only pass the argument when the value differs from the default. +9. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. +10. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of + `$acc`. +11. No generic identifiers exist. Use domain-specific names instead. Examples are `$data` to + `$payload`, `$value` to `$totalAmount`, `$item` to `$element`, `$info` to `$currencyDetails`, + `$result` to `$conversionOutcome`. +12. No raw arrays exist where a typed collection or value object is available. When data is + `Collectible`, use the `tiny-blocks/collection` fluent API (`Collection`, `Collectible`). Use + `createLazyFrom` when elements are consumed once. Raw arrays are acceptable only for primitive + configuration data, variadic pass-through, and interop at system boundaries. See "Collection + usage" for the full rule and example. +13. No private methods exist except for private constructors in factory patterns, methods inside + `src/Internal/` (implementation detail by definition, where the namespace is the abstraction + boundary), and `setUp` or `tearDown` overrides in PHPUnit test classes. Outside these cases, + inline trivial logic at the call site or extract it to a collaborator or value object. 14. No logic is duplicated across two or more places (DRY). 15. No abstraction exists without real duplication or isolation need (KISS). -16. All identifiers, comments, and documentation are written in American English. -17. No justification comments exist (`// NOTE:`, `// REASON:`, etc.). Code speaks for itself. -18. `// TODO: ` is used when implementation is unknown, uncertain, or intentionally deferred. - Never leave silent gaps. -19. All class references use `use` imports at the top of the file. Fully qualified names inline are prohibited. -20. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. -21. Never create public methods, constants, or classes in `src/` solely to serve tests. If production code does not - need it, it does not exist. -22. Always use the most current and clean syntax available in the target PHP version. Prefer match to switch, - first-class callables over `Closure::fromCallable()`, readonly promotion over manual assignment, enum methods - over external switch/if chains, named arguments over positional ambiguity (except where excluded by rule 5), - and `Collection::map` over foreach accumulation. -23. No vertical alignment of types in parameter lists or property declarations. Use a single space between - type and variable name. Never pad with extra spaces to align columns: - `public OrderId $id` — not `public OrderId $id`. -24. Opening brace `{` follows PSR-12: on a **new line** for classes, interfaces, traits, enums, and methods - (including constructors); on the **same line** for closures and control structures (`if`, `for`, `foreach`, - `while`, `switch`, `match`, `try`). -25. Never pass an argument whose value equals the parameter's default. Omit the argument entirely. - Example — `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`: - `$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` → `$collection->toArray()`. - Only pass the argument when the value differs from the default. -26. No trailing comma in any multi-line list. This applies to parameter lists (constructors, methods, - closures), argument lists at call sites, array literals, match arms, and any other comma-separated - multi-line structure. The last element never has a comma after it. PHP accepts trailing commas in - parameter lists, but this project prohibits them for visual consistency. - Example — correct: - ``` - new Precision( - value: 2, - rounding: RoundingMode::HALF_UP - ); - ``` - Example — prohibited: - ``` - new Precision( - value: 2, - rounding: RoundingMode::HALF_UP, - ); - ``` +16. No inline comments exist in `src/` or `tests/`, except `# TODO: ` when implementation + is unknown, uncertain, or intentionally deferred. Code is the documentation. Block comments + (`/* */`) never appear outside docblocks (`/** */`). The `#` style for inline PHP comments + applies only to code examples inside Markdown files (see `php-library-documentation.md`). +17. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. +18. Never create public methods, constants, or classes in `src/` solely to serve tests. If + production code does not need it, it does not exist. +19. Format strings with placeholders (`%s`, `%d`, `%f`, etc.) are assigned to a `$template` + variable before being passed to `sprintf`. The variable assignment and the `sprintf` call live + on separate statements. See "Format strings" for examples. +20. All class references use `use` imports at the top of the file. Fully qualified names inline are + prohibited. +21. Return types and `new` calls use the explicit class name. `self` is prohibited as a type, + as a return type, and in `new self()` instantiation. Constant access via `self::CONST_NAME` + is permitted. `static` is permitted only inside extension-point classes (declared `class` + without `final readonly`) and inside traits, where late static binding lets subclasses or + consuming classes instantiate the correct concrete type. In every other context, use the + class name. +22. Always use the most current and clean syntax available in the target PHP version. Prefer + `match` over `switch`, first-class callables over `Closure::fromCallable()`, readonly promotion + over manual assignment, enum methods over external switch or if chains, named arguments over + positional ambiguity (except where excluded by rule 4), `Collection::map` over foreach + accumulation, and **unparenthesized constructor chaining** (PHP 8.4+): + `new Foo()->bar()` instead of `(new Foo())->bar()`. The parentheses around the `new` + expression are no longer required and add visual noise. +23. All identifiers, comments, and documentation use American English. See "American English" for + the spelling list. + +## Naming -## Casing conventions +- Internal code (variables, methods, classes) uses `camelCase`. +- Constants and enum-backed values when representing codes use `SCREAMING_SNAKE_CASE`. +- Names describe what in domain terms, not how technically. `$monthlyRevenue` instead of + `$calculatedValue`. Generic technical verbs are avoided. See `php-library-modeling.md` for the + full banlist of generic and anemic names. +- Booleans use predicate form. Examples are `isActive`, `hasPermission`, `wasProcessed`. +- Collections are always plural. Examples are `$orders`, `$lines`. +- Methods returning `bool` use prefixes `is`, `has`, `can`, `was`, `should`. -- Internal code (variables, methods, classes): **`camelCase`**. -- Constants and enum-backed values when representing codes: **`SCREAMING_SNAKE_CASE`**. +## Class self-references -## Naming +Type declarations, return types, and `new` calls inside a class use the explicit class name. +The class name is unambiguous, survives refactors that move the method to a different class, +and reads identically inside the class body and at the call site. + +- `self` is prohibited everywhere as a type, as a return type, and in `new self()` + instantiation. Constant access via `self::CONST_NAME` is **permitted**. The prohibition + covers the forms that carry refactoring ambiguity when a method moves to a different class + (the type-or-instantiation forms). Constant access does not have that ambiguity because the + constant is declared in the same class body. +- `static` is permitted only inside extension-point classes (declared `class` without + `final readonly`) and inside traits, where late static binding is required for subclasses or + consuming classes to instantiate the correct concrete type. +- In every other context (the default `final readonly class`, factory methods, return types), + use the class name. + +**Prohibited.** `self` as return type and `new self()` inside a final class: + +```php +final readonly class UserAgent +{ + public static function from(string $product): self + { + return new self(product: $product); + } +} +``` + +**Correct.** Explicit class name in a final class: + +```php +final readonly class UserAgent +{ + public static function from(string $product): UserAgent + { + return new UserAgent(product: $product); + } +} +``` + +**Correct.** `static` permitted in an extension-point class: -- Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`. -- Generic technical verbs are avoided. See `php-library-modeling.md` — Nomenclature. -- Booleans use predicate form: `isActive`, `hasPermission`, `wasProcessed`. -- Collections are always plural: `$orders`, `$lines`. -- Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`. +```php +class Collection +{ + public static function createFrom(iterable $elements): static + { + return new static(elements: $elements); + } +} +``` -## Inheritance +## Inheritance and constructors +- All classes are `final readonly` by default. +- Use `class` (without `final` or `readonly`) only when the class is designed as an extension point + for consumers, for example `Collection` or `ValueObject`. +- Use `final class` without `readonly` only when the parent class is not readonly, for example + when extending a third-party abstract class. +- Use `final class` without `readonly` is also permitted for `src/Internal/` collaborators that + carry intrinsically mutable state (resource handles, counters, cursors) where the mutation is + central to the class's responsibility (`Stream` closing a resource, `Cursor` advancing a + position). The class must remain confined to `src/Internal/`. +- Use `final class` without `readonly` for classes that consist exclusively of `static` methods + (no instance properties, no instance methods, only static factories or utilities). Pair it + with `private function __construct() {}` to prevent instantiation. `readonly` is meaningless + without instance state, and the private constructor signals that the class is a static + surface, not a value type. - Inheritance between concrete classes is prohibited. Every concrete class is `final`. -- Polymorphism uses interfaces + composition, never extension of concrete types. -- The only allowed `extends` is against framework or SPL base classes that the - language requires (e.g., extending `RuntimeException`, `LogicException`, - `PHPUnit\Framework\TestCase`). -- Constructors of `final` classes are `private` when paired with named factories, - `public` otherwise. `protected` constructors are prohibited (no subclasses - exist to call them). +- Polymorphism uses interfaces plus composition, never extension of concrete types. +- The only allowed `extends` is against framework or SPL base classes that the language requires. + Examples are `RuntimeException`, `LogicException`, `PHPUnit\Framework\TestCase`. +- Constructors of `final` classes are `private` when paired with named factory methods, `public` + otherwise. `protected` constructors are prohibited because no subclasses exist to call them. ## Comparisons -1. Null checks: use `is_null($variable)`, never `$variable === null`. -2. Empty string checks on typed `string` parameters: use `$variable === ''`. Avoid `empty()` on typed strings - because `empty('0')` returns `true`. -3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`): use `empty($variable)`. +1. Null checks use `is_null($variable)`, never `$variable === null`. +2. Empty string checks on typed `string` parameters use `$variable === ''`. Avoid `empty()` on + typed strings because `empty('0')` returns `true`. +3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`) use + `empty($variable)`. ## American English -All identifiers, enum values, comments, and error codes use American English spelling: -`canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not `initialise`), -`behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not `labelled`), -`fulfill` (not `fulfil`), `color` (not `colour`). +All identifiers, enum values, comments, and error codes use American English spelling. Examples +are `canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not +`initialise`), `behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not +`labelled`), `fulfill` (not `fulfil`), `color` (not `colour`). ## PHPDoc +### When required + +- Every method of an interface. +- Every public method of a concrete class outside `src/Internal/`. Public classes are at the + public API boundary by definition. Consumers call every public method directly, and the + PHPDoc is the contract for each call. Trivial getters and `with*` methods are not exempt. + The only exception is a public method whose contract is already documented on an implemented + interface (the interface carries the docblock). + +### When prohibited + +- Constructors. The constructor signature with property promotion is self-documenting. Parameter + types are already explicit in the signature. +- Private and protected methods. +- Public methods of concrete classes whose contract is already documented on an implemented + interface. The interface carries the docblock. +- Anything inside `src/Internal/`. Internal types are implementation detail and must not carry + PHPDoc. The namespace itself is the boundary. See `php-library-architecture.md` for the + architectural meaning of `Internal/`. +- Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyGivenThenZzz` + naming convention, and the `@Given`/`@When`/`@Then`/`@And` annotation blocks defined in + `php-library-testing.md` describe the steps. PHPDoc documentation (summary plus + `@param`/`@return` descriptions) is prohibited on test methods, data providers, fixtures, + setUp/tearDown overrides, and anonymous classes inside tests. The BDD annotations are not + PHPDoc documentation in the sense of this section and remain required per the testing rule. +- Single-line PHPDocs with only a tag (`/** @param ... */`, `/** @return ... */`, + `/** @throws ... */`). PHPDoc always opens with a summary line. Bare-tag docblocks are + prohibited regardless of how few tags they carry. + +The prohibitions above apply to **every form of PHPDoc** in the prohibited scope: +method-level docblocks, property-level docblocks, inline `@var` annotations on local variables, +and PHPDoc blocks placed above anonymous functions or closures inside method bodies. Inside +`src/Internal/` and `tests/`, zero PHPDoc is the rule with no exception. PHPStan errors that +result from the missing annotations route through `ignoreErrors` (see below). + +The PHPDoc prohibitions above take priority over the typed-array case. When PHPStan at +`level: max` flags a missing iterable value type (`missingType.iterableValue`, +`argument.type`, `return.type`): + +- On a **constructor parameter** → suppress via `ignoreErrors` in `phpstan.neon.dist`. Do not + add PHPDoc. +- On anything inside **`src/Internal/`** → suppress via `ignoreErrors`. Do not add PHPDoc. +- On anything inside **`tests/`** → suppress via `ignoreErrors`. Do not add PHPDoc. +- On a **public method of a public (non-Internal) class** → add full PHPDoc with summary, + `@param` descriptions, and the typed-array information. The bare-tag form remains + prohibited. This is the normal case where PHPDoc is permitted by "When required" above. + +The summary requirement and the bare-tag prohibition are never waived. Use `ignoreErrors` only +when the context (constructor, `src/Internal/`, `tests/`) makes PHPDoc impossible. Every public +method of a public concrete class carries PHPDoc per "When required", whether the method +has typed-array parameters. + +### Style + +- Summary on the first line, in domain terms. **Mandatory.** PHPDoc without a summary line is + prohibited, even when it carries a single `@param` or `@return`. +- Optional detailed body in `

` paragraphs below the summary. +- Tags use the form `@param Type $name Description.`, `@return Type Description.`, + `@throws ExceptionClass If .`. - Document `@throws` for every exception the method may raise. -- Document time and space complexity in Big O form. When a method participates in - a fused pipeline (e.g., collection pipelines), express cost as a two-part form: - call-site cost + fused-pass contribution. Include a legend defining variables - (e.g., `N` for input size, `K` for number of stages). -- PHPDoc is required on: - - Every method of an interface. - - Every public method of a concrete class at the public API entry point - (façades, builders, value objects that consumers interact with directly) - when the method is not already documented by an implemented interface. -- PHPDoc is prohibited on: - - Private and protected methods. - - Public methods of concrete classes whose contract is already documented - on an implemented interface — the interface carries the docblock. - - Anything inside `src/Internal/`. Internal types are implementation detail; - they must not carry PHPDoc — the namespace itself is the boundary. +- HTML tags allowed inside descriptions are `

` for paragraphs, `

  • ` for lists, + `` for inline code, `` and `` for emphasis. + +### Summary patterns + +The summary line is not a creative intent statement. It is a template selected by the method's +name prefix. Apply the matching template. Only methods with no matching prefix require a +free-form one-line summary in domain terms. + +| Method shape | Template | +|-------------------------------------------------------------------------|--------------------------------------------------------------------------------| +| Static factory (`create`, `from`, `fromX`, `with*` when static) | `Creates a {ClassName} from {input}.` or `Builds a {ClassName} with {fields}.` | +| `with*` instance method | `Returns a copy of the {ClassName} with the {field} replaced.` | +| Getter (no prefix, returns a property: `code()`, `body()`, `headers()`) | `Returns the {field}.` | +| Predicate (`is*`, `has*`, `can*`, `was*`, `should*`) | `Tells whether {condition}.` | +| Converter (`toArray`, `toString`, `asX`) | `Returns the {ClassName} as {target shape}.` | +| `apply*`, `merge*`, `add*`, and other side-effect-free operations | One-line summary in domain terms describing the operation. | + +The patterns are mandatory when applicable. They make summary lines mechanical: substitute +`{ClassName}` and `{field}` and the summary is complete. No per-method intent decision is +required. Volume is never a reason to skip the summary. Many methods just mean applying the +template many times. + +### Cross-references + +- `{@see ClassName}` for links to other types in the codebase. +- `@see Author, Title (Publisher, Year), Chapter X.` for bibliographical references. + +### Examples + +**Prohibited.** Single-line bare-tag PHPDoc, no summary: + +```php +/** @param array|null $body */ +public static function with(Code $code, ?array $body = null): Response +``` + +**Prohibited.** PHPDoc on a constructor: + +```php +/** @param array $entries */ +public function __construct(public array $entries) +{ +} +``` + +**Prohibited.** PHPDoc on anything inside `src/Internal/`: + +```php +namespace TinyBlocks\Http\Internal\Client; + +final readonly class Url +{ + /** @param array|null $query */ + public static function compose(string $path, ?array $query, string $baseUrl): string + { + } +} +``` + +**Correct.** Generic array type with summary and `@param` description: + +```php +/** + * Builds a synthesized response from a status code and an optional body. + * + * @param array|null $body The response body as an associative array. + * @return Response The synthesized response instance. + */ +public static function with(Code $code, ?array $body = null): Response +``` + +**Correct.** Interface with rich description, paragraphs, cross-references, and bibliography: + +```php +/** + * Money tied to a specific currency. + * + *

    Operations between different currencies raise CurrencyMismatch. Arithmetic + * preserves the currency.

    + * + *

    Sibling of {@see Quantity}, not a parent. Money carries currency semantics.

    + * + * @see Eric Evans, Domain-Driven Design (Addison-Wesley, 2003), Chapter 5. + */ +interface Money +{ + /** + * Adds the given amount. + * + * @param Money $other The amount to add. + * @return Money A new instance with the summed amount. + * @throws CurrencyMismatch If $other has a different currency. + */ + public function add(Money $other): Money; +} +``` + +**Correct.** Concrete class with a short summary and direct tags: + +```php +/** + * IANA timezone identifier (e.g. America/Sao_Paulo). + */ +final readonly class Timezone +{ + /** + * Creates a Timezone from a valid IANA identifier. + * + * @param string $identifier The IANA timezone identifier. + * @return Timezone The created instance. + * @throws InvalidTimezone If the identifier is not a valid IANA timezone. + */ + public static function from(string $identifier): Timezone + { + # ... + } +} +``` + +## Dependencies + +When the library needs an external dependency, prefer packages from the `tiny-blocks` ecosystem +(https://github.com/tiny-blocks) whenever a suitable option exists. Reach for outside packages +only when the ecosystem has no equivalent that fits the use case. ## Collection usage -When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array functions such as -`array_map`, `array_filter`, `iterator_to_array`, or `foreach` + accumulation. The same applies to `filter()`, -`reduce()`, `each()`, and all other `Collectible` operations. Chain them fluently. Never materialize with -`iterator_to_array` to then pass into a raw `array_*` function. +When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array +functions such as `array_map`, `array_filter`, `iterator_to_array`, or `foreach` plus accumulation. +The same applies to `filter()`, `reduce()`, `each()`, and every other `Collectible` operation. +Chain them fluently. Never materialize with `iterator_to_array` to then pass into a raw `array_*` +function. -**Prohibited — `array_map` + `iterator_to_array` on a Collectible:** +**Prohibited.** `array_map` plus `iterator_to_array` on a `Collectible`: ```php $names = array_map( @@ -171,10 +426,161 @@ $names = array_map( ); ``` -**Correct — fluent chain with `map()` + `toArray()`:** +**Correct.** Fluent chain with `map()` plus `toArray()`: ```php $names = $collection ->map(transformations: static fn(Element $element): string => $element->name()) ->toArray(keyPreservation: KeyPreservation::DISCARD); ``` + +## Format strings + +When building a message with placeholders, assign the format string to a `$template` variable +first. Pass it to `sprintf` on a separate statement. The format and the data are visually +separated, and the template line stays scannable. + +**Prohibited.** Format string inline with the call: + +```php +if ($value < 0 || $value > 16) { + throw new PrecisionOutOfRange( + message: sprintf('Precision must be between 0 and 16, got %d.', $value) + ); +} +``` + +**Correct.** Format string in a `$template` variable: + +```php +if ($value < 0 || $value > 16) { + $template = 'Precision must be between 0 and 16, got %d.'; + + throw new PrecisionOutOfRange(message: sprintf($template, $value)); +} +``` + +## Constructor chaining + +PHP 8.4 allows chained method calls directly on a `new` expression without wrapping it in +parentheses. The parentheses are no longer required and only add visual noise. Apply this +everywhere a `new` is followed by a method call. + +**Prohibited.** Parentheses around the `new` expression: + +```php +$body = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withHeader('Accept', 'application/json') + ->getBody(); +``` + +**Correct.** No parentheses: + +```php +$body = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withHeader('Accept', 'application/json') + ->getBody(); +``` + +## Formatting overrides + +Three formatting rules are not covered by the canonical `phpcs.xml` (which references `PSR-12` +only). Apply them manually. + +### No vertical alignment in parameter lists + +Use a single space between the type and the variable name in parameter lists (constructors, +function signatures, closures). Never pad with extra spaces to align columns. This rule applies +only to parameter lists, not to other contexts that use `=>` alignment (see "Vertical alignment +of `=>`" below). + +**Prohibited.** Vertical alignment of types: + +```php +public function __construct( + public OrderId $id, + public Money $total, + public Customer $customer, + public Precision $precision +) {} +``` + +**Correct.** Single space between type and variable: + +```php +public function __construct( + public OrderId $id, + public Money $total, + public Customer $customer, + public Precision $precision +) {} +``` + +### Vertical alignment of `=>` in match arms and array literals + +Multi-line `match` expressions and multi-line array literals with `=>` align the `=>` column +across all arms or entries by padding shorter left-hand sides with spaces. Single-line cases +(one-arm match, single-line array) keep the standard PSR-12 single-space form. + +**Prohibited.** Unaligned `=>` in match: + +```php +return match ($this) { + self::MAX_AGE => sprintf($template, $this->value, $value), + default => $this->value +}; +``` + +**Correct.** Aligned `=>` in match: + +```php +return match ($this) { + self::MAX_AGE => sprintf($template, $this->value, $value), + default => $this->value +}; +``` + +**Prohibited.** Unaligned `=>` in array literal: + +```php +return [ + 'name' => 'Gustavo', + 'role' => 'developer', + 'company' => 'Anthropic' +]; +``` + +**Correct.** Aligned `=>` in array literal: + +```php +return [ + 'name' => 'Gustavo', + 'role' => 'developer', + 'company' => 'Anthropic' +]; +``` + +### No trailing comma in multi-line lists + +Never place a trailing comma after the last element of any multi-line list. Applies to parameter +lists, argument lists, array literals, match arms, and every other comma-separated multi-line +structure. PHP accepts trailing commas in these positions, but this ecosystem prohibits them for +visual consistency. + +**Prohibited.** Trailing comma after the last argument: + +```php +new Precision( + value: 2, + rounding: RoundingMode::HALF_UP, +); +``` + +**Correct.** No trailing comma: + +```php +new Precision( + value: 2, + rounding: RoundingMode::HALF_UP +); +``` diff --git a/.claude/rules/php-library-commits.md b/.claude/rules/php-library-commits.md new file mode 100644 index 0000000..feefcf5 --- /dev/null +++ b/.claude/rules/php-library-commits.md @@ -0,0 +1,111 @@ +--- +description: Conventional Commits format. Applied on request when generating commit messages. +--- + +# Commits + +Applied only when generating commit messages, never automatically. All commit messages are +written in English. + +## Format + +`: ` + +The description starts with a capital letter, uses imperative present tense ("Add", "Fix", +"Change", not "Added", "Adds", or "Adding"), and ends with a period. Subject under 300 +characters. If it does not fit, split the change into multiple commits or move detail into the +body. + +Scopes are prohibited. `feat(orders): ...` is wrong. The type stands alone. + +## Allowed types + +Each entry below is a bullet that starts with a capital letter and ends with a period. This is +the canonical example of bullet punctuation enforced everywhere in this document. + +- `ci` for CI configuration changes. +- `fix` for a bug fix. +- `feat` for a user-facing feature. +- `docs` for documentation only. +- `test` for adding or correcting tests. +- `chore` for maintenance with no production code change. +- `build` for build or dependency changes. +- `revert` for reverting a previous commit. +- `refactor` for a code change that neither fixes a bug nor adds a feature. + +`style` is not used. Formatting is enforced by the linter and never appears as a standalone +commit. + +## Subject examples + +Good: + +- `fix: Handle zero-amount transactions.` +- `feat: Add order cancellation endpoint.` +- `refactor: Extract OrderStatus into its own enum.` + +Bad: + +- `Added order cancellation` is past tense, missing type, missing period. +- `feat: Adds order cancellation.` is third-person singular instead of imperative. +- `feat: added order cancellation.` starts lowercase and is past tense. +- `feat: Add cancellation, and fix billing rounding.` bundles two changes. Split. +- `feat(orders): Add cancellation.` uses a scope. Prohibited. + +## Body + +The body is **optional and rarely needed**. Single-purpose commits never have a body. Add a body +ONLY when the reason cannot be inferred from the diff (a non-obvious trade-off, a workaround for +an external bug, a decision worth recording). + +Separate the body from the subject with a blank line. Wrap at 72 characters per line. Explain +why, not what. The diff already shows what. + +## Prose vs. bullets in the body + +**Default to prose.** One or two paragraphs fits almost every commit that has a body at all. + +**Use bullets only when ALL of these are true:** + +1. The commit covers 3 or more independent changes that genuinely belong in the same commit. +2. The list cannot be expressed as continuous prose without becoming disconnected sentences. +3. Each item is independently meaningful (no sub-bullets, no continuation across bullets). + +A two-item bullet list is the wrong shape. Use prose. + +## Bullet formatting (when used) + +Every bullet starts with a capital letter and ends with a period. Imperative verb in present +tense, same as the subject line. Without exception. + +Wrong (do NOT generate): + +- `add the OrderCancelling port` lowercase, missing period. +- `Add the OrderCancelling port` capital but missing period. +- `Adds the OrderCancelling port.` third-person singular instead of imperative. + +## Body example with bullets + +``` +feat: Add order cancellation flow. + +- Add the OrderCancelling inbound port and OrderCancellingHandler. +- Add the CancelOrder command and its validator. +- Cover the cancellation path in the integration test suite. +``` + +## Body example with prose (preferred for most commits) + +``` +fix: Handle zero-amount transactions. + +The payment gateway rejects zero-amount charges with a generic 400 instead +of a documented error code, so the adapter short-circuits before the HTTP +call and raises ZeroAmountNotAllowed directly. +``` + +## Commit splitting + +Prefer one logical change per commit. Refactor commits never modify behavior. When a task +requires multiple types of change, produce multiple commits in order: `refactor` first, then +`feat` or `fix` on top. diff --git a/.claude/rules/php-library-documentation.md b/.claude/rules/php-library-documentation.md index 4791cb9..b7e0da4 100644 --- a/.claude/rules/php-library-documentation.md +++ b/.claude/rules/php-library-documentation.md @@ -1,40 +1,313 @@ --- -description: Standards for README files and all project documentation in PHP libraries. +description: Standards for README and other public-facing Markdown docs in PHP libraries. paths: - "**/*.md" --- # Documentation +Standards for `README.md` and other public-facing Markdown files in the repository. PHPDoc rules +for `.php` files live in `php-library-code-style.md`. American English applies everywhere (see +the American English section in `php-library-code-style.md`). + +The `CONTRIBUTING.md` file is centralized at +`https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md`. Each library's README and +pull request template link to that location. No local `CONTRIBUTING.md` is created per library. + +## Pre-output checklist + +Verify every item before producing any Markdown documentation. If any item fails, revise before +outputting. + +1. README title is `# ` with spaces between words (`# Building Blocks`, not + `# BuildingBlocks`). +2. License badge is the only badge. No build, coverage, Packagist, or version badges. +3. Header is followed by an anchor-linked table of contents. +4. Table of contents uses `*` for top-level (H2) entries, `+` indented by 4 spaces for + second-level (H3) entries, and `-` indented by 8 spaces for third-level (H4) entries. Every + heading from the document appears in the TOC, except FAQ entries: the FAQ is represented by + a single `* [FAQ](#faq)` line regardless of how many questions it contains. +5. Sections appear in the canonical order: Overview, Installation, How to use, FAQ (optional), + License, Contributing. +6. FAQ exists only when there are genuine points of confusion or unusual design decisions. Skip + it entirely when not needed. +7. **Self-contained code examples** are blocks that include any of: a `use` statement, a + `class`/`enum`/`interface`/`trait`/`function` declaration, or more than 3 lines of + executable code. Self-contained blocks open with `?` with zero-padded numbering + (`### 01.`, `### 02.`). +12. FAQ bibliographic citations use the format + `> Author, *Title* (Publisher, Year), Chapter X, "Section Name".` +13. License and Contributing sections each follow the canonical one-line template. +14. Repository includes `SECURITY.md`, `.github/ISSUE_TEMPLATE/bug_report.md`, + `.github/ISSUE_TEMPLATE/feature_request.md`, and `.github/PULL_REQUEST_TEMPLATE.md`, each + matching the canonical template in "Other documentation files". + ## README -1. Include an anchor-linked table of contents. -2. Start with a concise one-line description of what the library does. -3. Include a **license** badge. Do not include any other badges. -4. Provide an **Overview** section explaining the problem the library solves and its design philosophy. -5. **Installation** section: Composer command (`composer require vendor/package`). -6. **How to use** section: complete, runnable code examples covering the primary use cases. Each example - includes a brief heading describing what it demonstrates. -7. If the library exposes multiple entry points, strategies, or container types, document each with its own - subsection and example. -8. **FAQ** section: include entries for common pitfalls, non-obvious behaviors, or design decisions that users - frequently ask about. Each entry is a numbered question as heading (e.g., `### 01. Why does X happen?`) - followed by a concise explanation. Only include entries that address real confusion points. -9. **License** and **Contributing** sections at the end. -10. Write strictly in American English. See `php-library-code-style.md` American English section for spelling - conventions. +### Structure + +The README follows a fixed section order: + +1. **Overview**. One or more paragraphs explaining the problem the library solves and its design + philosophy. Cross-references to related `tiny-blocks` libraries belong here. +2. **Installation**. Composer command in a code block, with no surrounding prose unless strictly + necessary. +3. **How to use**. Runnable examples covering the primary use cases. Each subsection demonstrates + one capability with a heading and a self-contained code block. +4. **FAQ** (optional). Numbered questions that address real points of confusion or unusual design + decisions. +5. **License**. One-line link to the `LICENSE` file. +6. **Contributing**. One-line link to the centralized `CONTRIBUTING.md` in + `tiny-blocks/tiny-blocks`. + +### Header and license badge + +The first line is `# ` followed by a blank line and the license badge: + +```markdown +# Outbox + +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks//blob/main/LICENSE) +``` + +Replace `` with the library's repository name. The badge is the only badge in the document. + +### Table of contents + +The table of contents is anchor-linked. Top-level (H2) entries use `*`. Second-level (H3) +entries use `+` indented by 4 spaces. Third-level (H4) entries use `-` indented by 8 spaces. +Every heading from the document appears, with one exception: the FAQ is represented by a single +`* [FAQ](#faq)` line. Its questions never appear as TOC sub-entries, regardless of how many +exist. + +```markdown +* [Overview](#overview) +* [Installation](#installation) +* [How to use](#how-to-use) + + [Subtopic A](#subtopic-a) + + [Subtopic B](#subtopic-b) +* [FAQ](#faq) +* [License](#license) +* [Contributing](#contributing) +``` + +Use the third level whenever the document has H4 headings, regardless of whether they form a +two-axis split. The TOC mirrors the document structure exactly. + +```markdown +* [How to use](#how-to-use) + + [Entity](#entity) + - [Single-field identity](#single-field-identity) + - [Compound identity](#compound-identity) + + [Aggregate](#aggregate) +``` + +### Code examples + +Code examples fall into two categories. + +**Self-contained examples** include at least one of: + +- A `use` statement. +- A `class`, `enum`, `interface`, `trait`, or `function` declaration. +- More than 3 lines of executable code. + +They open with `push(records: $order->recordedEvents()); +``` + +**Inline fragment examples** have all of: + +- At most 3 lines of executable code. +- No `use` statements. +- No type declarations. + +Fragments may omit the prologue. + +```php +Code::OK->value; +``` + +The criteria are mechanical: a block that meets any self-contained condition gets the prologue. A block that meets every fragment condition may omit it. There is no middle ground. + +The `#` convention for inline comments applies only to code examples inside Markdown files. PHP +files under `src/` and `tests/` have no inline comments at all, except `# TODO: ` (see +item 16 in `php-library-code-style.md`). + +### FAQ + +FAQ entries are numbered with zero-padded prefixes and end with a question mark: + +```markdown +### 01. Why is DomainEvent close to a marker interface? + +A domain event is a fact about something that happened in the domain. The contract carries only +`revision()` so the library can route schema migrations through upcasters. Everything else +(aggregate identity, sequence number, aggregate type, occurrence timestamp) is envelope metadata +that belongs to `EventRecord`. + +> Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 8, +> "Domain Events". +``` + +Bibliographic citations follow the format +`> Author, *Title* (Publisher, Year), Chapter X, "Section Name".` The chapter and section +fragments are optional when the title is precise enough on its own. Multiple citations can be +stacked as separate blockquote lines. + +### License and Contributing + +The License section is a single line: + +```markdown +## License + + is licensed under [MIT](LICENSE). +``` + +The Contributing section is a single line pointing to the centralized guideline: + +```markdown +## Contributing + +Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to +contribute to the project. +``` ## Structured data -1. When documenting constructors, factory methods, or configuration options with more than 3 parameters, - use tables with columns: Parameter, Type, Required, Description. -2. Prefer tables to prose for any structured information. +Tables are preferred to prose for any structured information: constructor parameter lists, +builder method catalogs, default value tables, complexity tables, and configuration matrices. +Column layout is chosen per case. No fixed column set is mandated. + +## Other documentation files + +Every library repository includes the following files in addition to the README. Each follows +the canonical template below. + +### SECURITY.md + +```markdown +# Security Policy + +## Supported versions + +Only the latest release receives security updates. + +## Reporting a vulnerability + +Report security vulnerabilities privately via +[GitHub Security Advisories](https://github.com/tiny-blocks//security/advisories/new). + +Please do not disclose the vulnerability publicly until it has been addressed. +``` + +Replace `` with the repository name. + +### .github/ISSUE_TEMPLATE/bug_report.md + +```markdown +--- +name: Bug report +about: Report a bug to help improve the library +labels: bug +--- + +## Description + +A clear and concise description of the bug. + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + +What should happen. + +## Actual behavior + +What actually happens. + +## Environment + +- PHP version: +- Library version: +- OS: +``` + +### .github/ISSUE_TEMPLATE/feature_request.md + +```markdown +--- +name: Feature request +about: Suggest a feature for the library +labels: enhancement +--- + +## Problem + +What problem does this feature solve? + +## Proposed solution + +How should the feature work? + +## Alternatives considered + +Other approaches considered. +``` + +### .github/PULL_REQUEST_TEMPLATE.md + +```markdown +> Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md). + +## Summary + +What this pull request does. + +## Related issue + +Closes #... -## Style +## Checklist -1. Keep language concise and scannable. -2. Never include placeholder content (`TODO`, `TBD`). -3. Code examples must be syntactically correct and self-contained. -4. Code examples include every `use` statement needed to compile. Each example stands alone — copyable into - a fresh file without modification. -5. Do not document `Internal/` classes or private API. Only document what consumers interact with. +- [ ] Tests added or updated. +- [ ] Documentation updated when applicable. +- [ ] `composer review` passes. +- [ ] `composer tests` passes. +``` diff --git a/.claude/rules/php-library-github-workflows.md b/.claude/rules/php-library-github-workflows.md new file mode 100644 index 0000000..396c40a --- /dev/null +++ b/.claude/rules/php-library-github-workflows.md @@ -0,0 +1,287 @@ +--- +description: Structure, ordering, and pinning rules for GitHub Actions workflows in PHP libraries. +paths: + - ".github/workflows/**/*.yml" + - ".github/workflows/**/*.yaml" +--- + +# Workflows + +Conventions for GitHub Actions workflows in PHP libraries. CD does not apply. Libraries publish +to Packagist via tags and never deploy. + +`.github/workflows/ci.yml` is mandatory and follows the canonical structure defined in the +"ci.yml" section below. Additional workflow files (security scanning, automated triage, +scheduled tasks, dependency updates, etc.) may exist and follow the general rules in this file. +Their trigger, job structure, and steps are chosen by their purpose. + +The Composer scripts invoked by `ci.yml` (`composer review`, `composer tests`) are defined in +`php-library-tooling.md`. + +## Pre-output checklist + +Verify every item before producing or editing any workflow YAML. If any item fails, revise +before outputting. + +### Rules for every workflow + +These rules apply to `ci.yml` and to every additional workflow in `.github/workflows/`. + +1. Keys at the workflow root follow the canonical order `name`, `on`, `concurrency`, + `permissions`, `jobs`. Keys absent in a given workflow are simply omitted. The relative order + of the remaining keys is preserved. +2. Properties inside a job follow the canonical order `name`, `needs`, `runs-on`, + `timeout-minutes`, `outputs`, `env`, `steps`. Same omission rule as above. +3. Inside any block (`env`, `outputs`, `with`, `permissions`), entries are ordered by key length + ascending. +4. The workflow `name`, every job `name`, and every step `name` are mandatory and use sentence + case (`Resolve PHP version`, not `RESOLVE_PHP_VERSION` or `resolve_php_version`). Step names + start with a verb. Job keys describe the job's purpose. Generic keys (`run`, `job`, `do`) are + discouraged in favor of descriptive identifiers (`auto-assign`, `analyze`, `notify`). +5. `concurrency` is set at the workflow root with `cancel-in-progress: true` and a `group` + expression scoped by the workflow's trigger: + - `pull_request`: `-${{ github.event.pull_request.number }}`. + - `issues`, or `issues` combined with `pull_request`: + `-${{ github.event.issue.number || github.event.pull_request.number }}`. + - `push`, `schedule`, or both: `-${{ github.ref }}`. + + `` is the workflow's short name (`ci`, `codeql`, `auto-assign`). +6. `permissions` is declared at the workflow root with the minimum scope every job needs. + Job-level `permissions` blocks are allowed only when a specific job needs a narrower scope + than the root, never broader. +7. Every job sets `timeout-minutes`. Defaults: 5 for trivial steps (single API call, lightweight + script), 15 for jobs with PHP setup or test runs, 30 for analysis-heavy jobs (CodeQL, + security scanning). Adjust based on observed runtime when prior runs exist. +8. Every action is pinned to a fixed major version tag written explicitly. Examples are + `actions/checkout@v6` and `shivammathur/setup-php@v2`. Never use `@latest`, `@main`, a branch + name, or a commit SHA. When the existing pin is an explicit minor or patch, derive the major + version while **preserving the prefix style** of the original tag: `@v2.1.0` → `@v2`, + `@2.1.0` → `@2`. The action's tag convention is reflected in the existing pin. Web lookup is + required only when the existing pin is missing, ambiguous, or pointing to a non-version + reference. Example versions cited in this file may be outdated and are not a license to skip + the lookup when it is required. +9. Inline shell logic longer than 3 lines is extracted to a script in `scripts/ci/`. +10. All text (workflow name, job names, step names, comments) uses American English with correct + spelling and punctuation. Sentences and descriptions end with a period. + +### Rules specific to ci.yml + +These rules apply only to `.github/workflows/ci.yml`. Additional workflows are not bound by them. + +1. File path is `.github/workflows/ci.yml`. The workflow `name` field is exactly `CI`. +2. Trigger is `pull_request` only. No `push`, no branch filter, no `workflow_dispatch`. +3. Jobs run in the fixed sequence `resolve-php-version`, `build`, `auto-review`, `tests`. Each + downstream job lists its upstream jobs in `needs`. +4. PHP version is never hardcoded. The `resolve-php-version` job reads `.require.php` from + `composer.json` at runtime and exposes the minor version (for example, `8.5`) as the job + output `php-version`. Downstream jobs reference + `${{ needs.resolve-php-version.outputs.php-version }}` when setting up PHP. +5. The `auto-review` job runs `composer review`. The `tests` job runs `composer tests`. Both + scripts are defined in `composer.json` per `php-library-tooling.md`. No other command is + invoked in either job. +6. The `build` job uploads `vendor/` and `composer.lock` as a single artifact named + `vendor-artifact`. The `auto-review` and `tests` jobs download that artifact instead of + running `composer install` again. +7. The `tests` job is the only job that may extend with extra setup required by the library, + such as service containers, fixture preparation, or environment variables used during + testing. The other three jobs are identical across every library in the ecosystem. +8. `concurrency.group` is `pr-${{ github.event.pull_request.number }}`. `timeout-minutes` is 5 + for `resolve-php-version` and 15 for `build`, `auto-review`, and `tests`. `permissions` is + `contents: read`. + +## ci.yml + +`ci.yml` is the mandatory workflow that gates every pull request. It contains four jobs in the +exact order below. The first three jobs are identical across every library. Only `tests` may +extend with extra setup required by the library. + +### Resolve PHP version + +Reads `.require.php` from `composer.json` and exposes the minor version (for example, `8.5`) as the +output `php-version`. A single step uses `jq` and a short regex to extract the value. Downstream jobs +consume the output to configure their PHP setup. + +### Build + +Sets up PHP using the resolved version, validates `composer.json`, installs dependencies with +`--no-progress --optimize-autoloader --prefer-dist --no-interaction`, and uploads `vendor/` and +`composer.lock` as the artifact `vendor-artifact`. + +### Auto review + +Depends on `resolve-php-version` and `build`. Downloads `vendor-artifact`, sets up PHP, and runs +`composer review`. The `review` script in `composer.json` aggregates lint, static analysis, and style +checks for the library. + +### Tests + +Depends on `resolve-php-version` and `auto-review`. Downloads `vendor-artifact`, sets up PHP, and runs +`composer tests`. Any setup required by the library's tests (service containers, fixture preparation, +environment variables used during testing) lives in this job only. + +## Reference shape + +The YAML below is the canonical minimal form. Every library starts from this exact shape and extends +only the `tests` job when its tests require extra setup. Action versions cited here may be outdated. +Look up the current major version of every action via web search before adopting this shape verbatim. + +### Minimal workflow + +```yaml +name: CI + +on: + pull_request: + +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + resolve-php-version: + name: Resolve PHP version + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + php-version: ${{ steps.config.outputs.php-version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve PHP version from composer.json + id: config + run: | + version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1) + echo "php-version=$version" >> "$GITHUB_OUTPUT" + + build: + name: Build + needs: resolve-php-version + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} + + - name: Validate composer.json + run: composer validate --no-interaction + + - name: Install dependencies + run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction + + - name: Upload vendor and composer.lock as artifact + uses: actions/upload-artifact@v7 + with: + name: vendor-artifact + path: | + vendor + composer.lock + + auto-review: + name: Auto review + needs: [resolve-php-version, build] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} + + - name: Download vendor artifact from build + uses: actions/download-artifact@v8 + with: + name: vendor-artifact + path: . + + - name: Run review + run: composer review + + tests: + name: Tests + needs: [resolve-php-version, auto-review] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} + + - name: Download vendor artifact from build + uses: actions/download-artifact@v8 + with: + name: vendor-artifact + path: . + + - name: Run tests + run: composer tests +``` + +### Extending the tests job + +When the library's tests need external services, env vars, or fixture preparation, the additions live +inside the `tests` job only. The example below shows the same `tests` job extended with a MySQL service +container and the env vars consumed by the test suite. + +```yaml +tests: + name: Tests + needs: [resolve-php-version, auto-review] + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + DB_HOST: 127.0.0.1 + DB_NAME: library_test + DB_PORT: '3306' + DB_USER: library + DB_PASSWORD: library + services: + mysql: + image: mysql:8 + ports: + - 3306:3306 + env: + MYSQL_DATABASE: library_test + MYSQL_ROOT_PASSWORD: library + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} + + - name: Download vendor artifact from build + uses: actions/download-artifact@v8 + with: + name: vendor-artifact + path: . + + - name: Run tests + run: composer tests +``` diff --git a/.claude/rules/php-library-modeling.md b/.claude/rules/php-library-modeling.md index bedb733..127413c 100644 --- a/.claude/rules/php-library-modeling.md +++ b/.claude/rules/php-library-modeling.md @@ -1,112 +1,199 @@ --- -description: Library modeling rules — folder structure, public API boundary, naming, value objects, exceptions, enums, extension points, and complexity. +description: Semantic modeling rules for PHP libraries (nomenclature, value objects, exceptions, enums, extension points, complexity). paths: - "src/**/*.php" --- -# Library modeling +# Modeling + +Library modeling rules. How to model the concepts the library exposes. Folder structure and +public API boundary live in `php-library-architecture.md`. Code style lives in +`php-library-code-style.md`. Tooling lives in `php-library-tooling.md`. + +## Pre-output checklist + +Verify every item before producing any PHP code that defines a model, an exception, or an +algorithm. If any item fails, revise before outputting. + +1. Each model has a single, clear responsibility. Apply DDD, SOLID, DRY, and KISS where they + sharpen the design, not as dogma. +2. Concept names. Every class, property, method, and exception name reflects the concept the + library represents, not a technical role. +3. No always-banned names. Never use `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity` as + class suffix, prefix, or method name. Never use `Exception` as a class suffix. Exception: + names that correspond to externally standardized identifiers (HTTP status text from RFC + documents, PSR interface names being mirrored, etc.) are permitted. The standard reference + is the meaning carrier. +4. No anemic verbs as the primary operation name (`ensure`, `validate`, `check`, `verify`, + `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, `transform`, `parse`) unless + the verb is the library's reason to exist. +5. Architectural role names (`Manager`, `Handler`, `Processor`, `Service`, and their verb forms + `process`, `handle`, `execute`) are allowed only when the class IS that role for consumers + integrating with the library. +6. Value objects are immutable. No setters. Operations return new instances. +7. Value objects compare by value, never by reference. No identity field. +8. Value objects validate invariants in the constructor and throw a dedicated exception on + invalid input. +9. Value objects with multiple creation paths use static factory methods (`from`, `of`, `zero`) + with a private constructor. +10. Every failure throws a dedicated exception class named after the invariant it guards. Never + `throw new DomainException(...)`, `throw new InvalidArgumentException(...)`, or any other + generic native exception directly. +11. Dedicated exception classes extend the appropriate native PHP exception (`DomainException`, + `InvalidArgumentException`, `OverflowException`, etc.). +12. Exceptions are pure. No transport-specific fields (HTTP status in `code`, formatted message + for end-user display). They signal invariant violations only, never control flow. +13. Enums are PHP backed enums. They include methods only when those methods carry vocabulary + meaning. +14. Extension points use `class` instead of `final readonly class`. They expose a private + constructor with static factory methods as the only creation path. Internal state is + injected via the constructor. +15. Algorithms run in O(N) or O(N log N) unless the problem inherently requires worse. O(N²) + or worse needs explicit justification. +16. Prefer lazy or streaming evaluation over materializing intermediate results. Memory usage + is bounded and proportional to the output, not to the sum of intermediate stages. + +## Modeling principles + +Apply the following principles where they sharpen the design. Treat them as guides, not as dogma. + +- Single responsibility. Each model represents one concept, has one reason to change, and + exposes operations that belong to that concept. +- DDD ubiquitous language. Names, types, and operations match the vocabulary the library's + domain uses. Code and conversation share the same terms. +- SOLID. Interfaces define narrow contracts. Composition is preferred to inheritance. + Substitutability holds at every interface boundary. +- DRY. No duplicated logic across two or more places. +- KISS. No abstraction without real duplication or isolation need. -Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O. Refer to -`php-library-code-style.md` for the pre-output checklist applied to all PHP code. +## Nomenclature -## Folder structure +- Every class, property, method, and exception name reflects the concept the library represents. + A math library uses `Precision` and `RoundingMode`. A money library uses `Currency` and + `Amount`. A collection library uses `Collectible` and `Order`. +- Name classes after what they represent, not after what they do technically. Use `Money`, + `Color`, `Pipeline`, not `MoneyCalculator`, `ColorHelper`, `PipelineProcessor`. +- Name methods after the operation in the library's vocabulary. Use `add()`, `convertTo()`, + `splitAt()`, not `compute()`, `process()`, `handle()`. -``` -src/ -├── .php # Primary contract for consumers -├── .php # Main implementation or extension point -├── .php # Public enum -├── Contracts/ # Interfaces for data returned to consumers -├── Internal/ # Implementation details (not part of public API) -│ ├── .php -│ └── Exceptions/ # Internal exception classes -├── / # Feature-specific subdirectory when needed -└── Exceptions/ # Public exception classes (when part of the API) -``` +### Always banned -Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. +These names carry zero semantic content. Never use them anywhere as class suffix, prefix, or +method name. -## Public API boundary +- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`. +- `Exception` as a class suffix (e.g., `FooException`). Use the invariant name when extending a + native exception (e.g., `PrecisionOutOfRange`, not `InvalidPrecisionException`). -Only interfaces, extension points, enums, and thin orchestration classes live at the `src/` root. These classes -define the contract consumers interact with and delegate all real work to collaborators inside `src/Internal/`. -If a class contains substantial logic (algorithms, state machines, I/O), it belongs in `Internal/`, not at the root. +### Externally standardized names (exception to the banlist) -The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them. -Breaking changes inside `Internal/` are not semver-breaking for the library. +Names that correspond to externally standardized identifiers are exempt from the banlist. The +standard reference is the meaning carrier. Renaming weakens it. Examples: -## Nomenclature +- HTTP status text from RFC documents (`unprocessableEntity` from RFC 4918, `noContent`). +- PSR interface names being mirrored as test doubles (`ClientException` mirroring + `Psr\Http\Client\ClientExceptionInterface`). +- Unicode category names, locale identifiers, MIME type tokens, and similar registered names. -1. Every class, property, method, and exception name reflects the **concept** the library represents. A math library - uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection library uses - `Collectible`, `Order`. -2. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically. -3. Name methods after the operation in the library's vocabulary: `add()`, `convertTo()`, `splitAt()`. +This exception applies only when the external standard is the actual source of the name. It +does not authorize using `Data` or `Entity` as generic suffixes when no external reference is +involved. -### Always banned +### Anemic verbs -These names carry zero semantic content. Never use them anywhere, as class suffixes, prefixes, or method names: +These verbs hide what is actually happening behind a generic action. Banned unless the verb IS +the operation that constitutes the library's reason to exist (e.g., a JSON parser may have +`parse()`, a hashing library may have `compute()`). -- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`. -- `Exception` as a class suffix (e.g., `FooException` — use `Foo` when it already extends a native exception). +- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`, + `compute`, `transform`, `parse`. -### Anemic verbs (banned by default) +When in doubt, prefer the domain operation name. `Password::hash()` beats `Password::compute()`. +`Email::parse()` is fine in a parser library but suspicious elsewhere. Use `Email::from()` +instead. -These verbs hide what is actually happening behind a generic action. Banned unless the verb **is** the operation -that constitutes the library's reason to exist (e.g., a JSON parser may have `parse()`; a hashing library may -have `compute()`): +### Architectural roles -- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, - `transform`, `parse`. +These names describe a role the library offers as a building block. Acceptable when the class IS +that role (e.g., `EventHandler` in an events library, `CacheManager` in a cache library, +`Upcaster` in an event-sourcing library). Not acceptable on domain objects inside the library +(value objects, enums, contract interfaces). -When in doubt, prefer the domain operation name. `Password::hash()` beats `Password::compute()`; `Email::parse()` -is fine in a parser library but suspicious elsewhere (use `Email::from()` instead). +- `Manager`, `Handler`, `Processor`, `Service`. +- Verb forms: `process`, `handle`, `execute`. -### Architectural roles (allowed with justification) +The test. If the consumer instantiates or extends this class to integrate with the library, the +role name is legitimate. If the class models a concept the consumer manipulates (a money amount, +a country code, a color), the role name is wrong. -These names describe a role the library offers as a building block. Acceptable when the class **is** that role -(e.g., `EventHandler` in an events library, `CacheManager` in a cache library, `Upcaster` in an event-sourcing -library). Not acceptable on domain objects inside the library (value objects, enums, contract interfaces): +## Value objects -- `Manager`, `Handler`, `Processor`, `Service`, and their verb forms `process`, `handle`, `execute`. +- Are immutable. No setters. No mutation after construction. Operations return new instances. +- Compare by value, not by reference. +- Validate invariants in the constructor and throw a dedicated exception on invalid input. +- Have no identity field. +- Use static factory methods (`from`, `of`, `zero`) with a private constructor when multiple + creation paths exist. The factory name communicates the semantic intent. -The test: if the consumer instantiates or extends this class to integrate with the library, the role name is -legitimate. If the class models a concept the consumer manipulates (a money amount, a country code, a color), -the role name is wrong. +**Prohibited.** Public constructor with multiple creation paths. Semantics are unclear at the +call site: -## Value objects +```php +final readonly class Money +{ + public function __construct(public int $amount, public Currency $currency) {} +} + +new Money(amount: 1000, currency: Currency::BRL); +new Money(amount: 0, currency: Currency::USD); +``` + +**Correct.** Private constructor with named factory methods. Each factory name communicates +intent: + +```php +final readonly class Money +{ + private function __construct(public int $amount, public Currency $currency) {} -1. Are immutable: no setters, no mutation after construction. Operations return new instances. -2. Compare by value, not by reference. -3. Validate invariants in the constructor and throw on invalid input. -4. Have no identity field. -5. Use static factory methods (e.g., `from`, `of`, `zero`) with a private constructor when multiple creation paths - exist. The factory name communicates the semantic intent. + public static function of(int $amount, Currency $currency): Money + { + return new Money(amount: $amount, currency: $currency); + } + + public static function zero(Currency $currency): Money + { + return new Money(amount: 0, currency: $currency); + } +} + +Money::of(amount: 1000, currency: Currency::BRL); +Money::zero(currency: Currency::USD); +``` ## Exceptions -1. Every failure throws a **dedicated exception class** named after the invariant it guards — never - `throw new DomainException('...')`, `throw new InvalidArgumentException('...')`, - `throw new RuntimeException('...')`, or any other generic native exception thrown directly. If the invariant - is worth throwing for, it is worth a named class. -2. Dedicated exception classes **extend** the appropriate native PHP exception (`DomainException`, - `InvalidArgumentException`, `OverflowException`, etc.) — the native class is the parent, never the thing that - is thrown. Consumers that catch the broad standard types continue to work; consumers that need precise handling - can catch the specific classes. -3. Exceptions are pure: no transport-specific fields (`code` populated with HTTP status, formatted `message` meant - for end-user display). Formatting to any transport happens at the consumer's boundary, not inside the library. -4. Exceptions signal invariant violations only, not control flow. -5. Name the class after the invariant violated, never after the technical type: - - `PrecisionOutOfRange` — not `InvalidPrecisionException`. - - `CurrencyMismatch` — not `BadCurrencyException`. - - `ContainerWaitTimeout` — not `TimeoutException`. -6. A descriptive `message` argument is allowed and encouraged when it carries **debugging context** — the violating - value, the boundary that was crossed, the state the library was in. The class name identifies the invariant; - the message describes the specific violation for stack traces and test assertions. Do not build messages meant - for end-user display or transport rendering. Keep them short, factual, and in American English. -7. Public exceptions live in `src/Exceptions/`. Internal exceptions live in `src/Internal/Exceptions/`. - -**Prohibited** — throwing a native exception directly: +- Every failure throws a dedicated exception class named after the invariant it guards. Never + `throw new DomainException(...)`, `throw new InvalidArgumentException(...)`, + `throw new RuntimeException(...)`, or any other generic native exception directly. If the + invariant is worth throwing for, it is worth a named class. +- Dedicated exception classes extend the appropriate native PHP exception (`DomainException`, + `InvalidArgumentException`, `OverflowException`, etc.). The native class is the parent, never + the thing that is thrown. Consumers that catch the broad standard types continue to work. + Consumers that need precise handling can catch the specific classes. +- Exceptions are pure. No transport-specific fields (`code` populated with HTTP status, + formatted `message` meant for end-user display). Formatting to any transport happens at the + consumer's boundary, not inside the library. +- Exceptions signal invariant violations only, not control flow. +- Name the class after the invariant violated, never after the technical type. Use + `PrecisionOutOfRange`, not `InvalidPrecisionException`. Use `CurrencyMismatch`, not + `BadCurrencyException`. Use `ContainerWaitTimeout`, not `TimeoutException`. +- A descriptive `message` argument is allowed and encouraged when it carries debugging context + (the violating value, the boundary crossed, the state the library was in). The class name + identifies the invariant. The message describes the specific violation for stack traces and + test assertions. Keep messages short, factual, and in American English. + +**Prohibited.** Throwing a native exception directly: ```php if ($value < 0) { @@ -114,50 +201,76 @@ if ($value < 0) { } ``` -**Correct** — dedicated class, no message (class name is sufficient): +**Correct.** Dedicated class, no message (class name is sufficient): ```php -// src/Exceptions/PrecisionOutOfRange.php final class PrecisionOutOfRange extends InvalidArgumentException { } -// at the callsite if ($value < 0) { throw new PrecisionOutOfRange(); } ``` -**Correct** — dedicated class with debugging context: +**Correct.** Dedicated class with debugging context in the message: ```php if ($value < 0 || $value > 16) { - throw new PrecisionOutOfRange(sprintf('Precision must be between 0 and 16, got %d.', $value)); + $template = 'Precision must be between 0 and 16, got %d.'; + + throw new PrecisionOutOfRange(message: sprintf($template, $value)); } ``` ## Enums -1. Are PHP backed enums. -2. Include methods when they carry vocabulary meaning (e.g., `Order::ASCENDING_KEY`, `RoundingMode::apply()`). -3. Live at the `src/` root when public. Enums used only by internals live in `src/Internal/`. +- Are PHP backed enums. +- Include methods only when those methods carry vocabulary meaning. Examples are + `Order::ASCENDING_KEY` and `RoundingMode::apply()`. ## Extension points -1. When a class is designed to be extended by consumers (e.g., `Collection`, `ValueObject`), it uses `class` instead - of `final readonly class`. All other classes use `final readonly class`. -2. Extension point classes use a private constructor with static factory methods (`createFrom`, `createFromEmpty`) - as the only creation path. -3. Internal state is injected via the constructor and stored in a `private readonly` property. +- A class designed to be extended by consumers (e.g., `Collection`, `ValueObject`) uses `class` + instead of `final readonly class`. All other classes use `final readonly class`. See + "Inheritance and constructors" in `php-library-code-style.md`. +- Extension point classes use a private constructor with static factory methods (`createFrom`, + `createFromEmpty`) as the only creation path. +- Internal state is injected via the constructor and stored in a `private readonly` property. ## Time and space complexity -1. Every public method has predictable, documented complexity. Document Big O in PHPDoc on the interface - (see `php-library-code-style.md`, "PHPDoc" section). -2. Algorithms run in `O(N)` or `O(N log N)` unless the problem inherently requires worse. `O(N²)` or worse must - be justified and documented. -3. Prefer lazy/streaming evaluation over materializing intermediate results. In pipeline-style libraries, fuse - stages so a single pass suffices. -4. Memory usage is bounded and proportional to the output, not to the sum of intermediate stages. -5. Validate complexity claims with benchmarks against a reference implementation when optimizing critical paths. - Parity testing against the reference library is the validation standard for optimization work. +- Algorithms run in O(N) or O(N log N) unless the problem inherently requires worse. O(N²) or + worse needs explicit justification at the point of definition. +- Prefer lazy or streaming evaluation over materializing intermediate results. In pipeline-style + libraries, fuse stages so a single pass suffices over the input. +- Memory usage is bounded and proportional to the output, not to the sum of intermediate stages. +- Never re-iterate the same source. When a sequence is consumed once, use lazy creation + primitives (`createLazyFrom`) instead of materializing. + +**Prohibited.** Eager pipeline that materializes between stages: + +```php +$paidTotals = array_map( + static fn(Order $order): float => $order->total(), + array_filter( + $orders->toArray(), + static fn(Order $order): bool => $order->isPaid() + ) +); +``` + +Each stage allocates a full intermediate array. Memory grows with the input size, even when only +the final scalar matters. + +**Correct.** Fused pipeline that runs in a single pass: + +```php +$paidTotals = $orders + ->filter(predicates: static fn(Order $order): bool => $order->isPaid()) + ->map(transformations: static fn(Order $order): float => $order->total()) + ->toArray(keyPreservation: KeyPreservation::DISCARD); +``` + +Operations stack on the same iterator. No intermediate array is built. Memory stays bounded by +the final output. diff --git a/.claude/rules/php-library-testing.md b/.claude/rules/php-library-testing.md index 610b928..86a0c10 100644 --- a/.claude/rules/php-library-testing.md +++ b/.claude/rules/php-library-testing.md @@ -1,17 +1,79 @@ --- -description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries. +description: BDD Given/When/Then structure, PHPUnit conventions, fixture rules, and coverage discipline. paths: - "tests/**/*.php" --- -# Testing conventions +# Testing -Framework: **PHPUnit**. Refer to `php-library-code-style.md` for the code style checklist, which also applies to -test files. +PHPUnit conventions for tests in PHP libraries. Covers BDD structure, fixture rules, and coverage +discipline. Code style applies to test files as well. See `php-library-code-style.md`. Folder +structure for `tests/` lives in `php-library-architecture.md`. Canonical thresholds (MSI 100, +covered MSI 100) live in `php-library-tooling.md`. + +## Pre-output checklist + +Verify every item before producing any test code. If any item fails, revise before outputting. + +1. Each test contains exactly one `@When` block. Two actions require two tests. +2. Use `@And` for complementary preconditions or actions within the same scenario, avoiding + consecutive `@Given` or `@When` tags. +3. Each `@Given` or `@And` block contains exactly one annotation line followed by one expression + or assignment. Never place multiple variable declarations or object constructions under a + single annotation. **Exception for data-provider tests.** When the test method binds its + inputs through a `#[DataProvider]` attribute (or the equivalent `@dataProvider` annotation), + the `@Given` block may declare the input shape in prose form, without an expression below + it. The values are bound by PHPUnit before the test body runs, so the prose annotation + replaces the assignment that would otherwise sit under the `@Given`. + + `@When` blocks follow the same one-expression rule by default: the block represents the + single action under test. **Exception for repeated-invocation tests** (idempotence, caching, + memoization). When the purpose of the test is asserting that the same operation produces the + same outcome across N invocations, the `@When` block may contain N consecutive identical + invocations, each captured in a numbered variable (`$first`, `$second`, ...), and the + annotation reads `@When invoked twice` (or thrice, etc.) to make the composite-action + semantic explicit. Two unrelated actions still require two tests. +4. No intermediate variables used only once. Chain method calls when the intermediate state is + not referenced elsewhere (e.g., `Money::of(...)->add(...)` instead of + `$money = Money::of(...)` followed by `$money->add(...)`). +5. No private or helper methods in test classes. The only non-test methods allowed are data + providers. Setup logic complex enough to extract belongs in a dedicated fixture class. +6. Test only the public API. Never assert on private state or `Internal/` classes directly. +7. Test the behavior that **raises** an exception, never the exception itself. Exception classes + represent invariant violations and are value objects, not the subject of behavior tests. A + test constructs the conditions, invokes the public method that is supposed to fail, and + asserts the expected exception class is raised (plus its accessor values when they carry + information relevant to the failure). Constructing an exception directly + (`new HttpRequestInvalid(...)`) and asserting on its accessors is **prohibited**: the + exception's structure is exercised through the call path that produces it. If a method does + not exist whose call path produces the exception, the exception is dead code and should be + removed. +8. Never mock internal collaborators. Use real objects. Test doubles are used only at system + boundaries (filesystem, clock, network) when the library interacts with external resources. +9. Name tests after behavior, not method names. +10. Use domain-specific names in variables and properties. Never `$spy`, `$mock`, `$stub`, + `$fake`, `$dummy` as variable or property names. Use the domain concept the object + represents (`$collection`, `$amount`, `$currency`, `$sortedElements`). Class names like + `ClientMock` or `GatewaySpy` are acceptable. The variable holding the instance is what matters. +11. Annotations use domain language. Write `/** @Given a collection of amounts */`, not + `/** @Given a mocked collection in test state */`. +12. Never use the `/** @test */` annotation. Test methods are discovered by the `test` prefix in + the method name. +13. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`, + `expectException`, etc.). Pass arguments positionally. +14. Never include conditional logic inside tests. Each `@Then` block expresses one logical + concept. The only allowed `try`/`catch` is when the assertion target is a property of the + caught exception that cannot be expressed via `expectException*` methods (notably + `getPrevious()` for chain inspection). The catch block contains only assertions against the + caught exception, no branching. +15. Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from + coverage. Never suppress mutants via `infection.json.dist` or any other mechanism. See + "Coverage and mutation discipline". ## Structure: Given/When/Then (BDD) -Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception. +Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments +without exception. ### Happy path example @@ -20,26 +82,30 @@ public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void { /** @Given two money instances in the same currency */ $ten = Money::of(amount: 1000, currency: Currency::BRL); + + /** @And another money instance with the same currency */ $five = Money::of(amount: 500, currency: Currency::BRL); /** @When adding them together */ $total = $ten->add(other: $five); /** @Then the result contains the sum of both amounts */ - self::assertEquals(expected: 1500, actual: $total->amount()); + self::assertEquals(1500, $total->amount()); } ``` ### Exception example -When testing that an exception is thrown, place `@Then` (expectException) **before** `@When`. PHPUnit requires this -ordering. +When testing that an exception is thrown, place `@Then` (`expectException`) before `@When`. +PHPUnit requires this ordering. ```php public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void { /** @Given two money instances in different currencies */ $brl = Money::of(amount: 1000, currency: Currency::BRL); + + /** @And another money instance with a different currency */ $usd = Money::of(amount: 500, currency: Currency::USD); /** @Then an exception indicating currency mismatch should be thrown */ @@ -50,67 +116,210 @@ public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void } ``` -Use `@And` for complementary preconditions or actions within the same scenario, avoiding consecutive `@Given` or -`@When` tags. - -## Rules - -1. Include exactly one `@When` per test. Two actions require two tests. -2. Test only the public API. Never assert on private state or `Internal/` classes directly. -3. Never mock internal collaborators. Use real objects. Use test doubles only at system boundaries (filesystem, - clock, network) when the library interacts with external resources. -4. Name tests to describe behavior, not method names. -5. Never include conditional logic inside tests. -6. Include one logical concept per `@Then` block. -7. Maintain strict independence between tests. No inherited state. -8. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts - (e.g., `Amount`, `Invoice`, `Order`). -9. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries - (e.g., `ClientMock`, `ExecutionCompletedMock`). -10. Exercise invariants and edge cases through the library's public entry point. Create a dedicated test class - for an internal model only when the condition cannot be reached through the public API. -11. Never use `/** @test */` annotation. Test methods are discovered by the `test` prefix in the method name. -12. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`, - `expectException`, etc.). Pass arguments positionally. +Use `@And` for complementary preconditions or actions within the same scenario, avoiding +consecutive `@Given` or `@When` tags. + +## Testing exceptions + +Exception classes are value objects describing an invariant violation. They are not the subject +of behavior tests. A test verifies that a public method, under specific conditions, raises a +specific exception. Constructing the exception directly and asserting on its accessors is +prohibited. The exception's structure is exercised through the call path that produces it. + +**Prohibited.** Testing the exception as a value object: + +```php +public function testFromWhenAllFieldsGivenThenExposesEveryAccessor(): void +{ + /** @Given a URL */ + $url = 'https://api.example.com'; + + /** @And an HTTP method */ + $method = Method::GET; + + /** @And a reason */ + $reason = 'Connection refused.'; + + /** @When the exception is constructed */ + $exception = HttpNetworkFailed::from(url: $url, method: $method, reason: $reason); + + /** @Then it exposes the URL */ + self::assertSame($url, $exception->url()); +} +``` + +The test constructs the exception in isolation and asserts on its accessors. No production code +is exercised. The same coverage is achieved (and made meaningful) by the test below, which +drives the path that raises the exception. + +**Correct.** Testing the behavior that raises the exception: + +```php +public function testSendRequestWhenTransportCannotReachServerThenThrowsHttpNetworkFailed(): void +{ + /** @Given an HTTP client backed by a transport that always raises a network error */ + $http = Http::usingTransport(transport: new ThrowingClient()); + + /** @And a target request to that transport */ + $request = Request::create(url: 'https://api.example.com', method: Method::GET); + + /** @Then a network failure exception describing the unreachable target is raised */ + $this->expectException(HttpNetworkFailed::class); + + /** @When the request is sent */ + $http->send(request: $request); +} +``` + +When the accessor values on the raised exception are part of the assertion, `expectException` +alone is not enough (it asserts only the class). Use a `try`/`catch` block as permitted by +rule 14. The catch block contains only assertions against the caught exception, no branching. + +```php +public function testSendRequestWhenTargetUnreachableThenExceptionCarriesUrlAndMethod(): void +{ + /** @Given an HTTP client backed by a transport that always raises a network error */ + $http = Http::usingTransport(transport: new ThrowingClient()); + + /** @And a target request to that transport */ + $request = Request::create(url: 'https://api.example.com', method: Method::GET); + + try { + /** @When the request is sent */ + $http->send(request: $request); + } catch (HttpNetworkFailed $failure) { + /** @Then the exception exposes the target URL and method */ + self::assertSame('https://api.example.com', $failure->url()); + self::assertSame(Method::GET, $failure->method()); + } +} +``` + +If a method does not exist whose call path produces the exception, the exception itself is dead +code. Remove it instead of writing a behavior test against a constructor. + +**The `try`/`catch` form is reserved for assertions that PHPUnit's `expectException*` family +does not cover.** Message, code, and class are covered by PHPUnit (`expectException`, +`expectExceptionMessage`, `expectExceptionMessageMatches`, `expectExceptionCode`): use those +methods, not `try`/`catch`. The only case that warrants `try`/`catch` is inspecting accessors +that PHPUnit cannot reach — notably `getPrevious()` for chain inspection, or domain-specific +accessors on a `TransportFailure` (`url()`, `method()`, `reason()`). + +**Prohibited.** `try`/`catch` to assert message: + +```php +try { + $http->send(request: $request); + self::fail('NoMoreResponses was expected.'); +} catch (NoMoreResponses $exception) { + self::assertStringContainsString('queue exhausted', $exception->getMessage()); +} +``` + +**Correct.** PHPUnit's `expectExceptionMessage`: + +```php +$this->expectException(NoMoreResponses::class); +$this->expectExceptionMessage('queue exhausted'); + +$http->send(request: $request); +``` ## Test setup and fixtures -1. **One annotation = one statement.** Each `@Given` or `@And` block contains exactly one annotation line - followed by one expression or assignment. Never place multiple variable declarations or object - constructions under a single annotation. -2. **No intermediate variables used only once.** If a value is consumed in a single place, inline it at the - call site. Chain method calls when the intermediate state is not referenced elsewhere - (e.g., `Money::of(...)->add(...)` instead of `$money = Money::of(...); $money->add(...);`). -3. **No private or helper methods in test classes.** The only non-test methods allowed are data providers. - If setup logic is complex enough to extract, it belongs in a dedicated fixture class, not in a - private method on the test class. -4. **Domain terms in variables and annotations.** Never use technical testing jargon (`$spy`, `$mock`, - `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object - represents: `$collection`, `$amount`, `$currency`, `$sortedElements`. Class names like - `ClientMock` or `GatewaySpy` are acceptable — the variable holding the instance is what matters. -5. **Annotations use domain language.** Write `/** @Given a collection of amounts */`, not - `/** @Given a mocked collection in test state */`. The annotation describes the domain - scenario, not the technical setup. - -## Test organization +- Each `@Given` or `@And` block contains exactly one annotation followed by one expression or + assignment. Never place multiple declarations under a single annotation. The exception for + data-provider tests applies here as well (see rule 3). +- No intermediate variables used only once. Chain method calls when the intermediate state is + not referenced elsewhere. +- No private or helper methods in test classes. The only non-test methods allowed are data + providers. Setup logic complex enough to extract belongs in a dedicated fixture class, not in + a private method on the test class. +- Domain terms in variables and properties. Never use technical testing jargon (`$spy`, `$mock`, + `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object + represents (`$collection`, `$amount`, `$currency`, `$sortedElements`). Class names like + `ClientMock` or `GatewaySpy` are acceptable. The variable holding the instance is what + matters. +- Annotations use domain language. Write `/** @Given a collection of amounts */`, not + `/** @Given a mocked collection in test state */`. The annotation describes the domain + scenario, not the technical setup. + +**Prohibited.** Multiple declarations under a single annotation: + +```php +/** @And two money instances in different currencies */ +$usd = Money::of(amount: 500, currency: Currency::USD); +$eur = Money::of(amount: 300, currency: Currency::EUR); +``` + +**Correct.** One annotation per declaration: +```php +/** @And a money instance in USD */ +$usd = Money::of(amount: 500, currency: Currency::USD); + +/** @And a money instance in EUR */ +$eur = Money::of(amount: 300, currency: Currency::EUR); ``` -tests/ -├── Models/ # Domain-specific fixtures reused across tests -├── Mocks/ # Test doubles for system boundaries -├── Unit/ # Unit tests for public API -│ └── Mocks/ # Alternative location for test doubles -├── Integration/ # Tests requiring real external resources (Docker, filesystem) -└── bootstrap.php # Test bootstrap when needed + +**Also prohibited.** Setup multi-statement grouped under a single annotation because "the +statements build one coherent concept": + +```php +/** @Given transport seeded with two responses */ +$first = Response::with(code: Code::OK); +$second = Response::with(code: Code::CREATED); +$transport = InMemoryTransport::with(responses: [$first, $second]); +``` + +Three statements, one annotation. The fact that the three lines together build a single +setup concept is **not** a license to share one annotation. Each declaration takes its own +`@And` block. The same applies under `@When` when the test prepares the input alongside the +action: the input preparation goes back to `@And` under `@Given`, and `@When` contains only +the action under test. + +**Correct.** Each statement keeps its own annotation: + +```php +/** @Given a first queued response */ +$first = Response::with(code: Code::OK); + +/** @And a second queued response */ +$second = Response::with(code: Code::CREATED); + +/** @And transport with both responses */ +$transport = InMemoryTransport::with(responses: [$first, $second]); ``` -`tests/Integration/` is only present when the library interacts with infrastructure. +## Test doubles + +Conventions for naming and locating test doubles (mocks, spies, stubs, fakes, dummies). + +### Naming + +- Variables and properties never carry the technical role in their name. Never `$spy`, `$mock`, + `$stub`, `$fake`, `$dummy`. Use the domain concept the object represents (`$gateway`, + `$clock`, `$repository`, `$client`). +- Class names may carry the technical role as suffix when the class IS a test double + (`ClientMock`, `GatewaySpy`, `ClockFake`). The suffix signals that the file is a collaborator + built for tests, not a production type. + +### Location + +- Test doubles live at the root of `tests/Unit/`. When integration tests exist, doubles used + there live at the root of `tests/Integration/`. +- No dedicated `Mocks/` or `Doubles/` subdirectory exists. +- Domain fixtures that represent real domain concepts live in `tests/Models/`. See + `php-library-architecture.md` for the canonical `tests/` folder layout. + +## Coverage and mutation discipline -## Coverage and mutation testing +- Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from coverage. +- Never suppress mutants via `infection.json.dist` or any other mechanism. +- If a line or mutation cannot be covered or killed, the design is wrong. Refactor the + production code to make it testable. Never work around the tool. -1. Line and branch coverage must be **100%**. No annotations (`@codeCoverageIgnore`), attributes, or configuration - that exclude code from coverage are allowed. -2. All mutations reported by Infection must be **killed**. Never ignore or suppress mutants via `infection.json.dist` - or any other mechanism. -3. If a line or mutation cannot be covered or killed, it signals a design problem in the production code. Refactor - the code to make it testable, do not work around the tool. +Canonical thresholds (MSI 100, covered MSI 100) live in `php-library-tooling.md`. They are +enforced by `infection.json.dist`. Achieving MSI 100 implies effective full coverage of `src/` +because every mutation must be killed by an assertion. This file covers only the behavioral +rules that complement those thresholds. diff --git a/.claude/rules/php-library-tooling.md b/.claude/rules/php-library-tooling.md new file mode 100644 index 0000000..3b55111 --- /dev/null +++ b/.claude/rules/php-library-tooling.md @@ -0,0 +1,464 @@ +--- +description: Canonical config files for PHP libraries in the tiny-blocks ecosystem. +paths: + - "composer.json" + - "phpcs.xml" + - "phpstan.neon.dist" + - "phpunit.xml" + - "infection.json.dist" + - ".editorconfig" + - ".gitattributes" + - ".gitignore" + - "Makefile" +--- + +# Tooling + +Canonical configuration files for a PHP library in the tiny-blocks ecosystem. Each file has a +fixed shape. Deviations require justification. Folder structure lives in +`php-library-architecture.md`. Code style lives in `php-library-code-style.md`. + +## Pre-output checklist + +Verify every item before creating, editing, or relocating any of the files below. If any item +fails, revise before outputting. + +1. The library repository contains all the following files at its root: `composer.json`, + `phpcs.xml`, `phpstan.neon.dist`, `phpunit.xml`, `infection.json.dist`, `.editorconfig`, + `.gitattributes`, `.gitignore`, `Makefile`. +2. `composer.json` exposes exactly five scripts: `configure`, `configure-and-update`, `review`, + `test-file`, `tests`. No other public scripts are defined. +3. `composer.json` fixed fields use the canonical values defined in the "composer.json" section + (`license`, `type`, `minimum-stability`, `prefer-stable`, `authors`, `config`, `require.php`). +4. `composer.json` `description` is a single short sentence describing what the library does. + Multi-sentence or multi-paragraph descriptions belong in the README Overview, not in Composer + metadata. +5. `composer.json` includes a `keywords` array. The first keyword is always `"tiny-blocks"`. + Additional keywords are topic tokens derived from the library's purpose (`psr-7`, + `http-client`, `event-sourcing`, etc.). +6. `phpcs.xml` references only the `PSR12` ruleset. No additional sniffs are added. +7. `phpunit.xml` sets all five `failOn*` flags to `true`: `failOnDeprecation`, `failOnNotice`, + `failOnPhpunitDeprecation`, `failOnRisky`, `failOnWarning`. +8. `phpunit.xml` sets `executionOrder="random"` and `beStrictAboutOutputDuringTests="true"`. +9. `infection.json.dist` sets `minMsi: 100` and `minCoveredMsi: 100`. Lowering either value is + prohibited. +10. `.editorconfig` sets `max_line_length = 120`, `indent_size = 4`, `indent_style = space`, and + `end_of_line = lf` for PHP files. YAML uses `indent_size = 2`. Makefile uses `indent_style = tab`. +11. `.gitattributes` sets `* text=auto eol=lf` and lists every dev-only file under `export-ignore`. + The Packagist tarball contains only `src/`, `composer.json`, `README.md`, and `LICENSE`. + `.claude/` is listed under `export-ignore` (versioned on GitHub for contributor parity, + excluded from the published package). +12. `.gitignore` follows the canonical content in the ".gitignore" section. `.claude/` is **not** + listed (it is versioned on GitHub). +13. `Makefile` wraps every PHP and Composer command in a Docker container using the canonical + image `gustavofreze/php:8.5-alpine`. No PHP command runs on the host directly. +14. All test artifact paths use `reports/` (plural). The directory is consistent across + `composer tests`, `infection.json.dist`, `phpunit.xml`, and `Makefile`. +15. The `reports/` directory is listed under `export-ignore` in `.gitattributes`. + +## composer.json + +Fixed fields, identical in every library: `license`, `type`, `minimum-stability`, `prefer-stable`, +`require.php`, `authors`, `config.allow-plugins`, `config.sort-packages`, `scripts`, and the five +universal dev dependencies (`ergebnis/composer-normalize`, `infection/infection`, `phpstan/phpstan`, +`phpunit/phpunit`, `squizlabs/php_codesniffer`). + +Per-library fields, vary by library: `name`, `description`, `keywords`, `homepage`, `support`, +`autoload`, `autoload-dev`. The `require-dev` section may add libraries needed by tests (for +example, HTTP client implementations in a PSR-7 library) on top of the five universal tools. + +```json +{ + "name": "tiny-blocks/", + "description": "", + "license": "MIT", + "type": "library", + "keywords": [ + "tiny-blocks", + "", + "" + ], + "authors": [ + { + "name": "Gustavo Freze de Araujo Santos", + "homepage": "https://github.com/gustavofreze" + } + ], + "homepage": "https://github.com/tiny-blocks/", + "support": { + "issues": "https://github.com/tiny-blocks//issues", + "source": "https://github.com/tiny-blocks/" + }, + "require": { + "php": "^8.5" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51", + "infection/infection": "^0.32", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^13.1", + "squizlabs/php_codesniffer": "^4.0" + }, + "minimum-stability": "stable", + "prefer-stable": true, + "autoload": { + "psr-4": { + "TinyBlocks\\\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\TinyBlocks\\\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "infection/extension-installer": true + }, + "sort-packages": true + }, + "scripts": { + "configure": [ + "@composer install --optimize-autoloader", + "@composer normalize" + ], + "configure-and-update": [ + "@composer update --optimize-autoloader", + "@composer normalize" + ], + "review": [ + "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests", + "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress" + ], + "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", + "tests": [ + "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" + ] + } +} +``` + +Script usage: + +- `composer configure` runs `composer install --optimize-autoloader` followed by `composer normalize`. + Use this after cloning the repository or pulling new changes. +- `composer configure-and-update` runs `composer update --optimize-autoloader` followed by + `composer normalize`. Use this when intentionally updating dependencies. +- `composer review` runs `phpcs` and `phpstan` in sequence. Used by CI and local validation. +- `composer tests` runs `phpunit` followed by `infection`. Used by CI. +- `composer test-file ` runs a filtered subset of tests without coverage. Local + development only. + +## phpcs.xml + +References only the `PSR12` ruleset. Additional formatting rules (vertical alignment, trailing +comma, etc.) live in `php-library-code-style.md` under "Formatting overrides". + +```xml + + + Code style for the tiny-blocks library. + + src + tests + +``` + +## phpstan.neon.dist + +Static analysis configuration. Runs at the highest level on both `src/` and `tests/`. Invoked +by the `review` Composer script. + +```neon +parameters: + level: max + paths: + - src + - tests + reportUnmatchedIgnoredErrors: true +``` + +`ignoreErrors` is permitted to suppress legitimate false positives produced by `level: max` +(third-party type signatures with `mixed`, PHP-FIG interfaces returning untyped arrays, trait +unused-method warnings on shared behavior, etc.). Each entry follows these rules: + +- A short comment above the entry justifies its existence. +- Prefer scoping via `identifier:` plus `path:` over raw `#...#` message patterns. +- `reportUnmatchedIgnoredErrors: true` is mandatory. Obsolete entries fail the build, forcing + cleanup. + +Example with `ignoreErrors`: + +```neon +parameters: + level: max + paths: + - src + - tests + ignoreErrors: + # Trait method intentionally unused by the consuming aggregate; reflection wires it. + - identifier: trait.unused + path: src/Internal/EventualAggregateRootBehavior.php + + # json_encode signature carries `mixed` for backward compatibility at level max. + - identifier: argument.type + path: src/Internal/Serialization/JsonEncoder.php + reportUnmatchedIgnoredErrors: true +``` + +## phpunit.xml + +Strict configuration. All `failOn*` flags are `true`. `executionOrder="random"` forces tests to be +independent of one another. Coverage and JUnit reports go under `reports/`. + +```xml + + + + + + src + + + + + + tests + + + + + + + + + + + + + + + + + +``` + +Root attributes are sorted alphabetically. + +## infection.json.dist + +Mutation testing configuration. `minMsi` and `minCoveredMsi` are both `100`. Mutants that escape +make the build fail. + +```json +{ + "logs": { + "text": "reports/infection/logs/infection-text.log", + "summary": "reports/infection/logs/infection-summary.log" + }, + "tmpDir": "reports/infection/", + "minMsi": 100, + "timeout": 30, + "source": { + "directories": [ + "src" + ] + }, + "phpUnit": { + "configDir": "", + "customPath": "./vendor/bin/phpunit" + }, + "mutators": { + "@default": true + }, + "minCoveredMsi": 100, + "testFramework": "phpunit" +} +``` + +## .editorconfig + +Whitespace and line ending rules applied by editor integrations. + +```ini +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +max_line_length = 120 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false +``` + +## .gitattributes + +Normalizes line endings to LF and excludes every dev-only file from the Packagist tarball. The +published package contains only `src/`, `composer.json`, `README.md`, and `LICENSE`. + +``` +* text=auto eol=lf + +*.php text diff=php + +# Dev-only, excluded from the Packagist tarball +/.github export-ignore +/tests export-ignore +/.claude export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon export-ignore +/phpstan.neon.dist export-ignore +/phpcs.xml export-ignore +/phpcs.xml.dist export-ignore +/infection.json export-ignore +/infection.json.dist export-ignore +/Makefile export-ignore +/CONTRIBUTING.md export-ignore +/CHANGES.md export-ignore +/reports export-ignore +/.phpunit.cache export-ignore +``` + +## .gitignore + +Keeps the repository working tree clean of artifacts that should never be committed. Entries +are grouped from most fundamental (PHP dependencies) to least critical (OS files). The +`.claude/` directory is **not** listed here. It is versioned on GitHub so other contributors +share the same rules, and it is excluded from the published Packagist tarball through +`export-ignore` in `.gitattributes` (see above). + +``` +# PHP dependencies +/vendor/ +composer.lock + +# Tooling cache +.phpcs-cache +.phpunit.cache/ +.php-cs-fixer.cache +.phpunit.result.cache + +# Coverage and reports +build/ +reports/ +coverage/ +infection.log + +# Editors and agents +.idea/ +.cursor/ +.vscode/ + +# OS +Thumbs.db +.DS_Store +Desktop.ini +``` + +## Makefile + +Thin wrapper over Composer scripts. Every PHP and Composer command runs inside a Docker container +using the canonical image `gustavofreze/php:8.5-alpine`. Targets that match a Composer script +delegate to it directly, avoiding duplication. + +```makefile +PWD := $(CURDIR) +ARCH := $(shell uname -m) +PLATFORM := + +ifeq ($(ARCH),arm64) + PLATFORM := --platform=linux/amd64 +endif + +DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine + +RESET := \033[0m +GREEN := \033[0;32m +YELLOW := \033[0;33m + +.DEFAULT_GOAL := help + +.PHONY: configure +configure: ## Configure development environment + @${DOCKER_RUN} composer configure + +.PHONY: configure-and-update +configure-and-update: ## Configure development environment and update dependencies + @${DOCKER_RUN} composer configure-and-update + +.PHONY: tests +tests: ## Run unit and mutation tests with coverage + @${DOCKER_RUN} composer tests + +.PHONY: test-file +test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest) + @${DOCKER_RUN} composer test-file ${FILE} + +.PHONY: review +review: ## Run lint and static analysis + @${DOCKER_RUN} composer review + +.PHONY: show-reports +show-reports: ## Open coverage and mutation reports in the browser + @sensible-browser reports/coverage/coverage-html/index.html reports/coverage/mutation-report.html + +.PHONY: show-outdated +show-outdated: ## Show outdated direct dependencies + @${DOCKER_RUN} composer outdated --direct + +.PHONY: clean +clean: ## Remove dependencies and generated artifacts + @sudo chown -R ${USER}:${USER} ${PWD} + @rm -rf reports vendor .phpunit.cache *.lock + +.PHONY: help +help: ## Display this help message + @echo "Usage: make [target]" + @echo "" + @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" + @grep -E '^(configure|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')" + @grep -E '^(tests|test-file):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" + @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')" + @grep -E '^(show-reports|show-outdated):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')" + @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' +``` diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 77c2bb8..e34c801 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,10 +2,11 @@ ## Context -PHP library (tiny-blocks). Immutable domain models, zero infrastructure dependencies in core. +PHP library in the tiny-blocks ecosystem. ## Mandatory pre-task step -Before starting any task, read and strictly follow all instruction files located in `.claude/CLAUDE.md` and -`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every rule strictly. Do not -deviate from the patterns, folder structure, or naming conventions defined in them. +Before starting any task, read and strictly follow `.claude/CLAUDE.md` and every rule file in +`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every +rule strictly. Do not deviate from the patterns, folder structure, or naming conventions defined +in them. From 3dee2bc43b2a616210e55059c01477bd00135fc7 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 16 May 2026 09:11:25 -0300 Subject: [PATCH 22/27] docs: Expand README and add SECURITY policy. --- README.md | 313 +++++++++++++++++++++++++++++++++++++--------------- SECURITY.md | 12 ++ 2 files changed, 237 insertions(+), 88 deletions(-) create mode 100644 SECURITY.md diff --git a/README.md b/README.md index 86a66d0..a097649 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,26 @@ # Http -[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks/http/blob/main/LICENSE) * [Overview](#overview) * [Installation](#installation) * [How to use](#how-to-use) + [Server](#server) - * [Decoding a request](#decoding-a-request) - * [Creating a response](#creating-a-response) - * [Setting cookies](#setting-cookies) - * [Status code](#status-code) + - [Decoding a request](#decoding-a-request) + - [Creating a response](#creating-a-response) + - [Setting cookies](#setting-cookies) + - [Status code](#status-code) + [Client](#client) - * [Building Http with a PSR-18 client and PSR-17 factories](#building-http-with-a-psr-18-client-and-psr-17-factories) - * [Making a request](#making-a-request) - * [Reading the response](#reading-the-response) - * [Query parameters](#query-parameters) - * [Custom headers and content type](#custom-headers-and-content-type) - * [Setting the User-Agent](#setting-the-user-agent) - * [Error handling](#error-handling) - * [Configuring timeouts](#configuring-timeouts) - * [Testing with InMemoryTransport](#testing-with-inmemorytransport) - * [Extending with custom transports](#extending-with-custom-transports) + - [Building Http with a PSR-18 client and PSR-17 factories](#building-http-with-a-psr-18-client-and-psr-17-factories) + - [Making a request](#making-a-request) + - [Reading the response](#reading-the-response) + - [Query parameters](#query-parameters) + - [Custom headers and content type](#custom-headers-and-content-type) + - [Setting the User-Agent](#setting-the-user-agent) + - [Error handling](#error-handling) + - [Configuring timeouts](#configuring-timeouts) + - [Testing with InMemoryTransport](#testing-with-inmemorytransport) + - [Extending with custom transports](#extending-with-custom-transports) * [FAQ](#faq) * [License](#license) * [Contributing](#contributing) @@ -29,9 +29,9 @@ The library covers both sides of an HTTP exchange: -- **Server side** (`TinyBlocks\Http\Server`) — decodes a PSR-7 `ServerRequestInterface` into typed accessors and builds +- **Server side** (`TinyBlocks\Http\Server`) - decodes a PSR-7 `ServerRequestInterface` into typed accessors and builds outgoing `ResponseInterface` instances with cookies, cache-control, and status codes. -- **Client side** (`TinyBlocks\Http\Client`) — composes outbound requests, sends them through a `Transport` port backed +- **Client side** (`TinyBlocks\Http\Client`) - composes outbound requests, sends them through a `Transport` port backed by any PSR-18 client, and exposes responses with typed body and header access. Shared primitives at `TinyBlocks\Http\`: `Method`, `Code`, `Headers`, `Headerable`, `ContentType`, `MimeType`, @@ -52,6 +52,10 @@ composer require tiny-blocks/http Wrap a PSR-7 `ServerRequestInterface` and read typed fields from the body, route parameters, and query string. ```php +body()->get(key: 'amount')->toFloat(); The HTTP method is available as a typed `Method` enum: ```php +method(); Each helper returns a PSR-7 `ResponseInterface` and defaults to `application/json`: ```php + 'Resource created successfully.']); @@ -90,6 +102,10 @@ Response::notFound(body: ['error' => 'Resource not found.']); For custom status codes, use `from(...)`: ```php + 'accepted'], code: Code::ACCEPTED); Attach additional headers via varargs of `Headerable`: ```php + true], $cacheControl, ContentType::applicationJson()) - ->withHeader('X-Trace-Id', 'abc-123'); + ->withHeader(name: 'X-Trace-Id', value: 'abc-123'); ``` #### Setting cookies @@ -117,16 +137,20 @@ Response::ok(['ok' => true], $cacheControl, ContentType::applicationJson()) `Cookie` implements `Headerable` and composes naturally with `Response`: ```php +httpOnly() ->secure() - ->withSameSite(sameSite: SameSite::STRICT) + ->httpOnly() ->withPath(path: '/v1/sessions') - ->withMaxAge(seconds: 604800); + ->withMaxAge(seconds: 604800) + ->withSameSite(sameSite: SameSite::STRICT); Response::ok(['ok' => true], $session); ``` @@ -134,15 +158,19 @@ Response::ok(['ok' => true], $session); To expire a cookie, use `Cookie::expire(...)` with the same `Path` and `Domain` used at creation. ```php +httpOnly() ->secure() - ->withSameSite(sameSite: SameSite::STRICT) - ->withPath(path: '/v1/sessions'); + ->httpOnly() + ->withPath(path: '/v1/sessions') + ->withSameSite(sameSite: SameSite::STRICT); Response::noContent($expired); ``` @@ -152,42 +180,54 @@ Response::noContent($expired); The `Code` enum carries the full RFC HTTP status set with typed helpers: ```php +value; // 200 -Code::OK->message(); // "OK" -Code::OK->isSuccess(); // true -Code::INTERNAL_SERVER_ERROR->isError(); // true +Code::OK->value; # 200 +Code::OK->message(); # "OK" +Code::OK->isSuccess(); # true +Code::INTERNAL_SERVER_ERROR->isError(); # true -Code::isValidCode(code: 200); // true -Code::isErrorCode(code: 500); // true -Code::isSuccessCode(code: 200); // true +Code::isValidCode(code: 200); # true +Code::isErrorCode(code: 500); # true +Code::isSuccessCode(code: 200); # true ``` ### Client #### Building Http with a PSR-18 client and PSR-17 factories -Assemble the façade with any PSR-18 client and PSR-17 factories. +Assemble the facade with any PSR-18 client and PSR-17 factories. ```php + 30, 'connect_timeout' => 5]); +$client = new Client(config: ['timeout' => 30, 'connect_timeout' => 5]); $http = Http::create() - ->withTransport(transport: NetworkTransport::with(client: $client, factory: $factory)) ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with(client: $client, factory: $factory)) ->build(); ``` For a single-call construction without the fluent builder: ```php + 30, 'connect_timeout' => 5]), + client: new Client(config: ['timeout' => 30, 'connect_timeout' => 5]), factory: $factory ) ); @@ -206,72 +246,114 @@ $http = Http::with( #### Making a request -`Request::create(...)` accepts only `url` as required. Everything else has sensible defaults. +Every parameter on `Request::create(...)` is explicit. Pass `null` for `body` and `query` when absent. Pass +`Method::GET` (or another method) for `method`. Build `headers` from one or more `Headerable` instances via +`Headers::from(...)`, or call `Headers::from()` with no arguments when no headers apply. ```php +send( request: Request::create( url: '/v1/charges', body: ['amount' => 1000, 'currency' => 'usd'], - method: Method::POST + query: null, + method: Method::POST, + headers: Headers::from(ContentType::applicationJson()) ) ); ``` -A simple `GET` needs only the URL: +A simple `GET` still passes every parameter, with `Headers::from()` for the empty header set: ```php +send(request: Request::create(url: '/v1/charges/abc123')); +$response = $http->send( + request: Request::create( + url: '/v1/charges/abc123', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ) +); ``` #### Reading the response ```php +isSuccess()) { $id = $response->body()->get(key: 'id')->toString(); $amount = $response->body()->get(key: 'amount')->toInteger(); } -$response->code(); // Code enum -$response->headers(); // TinyBlocks\Http\Headers value object -$response->raw(); // Psr\Http\Message\ResponseInterface +$response->raw(); # Psr\Http\Message\ResponseInterface +$response->code(); # Code enum +$response->headers(); # TinyBlocks\Http\Headers value object ``` `Headers` exposes case-insensitive lookup: ```php -$contentType = $response->headers()->get(name: 'content-type'); // "application/json" -$hasTrace = $response->headers()->has(name: 'X-Trace-Id'); // true +$contentType = $response->headers()->get(name: 'content-type'); # "application/json" +$hasTrace = $response->headers()->has(name: 'X-Trace-Id'); # true ``` #### Query parameters -Pass the query as a named parameter — the library encodes it in RFC3986 form. +Pass the query as a named parameter - the library encodes it in RFC3986 form. ```php +send( request: Request::create( url: '/v1/charges', - query: ['status' => 'succeeded', 'limit' => 50] + body: null, + query: ['status' => 'succeeded', 'limit' => 50], + method: Method::GET, + headers: Headers::from() ) ); ``` #### Custom headers and content type -Any `Headerable` composes via varargs: +Compose any combination of `Headerable` via `Headers::from(...)`: ```php +send( request: Request::create( - '/v1/charges', - ['amount' => 1000], - null, - Method::POST, - ContentType::applicationJson(), - new IdempotencyKey(value: $key) + url: '/v1/charges', + body: ['amount' => 1000], + query: null, + method: Method::POST, + headers: Headers::from( + ContentType::applicationJson(), + new IdempotencyKey(value: $key) + ) ) ); ``` Custom headers always win over the library's JSON defaults. -
    - #### Setting the User-Agent The `UserAgent` value object implements `Headerable` and renders the standard -`User-Agent` header. Empty version is normalized to "no version" — the rendered +`User-Agent` header. Empty version is normalized to "no version" - the rendered header carries only the product token in that case, so configuration with an optional version flows in directly. ```php +send( - request: Request::create('/v1/charges', null, null, Method::GET, $userAgent) + request: Request::create( + url: '/v1/charges', + body: null, + query: null, + method: Method::GET, + headers: Headers::from($userAgent) + ) ); ``` When the version is unknown: ```php +send( request: Request::create( - '/v1/charges', - ['amount' => 1000], - null, - Method::POST, - UserAgent::from(product: 'MyApp', version: '1.2.3'), - ContentType::applicationJson() + url: '/v1/charges', + body: ['amount' => 1000], + query: null, + method: Method::POST, + headers: Headers::from( + UserAgent::from(product: 'MyApp', version: '1.2.3'), + ContentType::applicationJson() + ) ) ); ``` #### Error handling -Every failure raises an `HttpException`. The hierarchy is flat — each exception extends a native PHP base -(`RuntimeException` or `LogicException`) and implements `HttpException` directly. Catch the specific class when -you need to react to a particular failure mode; otherwise catch the umbrella `HttpException`. +Every failure raises an `HttpException`. `TransportFailure` (which extends `HttpException`) carries `url()`, +`method()`, and `reason()`, and is implemented by every exception raised by the transport layer. The remaining +`HttpException` implementations carry only the marker contract. Inspect their concrete class for the invariant +they violated. Catch the specific class when you need to react to a particular failure mode. Order of catch +branches matters because PHP matches the first applicable branch. ```php +send(request: $request); -} catch (HttpNetworkFailed $exception) { - // DNS, connection refused, timeout — retry candidate -} catch (MalformedPath $exception) { - // path contained a scheme, was protocol-relative, or held control chars + $http->send(request: $request); +} catch (HttpRequestInvalid $exception) { + # PSR-18 RequestExceptionInterface: request malformed before transport. + echo $exception->url(); + echo $exception->method()->name; + echo $exception->reason(); +} catch (TransportFailure $exception) { + # Other transport failures (network errors, generic PSR-18 client failures). + echo $exception->url(); + echo $exception->method()->name; + echo $exception->reason(); } catch (HttpException $exception) { - // any other library-thrown failure - $exception->url(); - $exception->method(); - $exception->reason(); + # Library-level failures (configuration, malformed path, exhausted in-memory transport). + echo $exception::class; } ``` | Exception | Cause | |-------------------------------|---------------------------------------------------------------------------------------| | `HttpRequestFailed` | Generic PSR-18 `ClientExceptionInterface`. | -| `HttpNetworkFailed` | PSR-18 `NetworkExceptionInterface` — DNS, timeout, connection refused. | -| `HttpRequestInvalid` | PSR-18 `RequestExceptionInterface` — request malformed before transport. | +| `HttpNetworkFailed` | PSR-18 `NetworkExceptionInterface` - DNS, timeout, connection refused. | +| `HttpRequestInvalid` | PSR-18 `RequestExceptionInterface` - request malformed before transport. | | `MalformedPath` | Path attempts to escape the base URL (scheme, protocol-relative, control characters). | | `NoMoreResponses` | `InMemoryTransport` exhausted (programmer error). | | `HttpConfigurationInvalid` | Builder called without required dependencies. | @@ -392,18 +506,26 @@ PSR-18 does not standardize timeouts. Configure them on the underlying client be **Guzzle:** ```php + 30, 'connect_timeout' => 5]); +$client = new Client(config: ['timeout' => 30, 'connect_timeout' => 5]); ``` **Symfony HttpClient:** ```php + 30])); +$client = new Psr18Client(client: HttpClient::create(defaultOptions: ['timeout' => 30])); ``` #### Testing with InMemoryTransport @@ -411,6 +533,10 @@ $client = new Psr18Client(client: HttpClient::create(['timeout' => 30])); Pre-program responses with `Response::with(...)` and feed them to `InMemoryTransport`: ```php +withTransport(transport: $transport) ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: $transport) ->build(); ``` @@ -437,6 +563,10 @@ Implement `Transport` to add retry, logging, circuit breaker, or any other cross any inner `Transport`. ```php +withBaseUrl(url: 'https://api.example.com') ->withTransport( transport: new RetryingTransport( inner: NetworkTransport::with(client: $client, factory: $factory), maxAttempts: 3 ) ) - ->withBaseUrl(url: 'https://api.example.com') ->build(); ``` @@ -487,18 +624,18 @@ $http = Http::create() ### 01. Why is there a `Headerable` interface and a `Headers` value object? -`Headerable` is the contract implemented by classes that emit one or more header lines — `ContentType`, `Cookie`, +`Headerable` is the contract implemented by classes that emit one or more header lines such as `ContentType`, `Cookie`, `CacheControl`, and any custom header type. `Headers` is the value object that carries the consolidated header set of an HTTP request or response, with case-insensitive lookup and merging. ### 02. Why are timeouts not part of the public API? -PSR-18 does not standardize timeouts. Exposing them in the façade would require a transport-specific contract that leaks +PSR-18 does not standardize timeouts. Exposing them in the facade would require a transport-specific contract that leaks the underlying client. Configure timeouts on the PSR-18 client before injecting it. ### 03. Why does `Response::raw()` throw on a synthesized response? -A response created via `Response::with(...)` has no PSR-7 backing — it exists only for in-process scenarios (tests, +A response created via `Response::with(...)` has no PSR-7 backing - it exists only for in-process scenarios (tests, `InMemoryTransport`). Calling `raw()` in that mode is a programmer error and raises `SynthesizedResponseHasNoRaw`. ### 04. Why is path validation enforced at the resolver? diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1001558 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported versions + +Only the latest release receives security updates. + +## Reporting a vulnerability + +Report security vulnerabilities privately via +[GitHub Security Advisories](https://github.com/tiny-blocks/http/security/advisories/new). + +Please do not disclose the vulnerability publicly until it has been addressed. From 017caaba8ded72f5c9daa133074a15ba83d07502 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 16 May 2026 09:11:32 -0300 Subject: [PATCH 23/27] build: Adjust composer, static analysis, test and editor configs. --- .editorconfig | 1 + .gitattributes | 4 +++- .gitignore | 27 ++++++++++++++++----------- Makefile | 29 ++++++++++++++--------------- composer.json | 37 +++++++++++++++++++++++-------------- infection.json.dist | 9 ++++++--- phpcs.xml | 4 ++-- phpstan.neon.dist | 22 +++++++++++++++++----- phpunit.xml | 22 ++++++++++++---------- 9 files changed, 94 insertions(+), 61 deletions(-) diff --git a/.editorconfig b/.editorconfig index 73e3c9a..be5640e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,6 +5,7 @@ charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space +max_line_length = 120 insert_final_newline = true trim_trailing_whitespace = true diff --git a/.gitattributes b/.gitattributes index 744a43b..eedb473 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,7 +2,7 @@ *.php text diff=php -# Dev-only — excluded from the Packagist tarball +# Dev-only, excluded from the Packagist tarball /.github export-ignore /tests export-ignore /.claude export-ignore @@ -20,3 +20,5 @@ /Makefile export-ignore /CONTRIBUTING.md export-ignore /CHANGES.md export-ignore +/reports export-ignore +/.phpunit.cache export-ignore diff --git a/.gitignore b/.gitignore index bd5baa3..6107765 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,25 @@ -# Agent/IDE -.claude/ -.idea/ -.vscode/ -.cursor/ - -# Composer +# PHP dependencies /vendor/ composer.lock -# PHPUnit / coverage +# Tooling cache +.phpcs-cache .phpunit.cache/ +.php-cs-fixer.cache .phpunit.result.cache -report/ -coverage/ + +# Coverage and reports build/ +reports/ +coverage/ +infection.log + +# Editors and agents +.idea/ +.cursor/ +.vscode/ # OS -.DS_Store Thumbs.db +.DS_Store +Desktop.ini diff --git a/Makefile b/Makefile index 07acc3b..4f0e85d 100644 --- a/Makefile +++ b/Makefile @@ -16,28 +16,27 @@ YELLOW := \033[0;33m .PHONY: configure configure: ## Configure development environment - @${DOCKER_RUN} composer update --optimize-autoloader - @${DOCKER_RUN} composer normalize + @${DOCKER_RUN} composer configure -.PHONY: test -test: ## Run all tests with coverage +.PHONY: configure-and-update +configure-and-update: ## Configure development environment and update dependencies + @${DOCKER_RUN} composer configure-and-update + +.PHONY: tests +tests: ## Run unit and mutation tests with coverage @${DOCKER_RUN} composer tests .PHONY: test-file test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest) @${DOCKER_RUN} composer test-file ${FILE} -.PHONY: test-no-coverage -test-no-coverage: ## Run all tests without coverage - @${DOCKER_RUN} composer tests-no-coverage - .PHONY: review -review: ## Run static code analysis +review: ## Run lint and static analysis @${DOCKER_RUN} composer review .PHONY: show-reports -show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser - @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html +show-reports: ## Open coverage and mutation reports in the browser + @sensible-browser reports/coverage/coverage-html/index.html reports/coverage/mutation-report.html .PHONY: show-outdated show-outdated: ## Show outdated direct dependencies @@ -46,18 +45,18 @@ show-outdated: ## Show outdated direct dependencies .PHONY: clean clean: ## Remove dependencies and generated artifacts @sudo chown -R ${USER}:${USER} ${PWD} - @rm -rf report vendor .phpunit.cache *.lock + @rm -rf reports vendor .phpunit.cache *.lock .PHONY: help -help: ## Display this help message +help: ## Display this help message @echo "Usage: make [target]" @echo "" @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" - @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(configure|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')" - @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(tests|test-file):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" diff --git a/composer.json b/composer.json index ab49091..06622c3 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,17 @@ { "name": "tiny-blocks/http", - "description": "Implements PSR-7, PSR-15, and PSR-18 HTTP primitives for PHP, with a fluent response builder, cookies, cache control, and a PSR-18 client façade.\n\nThe package is designed to be used in any PHP application, and can be used as a standalone library or as part of a larger framework.", + "description": "Implements PSR-7, PSR-15, and PSR-18 HTTP primitives for PHP, with a fluent response builder, cookies, cache control, and a PSR-18 client facade.", "license": "MIT", "type": "library", + "keywords": [ + "http", + "psr-7", + "psr-15", + "psr-18", + "http-codes", + "tiny-blocks", + "http-client" + ], "authors": [ { "name": "Gustavo Freze de Araujo Santos", @@ -55,22 +64,22 @@ "sort-packages": true }, "scripts": { - "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", - "phpcs": "php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests", - "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress", + "configure": [ + "@composer install --optimize-autoloader", + "@composer normalize" + ], + "configure-and-update": [ + "@composer update --optimize-autoloader", + "@composer normalize" + ], "review": [ - "@phpcs", - "@phpstan" + "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests", + "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress" ], - "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", - "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", - "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests", + "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", "tests": [ - "@test", - "@mutation-test" - ], - "tests-no-coverage": [ - "@test-no-coverage" + "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" ] } } diff --git a/infection.json.dist b/infection.json.dist index ec34860..aab8c7e 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -1,9 +1,9 @@ { "logs": { - "text": "report/infection/logs/infection-text.log", - "summary": "report/infection/logs/infection-summary.log" + "text": "reports/infection/logs/infection-text.log", + "summary": "reports/infection/logs/infection-summary.log" }, - "tmpDir": "report/infection/", + "tmpDir": "reports/infection/", "minMsi": 100, "timeout": 30, "source": { @@ -15,6 +15,9 @@ "configDir": "", "customPath": "./vendor/bin/phpunit" }, + "mutators": { + "@default": true + }, "minCoveredMsi": 100, "testFramework": "phpunit" } diff --git a/phpcs.xml b/phpcs.xml index 440b463..a52372c 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -1,6 +1,6 @@ - - Code style for tiny-blocks/http. + + Code style for the tiny-blocks library. src tests diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 08440b6..1e394cf 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,6 +1,18 @@ parameters: - paths: - - src - - tests - level: 9 - tmpDir: report/phpstan + level: max + paths: + - src + - tests + tmpDir: reports/phpstan + ignoreErrors: + - identifier: return.type + - identifier: argument.type + - identifier: missingType.iterableValue + - identifier: offsetAccess.nonOffsetAccessible + # PHPDoc is prohibited inside tests/; generic IteratorAggregate types are unspecified by design. + - identifier: missingType.generics + path: tests/Models/Products.php + # PHPDoc is prohibited inside tests/; the closure's typed return cannot be expressed. + - identifier: throw.notThrowable + path: tests/Unit/FailingTransport.php + reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml b/phpunit.xml index 40c80a2..9cc6d13 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,15 @@ + failOnDeprecation="true" + failOnNotice="true" + failOnPhpunitDeprecation="true" + failOnRisky="true" + failOnWarning="true"> @@ -23,15 +25,15 @@ - - - - + + + + - + From 60c9764cce474774874898193a6f2b3c6a3049c8 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 16 May 2026 09:11:38 -0300 Subject: [PATCH 24/27] ci: Update GitHub workflow and add issue and pull request templates. --- .github/ISSUE_TEMPLATE/bug_report.md | 29 ++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 17 +++++++++ .github/PULL_REQUEST_TEMPLATE.md | 16 ++++++++ .github/workflows/ci.yml | 46 ++++++++++++++++------- 4 files changed, 94 insertions(+), 14 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8ddd1db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Report a bug to help improve the library +labels: bug +--- + +## Description + +A clear and concise description of the bug. + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + +What should happen. + +## Actual behavior + +What actually happens. + +## Environment + +- PHP version: +- Library version: +- OS: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b344d9e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest a feature for the library +labels: enhancement +--- + +## Problem + +What problem does this feature solve? + +## Proposed solution + +How should the feature work? + +## Alternatives considered + +Other approaches considered. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7a2c836 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +> Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md). + +## Summary + +What this pull request does. + +## Related issue + +Closes #... + +## Checklist + +- [ ] Tests added or updated. +- [ ] Documentation updated when applicable. +- [ ] `composer review` passes. +- [ ] `composer tests` passes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71e59e0..d395d35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,26 +3,44 @@ name: CI on: pull_request: +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read -env: - PHP_VERSION: '8.5' - jobs: + resolve-php-version: + name: Resolve PHP version + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + php-version: ${{ steps.config.outputs.php-version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve PHP version from composer.json + id: config + run: | + version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1) + echo "php-version=$version" >> "$GITHUB_OUTPUT" + build: name: Build + needs: resolve-php-version runs-on: ubuntu-latest - + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Validate composer.json run: composer validate --no-interaction @@ -40,18 +58,18 @@ jobs: auto-review: name: Auto review + needs: [resolve-php-version, build] runs-on: ubuntu-latest - needs: build - + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Download vendor artifact from build uses: actions/download-artifact@v8 @@ -64,18 +82,18 @@ jobs: tests: name: Tests + needs: [resolve-php-version, auto-review] runs-on: ubuntu-latest - needs: auto-review - + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Download vendor artifact from build uses: actions/download-artifact@v8 From f524628b05461c67e4eb377277292396bfc8c58a Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 16 May 2026 09:12:56 -0300 Subject: [PATCH 25/27] refactor: Reorganize namespaces and expose decoded server primitives. Promote Attribute and Body out of Internal/Shared into the public src root, move DecodedRequest, QueryParameters, and Uri under Server/Decoded so consumers can depend on them without crossing the Internal boundary, and split path-validation failures into dedicated PathContainsScheme and PathContainsControlChars exceptions. Add TransportFailure and UserAgentProductIsEmpty for the previously generic transport and user agent error paths. --- src/{Internal/Shared => }/Attribute.php | 34 ++++- src/{Internal/Shared => }/Body.php | 74 ++++++---- src/CacheControl.php | 7 +- src/Charset.php | 9 +- src/Client/Request.php | 112 +++++++++++---- src/Client/Response.php | 49 ++++++- src/Client/Transports/InMemoryTransport.php | 8 +- src/Client/Transports/NetworkTransport.php | 20 ++- src/Code.php | 76 +++++------ src/ContentType.php | 47 ++++++- src/Cookie.php | 100 ++++++++++++-- src/Exceptions/HttpConfigurationInvalid.php | 39 ++---- src/Exceptions/HttpException.php | 27 ---- src/Exceptions/HttpNetworkFailed.php | 36 +++-- src/Exceptions/HttpRequestFailed.php | 36 +++-- src/Exceptions/HttpRequestInvalid.php | 36 +++-- src/Exceptions/MalformedPath.php | 43 +++--- src/Exceptions/NoMoreResponses.php | 37 ++--- .../SynthesizedResponseHasNoRaw.php | 32 ++--- src/Exceptions/TransportFailure.php | 31 +++++ src/Exceptions/UserAgentProductIsEmpty.php | 22 +++ src/Headerable.php | 6 +- src/Headers.php | 46 ++++++- src/Http.php | 4 +- src/HttpBuilder.php | 4 +- .../Exceptions/PathContainsControlChars.php | 24 ++++ .../Client/Exceptions/PathContainsScheme.php | 24 ++++ src/Internal/Client/RequestResolver.php | 11 +- src/Internal/Client/Url.php | 23 ++-- .../CacheControl/CacheControlDirective.php | 10 +- .../Server/CacheControl/Directives.php | 6 +- src/Internal/Server/Cookies/CookieName.php | 8 +- src/Internal/Server/Cookies/CookieValue.php | 8 +- .../ConflictingLifetimeAttributes.php | 2 +- .../Server/Exceptions/CookieNameIsInvalid.php | 4 +- .../Exceptions/CookieValueIsInvalid.php | 4 +- .../Server/Exceptions/InvalidResource.php | 17 --- .../Exceptions/MissingResourceStream.php | 2 +- .../Server/Exceptions/NonReadableStream.php | 2 +- .../Server/Exceptions/NonSeekableStream.php | 2 +- .../Server/Exceptions/NonWritableStream.php | 2 +- .../Exceptions/SameSiteNoneRequiresSecure.php | 2 +- .../Server/Request/DecodedRequest.php | 29 ---- src/Internal/Server/Request/Decoder.php | 4 +- .../Server/Request/QueryParameters.php | 34 ----- .../Server/Request/RouteParameterResolver.php | 80 +++++++---- src/Internal/Server/Request/Uri.php | 85 ------------ .../Server/Response/InternalResponse.php | 86 ++++++------ .../Server/Response/ProtocolVersion.php | 4 +- .../Server/Response/ResponseHeaders.php | 62 ++++----- src/Internal/Server/Stream/Stream.php | 129 +++++++++++------- src/Internal/Server/Stream/StreamFactory.php | 45 +++--- src/ResponseCacheDirectives.php | 10 ++ src/Server/Decoded/DecodedRequest.php | 46 +++++++ src/Server/Decoded/QueryParameters.php | 49 +++++++ src/Server/Decoded/Uri.php | 88 ++++++++++++ src/Server/Request.php | 24 +++- src/Server/Response.php | 16 ++- src/Server/Responses.php | 18 +++ src/UserAgent.php | 16 +-- 60 files changed, 1225 insertions(+), 686 deletions(-) rename src/{Internal/Shared => }/Attribute.php (52%) rename src/{Internal/Shared => }/Body.php (56%) create mode 100644 src/Exceptions/TransportFailure.php create mode 100644 src/Exceptions/UserAgentProductIsEmpty.php create mode 100644 src/Internal/Client/Exceptions/PathContainsControlChars.php create mode 100644 src/Internal/Client/Exceptions/PathContainsScheme.php delete mode 100644 src/Internal/Server/Exceptions/InvalidResource.php delete mode 100644 src/Internal/Server/Request/DecodedRequest.php delete mode 100644 src/Internal/Server/Request/QueryParameters.php delete mode 100644 src/Internal/Server/Request/Uri.php create mode 100644 src/Server/Decoded/DecodedRequest.php create mode 100644 src/Server/Decoded/QueryParameters.php create mode 100644 src/Server/Decoded/Uri.php diff --git a/src/Internal/Shared/Attribute.php b/src/Attribute.php similarity index 52% rename from src/Internal/Shared/Attribute.php rename to src/Attribute.php index 50e8fda..47da7a3 100644 --- a/src/Internal/Shared/Attribute.php +++ b/src/Attribute.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Shared; +namespace TinyBlocks\Http; final readonly class Attribute { @@ -10,12 +10,22 @@ private function __construct(private mixed $value) { } + /** + * Creates an Attribute wrapping the given value. + * + * @param mixed $value The value carried by the Attribute. + * @return Attribute An Attribute wrapping the supplied value. + */ public static function from(mixed $value): Attribute { return new Attribute(value: $value); } - /** @return array */ + /** + * Returns the Attribute as an array. + * + * @return array The wrapped value when it is an array, otherwise an empty array. + */ public function toArray(): array { return match (true) { @@ -24,6 +34,11 @@ public function toArray(): array }; } + /** + * Returns the Attribute as a float. + * + * @return float The wrapped value coerced to a float, or 0.00 when it is not scalar. + */ public function toFloat(): float { return match (true) { @@ -32,6 +47,11 @@ public function toFloat(): float }; } + /** + * Returns the Attribute as a string. + * + * @return string The wrapped value coerced to a string, or an empty string when it is not scalar. + */ public function toString(): string { return match (true) { @@ -40,6 +60,11 @@ public function toString(): string }; } + /** + * Returns the Attribute as an integer. + * + * @return int The wrapped value coerced to an integer, or 0 when it is not scalar. + */ public function toInteger(): int { return match (true) { @@ -48,6 +73,11 @@ public function toInteger(): int }; } + /** + * Returns the Attribute as a boolean. + * + * @return bool The wrapped value coerced to a boolean, or false when it is not scalar. + */ public function toBoolean(): bool { return match (true) { diff --git a/src/Internal/Shared/Body.php b/src/Body.php similarity index 56% rename from src/Internal/Shared/Body.php rename to src/Body.php index 2cd0d78..2d5dc36 100644 --- a/src/Internal/Shared/Body.php +++ b/src/Body.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Http\Internal\Shared; +namespace TinyBlocks\Http; use JsonException; use Psr\Http\Message\ResponseInterface; @@ -13,17 +13,48 @@ { private const int MAX_JSON_DEPTH = 64; - /** @param array $data */ private function __construct(private array $data) { } - /** @param array $data */ + /** + * Creates a Body from an associative array of decoded data. + * + * @param array $data The decoded body data. + * @return Body A Body wrapping the supplied data. + */ public static function fromArray(array $data): Body { return new Body(data: $data); } + /** + * Creates a Body from a PSR-7 server request, parsing JSON or falling back to the parsed body. + * + * @param ServerRequestInterface $request The incoming PSR-7 server request. + * @return Body A Body carrying the decoded request payload. + */ + public static function fromServerRequest(ServerRequestInterface $request): Body + { + $streamFactory = StreamFactory::fromStream(stream: $request->getBody()); + + if (!$streamFactory->isEmptyContent()) { + $decoded = json_decode($streamFactory->content(), true); + + return new Body(data: is_array($decoded) ? $decoded : []); + } + + $parsedBody = $request->getParsedBody(); + + return new Body(data: is_array($parsedBody) ? $parsedBody : []); + } + + /** + * Creates a Body from a PSR-7 response, decoding the JSON payload and degrading to empty on failure. + * + * @param ResponseInterface $response The PSR-7 response whose body is decoded. + * @return Body A Body carrying the decoded payload, or an empty Body when decoding fails. + */ public static function fromResponse(ResponseInterface $response): Body { $stream = $response->getBody(); @@ -42,7 +73,7 @@ public static function fromResponse(ResponseInterface $response): Body $decoded = json_decode( $raw, true, - self::MAX_JSON_DEPTH, + Body::MAX_JSON_DEPTH, JSON_THROW_ON_ERROR ); } catch (JsonException) { @@ -52,31 +83,26 @@ public static function fromResponse(ResponseInterface $response): Body return new Body(data: is_array($decoded) ? $decoded : []); } - public static function fromServerRequest(ServerRequestInterface $request): Body + /** + * Returns the Body as an associative array. + * + * @return array The decoded body data. + */ + public function toArray(): array { - $streamFactory = StreamFactory::fromStream(stream: $request->getBody()); - - if (!$streamFactory->isEmptyContent()) { - $decoded = json_decode($streamFactory->content(), true); - - return new Body(data: is_array($decoded) ? $decoded : []); - } - - $parsedBody = $request->getParsedBody(); - - return new Body(data: is_array($parsedBody) ? $parsedBody : []); + return $this->data; } + /** + * Returns the Attribute associated with the given key. + * + * @param string $key The key to look up in the body. + * @return Attribute The Attribute wrapping the value, or wrapping null when absent. + */ public function get(string $key): Attribute { - $value = ($this->data[$key] ?? null); - - return Attribute::from(value: $value); - } + $attributeValue = ($this->data[$key] ?? null); - /** @return array */ - public function toArray(): array - { - return $this->data; + return Attribute::from(value: $attributeValue); } } diff --git a/src/CacheControl.php b/src/CacheControl.php index bccd7df..f1ea89c 100644 --- a/src/CacheControl.php +++ b/src/CacheControl.php @@ -6,11 +6,16 @@ final readonly class CacheControl implements Headerable { - /** @param list $directives */ private function __construct(private array $directives) { } + /** + * Creates a CacheControl from a list of response directives. + * + * @param ResponseCacheDirectives ...$directives The directives folded into the Cache-Control header. + * @return CacheControl A header carrying every supplied directive in the given order. + */ public static function fromResponseDirectives(ResponseCacheDirectives ...$directives): CacheControl { $values = []; diff --git a/src/Charset.php b/src/Charset.php index 67faa2a..2b660aa 100644 --- a/src/Charset.php +++ b/src/Charset.php @@ -17,8 +17,15 @@ enum Charset: string case ISO_8859_1 = 'iso-8859-1'; case WINDOWS_1252 = 'windows-1252'; + /** + * Returns the Charset as a Content-Type charset parameter. + * + * @return string The header fragment in the form charset={value}. + */ public function toString(): string { - return sprintf('charset=%s', $this->value); + $template = 'charset=%s'; + + return sprintf($template, $this->value); } } diff --git a/src/Client/Request.php b/src/Client/Request.php index c515774..97e0e03 100644 --- a/src/Client/Request.php +++ b/src/Client/Request.php @@ -4,45 +4,96 @@ namespace TinyBlocks\Http\Client; -use TinyBlocks\Http\Headerable; use TinyBlocks\Http\Headers; use TinyBlocks\Http\Method; final readonly class Request { - /** - * @param array|null $body - * @param array|null $query - */ - public function __construct( - public string $url, - public ?array $body, - public ?array $query, - public Method $method, - public Headers $headers + private function __construct( + private string $url, + private ?array $body, + private ?array $query, + private Method $method, + private Headers $headers ) { } /** - * @param array|null $body - * @param array|null $query + * Builds an outbound request with the given URL, body, query, method, and headers. + * + * @param string $url The URL (relative or absolute) the request targets. + * @param array|null $body The request body as an associative array, or null when absent. + * @param array|null $query The query string parameters, or null when absent. + * @param Method $method The HTTP method used by the request. + * @param Headers $headers The headers folded into the request. + * @return Request A new immutable request instance. */ public static function create( string $url, - ?array $body = null, - ?array $query = null, - Method $method = Method::GET, - Headerable ...$headers + ?array $body, + ?array $query, + Method $method, + Headers $headers ): Request { - return new Request( - url: $url, - body: $body, - query: $query, - method: $method, - headers: Headers::from(...$headers) - ); + return new Request(url: $url, body: $body, query: $query, method: $method, headers: $headers); } + /** + * Returns the url. + * + * @return string The URL the request targets. + */ + public function url(): string + { + return $this->url; + } + + /** + * Returns the body. + * + * @return array|null The request body, or null when absent. + */ + public function body(): ?array + { + return $this->body; + } + + /** + * Returns the query. + * + * @return array|null The query string parameters, or null when absent. + */ + public function query(): ?array + { + return $this->query; + } + + /** + * Returns the method. + * + * @return Method The HTTP method used by the request. + */ + public function method(): Method + { + return $this->method; + } + + /** + * Returns the headers. + * + * @return Headers The headers carried by the request. + */ + public function headers(): Headers + { + return $this->headers; + } + + /** + * Returns a copy of the Request with the URL replaced. + * + * @param string $url The replacement URL. + * @return Request A new instance with the replaced URL. + */ public function withUrl(string $url): Request { return new Request( @@ -54,7 +105,12 @@ public function withUrl(string $url): Request ); } - /** @param array|null $query */ + /** + * Returns a copy of the request carrying the given query parameters. + * + * @param array|null $query The query string parameters, or null to clear them. + * @return Request A new instance with the replaced query. + */ public function withQuery(?array $query): Request { return new Request( @@ -66,6 +122,12 @@ public function withQuery(?array $query): Request ); } + /** + * Returns a copy of the Request with the given default headers merged in. + * + * @param Headers $defaults The default headers to merge under existing entries. + * @return Request A new instance carrying the merged headers. + */ public function withMergedHeaders(Headers $defaults): Request { return new Request( diff --git a/src/Client/Response.php b/src/Client/Response.php index 0c32cd8..2aed34b 100644 --- a/src/Client/Response.php +++ b/src/Client/Response.php @@ -5,10 +5,10 @@ namespace TinyBlocks\Http\Client; use Psr\Http\Message\ResponseInterface; +use TinyBlocks\Http\Body; use TinyBlocks\Http\Code; use TinyBlocks\Http\Exceptions\SynthesizedResponseHasNoRaw; use TinyBlocks\Http\Headers; -use TinyBlocks\Http\Internal\Shared\Body; final readonly class Response { @@ -20,6 +20,12 @@ private function __construct( ) { } + /** + * Creates a Response from a PSR-7 response. + * + * @param ResponseInterface $response The underlying PSR-7 response. + * @return Response A wrapped Response carrying the PSR-7 message. + */ public static function from(ResponseInterface $response): Response { return new Response( @@ -30,7 +36,14 @@ public static function from(ResponseInterface $response): Response ); } - /** @param array|null $body */ + /** + * Synthesizes a response from a status code and an optional body and headers. + * + * @param Code $code The HTTP status code carried by the synthesized response. + * @param array|null $body The response body as an associative array, or null for an empty body. + * @param Headers|null $headers The response headers, or null for an empty headers instance. + * @return Response A synthesized response without a backing PSR-7 message. + */ public static function with(Code $code, ?array $body = null, ?Headers $headers = null): Response { return new Response( @@ -41,31 +54,63 @@ public static function with(Code $code, ?array $body = null, ?Headers $headers = ); } + /** + * Returns the status code. + * + * @return Code The status code carried by the response. + */ public function code(): Code { return $this->code; } + /** + * Returns the body. + * + * @return Body The parsed body of the response. + */ public function body(): Body { return $this->body; } + /** + * Returns the headers. + * + * @return Headers The headers carried by the response. + */ public function headers(): Headers { return $this->headers; } + /** + * Tells whether the status code denotes an error response. + * + * @return bool True when the code falls in the 4xx or 5xx range, otherwise false. + */ public function isError(): bool { return $this->code->isError(); } + /** + * Tells whether the status code denotes a successful response. + * + * @return bool True when the code falls in the 2xx range, otherwise false. + */ public function isSuccess(): bool { return $this->code->isSuccess(); } + /** + * Returns the underlying PSR-7 response. + * + * @return ResponseInterface The original PSR-7 response wrapped by this instance. + * @throws SynthesizedResponseHasNoRaw If the response was synthesized via {@see Response::with()} and + * has no backing PSR-7 message. + */ public function raw(): ResponseInterface { if (is_null($this->psr)) { diff --git a/src/Client/Transports/InMemoryTransport.php b/src/Client/Transports/InMemoryTransport.php index b99e7f4..79dc97e 100644 --- a/src/Client/Transports/InMemoryTransport.php +++ b/src/Client/Transports/InMemoryTransport.php @@ -12,12 +12,16 @@ final readonly class InMemoryTransport implements Transport { - /** @param list $responses */ private function __construct(private Cursor $cursor, private array $responses) { } - /** @param list $responses */ + /** + * Creates an InMemoryTransport seeded with a FIFO queue of responses. + * + * @param array $responses The pre-built responses served in order on each send. + * @return InMemoryTransport A transport that returns each seeded response in sequence. + */ public static function with(array $responses): InMemoryTransport { return new InMemoryTransport(cursor: new Cursor(), responses: $responses); diff --git a/src/Client/Transports/NetworkTransport.php b/src/Client/Transports/NetworkTransport.php index 8c10eea..957fb34 100644 --- a/src/Client/Transports/NetworkTransport.php +++ b/src/Client/Transports/NetworkTransport.php @@ -27,6 +27,14 @@ private function __construct( ) { } + /** + * Creates a NetworkTransport backed by a PSR-18 client and a PSR-17 factory. + * + * @param ClientInterface $client The PSR-18 client that performs the actual network call. + * @param RequestFactoryInterface&StreamFactoryInterface $factory The PSR-17 factory used to build the + * PSR-7 request and body stream. + * @return NetworkTransport A transport that dispatches each request through the given client. + */ public static function with( ClientInterface $client, RequestFactoryInterface&StreamFactoryInterface $factory @@ -36,12 +44,14 @@ public static function with( public function send(Request $request): Response { - $psrRequest = $this->factory->createRequest(method: $request->method->value, uri: $request->url); - $psrRequest = $request->headers->applyTo(message: $psrRequest); + $psrRequest = $this->factory->createRequest($request->method()->value, $request->url()); + $psrRequest = $request->headers()->applyTo(message: $psrRequest); - if (!is_null($request->body)) { - $encoded = json_encode($request->body, self::JSON_FLAGS); - $psrRequest = $psrRequest->withBody(body: $this->factory->createStream(content: $encoded)); + $body = $request->body(); + + if (!is_null($body)) { + $encoded = json_encode($body, NetworkTransport::JSON_FLAGS); + $psrRequest = $psrRequest->withBody(body: $this->factory->createStream($encoded)); } try { diff --git a/src/Code.php b/src/Code.php index 59d5f3f..37e33e6 100644 --- a/src/Code.php +++ b/src/Code.php @@ -90,79 +90,73 @@ enum Code: int case NETWORK_AUTHENTICATION_REQUIRED = 511; /** - * Indicates whether the status code falls in the 4xx or 5xx range. + * Tells whether the given code falls in the error range (4xx or 5xx). * - * @return bool True when the code represents an error response. - * - * @complexity O(1) time and space. + * @param int $code The HTTP status code to check. + * @return bool True when the code falls in the error range, otherwise false. */ - public function isError(): bool + public static function isErrorCode(int $code): bool { - return self::isErrorCode(code: $this->value); + return $code >= Code::BAD_REQUEST->value && $code <= Code::NETWORK_AUTHENTICATION_REQUIRED->value; } /** - * Indicates whether the status code falls in the 2xx range. + * Tells whether the given code falls in the success range (2xx). * - * @return bool True when the code represents a successful response. - * - * @complexity O(1) time and space. + * @param int $code The HTTP status code to check. + * @return bool True when the code falls in the success range, otherwise false. */ - public function isSuccess(): bool + public static function isSuccessCode(int $code): bool { - return self::isSuccessCode(code: $this->value); + return $code >= Code::OK->value && $code <= Code::IM_USED->value; } /** - * Returns the HTTP status message associated with the enum's code. + * Tells whether the given code is a valid HTTP status code represented by the enum. * - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages - * @return string The formatted message with the status code and name. + * @param int $code The HTTP status code to check. + * @return bool True when the code exists in the enum, otherwise false. */ - public function message(): string + public static function isValidCode(int $code): bool { - $subject = match ($this) { - self::OK => $this->name, - self::IM_USED => 'IM Used', - self::IM_A_TEAPOT => "I'm a teapot", - default => mb_convert_case($this->name, MB_CASE_TITLE) - }; - - return str_replace('_', ' ', $subject); + return !is_null(Code::tryFrom($code)); } /** - * Determines if the given code is a valid HTTP status code represented by the enum. + * Tells whether the status code falls in the 4xx or 5xx range. * - * @param int $code The HTTP status code to check. - * @return bool True if the code exists in the enum, otherwise false. + * @return bool True when the code represents an error response. */ - public static function isValidCode(int $code): bool + public function isError(): bool { - $mapper = fn(Code $code): int => $code->value; - - return in_array($code, array_map($mapper, self::cases())); + return Code::isErrorCode(code: $this->value); } /** - * Determines if the given code is in the error range (4xx or 5xx). + * Tells whether the status code falls in the 2xx range. * - * @param int $code The HTTP status code to check. - * @return bool True if the code is in the error range (4xx or 5xx), otherwise false. + * @return bool True when the code represents a successful response. */ - public static function isErrorCode(int $code): bool + public function isSuccess(): bool { - return $code >= self::BAD_REQUEST->value && $code <= self::NETWORK_AUTHENTICATION_REQUIRED->value; + return Code::isSuccessCode(code: $this->value); } /** - * Determines if the given code is in the success range (2xx). + * Returns the HTTP status message associated with the enum's code. * - * @param int $code The HTTP status code to check. - * @return bool True if the code is in the success range (2xx), otherwise false. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages + * @return string The formatted message with the status code and name. */ - public static function isSuccessCode(int $code): bool + public function message(): string { - return $code >= self::OK->value && $code <= self::IM_USED->value; + $subject = match ($this) { + Code::OK => $this->name, + Code::IM_USED => 'IM Used', + Code::IM_A_TEAPOT => "I'm a teapot", + default => mb_convert_case($this->name, MB_CASE_TITLE) + }; + + return str_replace('_', ' ', $subject); } } diff --git a/src/ContentType.php b/src/ContentType.php index 14cc41c..4a3c137 100644 --- a/src/ContentType.php +++ b/src/ContentType.php @@ -10,43 +10,80 @@ private function __construct(private MimeType $mimeType, private ?Charset $chars { } + /** + * Creates a ContentType for text/html with an optional charset. + * + * @param Charset|null $charset The optional charset folded into the header value. + * @return ContentType A ContentType for text/html. + */ public static function textHtml(?Charset $charset = null): ContentType { return new ContentType(mimeType: MimeType::TEXT_HTML, charset: $charset); } + /** + * Creates a ContentType for text/plain with an optional charset. + * + * @param Charset|null $charset The optional charset folded into the header value. + * @return ContentType A ContentType for text/plain. + */ public static function textPlain(?Charset $charset = null): ContentType { return new ContentType(mimeType: MimeType::TEXT_PLAIN, charset: $charset); } + /** + * Creates a ContentType for application/json with an optional charset. + * + * @param Charset|null $charset The optional charset folded into the header value. + * @return ContentType A ContentType for application/json. + */ public static function applicationJson(?Charset $charset = null): ContentType { return new ContentType(mimeType: MimeType::APPLICATION_JSON, charset: $charset); } + /** + * Creates a ContentType for application/pdf with an optional charset. + * + * @param Charset|null $charset The optional charset folded into the header value. + * @return ContentType A ContentType for application/pdf. + */ public static function applicationPdf(?Charset $charset = null): ContentType { return new ContentType(mimeType: MimeType::APPLICATION_PDF, charset: $charset); } + /** + * Creates a ContentType for application/octet-stream with an optional charset. + * + * @param Charset|null $charset The optional charset folded into the header value. + * @return ContentType A ContentType for application/octet-stream. + */ public static function applicationOctetStream(?Charset $charset = null): ContentType { return new ContentType(mimeType: MimeType::APPLICATION_OCTET_STREAM, charset: $charset); } + /** + * Creates a ContentType for application/x-www-form-urlencoded with an optional charset. + * + * @param Charset|null $charset The optional charset folded into the header value. + * @return ContentType A ContentType for application/x-www-form-urlencoded. + */ public static function applicationFormUrlencoded(?Charset $charset = null): ContentType { return new ContentType(mimeType: MimeType::APPLICATION_FORM_URLENCODED, charset: $charset); } - /** @return array> */ public function toArray(): array { - $value = is_null($this->charset) - ? $this->mimeType->value - : sprintf('%s; %s', $this->mimeType->value, $this->charset->toString()); + if (is_null($this->charset)) { + return ['Content-Type' => [$this->mimeType->value]]; + } - return ['Content-Type' => [$value]]; + $template = '%s; %s'; + + return ['Content-Type' => [sprintf($template, $this->mimeType->value, $this->charset->toString())]]; } } diff --git a/src/Cookie.php b/src/Cookie.php index 4df0434..6e997ee 100644 --- a/src/Cookie.php +++ b/src/Cookie.php @@ -10,6 +10,8 @@ use TinyBlocks\Http\Internal\Server\Cookies\CookieName; use TinyBlocks\Http\Internal\Server\Cookies\CookieValue; use TinyBlocks\Http\Internal\Server\Exceptions\ConflictingLifetimeAttributes; +use TinyBlocks\Http\Internal\Server\Exceptions\CookieNameIsInvalid; +use TinyBlocks\Http\Internal\Server\Exceptions\CookieValueIsInvalid; use TinyBlocks\Http\Internal\Server\Exceptions\SameSiteNoneRequiresSecure; final readonly class Cookie implements Headerable @@ -30,12 +32,21 @@ private function __construct( ) { } + /** + * Creates a Cookie from a name and a value, with no attributes set. + * + * @param string $name The cookie name. + * @param string $value The cookie value. + * @return Cookie A Cookie carrying the given name and value and no other attributes. + * @throws CookieNameIsInvalid If the name is empty or contains forbidden characters. + * @throws CookieValueIsInvalid If the value contains forbidden characters. + */ public static function create(string $name, string $value): Cookie { return new Cookie( - name: CookieName::from($name), + name: CookieName::from(value: $name), path: null, - value: CookieValue::from($value), + value: CookieValue::from(value: $value), domain: null, maxAge: null, secure: false, @@ -46,12 +57,19 @@ public static function create(string $name, string $value): Cookie ); } + /** + * Creates a Cookie that instructs the browser to discard an existing cookie with the given name. + * + * @param string $name The cookie name being expired. + * @return Cookie A Cookie with an empty value and Max-Age=0 set. + * @throws CookieNameIsInvalid If the name is empty or contains forbidden characters. + */ public static function expire(string $name): Cookie { return new Cookie( - name: CookieName::from($name), + name: CookieName::from(value: $name), path: null, - value: CookieValue::from(''), + value: CookieValue::from(value: ''), domain: null, maxAge: 0, secure: false, @@ -62,6 +80,11 @@ public static function expire(string $name): Cookie ); } + /** + * Returns a copy of the Cookie with the Secure attribute enabled. + * + * @return Cookie A new instance carrying the Secure attribute. + */ public function secure(): Cookie { return new Cookie( @@ -78,6 +101,11 @@ public function secure(): Cookie ); } + /** + * Returns a copy of the Cookie with the HttpOnly attribute enabled. + * + * @return Cookie A new instance carrying the HttpOnly attribute. + */ public function httpOnly(): Cookie { return new Cookie( @@ -94,6 +122,11 @@ public function httpOnly(): Cookie ); } + /** + * Returns a copy of the Cookie with the Partitioned attribute enabled. + * + * @return Cookie A new instance carrying the Partitioned attribute. + */ public function partitioned(): Cookie { return new Cookie( @@ -110,6 +143,12 @@ public function partitioned(): Cookie ); } + /** + * Returns a copy of the Cookie with the path replaced. + * + * @param string $path The replacement path. + * @return Cookie A new instance carrying the replaced path. + */ public function withPath(string $path): Cookie { return new Cookie( @@ -126,12 +165,19 @@ public function withPath(string $path): Cookie ); } + /** + * Returns a copy of the Cookie with the value replaced. + * + * @param string $value The replacement value. + * @return Cookie A new instance carrying the replaced value. + * @throws CookieValueIsInvalid If the value contains forbidden characters. + */ public function withValue(string $value): Cookie { return new Cookie( name: $this->name, path: $this->path, - value: CookieValue::from($value), + value: CookieValue::from(value: $value), domain: $this->domain, maxAge: $this->maxAge, secure: $this->secure, @@ -142,6 +188,12 @@ public function withValue(string $value): Cookie ); } + /** + * Returns a copy of the Cookie with the domain replaced. + * + * @param string $domain The replacement domain. + * @return Cookie A new instance carrying the replaced domain. + */ public function withDomain(string $domain): Cookie { return new Cookie( @@ -158,6 +210,12 @@ public function withDomain(string $domain): Cookie ); } + /** + * Returns a copy of the Cookie with the Max-Age replaced. + * + * @param int $seconds The replacement lifetime in seconds. + * @return Cookie A new instance carrying the replaced Max-Age. + */ public function withMaxAge(int $seconds): Cookie { return new Cookie( @@ -174,6 +232,12 @@ public function withMaxAge(int $seconds): Cookie ); } + /** + * Returns a copy of the Cookie with the Expires replaced and normalized to UTC. + * + * @param DateTimeInterface $expires The replacement expiration timestamp; normalized to UTC. + * @return Cookie A new instance carrying the replaced Expires. + */ public function withExpires(DateTimeInterface $expires): Cookie { return new Cookie( @@ -190,6 +254,12 @@ public function withExpires(DateTimeInterface $expires): Cookie ); } + /** + * Returns a copy of the Cookie with the SameSite attribute replaced. + * + * @param SameSite $sameSite The replacement SameSite attribute. + * @return Cookie A new instance carrying the replaced SameSite attribute. + */ public function withSameSite(SameSite $sameSite): Cookie { return new Cookie( @@ -211,29 +281,34 @@ public function toArray(): array $invariantViolation = match (true) { $this->sameSite === SameSite::NONE && !$this->secure => new SameSiteNoneRequiresSecure(), !is_null($this->maxAge) && !is_null($this->expires) => new ConflictingLifetimeAttributes(), - default => null, + default => null }; if (!is_null($invariantViolation)) { throw $invariantViolation; } - $parts = [sprintf('%s=%s', $this->name->toString(), $this->value->toString())]; + $nameValueTemplate = '%s=%s'; + $parts = [sprintf($nameValueTemplate, $this->name->toString(), $this->value->toString())]; if (!is_null($this->maxAge)) { - $parts[] = sprintf('Max-Age=%d', $this->maxAge); + $maxAgeTemplate = 'Max-Age=%d'; + $parts[] = sprintf($maxAgeTemplate, $this->maxAge); } if (!is_null($this->expires)) { - $parts[] = sprintf('Expires=%s', $this->expires->format(self::EXPIRES_FORMAT)); + $expiresTemplate = 'Expires=%s'; + $parts[] = sprintf($expiresTemplate, $this->expires->format(Cookie::EXPIRES_FORMAT)); } if (!is_null($this->path)) { - $parts[] = sprintf('Path=%s', $this->path); + $pathTemplate = 'Path=%s'; + $parts[] = sprintf($pathTemplate, $this->path); } if (!is_null($this->domain)) { - $parts[] = sprintf('Domain=%s', $this->domain); + $domainTemplate = 'Domain=%s'; + $parts[] = sprintf($domainTemplate, $this->domain); } if ($this->secure) { @@ -245,7 +320,8 @@ public function toArray(): array } if (!is_null($this->sameSite)) { - $parts[] = sprintf('SameSite=%s', $this->sameSite->value); + $sameSiteTemplate = 'SameSite=%s'; + $parts[] = sprintf($sameSiteTemplate, $this->sameSite->value); } if ($this->partitioned) { diff --git a/src/Exceptions/HttpConfigurationInvalid.php b/src/Exceptions/HttpConfigurationInvalid.php index 3aa7ac2..e8fde12 100644 --- a/src/Exceptions/HttpConfigurationInvalid.php +++ b/src/Exceptions/HttpConfigurationInvalid.php @@ -5,43 +5,34 @@ namespace TinyBlocks\Http\Exceptions; use LogicException; -use TinyBlocks\Http\Method; final class HttpConfigurationInvalid extends LogicException implements HttpException { private const string MISSING_BASE_URL_REASON = 'Base URL is required to build Http.'; private const string MISSING_TRANSPORT_REASON = 'Transport is required to build Http.'; - private function __construct( - private readonly string $url, - private readonly Method $method, - private readonly string $reason - ) { - parent::__construct($reason); + private function __construct(string $reason) + { + parent::__construct(message: $reason); } + /** + * Creates an HttpConfigurationInvalid signaling that the base URL is missing. + * + * @return HttpConfigurationInvalid A configuration error reporting the missing base URL. + */ public static function missingBaseUrl(): HttpConfigurationInvalid { - return new HttpConfigurationInvalid(url: '', method: Method::GET, reason: self::MISSING_BASE_URL_REASON); + return new HttpConfigurationInvalid(reason: HttpConfigurationInvalid::MISSING_BASE_URL_REASON); } + /** + * Creates an HttpConfigurationInvalid signaling that the transport is missing. + * + * @return HttpConfigurationInvalid A configuration error reporting the missing transport. + */ public static function missingTransport(): HttpConfigurationInvalid { - return new HttpConfigurationInvalid(url: '', method: Method::GET, reason: self::MISSING_TRANSPORT_REASON); - } - - public function url(): string - { - return $this->url; - } - - public function reason(): string - { - return $this->reason; - } - - public function method(): Method - { - return $this->method; + return new HttpConfigurationInvalid(reason: HttpConfigurationInvalid::MISSING_TRANSPORT_REASON); } } diff --git a/src/Exceptions/HttpException.php b/src/Exceptions/HttpException.php index 545fcde..c3c99d6 100644 --- a/src/Exceptions/HttpException.php +++ b/src/Exceptions/HttpException.php @@ -5,34 +5,7 @@ namespace TinyBlocks\Http\Exceptions; use Throwable; -use TinyBlocks\Http\Method; interface HttpException extends Throwable { - /** - * The fully composed URL the failed request targeted. - * - * @return string The absolute URL. - * - * @complexity O(1) time and space. - */ - public function url(): string; - - /** - * The transport-level or domain-level reason for the failure. - * - * @return string The human-readable reason already formatted into the message. - * - * @complexity O(1) time and space. - */ - public function reason(): string; - - /** - * The HTTP method used in the failed request. - * - * @return Method The verb of the failed request. - * - * @complexity O(1) time and space. - */ - public function method(): Method; } diff --git a/src/Exceptions/HttpNetworkFailed.php b/src/Exceptions/HttpNetworkFailed.php index ab1f9fc..15d0874 100644 --- a/src/Exceptions/HttpNetworkFailed.php +++ b/src/Exceptions/HttpNetworkFailed.php @@ -10,7 +10,7 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Method; -final class HttpNetworkFailed extends RuntimeException implements HttpException +final class HttpNetworkFailed extends RuntimeException implements TransportFailure { private const string REASON_TEMPLATE = 'Network failure for %s %s: %s'; @@ -20,9 +20,20 @@ private function __construct( private readonly string $reason, ?Throwable $previous = null ) { - parent::__construct(sprintf(self::REASON_TEMPLATE, $method->value, $url, $reason), 0, $previous); + $template = HttpNetworkFailed::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $method->value, $url, $reason), previous: $previous); } + /** + * Creates an HttpNetworkFailed from a URL, HTTP method, reason, and optional previous throwable. + * + * @param string $url The URL of the failed request. + * @param Method $method The HTTP method of the failed request. + * @param string $reason The transport-level reason for the failure. + * @param Throwable|null $previous The previous throwable preserved in the exception chain, if any. + * @return HttpNetworkFailed The composed network-failure exception. + */ public static function from( string $url, Method $method, @@ -32,13 +43,20 @@ public static function from( return new HttpNetworkFailed(url: $url, method: $method, reason: $reason, previous: $previous); } + /** + * Creates an HttpNetworkFailed from a Request and a PSR-18 network exception. + * + * @param Request $request The outbound request that triggered the failure. + * @param NetworkExceptionInterface $exception The PSR-18 network exception preserved as the previous throwable. + * @return HttpNetworkFailed The composed network-failure exception wrapping the original cause. + */ public static function fromClientException( Request $request, NetworkExceptionInterface $exception ): HttpNetworkFailed { - return self::from( - url: $request->url, - method: $request->method, + return HttpNetworkFailed::from( + url: $request->url(), + method: $request->method(), reason: $exception->getMessage(), previous: $exception ); @@ -49,13 +67,13 @@ public function url(): string return $this->url; } - public function reason(): string + public function method(): Method { - return $this->reason; + return $this->method; } - public function method(): Method + public function reason(): string { - return $this->method; + return $this->reason; } } diff --git a/src/Exceptions/HttpRequestFailed.php b/src/Exceptions/HttpRequestFailed.php index 444de43..dae12ff 100644 --- a/src/Exceptions/HttpRequestFailed.php +++ b/src/Exceptions/HttpRequestFailed.php @@ -10,7 +10,7 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Method; -final class HttpRequestFailed extends RuntimeException implements HttpException +final class HttpRequestFailed extends RuntimeException implements TransportFailure { private const string REASON_TEMPLATE = 'PSR-18 client failed for %s %s: %s'; @@ -20,9 +20,20 @@ private function __construct( private readonly string $reason, ?Throwable $previous = null ) { - parent::__construct(sprintf(self::REASON_TEMPLATE, $method->value, $url, $reason), 0, $previous); + $template = HttpRequestFailed::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $method->value, $url, $reason), previous: $previous); } + /** + * Creates an HttpRequestFailed from a URL, HTTP method, reason, and optional previous throwable. + * + * @param string $url The URL of the failed request. + * @param Method $method The HTTP method of the failed request. + * @param string $reason The transport-level reason for the failure. + * @param Throwable|null $previous The previous throwable preserved in the exception chain, if any. + * @return HttpRequestFailed The composed request-failure exception. + */ public static function from( string $url, Method $method, @@ -32,13 +43,20 @@ public static function from( return new HttpRequestFailed(url: $url, method: $method, reason: $reason, previous: $previous); } + /** + * Creates an HttpRequestFailed from a Request and a PSR-18 client exception. + * + * @param Request $request The outbound request that triggered the failure. + * @param ClientExceptionInterface $exception The PSR-18 client exception preserved as the previous throwable. + * @return HttpRequestFailed The composed request-failure exception wrapping the original cause. + */ public static function fromClientException( Request $request, ClientExceptionInterface $exception ): HttpRequestFailed { - return self::from( - url: $request->url, - method: $request->method, + return HttpRequestFailed::from( + url: $request->url(), + method: $request->method(), reason: $exception->getMessage(), previous: $exception ); @@ -49,13 +67,13 @@ public function url(): string return $this->url; } - public function reason(): string + public function method(): Method { - return $this->reason; + return $this->method; } - public function method(): Method + public function reason(): string { - return $this->method; + return $this->reason; } } diff --git a/src/Exceptions/HttpRequestInvalid.php b/src/Exceptions/HttpRequestInvalid.php index 5e4718e..6f3f5f5 100644 --- a/src/Exceptions/HttpRequestInvalid.php +++ b/src/Exceptions/HttpRequestInvalid.php @@ -10,7 +10,7 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Method; -final class HttpRequestInvalid extends RuntimeException implements HttpException +final class HttpRequestInvalid extends RuntimeException implements TransportFailure { private const string REASON_TEMPLATE = 'Request is invalid for %s %s: %s'; @@ -20,9 +20,20 @@ private function __construct( private readonly string $reason, ?Throwable $previous = null ) { - parent::__construct(sprintf(self::REASON_TEMPLATE, $method->value, $url, $reason), 0, $previous); + $template = HttpRequestInvalid::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $method->value, $url, $reason), previous: $previous); } + /** + * Creates an HttpRequestInvalid from a URL, HTTP method, reason, and optional previous throwable. + * + * @param string $url The URL of the failed request. + * @param Method $method The HTTP method of the failed request. + * @param string $reason The transport-level reason for the failure. + * @param Throwable|null $previous The previous throwable preserved in the exception chain, if any. + * @return HttpRequestInvalid The composed request-invalid exception. + */ public static function from( string $url, Method $method, @@ -32,13 +43,20 @@ public static function from( return new HttpRequestInvalid(url: $url, method: $method, reason: $reason, previous: $previous); } + /** + * Creates an HttpRequestInvalid from a Request and a PSR-18 request exception. + * + * @param Request $request The outbound request that triggered the failure. + * @param RequestExceptionInterface $exception The PSR-18 request exception preserved as the previous throwable. + * @return HttpRequestInvalid The composed request-invalid exception wrapping the original cause. + */ public static function fromClientException( Request $request, RequestExceptionInterface $exception ): HttpRequestInvalid { - return self::from( - url: $request->url, - method: $request->method, + return HttpRequestInvalid::from( + url: $request->url(), + method: $request->method(), reason: $exception->getMessage(), previous: $exception ); @@ -49,13 +67,13 @@ public function url(): string return $this->url; } - public function reason(): string + public function method(): Method { - return $this->reason; + return $this->method; } - public function method(): Method + public function reason(): string { - return $this->method; + return $this->reason; } } diff --git a/src/Exceptions/MalformedPath.php b/src/Exceptions/MalformedPath.php index 34ee76f..9f6475f 100644 --- a/src/Exceptions/MalformedPath.php +++ b/src/Exceptions/MalformedPath.php @@ -5,42 +5,39 @@ namespace TinyBlocks\Http\Exceptions; use RuntimeException; +use Throwable; use TinyBlocks\Http\Client\Request; -use TinyBlocks\Http\Method; final class MalformedPath extends RuntimeException implements HttpException { private const string REASON_TEMPLATE = 'Path "%s" is malformed and cannot be composed safely against a base URL.'; - private function __construct( - private readonly string $url, - private readonly Method $method, - private readonly string $reason - ) { - parent::__construct($reason); - } - - public static function fromRequest(Request $request): MalformedPath + private function __construct(private readonly string $path, ?Throwable $previous) { - return new MalformedPath( - url: $request->url, - method: $request->method, - reason: sprintf(self::REASON_TEMPLATE, $request->url) - ); - } + $template = MalformedPath::REASON_TEMPLATE; - public function url(): string - { - return $this->url; + parent::__construct(message: sprintf($template, $path), previous: $previous); } - public function reason(): string + /** + * Creates a MalformedPath from the offending Request, optionally wrapping the original cause. + * + * @param Request $request The request whose path could not be composed safely against the base URL. + * @param Throwable|null $previous The original cause preserved as the previous throwable. + * @return MalformedPath The composed exception describing the malformed path. + */ + public static function fromRequest(Request $request, ?Throwable $previous = null): MalformedPath { - return $this->reason; + return new MalformedPath(path: $request->url(), previous: $previous); } - public function method(): Method + /** + * Returns the path. + * + * @return string The malformed path that triggered the exception. + */ + public function path(): string { - return $this->method; + return $this->path; } } diff --git a/src/Exceptions/NoMoreResponses.php b/src/Exceptions/NoMoreResponses.php index 405b116..cc5a0fb 100644 --- a/src/Exceptions/NoMoreResponses.php +++ b/src/Exceptions/NoMoreResponses.php @@ -5,41 +5,26 @@ namespace TinyBlocks\Http\Exceptions; use LogicException; -use TinyBlocks\Http\Method; final class NoMoreResponses extends LogicException implements HttpException { private const string REASON_TEMPLATE = 'InMemoryTransport has no response queued at index %d.'; - private function __construct( - private readonly string $url, - private readonly Method $method, - private readonly string $reason - ) { - parent::__construct($reason); - } - - public static function atIndex(int $index): NoMoreResponses + private function __construct(int $index) { - return new NoMoreResponses( - url: '', - method: Method::GET, - reason: sprintf(self::REASON_TEMPLATE, $index) - ); - } + $template = NoMoreResponses::REASON_TEMPLATE; - public function url(): string - { - return $this->url; + parent::__construct(message: sprintf($template, $index)); } - public function reason(): string - { - return $this->reason; - } - - public function method(): Method + /** + * Creates a NoMoreResponses signaling that the seeded queue is exhausted at the given index. + * + * @param int $index The position the in-memory transport tried to read past the end of the queue. + * @return NoMoreResponses The composed exception describing the exhausted-queue state. + */ + public static function atIndex(int $index): NoMoreResponses { - return $this->method; + return new NoMoreResponses(index: $index); } } diff --git a/src/Exceptions/SynthesizedResponseHasNoRaw.php b/src/Exceptions/SynthesizedResponseHasNoRaw.php index 68ed2f0..8af8430 100644 --- a/src/Exceptions/SynthesizedResponseHasNoRaw.php +++ b/src/Exceptions/SynthesizedResponseHasNoRaw.php @@ -5,38 +5,24 @@ namespace TinyBlocks\Http\Exceptions; use LogicException; -use TinyBlocks\Http\Method; final class SynthesizedResponseHasNoRaw extends LogicException implements HttpException { private const string REASON = 'Response was synthesized via Response::with(...) and has no underlying PSR-7 raw ' . 'response.'; - private function __construct( - private readonly string $url, - private readonly Method $method, - private readonly string $reason - ) { - parent::__construct($reason); - } - - public static function create(): SynthesizedResponseHasNoRaw - { - return new SynthesizedResponseHasNoRaw(url: '', method: Method::GET, reason: self::REASON); - } - - public function url(): string + private function __construct() { - return $this->url; + parent::__construct(message: SynthesizedResponseHasNoRaw::REASON); } - public function reason(): string - { - return $this->reason; - } - - public function method(): Method + /** + * Creates a SynthesizedResponseHasNoRaw signaling that the response has no underlying PSR-7 raw message. + * + * @return SynthesizedResponseHasNoRaw The composed exception describing the synthesized-response state. + */ + public static function create(): SynthesizedResponseHasNoRaw { - return $this->method; + return new SynthesizedResponseHasNoRaw(); } } diff --git a/src/Exceptions/TransportFailure.php b/src/Exceptions/TransportFailure.php new file mode 100644 index 0000000..f396576 --- /dev/null +++ b/src/Exceptions/TransportFailure.php @@ -0,0 +1,31 @@ +> An associative array where the key is the header name - * and the value is the header value (or list of values). + * @return array> An associative array where the key is the header + * name and the value is the header value (or list of values). */ public function toArray(): array; } diff --git a/src/Headers.php b/src/Headers.php index 822c22c..77cba96 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -8,12 +8,9 @@ final readonly class Headers { - /** @var array */ private array $entries; - /** @var array */ private array $lowerIndex; - /** @param array $entries */ public function __construct(array $entries) { $lowerIndex = []; @@ -26,10 +23,15 @@ public function __construct(array $entries) $this->lowerIndex = $lowerIndex; } + /** + * Creates a Headers from a PSR-7 message, folding multi-value headers with commas. + * + * @param MessageInterface $message The PSR-7 message providing the headers. + * @return Headers A Headers carrying each header from the message, with multi-value entries folded. + */ public static function fromMessage(MessageInterface $message): Headers { $entries = array_map( - /** @param list $values */ static fn(array $values): string => implode(', ', $values), $message->getHeaders() ); @@ -37,6 +39,12 @@ public static function fromMessage(MessageInterface $message): Headers return new Headers(entries: $entries); } + /** + * Creates a Headers from a list of Headerable contributors, with the last one winning on collision. + * + * @param Headerable ...$headers The Headerable contributors merged into the result. + * @return Headers A Headers carrying every entry from the supplied contributors. + */ public static function from(Headerable ...$headers): Headers { $entries = []; @@ -50,17 +58,33 @@ public static function from(Headerable ...$headers): Headers return new Headers(entries: $entries); } + /** + * Tells whether a header with the given name exists, case-insensitively. + * + * @param string $name The header name to look up. + * @return bool True when a header with that name is present regardless of casing, otherwise false. + */ public function has(string $name): bool { return isset($this->lowerIndex[strtolower($name)]); } - /** @return array */ + /** + * Returns the headers as a name to value map. + * + * @return array The header name to single folded value map. + */ public function toArray(): array { return $this->entries; } + /** + * Returns the value associated with the given header name, looking up case-insensitively. + * + * @param string $name The header name to look up. + * @return string|null The folded header value, or null when no entry matches. + */ public function get(string $name): ?string { $key = strtolower($name); @@ -73,9 +97,11 @@ public function get(string $name): ?string } /** + * Applies every header in this collection to the given PSR-7 message, returning a new instance. + * * @template T of MessageInterface - * @param T $message - * @return T + * @param T $message The PSR-7 message that receives the headers. + * @return T A new message instance carrying every header. */ public function applyTo(MessageInterface $message): MessageInterface { @@ -88,6 +114,12 @@ public function applyTo(MessageInterface $message): MessageInterface return $applied; } + /** + * Returns a copy of these Headers merged with another instance, with existing entries winning on collision. + * + * @param Headers $other The Headers whose entries are merged under the existing ones. + * @return Headers A new instance carrying the union of both sets of headers. + */ public function mergedWith(Headers $other): Headers { $merged = $this->entries; diff --git a/src/Http.php b/src/Http.php index c5342a4..e90408a 100644 --- a/src/Http.php +++ b/src/Http.php @@ -33,14 +33,14 @@ public static function create(): HttpBuilder } /** - * Creates an Http instance directly from a base URL and a transport. + * Creates an Http instance directly from a base URL and transport. * * Explicit single-call alternative to the fluent builder returned by * create(). Both arguments are required. * * @param string $baseUrl The absolute base URL prepended to every request path. * @param Transport $transport The transport that delivers resolved requests. - * @return Http A configured Http façade. + * @return Http A configured Http facade. */ public static function with(string $baseUrl, Transport $transport): Http { diff --git a/src/HttpBuilder.php b/src/HttpBuilder.php index 6adbe2e..a3ffcca 100644 --- a/src/HttpBuilder.php +++ b/src/HttpBuilder.php @@ -36,12 +36,12 @@ public function withTransport(Transport $transport): HttpBuilder } /** - * Assembles the configured Http façade. + * Assembles the configured Http facade. * * Both a base URL and a transport must have been supplied via withBaseUrl() * and withTransport() before this call. * - * @return Http A configured Http façade. + * @return Http A configured Http facade. * @throws HttpConfigurationInvalid When the base URL or the transport is missing. */ public function build(): Http diff --git a/src/Internal/Client/Exceptions/PathContainsControlChars.php b/src/Internal/Client/Exceptions/PathContainsControlChars.php new file mode 100644 index 0000000..4f78d07 --- /dev/null +++ b/src/Internal/Client/Exceptions/PathContainsControlChars.php @@ -0,0 +1,24 @@ +url, query: $request->query, baseUrl: $this->baseUrl); - } catch (InvalidArgumentException) { - throw MalformedPath::fromRequest(request: $request); + $url = Url::compose(path: $request->url(), query: $request->query(), baseUrl: $this->baseUrl); + } catch (PathContainsScheme | PathContainsControlChars $exception) { + throw MalformedPath::fromRequest(request: $request, previous: $exception); } return $request ->withUrl(url: $url) ->withQuery(query: null) - ->withMergedHeaders(defaults: new Headers(entries: self::JSON_DEFAULTS)); + ->withMergedHeaders(defaults: new Headers(entries: RequestResolver::JSON_DEFAULTS)); } } diff --git a/src/Internal/Client/Url.php b/src/Internal/Client/Url.php index f97c10c..e331598 100644 --- a/src/Internal/Client/Url.php +++ b/src/Internal/Client/Url.php @@ -4,34 +4,35 @@ namespace TinyBlocks\Http\Internal\Client; -use InvalidArgumentException; +use TinyBlocks\Http\Internal\Client\Exceptions\PathContainsControlChars; +use TinyBlocks\Http\Internal\Client\Exceptions\PathContainsScheme; -final readonly class Url +final class Url { - private const string CONTROL_REASON = 'Path must not contain control characters.'; private const string CONTROL_CHARS_PATTERN = '/[\x00-\x1F\x7F]/'; - private const string SCHEME_REASON_TEMPLATE = 'Path "%s" must not contain a scheme or be protocol-relative.'; private const string SCHEME_OR_PROTOCOL_RELATIVE_PATTERN = '#^(?://|\\\\\\\\|[a-z][a-z0-9+.-]*:)#i'; - /** @param array|null $query */ public static function compose(string $path, ?array $query, string $baseUrl): string { - if (preg_match(self::SCHEME_OR_PROTOCOL_RELATIVE_PATTERN, $path) === 1) { - throw new InvalidArgumentException(sprintf(self::SCHEME_REASON_TEMPLATE, $path)); + if (preg_match(Url::SCHEME_OR_PROTOCOL_RELATIVE_PATTERN, $path) === 1) { + throw PathContainsScheme::create(path: $path); } - if (preg_match(self::CONTROL_CHARS_PATTERN, $path) === 1) { - throw new InvalidArgumentException(self::CONTROL_REASON); + if (preg_match(Url::CONTROL_CHARS_PATTERN, $path) === 1) { + throw PathContainsControlChars::create(path: $path); } + $joinTemplate = '%s/%s'; $absolute = $baseUrl === '' ? $path - : sprintf('%s/%s', rtrim($baseUrl, '/'), ltrim($path, '/')); + : sprintf($joinTemplate, rtrim($baseUrl, '/'), ltrim($path, '/')); if (is_null($query) || $query === []) { return $absolute; } - return sprintf('%s?%s', $absolute, http_build_query($query, '', '&', PHP_QUERY_RFC3986)); + $queryTemplate = '%s?%s'; + + return sprintf($queryTemplate, $absolute, http_build_query($query, '', '&', PHP_QUERY_RFC3986)); } } diff --git a/src/Internal/Server/CacheControl/CacheControlDirective.php b/src/Internal/Server/CacheControl/CacheControlDirective.php index 55215c1..7e73cd8 100644 --- a/src/Internal/Server/CacheControl/CacheControlDirective.php +++ b/src/Internal/Server/CacheControl/CacheControlDirective.php @@ -12,27 +12,27 @@ private function __construct(private readonly string $value) public static function maxAge(int $maxAgeInWholeSeconds): static { - return new self(value: Directives::MAX_AGE->toHeaderValue(value: $maxAgeInWholeSeconds)); + return new static(value: Directives::MAX_AGE->toHeaderValue(value: $maxAgeInWholeSeconds)); } public static function noCache(): static { - return new self(value: Directives::NO_CACHE->toHeaderValue()); + return new static(value: Directives::NO_CACHE->toHeaderValue()); } public static function noStore(): static { - return new self(value: Directives::NO_STORE->toHeaderValue()); + return new static(value: Directives::NO_STORE->toHeaderValue()); } public static function noTransform(): static { - return new self(value: Directives::NO_TRANSFORM->toHeaderValue()); + return new static(value: Directives::NO_TRANSFORM->toHeaderValue()); } public static function staleIfError(): static { - return new self(value: Directives::STALE_IF_ERROR->toHeaderValue()); + return new static(value: Directives::STALE_IF_ERROR->toHeaderValue()); } public function toString(): string diff --git a/src/Internal/Server/CacheControl/Directives.php b/src/Internal/Server/CacheControl/Directives.php index e1844c1..9e1b196 100644 --- a/src/Internal/Server/CacheControl/Directives.php +++ b/src/Internal/Server/CacheControl/Directives.php @@ -16,9 +16,11 @@ enum Directives: string public function toHeaderValue(?int $value = null): string { + $template = '%s=%d'; + return match ($this) { - self::MAX_AGE => sprintf('%s=%d', $this->value, $value), - default => $this->value + Directives::MAX_AGE => sprintf($template, $this->value, $value), + default => $this->value }; } } diff --git a/src/Internal/Server/Cookies/CookieName.php b/src/Internal/Server/Cookies/CookieName.php index 577e821..a2d0169 100644 --- a/src/Internal/Server/Cookies/CookieName.php +++ b/src/Internal/Server/Cookies/CookieName.php @@ -17,14 +17,14 @@ private function __construct(private string $value) public static function from(string $value): CookieName { if ($value === '' || preg_match('/[\x00-\x1F\x7F]/', $value) === 1) { - throw new CookieNameIsInvalid($value); + throw new CookieNameIsInvalid(name: $value); } - if (strpbrk($value, self::TOKEN_SEPARATORS) !== false) { - throw new CookieNameIsInvalid($value); + if (strpbrk($value, CookieName::TOKEN_SEPARATORS) !== false) { + throw new CookieNameIsInvalid(name: $value); } - return new CookieName($value); + return new CookieName(value: $value); } public function toString(): string diff --git a/src/Internal/Server/Cookies/CookieValue.php b/src/Internal/Server/Cookies/CookieValue.php index 393910d..d75f9a5 100644 --- a/src/Internal/Server/Cookies/CookieValue.php +++ b/src/Internal/Server/Cookies/CookieValue.php @@ -17,14 +17,14 @@ private function __construct(private string $value) public static function from(string $value): CookieValue { if (preg_match('/[\x00-\x1F\x7F]/', $value) === 1) { - throw new CookieValueIsInvalid($value); + throw new CookieValueIsInvalid(value: $value); } - if (strpbrk($value, self::FORBIDDEN_CHARACTERS) !== false) { - throw new CookieValueIsInvalid($value); + if (strpbrk($value, CookieValue::FORBIDDEN_CHARACTERS) !== false) { + throw new CookieValueIsInvalid(value: $value); } - return new CookieValue($value); + return new CookieValue(value: $value); } public function toString(): string diff --git a/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php b/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php index 1dc2574..634a93a 100644 --- a/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php +++ b/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php @@ -13,6 +13,6 @@ final class ConflictingLifetimeAttributes extends DomainException public function __construct() { - parent::__construct(self::REASON); + parent::__construct(message: ConflictingLifetimeAttributes::REASON); } } diff --git a/src/Internal/Server/Exceptions/CookieNameIsInvalid.php b/src/Internal/Server/Exceptions/CookieNameIsInvalid.php index 35a086b..12909fd 100644 --- a/src/Internal/Server/Exceptions/CookieNameIsInvalid.php +++ b/src/Internal/Server/Exceptions/CookieNameIsInvalid.php @@ -13,6 +13,8 @@ final class CookieNameIsInvalid extends InvalidArgumentException public function __construct(string $name) { - parent::__construct(sprintf(self::REASON_TEMPLATE, $name)); + $template = CookieNameIsInvalid::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $name)); } } diff --git a/src/Internal/Server/Exceptions/CookieValueIsInvalid.php b/src/Internal/Server/Exceptions/CookieValueIsInvalid.php index c6e4fb6..fe5a65c 100644 --- a/src/Internal/Server/Exceptions/CookieValueIsInvalid.php +++ b/src/Internal/Server/Exceptions/CookieValueIsInvalid.php @@ -14,6 +14,8 @@ final class CookieValueIsInvalid extends InvalidArgumentException public function __construct(string $value) { - parent::__construct(sprintf(self::REASON_TEMPLATE, $value)); + $template = CookieValueIsInvalid::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $value)); } } diff --git a/src/Internal/Server/Exceptions/InvalidResource.php b/src/Internal/Server/Exceptions/InvalidResource.php deleted file mode 100644 index d9017b3..0000000 --- a/src/Internal/Server/Exceptions/InvalidResource.php +++ /dev/null @@ -1,17 +0,0 @@ -uri; - } - - public function body(): Body - { - return $this->body; - } -} diff --git a/src/Internal/Server/Request/Decoder.php b/src/Internal/Server/Request/Decoder.php index d759aab..e6f29ac 100644 --- a/src/Internal/Server/Request/Decoder.php +++ b/src/Internal/Server/Request/Decoder.php @@ -5,7 +5,9 @@ namespace TinyBlocks\Http\Internal\Server\Request; use Psr\Http\Message\ServerRequestInterface; -use TinyBlocks\Http\Internal\Shared\Body; +use TinyBlocks\Http\Body; +use TinyBlocks\Http\Server\Decoded\DecodedRequest; +use TinyBlocks\Http\Server\Decoded\Uri; final readonly class Decoder { diff --git a/src/Internal/Server/Request/QueryParameters.php b/src/Internal/Server/Request/QueryParameters.php deleted file mode 100644 index d166c2b..0000000 --- a/src/Internal/Server/Request/QueryParameters.php +++ /dev/null @@ -1,34 +0,0 @@ - $data */ - private function __construct(private array $data) - { - } - - public static function from(ServerRequestInterface $request): QueryParameters - { - return new QueryParameters(data: $request->getQueryParams()); - } - - public function get(string $key): Attribute - { - $value = ($this->data[$key] ?? null); - - return Attribute::from(value: $value); - } - - /** @return array */ - public function toArray(): array - { - return $this->data; - } -} diff --git a/src/Internal/Server/Request/RouteParameterResolver.php b/src/Internal/Server/Request/RouteParameterResolver.php index 87196aa..532ba06 100644 --- a/src/Internal/Server/Request/RouteParameterResolver.php +++ b/src/Internal/Server/Request/RouteParameterResolver.php @@ -8,15 +8,6 @@ final readonly class RouteParameterResolver { - private const array KNOWN_ATTRIBUTE_KEYS = [ - '__route__', - '_route_params', - 'route', - 'routing', - 'routeResult', - 'routeInfo' - ]; - private const array OBJECT_METHODS = [ 'getArguments', 'getMatchedParams', @@ -31,6 +22,15 @@ 'parameters' ]; + private const array KNOWN_ATTRIBUTE_KEYS = [ + '__route__', + '_route_params', + 'route', + 'routing', + 'routeResult', + 'routeInfo' + ]; + private function __construct(private ServerRequestInterface $request) { } @@ -40,26 +40,39 @@ public static function from(ServerRequestInterface $request): RouteParameterReso return new RouteParameterResolver(request: $request); } - /** @return array */ - public function resolve(string $attributeName): array + public function resolveAttribute(string $key, string $attributeName, bool $scanKnownAttributes): mixed { + $parameters = $this->resolve(attributeName: $attributeName); + + if (array_key_exists($key, $parameters)) { + return $parameters[$key]; + } + $attribute = $this->request->getAttribute($attributeName); - if (is_array($attribute)) { + if (is_scalar($attribute)) { return $attribute; } - if (is_object($attribute)) { - return $this->extractFromObject(object: $attribute); + return $this->resolveFallback(key: $key, scanKnownAttributes: $scanKnownAttributes); + } + + private function resolveFallback(string $key, bool $scanKnownAttributes): mixed + { + if ($scanKnownAttributes) { + $allKnown = $this->resolveFromKnownAttributes(); + + if (array_key_exists($key, $allKnown)) { + return $allKnown[$key]; + } } - return []; + return $this->request->getAttribute($key); } - /** @return array */ - public function resolveFromKnownAttributes(): array + private function resolveFromKnownAttributes(): array { - foreach (self::KNOWN_ATTRIBUTE_KEYS as $key) { + foreach (RouteParameterResolver::KNOWN_ATTRIBUTE_KEYS as $key) { $parameters = $this->resolve(attributeName: $key); if (!empty($parameters)) { @@ -70,30 +83,39 @@ public function resolveFromKnownAttributes(): array return []; } - public function resolveDirectAttribute(string $key): mixed + private function resolve(string $attributeName): array { - return $this->request->getAttribute($key); + $attribute = $this->request->getAttribute($attributeName); + + if (is_array($attribute)) { + return $attribute; + } + + if (is_object($attribute)) { + return $this->extractFromObject(object: $attribute); + } + + return []; } - /** @return array */ private function extractFromObject(object $object): array { - foreach (self::OBJECT_METHODS as $method) { + foreach (RouteParameterResolver::OBJECT_METHODS as $method) { if (method_exists($object, $method)) { - $result = $object->{$method}(); + $parameters = $object->{$method}(); - if (is_array($result)) { - return $result; + if (is_array($parameters)) { + return $parameters; } } } - foreach (self::OBJECT_PROPERTIES as $property) { + foreach (RouteParameterResolver::OBJECT_PROPERTIES as $property) { if (property_exists($object, $property)) { - $value = $object->{$property}; + $parameters = $object->{$property}; - if (is_array($value)) { - return $value; + if (is_array($parameters)) { + return $parameters; } } } diff --git a/src/Internal/Server/Request/Uri.php b/src/Internal/Server/Request/Uri.php deleted file mode 100644 index 8516688..0000000 --- a/src/Internal/Server/Request/Uri.php +++ /dev/null @@ -1,85 +0,0 @@ -request->getUri()->__toString(); - } - - public function queryParameters(): QueryParameters - { - return QueryParameters::from(request: $this->request); - } - - public function route(string $name = self::ROUTE): Uri - { - return new Uri( - request: $this->request, - resolver: $this->resolver, - routeAttributeName: $name - ); - } - - public function get(string $key): Attribute - { - $value = $this->resolveValue(key: $key); - - return Attribute::from(value: $value); - } - - private function resolveValue(string $key): mixed - { - $parameters = $this->resolver->resolve(attributeName: $this->routeAttributeName); - - if (array_key_exists($key, $parameters)) { - return $parameters[$key]; - } - - $attribute = $this->request->getAttribute($this->routeAttributeName); - - if (is_scalar($attribute)) { - return $attribute; - } - - return $this->resolveFromFallbacks(key: $key); - } - - private function resolveFromFallbacks(string $key): mixed - { - if ($this->routeAttributeName === self::ROUTE) { - $allKnown = $this->resolver->resolveFromKnownAttributes(); - - if (array_key_exists($key, $allKnown)) { - return $allKnown[$key]; - } - } - - return $this->resolver->resolveDirectAttribute(key: $key); - } -} diff --git a/src/Internal/Server/Response/InternalResponse.php b/src/Internal/Server/Response/InternalResponse.php index 8c4c4c5..c61fa8b 100644 --- a/src/Internal/Server/Response/InternalResponse.php +++ b/src/Internal/Server/Response/InternalResponse.php @@ -41,6 +41,46 @@ public static function createWithoutBody(Code $code, Headerable ...$headers): Re ); } + public function hasHeader(string $name): bool + { + return $this->headers->hasHeader(name: $name); + } + + public function getBody(): StreamInterface + { + return $this->body; + } + + public function getHeader(string $name): array + { + return $this->headers->getByName(name: $name); + } + + public function getHeaders(): array + { + return $this->headers->toArray(); + } + + public function getStatusCode(): int + { + return $this->code->value; + } + + public function getHeaderLine(string $name): string + { + return implode(', ', $this->getHeader(name: $name)); + } + + public function getReasonPhrase(): string + { + return $this->code->message(); + } + + public function getProtocolVersion(): string + { + return $this->protocolVersion->version; + } + public function withBody(StreamInterface $body): MessageInterface { return new InternalResponse( @@ -61,8 +101,7 @@ public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterf ); } - /** @param string|list $value */ - public function withHeader(string $name, $value): MessageInterface + public function withHeader(string $name, mixed $value): MessageInterface { return new InternalResponse( body: $this->body, @@ -82,8 +121,7 @@ public function withoutHeader(string $name): MessageInterface ); } - /** @param string|list $value */ - public function withAddedHeader(string $name, $value): MessageInterface + public function withAddedHeader(string $name, mixed $value): MessageInterface { return new InternalResponse( body: $this->body, @@ -104,44 +142,4 @@ public function withProtocolVersion(string $version): MessageInterface protocolVersion: $protocolVersion ); } - - public function hasHeader(string $name): bool - { - return $this->headers->hasHeader(name: $name); - } - - public function getBody(): StreamInterface - { - return $this->body; - } - - public function getHeader(string $name): array - { - return $this->headers->getByName(name: $name); - } - - public function getHeaders(): array - { - return $this->headers->toArray(); - } - - public function getStatusCode(): int - { - return $this->code->value; - } - - public function getHeaderLine(string $name): string - { - return implode(', ', $this->getHeader(name: $name)); - } - - public function getReasonPhrase(): string - { - return $this->code->message(); - } - - public function getProtocolVersion(): string - { - return $this->protocolVersion->version; - } } diff --git a/src/Internal/Server/Response/ProtocolVersion.php b/src/Internal/Server/Response/ProtocolVersion.php index f0509df..5c037be 100644 --- a/src/Internal/Server/Response/ProtocolVersion.php +++ b/src/Internal/Server/Response/ProtocolVersion.php @@ -14,11 +14,11 @@ private function __construct(public string $version) public static function default(): ProtocolVersion { - return new ProtocolVersion(version: self::DEFAULT_PROTOCOL_VERSION); + return new ProtocolVersion(version: ProtocolVersion::DEFAULT_PROTOCOL_VERSION); } public static function from(string $version): ProtocolVersion { - return empty($version) ? self::default() : new ProtocolVersion(version: $version); + return $version === '' ? ProtocolVersion::default() : new ProtocolVersion(version: $version); } } diff --git a/src/Internal/Server/Response/ResponseHeaders.php b/src/Internal/Server/Response/ResponseHeaders.php index e45ca2d..632d436 100644 --- a/src/Internal/Server/Response/ResponseHeaders.php +++ b/src/Internal/Server/Response/ResponseHeaders.php @@ -10,7 +10,6 @@ final readonly class ResponseHeaders { - /** @param array> $headers */ private function __construct(private array $headers) { } @@ -21,7 +20,6 @@ public static function fromOrDefault(Headerable ...$headers): ResponseHeaders return new ResponseHeaders(headers: ContentType::applicationJson(charset: Charset::UTF_8)->toArray()); } - /** @var array> $merged */ $merged = []; foreach ($headers as $header) { @@ -34,50 +32,59 @@ public static function fromOrDefault(Headerable ...$headers): ResponseHeaders return new ResponseHeaders(headers: $merged); } - /** @return list */ + public function hasHeader(string $name): bool + { + return !empty($this->getByName(name: $name)); + } + + public function toArray(): array + { + return $this->headers; + } + public function getByName(string $name): array { $key = $this->findKey(name: $name); - return $key === null ? [] : $this->headers[$key]; + return is_null($key) ? [] : $this->headers[$key]; } - public function hasHeader(string $name): bool + private function findKey(string $name): ?string { - return !empty($this->getByName(name: $name)); + $lowered = strtolower($name); + + return array_find(array_keys($this->headers), static fn(string $key): bool => strtolower($key) === $lowered); } - public function removeByName(string $name): ResponseHeaders + public function withReplaced(string $name, string|array $value): ResponseHeaders { $headers = $this->headers; $existingKey = $this->findKey(name: $name); - - if ($existingKey !== null) { - unset($headers[$existingKey]); - } + $targetKey = $existingKey ?? $name; + $headers[$targetKey] = is_array($value) ? $value : [$value]; return new ResponseHeaders(headers: $headers); } - /** @param string|list $value */ - public function withReplaced(string $name, string|array $value): ResponseHeaders + public function removeByName(string $name): ResponseHeaders { $headers = $this->headers; $existingKey = $this->findKey(name: $name); - $targetKey = $existingKey ?? $name; - $headers[$targetKey] = is_array($value) ? $value : [$value]; + + if (!is_null($existingKey)) { + unset($headers[$existingKey]); + } return new ResponseHeaders(headers: $headers); } - /** @param string|list $value */ public function withAdded(string $name, string|array $value): ResponseHeaders { $headers = $this->headers; $existingKey = $this->findKey(name: $name); $appended = is_array($value) ? $value : [$value]; - if ($existingKey === null) { + if (is_null($existingKey)) { $headers[$name] = $appended; return new ResponseHeaders(headers: $headers); @@ -86,7 +93,7 @@ public function withAdded(string $name, string|array $value): ResponseHeaders $existingValues = $headers[$existingKey]; foreach ($appended as $next) { - if (!in_array($next, $existingValues, strict: true)) { + if (!in_array($next, $existingValues, true)) { $existingValues[] = $next; } } @@ -95,23 +102,4 @@ public function withAdded(string $name, string|array $value): ResponseHeaders return new ResponseHeaders(headers: $headers); } - - /** @return array> */ - public function toArray(): array - { - return $this->headers; - } - - private function findKey(string $name): ?string - { - $lowered = strtolower($name); - - foreach (array_keys($this->headers) as $key) { - if (strtolower($key) === $lowered) { - return $key; - } - } - - return null; - } } diff --git a/src/Internal/Server/Stream/Stream.php b/src/Internal/Server/Stream/Stream.php index dc5dd4d..0d5e0ef 100644 --- a/src/Internal/Server/Stream/Stream.php +++ b/src/Internal/Server/Stream/Stream.php @@ -5,7 +5,6 @@ namespace TinyBlocks\Http\Internal\Server\Stream; use Psr\Http\Message\StreamInterface; -use TinyBlocks\Http\Internal\Server\Exceptions\InvalidResource; use TinyBlocks\Http\Internal\Server\Exceptions\MissingResourceStream; use TinyBlocks\Http\Internal\Server\Exceptions\NonReadableStream; use TinyBlocks\Http\Internal\Server\Exceptions\NonSeekableStream; @@ -15,24 +14,78 @@ final class Stream implements StreamInterface { private const int OFFSET_ZERO = 0; - /** @var resource|null */ + private const array READABLE_MODES = [ + 'r', + 'r+', + 'rb', + 'rb+', + 'r+b', + 'w+', + 'wb+', + 'w+b', + 'a+', + 'ab+', + 'a+b', + 'x+', + 'xb+', + 'x+b', + 'c+', + 'cb+', + 'c+b', + 'rt', + 'r+t', + 'w+t', + 'a+t', + 'x+t', + 'c+t' + ]; + + private const array WRITABLE_MODES = [ + 'w', + 'w+', + 'wb', + 'wb+', + 'w+b', + 'a', + 'a+', + 'ab', + 'ab+', + 'a+b', + 'x', + 'x+', + 'xb', + 'xb+', + 'x+b', + 'c', + 'c+', + 'cb', + 'cb+', + 'c+b', + 'r+', + 'r+b', + 'rb+', + 'wt', + 'w+t', + 'at', + 'a+t', + 'xt', + 'x+t', + 'ct', + 'c+t' + ]; + private mixed $resource; - /** @param resource $resource */ - private function __construct(private readonly string $mode, private readonly bool $seekable, mixed $resource) + private function __construct(private readonly bool $seekable, mixed $resource) { $this->resource = $resource; } public static function from(mixed $resource): Stream { - if (!is_resource($resource)) { - throw new InvalidResource(); - } - $raw = stream_get_meta_data($resource); - return new Stream(mode: $raw['mode'], seekable: $raw['seekable'], resource: $resource); + return new Stream(seekable: $raw['seekable'], resource: $resource); } public function close(): void @@ -47,7 +100,7 @@ public function close(): void fclose($resource); } - public function detach() + public function detach(): mixed { $resource = $this->resource; $this->resource = null; @@ -72,13 +125,7 @@ public function tell(): int throw new MissingResourceStream(); } - $position = ftell($this->resource); - - if ($position === false) { - throw new MissingResourceStream(); - } - - return $position; + return ftell($this->resource); } public function eof(): bool @@ -92,16 +139,12 @@ public function seek(int $offset, int $whence = SEEK_SET): void throw new NonSeekableStream(); } - if (!$this->seekable) { - throw new NonSeekableStream(); - } - fseek($this->resource, $offset, $whence); } public function rewind(): void { - $this->seek(offset: self::OFFSET_ZERO); + $this->seek(Stream::OFFSET_ZERO); } public function read(int $length): string @@ -110,10 +153,6 @@ public function read(int $length): string throw new NonReadableStream(); } - if (!$this->modeAllowsReading()) { - throw new NonReadableStream(); - } - if ($length < 1) { throw new NonReadableStream(); } @@ -129,23 +168,29 @@ public function write(string $string): int throw new NonWritableStream(); } - $written = $this->modeAllowsWriting() ? fwrite($this->resource, $string) : false; - - if ($written === false) { - throw new NonWritableStream(); - } - - return $written; + return fwrite($this->resource, $string); } public function isReadable(): bool { - return is_resource($this->resource) && $this->modeAllowsReading(); + if (!is_resource($this->resource)) { + return false; + } + + $mode = stream_get_meta_data($this->resource)['mode']; + + return in_array($mode, Stream::READABLE_MODES, true); } public function isWritable(): bool { - return is_resource($this->resource) && $this->modeAllowsWriting(); + if (!is_resource($this->resource)) { + return false; + } + + $mode = stream_get_meta_data($this->resource)['mode']; + + return in_array($mode, Stream::WRITABLE_MODES, true); } public function isSeekable(): bool @@ -159,10 +204,6 @@ public function getContents(): string throw new NonReadableStream(); } - if (!$this->modeAllowsReading()) { - throw new NonReadableStream(); - } - $contents = stream_get_contents($this->resource); return $contents === false ? '' : $contents; @@ -191,14 +232,4 @@ public function __toString(): string return $this->getContents(); } - - private function modeAllowsReading(): bool - { - return str_contains($this->mode, 'r') || str_contains($this->mode, '+'); - } - - private function modeAllowsWriting(): bool - { - return strpbrk($this->mode, 'xwca+') !== false; - } } diff --git a/src/Internal/Server/Stream/StreamFactory.php b/src/Internal/Server/Stream/StreamFactory.php index 8931165..0ada976 100644 --- a/src/Internal/Server/Stream/StreamFactory.php +++ b/src/Internal/Server/Stream/StreamFactory.php @@ -15,29 +15,20 @@ private function __construct(private string $body) { - /** @var resource $resource */ $resource = fopen('php://memory', 'wb+'); $this->stream = Stream::from(resource: $resource); } - public static function fromBody(mixed $body): StreamFactory + public static function fromEmptyBody(): StreamFactory { - $dataToWrite = match (true) { - $body instanceof Mapper => $body->toJson(), - $body instanceof BackedEnum => self::toJsonFrom(body: $body->value), - $body instanceof UnitEnum => $body->name, - is_object($body) => self::toJsonFrom(body: get_object_vars($body)), - is_string($body) => $body, - is_scalar($body) || is_array($body) => self::toJsonFrom(body: $body), - default => '' - }; - - return new StreamFactory(body: $dataToWrite); + return new StreamFactory(body: ''); } - public static function fromEmptyBody(): StreamFactory + private static function toJsonFrom(mixed $body): string { - return new StreamFactory(body: ''); + $encoded = json_encode($body, JSON_PRESERVE_ZERO_FRACTION); + + return $encoded === false ? '' : $encoded; } public static function fromStream(StreamInterface $stream): StreamFactory @@ -55,6 +46,21 @@ public static function fromStream(StreamInterface $stream): StreamFactory return new StreamFactory(body: $body); } + public static function fromBody(mixed $body): StreamFactory + { + $dataToWrite = match (true) { + $body instanceof Mapper => $body->toJson(), + $body instanceof BackedEnum => StreamFactory::toJsonFrom(body: $body->value), + $body instanceof UnitEnum => $body->name, + is_object($body) => StreamFactory::toJsonFrom(body: get_object_vars($body)), + is_string($body) => $body, + is_scalar($body) || is_array($body) => StreamFactory::toJsonFrom(body: $body), + default => '' + }; + + return new StreamFactory(body: $dataToWrite); + } + public function content(): string { return $this->body; @@ -67,16 +73,9 @@ public function isEmptyContent(): bool public function write(): StreamInterface { - $this->stream->write(string: $this->body); + $this->stream->write($this->body); $this->stream->rewind(); return $this->stream; } - - private static function toJsonFrom(mixed $body): string - { - $encoded = json_encode($body, JSON_PRESERVE_ZERO_FRACTION); - - return $encoded === false ? '' : $encoded; - } } diff --git a/src/ResponseCacheDirectives.php b/src/ResponseCacheDirectives.php index b9ffbca..cbda0d1 100644 --- a/src/ResponseCacheDirectives.php +++ b/src/ResponseCacheDirectives.php @@ -16,11 +16,21 @@ { use CacheControlDirective; + /** + * Builds a ResponseCacheDirectives with the must-revalidate directive. + * + * @return ResponseCacheDirectives A directive that forbids using a stale response. + */ public static function mustRevalidate(): ResponseCacheDirectives { return new ResponseCacheDirectives(value: Directives::MUST_REVALIDATE->toHeaderValue()); } + /** + * Builds a ResponseCacheDirectives with the proxy-revalidate directive. + * + * @return ResponseCacheDirectives A directive that forbids shared caches from using a stale response. + */ public static function proxyRevalidate(): ResponseCacheDirectives { return new ResponseCacheDirectives(value: Directives::PROXY_REVALIDATE->toHeaderValue()); diff --git a/src/Server/Decoded/DecodedRequest.php b/src/Server/Decoded/DecodedRequest.php new file mode 100644 index 0000000..071accf --- /dev/null +++ b/src/Server/Decoded/DecodedRequest.php @@ -0,0 +1,46 @@ +uri; + } + + /** + * Returns the body. + * + * @return Body The decoded request body. + */ + public function body(): Body + { + return $this->body; + } +} diff --git a/src/Server/Decoded/QueryParameters.php b/src/Server/Decoded/QueryParameters.php new file mode 100644 index 0000000..a9bea41 --- /dev/null +++ b/src/Server/Decoded/QueryParameters.php @@ -0,0 +1,49 @@ +getQueryParams()); + } + + /** + * Returns the QueryParameters as an associative array. + * + * @return array The raw query parameters keyed by name. + */ + public function toArray(): array + { + return $this->data; + } + + /** + * Returns the Attribute associated with the given query key. + * + * @param string $key The query parameter name to look up. + * @return Attribute The Attribute wrapping the value, or wrapping null when absent. + */ + public function get(string $key): Attribute + { + $attributeValue = ($this->data[$key] ?? null); + + return Attribute::from(value: $attributeValue); + } +} diff --git a/src/Server/Decoded/Uri.php b/src/Server/Decoded/Uri.php new file mode 100644 index 0000000..82f0b31 --- /dev/null +++ b/src/Server/Decoded/Uri.php @@ -0,0 +1,88 @@ +request->getUri()->__toString(); + } + + /** + * Returns the query parameters carried by the request URI. + * + * @return QueryParameters The QueryParameters value object built from the request. + */ + public function queryParameters(): QueryParameters + { + return QueryParameters::from(request: $this->request); + } + + /** + * Returns the Attribute associated with the given route key. + * + * @param string $key The route attribute key to look up. + * @return Attribute The Attribute wrapping the resolved value, or wrapping null when absent. + */ + public function get(string $key): Attribute + { + $attributeValue = $this->resolver->resolveAttribute( + key: $key, + attributeName: $this->routeAttributeName, + scanKnownAttributes: $this->routeAttributeName === Uri::ROUTE + ); + + return Attribute::from(value: $attributeValue); + } + + /** + * Returns a copy of the Uri scoped to a different route attribute name. + * + * @param string $name The route attribute name to scope the Uri to. + * @return Uri A new Uri scoped to the supplied attribute name. + */ + public function route(string $name = Uri::ROUTE): Uri + { + return new Uri( + request: $this->request, + resolver: $this->resolver, + routeAttributeName: $name + ); + } +} diff --git a/src/Server/Request.php b/src/Server/Request.php index bd01756..2ab716f 100644 --- a/src/Server/Request.php +++ b/src/Server/Request.php @@ -5,9 +5,9 @@ namespace TinyBlocks\Http\Server; use Psr\Http\Message\ServerRequestInterface; -use TinyBlocks\Http\Internal\Server\Request\DecodedRequest; use TinyBlocks\Http\Internal\Server\Request\Decoder; use TinyBlocks\Http\Method; +use TinyBlocks\Http\Server\Decoded\DecodedRequest; final readonly class Request { @@ -15,18 +15,34 @@ private function __construct(private ServerRequestInterface $request) { } - public static function from(ServerRequestInterface $request): self + /** + * Creates a Request wrapping a PSR-7 server request. + * + * @param ServerRequestInterface $request The incoming PSR-7 server request. + * @return Request A wrapper exposing typed accessors over the PSR-7 request. + */ + public static function from(ServerRequestInterface $request): Request { - return new self(request: $request); + return new Request(request: $request); } + /** + * Decodes the PSR-7 server request into a typed view of URI and body. + * + * @return DecodedRequest A decoded view exposing the URI and the parsed body. + */ public function decode(): DecodedRequest { return Decoder::from(request: $this->request)->decode(); } + /** + * Returns the HTTP method as a typed enum. + * + * @return Method The HTTP method of the underlying PSR-7 request. + */ public function method(): Method { - return Method::from(value: $this->request->getMethod()); + return Method::from($this->request->getMethod()); } } diff --git a/src/Server/Response.php b/src/Server/Response.php index 7210ce6..c45cb16 100644 --- a/src/Server/Response.php +++ b/src/Server/Response.php @@ -9,8 +9,12 @@ use TinyBlocks\Http\Headerable; use TinyBlocks\Http\Internal\Server\Response\InternalResponse; -final readonly class Response implements Responses +final class Response implements Responses { + private function __construct() + { + } + public static function from(mixed $body, Code $code, Headerable ...$headers): ResponseInterface { return InternalResponse::createWithBody($body, $code, ...$headers); @@ -70,4 +74,14 @@ public static function internalServerError(mixed $body, Headerable ...$headers): { return InternalResponse::createWithBody($body, Code::INTERNAL_SERVER_ERROR, ...$headers); } + + public static function badGateway(mixed $body, Headerable ...$headers): ResponseInterface + { + return InternalResponse::createWithBody($body, Code::BAD_GATEWAY, ...$headers); + } + + public static function serviceUnavailable(mixed $body, Headerable ...$headers): ResponseInterface + { + return InternalResponse::createWithBody($body, Code::SERVICE_UNAVAILABLE, ...$headers); + } } diff --git a/src/Server/Responses.php b/src/Server/Responses.php index 6aa9f60..cb31cd5 100644 --- a/src/Server/Responses.php +++ b/src/Server/Responses.php @@ -122,4 +122,22 @@ public static function unprocessableEntity(mixed $body, Headerable ...$headers): * @return ResponseInterface The generated 500 Internal Server Error response. */ public static function internalServerError(mixed $body, Headerable ...$headers): ResponseInterface; + + /** + * Creates a response with a 502 Bad Gateway status. + * + * @param mixed $body The body of the response. + * @param Headerable ...$headers Optional additional headers for the response. + * @return ResponseInterface The generated 502 Bad Gateway response. + */ + public static function badGateway(mixed $body, Headerable ...$headers): ResponseInterface; + + /** + * Creates a response with a 503 Service Unavailable status. + * + * @param mixed $body The body of the response. + * @param Headerable ...$headers Optional additional headers for the response. + * @return ResponseInterface The generated 503 Service Unavailable response. + */ + public static function serviceUnavailable(mixed $body, Headerable ...$headers): ResponseInterface; } diff --git a/src/UserAgent.php b/src/UserAgent.php index 33d5f66..8db0330 100644 --- a/src/UserAgent.php +++ b/src/UserAgent.php @@ -4,14 +4,12 @@ namespace TinyBlocks\Http; -use InvalidArgumentException; +use TinyBlocks\Http\Exceptions\UserAgentProductIsEmpty; final readonly class UserAgent implements Headerable { - private function __construct( - private string $product, - private ?string $version - ) { + private function __construct(private string $product, private ?string $version) + { } /** @@ -24,12 +22,12 @@ private function __construct( * @param string $product The mandatory product token (e.g., "MyApp"). * @param string $version The optional version. Empty string means "absent". * @return UserAgent A new immutable value object. - * @throws InvalidArgumentException When the product token is empty. + * @throws UserAgentProductIsEmpty When the product token is empty. */ public static function from(string $product, string $version = ''): UserAgent { if ($product === '') { - throw new InvalidArgumentException('User-Agent product must not be empty.'); + throw UserAgentProductIsEmpty::create(); } return new UserAgent( @@ -44,6 +42,8 @@ public function toArray(): array return ['User-Agent' => $this->product]; } - return ['User-Agent' => sprintf('%s/%s', $this->product, $this->version)]; + $template = '%s/%s'; + + return ['User-Agent' => sprintf($template, $this->product, $this->version)]; } } From b0133db4f9605b726240c4a33b3237ffc336e4ba Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 16 May 2026 09:11:53 -0300 Subject: [PATCH 26/27] test: Restructure unit tests around public entry points. --- tests/Drivers/Laminas/LaminasTest.php | 24 +- tests/Drivers/Middleware.php | 2 +- tests/Drivers/Slim/SlimTest.php | 24 +- tests/Fixtures/Psr18/ClientException.php | 12 - tests/Models/Products.php | 6 - .../Client => Unit}/CapturingClient.php | 2 +- tests/Unit/Client/RequestTest.php | 155 ++++-- tests/Unit/Client/ResponseTest.php | 45 +- .../Transports/InMemoryTransportTest.php | 102 +++- .../Transports/NetworkTransportTest.php | 125 ++++- tests/Unit/CodeTest.php | 54 +- tests/Unit/CookieTest.php | 81 +-- .../HttpConfigurationInvalidTest.php | 32 -- .../Unit/Exceptions/HttpNetworkFailedTest.php | 76 --- .../Unit/Exceptions/HttpRequestFailedTest.php | 81 --- .../Exceptions/HttpRequestInvalidTest.php | 76 --- tests/Unit/Exceptions/MalformedPathTest.php | 53 -- tests/Unit/Exceptions/NoMoreResponsesTest.php | 32 -- tests/Unit/FailingTransport.php | 63 +++ tests/Unit/HeadersTest.php | 32 +- tests/Unit/HttpBuilderTest.php | 65 ++- tests/Unit/HttpTest.php | 416 +++++++++++++++- tests/Unit/Internal/Client/CursorTest.php | 50 -- .../Internal/Client/RequestResolverTest.php | 88 ---- tests/Unit/Internal/Client/UrlTest.php | 125 ----- .../Request/RouteParameterResolverTest.php | 169 ------- .../Server/Stream/StreamFactoryTest.php | 94 ---- .../Internal/Server/Stream/StreamTest.php | 375 -------------- tests/Unit/PsrClientException.php | 12 + .../PsrNetworkException.php} | 4 +- .../PsrRequestException.php} | 4 +- tests/Unit/SameSiteTest.php | 1 - tests/Unit/Server/HeadersTest.php | 131 ++--- tests/Unit/Server/ProtocolVersionTest.php | 2 +- tests/Unit/Server/RequestTest.php | 45 +- tests/Unit/Server/ResponseTest.php | 470 +++++++++++++++++- tests/Unit/Server/ResponseWithCookiesTest.php | 6 +- .../Client => Unit}/ThrowingClient.php | 2 +- tests/Unit/UserAgentTest.php | 6 +- 39 files changed, 1582 insertions(+), 1560 deletions(-) delete mode 100644 tests/Fixtures/Psr18/ClientException.php rename tests/{Fixtures/Client => Unit}/CapturingClient.php (93%) delete mode 100644 tests/Unit/Exceptions/HttpConfigurationInvalidTest.php delete mode 100644 tests/Unit/Exceptions/HttpNetworkFailedTest.php delete mode 100644 tests/Unit/Exceptions/HttpRequestFailedTest.php delete mode 100644 tests/Unit/Exceptions/HttpRequestInvalidTest.php delete mode 100644 tests/Unit/Exceptions/MalformedPathTest.php delete mode 100644 tests/Unit/Exceptions/NoMoreResponsesTest.php create mode 100644 tests/Unit/FailingTransport.php delete mode 100644 tests/Unit/Internal/Client/CursorTest.php delete mode 100644 tests/Unit/Internal/Client/RequestResolverTest.php delete mode 100644 tests/Unit/Internal/Client/UrlTest.php delete mode 100644 tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php delete mode 100644 tests/Unit/Internal/Server/Stream/StreamFactoryTest.php delete mode 100644 tests/Unit/Internal/Server/Stream/StreamTest.php create mode 100644 tests/Unit/PsrClientException.php rename tests/{Fixtures/Psr18/NetworkException.php => Unit/PsrNetworkException.php} (70%) rename tests/{Fixtures/Psr18/RequestException.php => Unit/PsrRequestException.php} (70%) rename tests/{Fixtures/Client => Unit}/ThrowingClient.php (92%) diff --git a/tests/Drivers/Laminas/LaminasTest.php b/tests/Drivers/Laminas/LaminasTest.php index cbcacd5..e6482d5 100644 --- a/tests/Drivers/Laminas/LaminasTest.php +++ b/tests/Drivers/Laminas/LaminasTest.php @@ -33,15 +33,15 @@ public function testProcessWhenLaminasMiddlewareInvokedThenReturnsConfiguredResp /** @Given a valid request */ $request = new ServerRequest(method: 'GET', uri: 'https://api.example.com/'); - /** @And the Content-Type and Cache-Control headers are set */ - $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()); - - /** @And an HTTP response is created with a 200 OK status and a JSON body */ - $response = Response::ok(['createdAt' => date(DateTimeInterface::ATOM)], $contentType, $cacheControl); + /** @And an HTTP response is created with a 200 OK status, a JSON body, Content-Type, and Cache-Control */ + $response = Response::ok( + ['createdAt' => date(DateTimeInterface::ATOM)], + ContentType::applicationJson(charset: Charset::UTF_8), + CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()) + ); /** @When the request is processed by the handler */ - $actual = $this->middleware->process(request: $request, handler: new Endpoint(response: $response)); + $actual = $this->middleware->process($request, new Endpoint(response: $response)); /** @Then the response is returned through the middleware unchanged */ self::assertSame(Code::OK->value, $actual->getStatusCode()); @@ -52,13 +52,11 @@ public function testProcessWhenLaminasMiddlewareInvokedThenReturnsConfiguredResp public function testEmitWhenLaminasEmitterUsedThenWritesBodyToOutputBuffer(): void { /** @Given a response with Content-Type, Cache-Control, and a custom header */ - $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()); $response = Response::ok( ['createdAt' => date(DateTimeInterface::ATOM)], - $contentType, - $cacheControl - )->withHeader(name: 'X-Request-ID', value: '123456'); + ContentType::applicationJson(charset: Charset::UTF_8), + CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()) + )->withHeader('X-Request-ID', '123456'); /** @When the response is emitted */ ob_start(); @@ -69,6 +67,6 @@ public function testEmitWhenLaminasEmitterUsedThenWritesBodyToOutputBuffer(): vo self::assertSame($response->getBody()->__toString(), $actual); self::assertSame(200, $response->getStatusCode()); self::assertSame('OK', $response->getReasonPhrase()); - self::assertSame('123456', $response->getHeaderLine(name: 'X-Request-ID')); + self::assertSame('123456', $response->getHeaderLine('X-Request-ID')); } } diff --git a/tests/Drivers/Middleware.php b/tests/Drivers/Middleware.php index 93ff112..942f84c 100644 --- a/tests/Drivers/Middleware.php +++ b/tests/Drivers/Middleware.php @@ -13,6 +13,6 @@ { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - return $handler->handle(request: $request); + return $handler->handle($request); } } diff --git a/tests/Drivers/Slim/SlimTest.php b/tests/Drivers/Slim/SlimTest.php index 181dfa5..3d8c1db 100644 --- a/tests/Drivers/Slim/SlimTest.php +++ b/tests/Drivers/Slim/SlimTest.php @@ -33,15 +33,15 @@ public function testProcessWhenSlimMiddlewareInvokedThenReturnsConfiguredRespons /** @Given a valid request */ $request = new ServerRequest(method: 'GET', uri: 'https://api.example.com/'); - /** @And the Content-Type and Cache-Control headers are set */ - $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()); - - /** @And an HTTP response is created with a 200 OK status and a JSON body */ - $response = Response::ok(['createdAt' => date(DateTimeInterface::ATOM)], $contentType, $cacheControl); + /** @And an HTTP response is created with a 200 OK status, a JSON body, Content-Type, and Cache-Control */ + $response = Response::ok( + ['createdAt' => date(DateTimeInterface::ATOM)], + ContentType::applicationJson(charset: Charset::UTF_8), + CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()) + ); /** @When the request is processed by the handler */ - $actual = $this->middleware->process(request: $request, handler: new Endpoint(response: $response)); + $actual = $this->middleware->process($request, new Endpoint(response: $response)); /** @Then the response is returned through the middleware unchanged */ self::assertSame(Code::OK->value, $actual->getStatusCode()); @@ -52,13 +52,11 @@ public function testProcessWhenSlimMiddlewareInvokedThenReturnsConfiguredRespons public function testEmitWhenSlimEmitterUsedThenWritesBodyToOutputBuffer(): void { /** @Given a response with Content-Type, Cache-Control, and a custom header */ - $contentType = ContentType::applicationJson(charset: Charset::UTF_8); - $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()); $response = Response::ok( ['createdAt' => date(DateTimeInterface::ATOM)], - $contentType, - $cacheControl - )->withHeader(name: 'X-Request-ID', value: '123456'); + ContentType::applicationJson(charset: Charset::UTF_8), + CacheControl::fromResponseDirectives(ResponseCacheDirectives::noCache()) + )->withHeader('X-Request-ID', '123456'); /** @When the response is emitted */ ob_start(); @@ -69,6 +67,6 @@ public function testEmitWhenSlimEmitterUsedThenWritesBodyToOutputBuffer(): void self::assertSame($response->getBody()->__toString(), $actual); self::assertSame(200, $response->getStatusCode()); self::assertSame('OK', $response->getReasonPhrase()); - self::assertSame('123456', $response->getHeaderLine(name: 'X-Request-ID')); + self::assertSame('123456', $response->getHeaderLine('X-Request-ID')); } } diff --git a/tests/Fixtures/Psr18/ClientException.php b/tests/Fixtures/Psr18/ClientException.php deleted file mode 100644 index 2f73014..0000000 --- a/tests/Fixtures/Psr18/ClientException.php +++ /dev/null @@ -1,12 +0,0 @@ - - */ final class Products implements IterableMapper, IteratorAggregate { use IterableMappability; - /** @var list */ private array $elements; - /** @param iterable $elements */ public function __construct(iterable $elements = []) { $this->elements = is_array($elements) ? array_values($elements) : iterator_to_array($elements, false); } - /** @return Traversable */ public function getIterator(): Traversable { return new ArrayIterator($this->elements); diff --git a/tests/Fixtures/Client/CapturingClient.php b/tests/Unit/CapturingClient.php similarity index 93% rename from tests/Fixtures/Client/CapturingClient.php rename to tests/Unit/CapturingClient.php index a6ee4ac..b4e1272 100644 --- a/tests/Fixtures/Client/CapturingClient.php +++ b/tests/Unit/CapturingClient.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http\Fixtures\Client; +namespace Test\TinyBlocks\Http\Unit; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Client\ClientInterface; diff --git a/tests/Unit/Client/RequestTest.php b/tests/Unit/Client/RequestTest.php index fb284ec..bfa49ad 100644 --- a/tests/Unit/Client/RequestTest.php +++ b/tests/Unit/Client/RequestTest.php @@ -15,52 +15,80 @@ final class RequestTest extends TestCase { - public function testCreateWhenMinimalParametersGivenThenDefaultsToGet(): void + public function testCreateWhenMinimalParametersGivenThenAccessorsReturnSuppliedValues(): void { - /** @When creating a request with only a URL */ - $request = Request::create(url: 'https://api.example.com/dragons'); - - /** @Then defaults are applied */ - self::assertSame('https://api.example.com/dragons', $request->url); - self::assertSame(Method::GET, $request->method); - self::assertNull($request->body); - self::assertNull($request->query); - self::assertSame([], $request->headers->toArray()); + /** @When creating a request with a URL and empty headers */ + $request = Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @Then accessors return the supplied values */ + self::assertSame('https://api.example.com/dragons', $request->url()); + self::assertSame(Method::GET, $request->method()); + self::assertNull($request->body()); + self::assertNull($request->query()); + self::assertSame([], $request->headers()->toArray()); } public function testCreateWhenNullBodyGivenThenCarriesNoBody(): void { /** @When creating a request with an explicit null body */ - $request = Request::create(url: '/dragons'); + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); /** @Then the body is null */ - self::assertNull($request->body); + self::assertNull($request->body()); } public function testCreateWhenMultipleHeadersGivenThenMergesEntries(): void { - /** @Given two distinct headers */ + /** @Given a Content-Type header with charset */ $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + + /** @And another Content-Type header without charset */ $accept = ContentType::applicationJson(); - /** @When creating a request with both headers via the variadic */ - $request = Request::create('/dragons', null, null, Method::POST, $contentType, $accept); + /** @When creating a request with both headers merged */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::POST, + headers: Headers::from($contentType, $accept) + ); /** @Then the merged headers contain Content-Type */ - self::assertTrue($request->headers->has('Content-Type')); + self::assertTrue($request->headers()->has('Content-Type')); } public function testCreateWhenSameHeaderProvidedTwiceThenLastOneWins(): void { - /** @Given two Content-Type headers with different values */ + /** @Given a Content-Type header with charset */ $first = ContentType::applicationJson(charset: Charset::UTF_8); + + /** @And another Content-Type header without charset */ $second = ContentType::applicationJson(); /** @When creating the request with both (last one wins) */ - $request = Request::create('/dragons', null, null, Method::POST, $first, $second); + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::POST, + headers: Headers::from($first, $second) + ); /** @Then the last one wins for the Content-Type key */ - self::assertSame('application/json', $request->headers->get('Content-Type')); + self::assertSame('application/json', $request->headers()->get('Content-Type')); } public function testCreateWhenQueryGivenThenPreservesArrayInProperty(): void @@ -69,51 +97,77 @@ public function testCreateWhenQueryGivenThenPreservesArrayInProperty(): void $query = ['sort' => 'name', 'order' => 'asc']; /** @When creating the request with query */ - $request = Request::create(url: '/dragons', query: $query); + $request = Request::create( + url: '/dragons', + body: null, + query: $query, + method: Method::GET, + headers: Headers::from() + ); /** @Then the query is preserved */ - self::assertSame($query, $request->query); + self::assertSame($query, $request->query()); } public function testWithUrlWhenInvokedThenReturnsNewInstanceWithReplacedUrl(): void { /** @Given a request with an original URL */ - $request = Request::create(url: '/dragons'); + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); /** @When calling withUrl */ $updated = $request->withUrl(url: '/dragons/42'); /** @Then a new instance is returned with the URL replaced */ self::assertNotSame($request, $updated); - self::assertSame('/dragons/42', $updated->url); - self::assertSame('/dragons', $request->url); + self::assertSame('/dragons/42', $updated->url()); + self::assertSame('/dragons', $request->url()); } public function testWithQueryWhenInvokedThenReturnsNewInstanceWithReplacedQuery(): void { /** @Given a request with an original query */ - $request = Request::create(url: '/dragons', query: ['sort' => 'name']); + $request = Request::create( + url: '/dragons', + body: null, + query: ['sort' => 'name'], + method: Method::GET, + headers: Headers::from() + ); /** @When calling withQuery */ $updated = $request->withQuery(query: ['order' => 'asc']); /** @Then a new instance is returned with the query replaced */ self::assertNotSame($request, $updated); - self::assertSame(['order' => 'asc'], $updated->query); - self::assertSame(['sort' => 'name'], $request->query); + self::assertSame(['order' => 'asc'], $updated->query()); + self::assertSame(['sort' => 'name'], $request->query()); } public function testCreateWhenDistinctKeyHeadersGivenThenBothPresent(): void { - /** @Given two headers with distinct keys */ + /** @Given a Content-Type header */ $contentType = ContentType::applicationJson(); + + /** @And a Cache-Control header */ $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::mustRevalidate()); /** @When creating a request with both headers */ - $request = Request::create('/dragons', null, null, Method::GET, $contentType, $cacheControl); + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from($contentType, $cacheControl) + ); /** @Then both header keys are present in the merged result */ - self::assertCount(2, $request->headers->toArray()); + self::assertCount(2, $request->headers()->toArray()); } public function testWithMergedHeadersWhenCustomConflictsWithDefaultThenCustomWins(): void @@ -121,17 +175,21 @@ public function testWithMergedHeadersWhenCustomConflictsWithDefaultThenCustomWin /** @Given a request with a custom Content-Type header */ $request = Request::create( url: '/dragons', + body: null, + query: null, method: Method::POST, - headers: ContentType::applicationJson(charset: Charset::UTF_8) + headers: Headers::from(ContentType::applicationJson(charset: Charset::UTF_8)) ); - /** @When merging defaults that include the same header */ + /** @And defaults that include the same header */ $defaults = new Headers(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); + + /** @When merging defaults under the existing headers */ $resolved = $request->withMergedHeaders(defaults: $defaults); /** @Then the custom header wins over the default */ - self::assertSame('application/json; charset=utf-8', $resolved->headers->get('Content-Type')); - self::assertSame('application/json', $resolved->headers->get('Accept')); + self::assertSame('application/json; charset=utf-8', $resolved->headers()->get('Content-Type')); + self::assertSame('application/json', $resolved->headers()->get('Accept')); } public function testHeadersWhenMixedCaseGivenThenLookupIsCaseInsensitive(): void @@ -139,32 +197,47 @@ public function testHeadersWhenMixedCaseGivenThenLookupIsCaseInsensitive(): void /** @Given a request with a Content-Type header */ $request = Request::create( url: '/dragons', - headers: ContentType::applicationJson() + body: null, + query: null, + method: Method::GET, + headers: Headers::from(ContentType::applicationJson()) ); /** @When looking up the header with different casing */ /** @Then the lookup succeeds regardless of case */ - self::assertTrue($request->headers->has('content-type')); - self::assertSame('application/json', $request->headers->get('CONTENT-TYPE')); + self::assertTrue($request->headers()->has('content-type')); + self::assertSame('application/json', $request->headers()->get('CONTENT-TYPE')); } public function testHeadersGetWhenMissingKeyGivenThenReturnsNull(): void { /** @Given a request with no headers */ - $request = Request::create(url: '/dragons'); + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); /** @When looking up a non-existent header */ /** @Then null is returned */ - self::assertNull($request->headers->get('X-Missing')); + self::assertNull($request->headers()->get('X-Missing')); } public function testHeadersWhenRequestCreatedThenExposesHeadersInstance(): void { /** @Given a request */ - $request = Request::create(url: '/dragons'); + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); /** @When accessing headers */ /** @Then a Headers instance is returned */ - self::assertInstanceOf(Headers::class, $request->headers); + self::assertInstanceOf(Headers::class, $request->headers()); } } diff --git a/tests/Unit/Client/ResponseTest.php b/tests/Unit/Client/ResponseTest.php index cf91412..b16b3ef 100644 --- a/tests/Unit/Client/ResponseTest.php +++ b/tests/Unit/Client/ResponseTest.php @@ -61,7 +61,7 @@ public function testFromWhenNonJsonBodyGivenThenReturnsSafeEmptyArray(): void self::assertSame([], $response->body()->toArray()); } - public function testFromWhen200ResponseGivenThenIsSuccessAndNotError(): void + public function testFromWhen200ResponseGivenThenIsSuccess(): void { /** @Given a 200 response */ $psrResponse = $this->factory->createResponse(200); @@ -69,12 +69,23 @@ public function testFromWhen200ResponseGivenThenIsSuccessAndNotError(): void /** @When wrapping the PSR response */ $response = Response::from(response: $psrResponse); - /** @Then isSuccess is true and isError is false */ + /** @Then isSuccess is true */ self::assertTrue($response->isSuccess()); + } + + public function testFromWhen200ResponseGivenThenIsNotError(): void + { + /** @Given a 200 response */ + $psrResponse = $this->factory->createResponse(200); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then isError is false */ self::assertFalse($response->isError()); } - public function testFromWhen500ResponseGivenThenIsErrorAndNotSuccess(): void + public function testFromWhen500ResponseGivenThenIsError(): void { /** @Given a 500 response */ $psrResponse = $this->factory->createResponse(500); @@ -82,8 +93,19 @@ public function testFromWhen500ResponseGivenThenIsErrorAndNotSuccess(): void /** @When wrapping the PSR response */ $response = Response::from(response: $psrResponse); - /** @Then isError is true and isSuccess is false */ + /** @Then isError is true */ self::assertTrue($response->isError()); + } + + public function testFromWhen500ResponseGivenThenIsNotSuccess(): void + { + /** @Given a 500 response */ + $psrResponse = $this->factory->createResponse(500); + + /** @When wrapping the PSR response */ + $response = Response::from(response: $psrResponse); + + /** @Then isSuccess is false */ self::assertFalse($response->isSuccess()); } @@ -116,9 +138,14 @@ public function testRawWhenPsrResponseWrappedThenReturnsUnderlyingInstance(): vo public function testWithWhenCodeAndBodyGivenThenSynthesizesAccessibleResponse(): void { - /** @Given code and body data */ + /** @Given a status code and a body payload */ + $code = Code::CREATED; + + /** @And a body payload */ + $body = ['id' => 1]; + /** @When synthesizing a response via with() */ - $response = Response::with(code: Code::CREATED, body: ['id' => 1]); + $response = Response::with(code: $code, body: $body); /** @Then code and body are accessible */ self::assertSame(Code::CREATED, $response->code()); @@ -142,9 +169,11 @@ public function testRawWhenSynthesizedResponseGivenThenThrowsSynthesizedResponse public function testWithWhenNullBodyGivenThenReturnsEmptyArray(): void { - /** @Given a synthesized response with null body */ + /** @Given a status code with no body payload */ + $code = Code::NO_CONTENT; + /** @When creating the response */ - $response = Response::with(code: Code::NO_CONTENT); + $response = Response::with(code: $code); /** @Then body is empty */ self::assertSame([], $response->body()->toArray()); diff --git a/tests/Unit/Client/Transports/InMemoryTransportTest.php b/tests/Unit/Client/Transports/InMemoryTransportTest.php index d0c92cf..34fdb50 100644 --- a/tests/Unit/Client/Transports/InMemoryTransportTest.php +++ b/tests/Unit/Client/Transports/InMemoryTransportTest.php @@ -10,34 +10,60 @@ use TinyBlocks\Http\Client\Transports\InMemoryTransport; use TinyBlocks\Http\Code; use TinyBlocks\Http\Exceptions\NoMoreResponses; +use TinyBlocks\Http\Headers; +use TinyBlocks\Http\Method; final class InMemoryTransportTest extends TestCase { public function testSendWhenMultipleResponsesQueuedThenServesInFifoOrder(): void { - /** @Given a transport seeded with two responses */ + /** @Given a first queued response carrying OK */ $first = Response::with(code: Code::OK); + + /** @And a second queued response carrying CREATED */ $second = Response::with(code: Code::CREATED); + + /** @And a transport seeded with both responses */ $transport = InMemoryTransport::with(responses: [$first, $second]); - /** @When calling send twice */ - $request = Request::create(url: '/dragons'); - $responseOne = $transport->send(request: $request); - $responseTwo = $transport->send(request: $request); + /** @And a request to dispatch */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When the queue is drained twice */ + $drained = [ + $transport->send(request: $request), + $transport->send(request: $request) + ]; - /** @Then responses are returned in FIFO order */ - self::assertSame(Code::OK, $responseOne->code()); - self::assertSame(Code::CREATED, $responseTwo->code()); + /** @Then the drained sequence preserves FIFO order */ + self::assertSame(Code::OK, $drained[0]->code()); + self::assertSame(Code::CREATED, $drained[1]->code()); } public function testSendWhenQueueExhaustedThenThrowsNoMoreResponses(): void { - /** @Given a transport seeded with one response that is already consumed */ + /** @Given a transport seeded with one response */ $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); - $request = Request::create(url: '/dragons'); + + /** @And a request to dispatch */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @And the seeded response is already consumed */ $transport->send(request: $request); - /** @Then NoMoreResponses is thrown on the second call */ + /** @Then NoMoreResponses is thrown on the next call */ $this->expectException(NoMoreResponses::class); /** @When sending a second request */ @@ -49,22 +75,62 @@ public function testSendWhenQueueEmptyThenThrowsNoMoreResponsesImmediately(): vo /** @Given a transport seeded with zero responses */ $transport = InMemoryTransport::with(responses: []); + /** @And a request to dispatch */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + /** @Then NoMoreResponses is thrown immediately */ $this->expectException(NoMoreResponses::class); - /** @When calling send */ - $transport->send(request: Request::create(url: '/dragons')); + /** @When sending a request against the empty queue */ + $transport->send(request: $request); } - public function testSendWhenSingleResponseQueuedThenReturnsIt(): void + public function testSendWhenQueueEmptyThenExceptionMessageReferencesExhaustedIndex(): void { - /** @Given a transport seeded with one response */ + /** @Given a transport seeded with zero responses */ + $transport = InMemoryTransport::with(responses: []); + + /** @And a request to dispatch */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @Then the raised exception message references the exhausted index */ + $this->expectException(NoMoreResponses::class); + $this->expectExceptionMessage('InMemoryTransport has no response queued at index 0'); + + /** @When sending a request against the empty queue */ + $transport->send(request: $request); + } + + public function testSendWhenSingleResponseQueuedThenReturnsTheQueuedResponse(): void + { + /** @Given a transport seeded with a single CREATED response */ $transport = InMemoryTransport::with(responses: [Response::with(code: Code::CREATED)]); - /** @When sending one request */ - $response = $transport->send(request: Request::create(url: '/dragons')); + /** @And a request to dispatch */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When the request is sent */ + $response = $transport->send(request: $request); - /** @Then the response is correct */ + /** @Then the returned response carries the queued CREATED code */ self::assertSame(Code::CREATED, $response->code()); } } diff --git a/tests/Unit/Client/Transports/NetworkTransportTest.php b/tests/Unit/Client/Transports/NetworkTransportTest.php index 72adedd..bb45568 100644 --- a/tests/Unit/Client/Transports/NetworkTransportTest.php +++ b/tests/Unit/Client/Transports/NetworkTransportTest.php @@ -6,11 +6,11 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; -use Test\TinyBlocks\Http\Fixtures\Client\CapturingClient; -use Test\TinyBlocks\Http\Fixtures\Client\ThrowingClient; -use Test\TinyBlocks\Http\Fixtures\Psr18\ClientException; -use Test\TinyBlocks\Http\Fixtures\Psr18\NetworkException; -use Test\TinyBlocks\Http\Fixtures\Psr18\RequestException; +use Test\TinyBlocks\Http\Unit\CapturingClient; +use Test\TinyBlocks\Http\Unit\PsrClientException; +use Test\TinyBlocks\Http\Unit\PsrNetworkException; +use Test\TinyBlocks\Http\Unit\PsrRequestException; +use Test\TinyBlocks\Http\Unit\ThrowingClient; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Client\Transports\NetworkTransport; use TinyBlocks\Http\Code; @@ -40,7 +40,9 @@ public function testSendWhenBodyGivenThenForwardsJsonAndContentTypeHeader(): voi request: Request::create( url: 'https://api.example.com/dragons', body: ['name' => 'Hydra'], - method: Method::POST + query: null, + method: Method::POST, + headers: Headers::from() )->withMergedHeaders(defaults: new Headers(entries: ['Content-Type' => 'application/json'])) ); @@ -57,7 +59,13 @@ public function testSendWhenNoBodyGivenThenForwardsEmptyBody(): void $transport = NetworkTransport::with(client: $client, factory: $this->factory); /** @When sending a request without body */ - $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); + $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); /** @Then the PSR-7 request body is empty */ self::assertNotNull($client->captured); @@ -72,8 +80,13 @@ public function testSendWhenCustomHeaderMergedThenForwardsToPsrRequest(): void /** @When sending a request with a custom header merged in */ $transport->send( - request: Request::create(url: 'https://api.example.com/dragons') - ->withMergedHeaders(defaults: new Headers(entries: ['X-Correlation-ID' => 'abc-123'])) + request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )->withMergedHeaders(defaults: new Headers(entries: ['X-Correlation-ID' => 'abc-123'])) ); /** @Then the PSR-7 request carries the custom header */ @@ -85,7 +98,7 @@ public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFai { /** @Given a PSR-18 client that throws NetworkExceptionInterface */ $transport = NetworkTransport::with( - client: ThrowingClient::throwing(exception: new NetworkException('connection refused')), + client: ThrowingClient::throwing(exception: new PsrNetworkException('connection refused')), factory: $this->factory ); @@ -93,14 +106,20 @@ public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFai $this->expectException(HttpNetworkFailed::class); /** @When sending the request */ - $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); + $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); } public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInvalid(): void { /** @Given a PSR-18 client that throws RequestExceptionInterface */ $transport = NetworkTransport::with( - client: ThrowingClient::throwing(exception: new RequestException('bad request')), + client: ThrowingClient::throwing(exception: new PsrRequestException('bad request')), factory: $this->factory ); @@ -108,14 +127,20 @@ public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInv $this->expectException(HttpRequestInvalid::class); /** @When sending the request */ - $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); + $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); } public function testSendWhenClientRaisesGenericClientExceptionThenThrowsHttpRequestFailed(): void { /** @Given a PSR-18 client that throws a generic ClientExceptionInterface */ $transport = NetworkTransport::with( - client: ThrowingClient::throwing(exception: new ClientException('generic failure')), + client: ThrowingClient::throwing(exception: new PsrClientException('generic failure')), factory: $this->factory ); @@ -123,7 +148,65 @@ public function testSendWhenClientRaisesGenericClientExceptionThenThrowsHttpRequ $this->expectException(HttpRequestFailed::class); /** @When sending the request */ - $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); + $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); + } + + public function testSendWhenClientRaisesRequestExceptionThenExceptionMessageDescribesInvalidRequest(): void + { + /** @Given a transport whose client throws RequestExceptionInterface */ + $transport = NetworkTransport::with( + client: ThrowingClient::throwing(exception: new PsrRequestException('bad request')), + factory: $this->factory + ); + + try { + /** @When sending the request */ + $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::POST, + headers: Headers::from() + )); + self::fail('HttpRequestInvalid was expected.'); + } catch (HttpRequestInvalid $exception) { + /** @Then the message names the method, the URL, and the client-supplied reason */ + self::assertStringContainsString('POST', $exception->getMessage()); + self::assertStringContainsString('https://api.example.com/dragons', $exception->getMessage()); + self::assertStringContainsString('bad request', $exception->getMessage()); + } + } + + public function testSendWhenClientRaisesGenericClientExceptionThenExceptionMessageDescribesClientFailure(): void + { + /** @Given a transport whose client throws a generic ClientExceptionInterface */ + $transport = NetworkTransport::with( + client: ThrowingClient::throwing(exception: new PsrClientException('generic failure')), + factory: $this->factory + ); + + try { + /** @When sending the request */ + $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::DELETE, + headers: Headers::from() + )); + self::fail('HttpRequestFailed was expected.'); + } catch (HttpRequestFailed $exception) { + /** @Then the message names the method, the URL, and the client-supplied reason */ + self::assertStringContainsString('DELETE', $exception->getMessage()); + self::assertStringContainsString('https://api.example.com/dragons', $exception->getMessage()); + self::assertStringContainsString('generic failure', $exception->getMessage()); + } } public function testSendWhenSuccessfulPsrResponseGivenThenWrapsInClientResponse(): void @@ -133,7 +216,13 @@ public function testSendWhenSuccessfulPsrResponseGivenThenWrapsInClientResponse( $transport = NetworkTransport::with(client: $client, factory: $this->factory); /** @When sending a request */ - $response = $transport->send(request: Request::create(url: 'https://api.example.com/dragons')); + $response = $transport->send(request: Request::create( + url: 'https://api.example.com/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); /** @Then the response code is correct */ self::assertSame(Code::OK, $response->code()); @@ -150,7 +239,9 @@ public function testSendWhenBodyHasInvalidUtf8ThenSubstitutesAndStillSends(): vo request: Request::create( url: 'https://api.example.com/dragons', body: ['value' => "\xB0\xB1\xB2"], - method: Method::POST + query: null, + method: Method::POST, + headers: Headers::from() ) ); diff --git a/tests/Unit/CodeTest.php b/tests/Unit/CodeTest.php index de3e758..5b8e713 100644 --- a/tests/Unit/CodeTest.php +++ b/tests/Unit/CodeTest.php @@ -54,25 +54,54 @@ public function testIsSuccessCodeWhenIntegerGivenThenReturnsExpected(int $code, self::assertSame($expected, $actual); } - public function testIsSuccessWhenCodeOkGivenThenReturnsTrueAndIsErrorFalse(): void + public function testIsSuccessWhenCodeOkGivenThenReturnsTrue(): void { /** @Given Code::OK */ - /** @When checking the instance methods */ - /** @Then isSuccess is true and isError is false */ - self::assertTrue(Code::OK->isSuccess()); - self::assertFalse(Code::OK->isError()); + $code = Code::OK; + + /** @When invoking isSuccess */ + $actual = $code->isSuccess(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsErrorWhenCodeOkGivenThenReturnsFalse(): void + { + /** @Given Code::OK */ + $code = Code::OK; + + /** @When invoking isError */ + $actual = $code->isError(); + + /** @Then the result is false */ + self::assertFalse($actual); } - public function testIsErrorWhenCodeInternalServerErrorGivenThenReturnsTrueAndIsSuccessFalse(): void + public function testIsErrorWhenCodeInternalServerErrorGivenThenReturnsTrue(): void { /** @Given Code::INTERNAL_SERVER_ERROR */ - /** @When checking the instance methods */ - /** @Then isError is true and isSuccess is false */ - self::assertTrue(Code::INTERNAL_SERVER_ERROR->isError()); - self::assertFalse(Code::INTERNAL_SERVER_ERROR->isSuccess()); + $code = Code::INTERNAL_SERVER_ERROR; + + /** @When invoking isError */ + $actual = $code->isError(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsSuccessWhenCodeInternalServerErrorGivenThenReturnsFalse(): void + { + /** @Given Code::INTERNAL_SERVER_ERROR */ + $code = Code::INTERNAL_SERVER_ERROR; + + /** @When invoking isSuccess */ + $actual = $code->isSuccess(); + + /** @Then the result is false */ + self::assertFalse($actual); } - /** @return array */ public static function messagesDataProvider(): array { return [ @@ -119,7 +148,6 @@ public static function messagesDataProvider(): array ]; } - /** @return array */ public static function codesDataProvider(): array { return [ @@ -132,7 +160,6 @@ public static function codesDataProvider(): array ]; } - /** @return array */ public static function errorCodesDataProvider(): array { return [ @@ -143,7 +170,6 @@ public static function errorCodesDataProvider(): array ]; } - /** @return array */ public static function successCodesDataProvider(): array { return [ diff --git a/tests/Unit/CookieTest.php b/tests/Unit/CookieTest.php index cba2ec8..3bdd14e 100644 --- a/tests/Unit/CookieTest.php +++ b/tests/Unit/CookieTest.php @@ -6,13 +6,11 @@ use DateTimeImmutable; use DateTimeZone; +use DomainException; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\Cookie; -use TinyBlocks\Http\Internal\Server\Exceptions\ConflictingLifetimeAttributes; -use TinyBlocks\Http\Internal\Server\Exceptions\CookieNameIsInvalid; -use TinyBlocks\Http\Internal\Server\Exceptions\CookieValueIsInvalid; -use TinyBlocks\Http\Internal\Server\Exceptions\SameSiteNoneRequiresSecure; use TinyBlocks\Http\SameSite; final class CookieTest extends TestCase @@ -33,13 +31,13 @@ public function testCreateWhenAllAttributesAppliedThenSerializesInCanonicalOrder { /** @Given a cookie composed with every supported attribute */ $cookie = Cookie::create(name: 'refresh_token', value: 'opaque-value') - ->withMaxAge(seconds: 604800) - ->withPath(path: '/v1/sessions') - ->withDomain(domain: 'api.example.com') ->secure() ->httpOnly() - ->withSameSite(sameSite: SameSite::STRICT) - ->partitioned(); + ->withPath(path: '/v1/sessions') + ->withMaxAge(seconds: 604800) + ->withDomain(domain: 'api.example.com') + ->partitioned() + ->withSameSite(sameSite: SameSite::STRICT); /** @When the header is serialized */ $actual = $cookie->toArray(); @@ -52,8 +50,7 @@ public function testCreateWhenAllAttributesAppliedThenSerializesInCanonicalOrder public function testExpireWhenInvokedThenEmitsEmptyValueAndMaxAgeZero(): void { - /** @Given a cookie deletion for an existing name */ - /** @And the same path used when the cookie was issued */ + /** @Given a cookie deletion bound to the path used when the cookie was issued */ $cookie = Cookie::expire(name: 'refresh_token')->withPath(path: '/v1/sessions'); /** @When the header is serialized */ @@ -63,17 +60,27 @@ public function testExpireWhenInvokedThenEmitsEmptyValueAndMaxAgeZero(): void self::assertSame(['Set-Cookie' => ['refresh_token=; Max-Age=0; Path=/v1/sessions']], $actual); } - public function testWithValueWhenInvokedThenReturnsNewInstanceAndOriginalIsUntouched(): void + public function testWithValueWhenInvokedThenLeavesOriginalUntouched(): void { /** @Given a cookie with an initial value */ $original = Cookie::create(name: 'session', value: 'initial'); /** @When a new value is assigned */ - $rotated = $original->withValue(value: 'rotated'); + $original->withValue(value: 'rotated'); /** @Then the original instance remains unchanged */ self::assertSame(['Set-Cookie' => ['session=initial']], $original->toArray()); - /** @And the new instance carries the replaced value */ + } + + public function testWithValueWhenInvokedThenReturnsNewInstanceWithReplacedValue(): void + { + /** @Given a cookie with an initial value */ + $original = Cookie::create(name: 'session', value: 'initial'); + + /** @When a new value is assigned */ + $rotated = $original->withValue(value: 'rotated'); + + /** @Then the new instance carries the replaced value */ self::assertSame(['Set-Cookie' => ['session=rotated']], $rotated->toArray()); } @@ -94,17 +101,27 @@ public function testWithExpiresWhenNonUtcDateGivenThenRendersInUtcRfcFormat(): v ); } - public function testSecureWhenInvokedThenReturnsNewInstanceWithFlag(): void + public function testSecureWhenInvokedThenLeavesBaseUntouched(): void { /** @Given a base cookie without the secure flag */ $base = Cookie::create(name: 'session', value: 'abc'); /** @When the secure flag is applied */ - $secured = $base->secure(); + $base->secure(); /** @Then the base instance remains unchanged */ self::assertSame(['Set-Cookie' => ['session=abc']], $base->toArray()); - /** @And the new instance has the secure flag applied */ + } + + public function testSecureWhenInvokedThenReturnsNewInstanceWithFlag(): void + { + /** @Given a base cookie without the secure flag */ + $base = Cookie::create(name: 'session', value: 'abc'); + + /** @When the secure flag is applied */ + $secured = $base->secure(); + + /** @Then the new instance has the secure flag applied */ self::assertSame(['Set-Cookie' => ['session=abc; Secure']], $secured->toArray()); } @@ -114,7 +131,7 @@ public function testToArrayWhenSameSiteNoneWithoutSecureGivenThenThrows(): void $cookie = Cookie::create(name: 'session', value: 'abc')->withSameSite(sameSite: SameSite::NONE); /** @Then an exception indicating the missing Secure flag is thrown */ - $this->expectException(SameSiteNoneRequiresSecure::class); + $this->expectException(DomainException::class); $this->expectExceptionMessage('SameSite=None require the Secure flag'); /** @When the header is serialized */ @@ -143,7 +160,7 @@ public function testToArrayWhenBothMaxAgeAndExpiresGivenThenThrows(): void ->withExpires(expires: new DateTimeImmutable('2030-01-15 12:00:00 UTC')); /** @Then an exception indicating conflicting lifetime attributes is thrown */ - $this->expectException(ConflictingLifetimeAttributes::class); + $this->expectException(DomainException::class); $this->expectExceptionMessage('Cookie lifetime attributes are conflicting'); /** @When the header is serialized */ @@ -168,7 +185,7 @@ public function testWithValueWhenForbiddenCharacterGivenThenThrows(): void $cookie = Cookie::create(name: 'session', value: 'abc'); /** @Then an exception indicating the value is invalid is thrown */ - $this->expectException(CookieValueIsInvalid::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cookie value is invalid'); /** @When the value is replaced with one containing forbidden characters */ @@ -178,7 +195,7 @@ public function testWithValueWhenForbiddenCharacterGivenThenThrows(): void public function testExpireWhenInvalidNameGivenThenThrows(): void { /** @Then an exception indicating the name is invalid is thrown */ - $this->expectException(CookieNameIsInvalid::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cookie name is invalid'); /** @When expiring a cookie with an invalid name */ @@ -188,33 +205,38 @@ public function testExpireWhenInvalidNameGivenThenThrows(): void public function testCreateWhenForbiddenCharacterInValueGivenThenThrows(): void { /** @Then an exception indicating the value is invalid is thrown */ - $this->expectException(CookieValueIsInvalid::class); + $this->expectException(InvalidArgumentException::class); /** @When creating a cookie with the invalid value */ Cookie::create(name: 'session', value: 'abc;def'); } + /** + * @Given an invalid cookie name + * @When Cookie::create is called with that name + * @Then it throws CookieNameIsInvalid + */ #[DataProvider('invalidNameProvider')] public function testCreateWhenInvalidNameGivenThenThrows(string $name): void { - /** @Then an exception indicating the name is invalid is thrown */ - $this->expectException(CookieNameIsInvalid::class); + $this->expectException(InvalidArgumentException::class); - /** @When creating a cookie with the invalid name */ Cookie::create(name: $name, value: 'value'); } + /** + * @Given an invalid cookie value + * @When Cookie::create is called with that value + * @Then it throws CookieValueIsInvalid + */ #[DataProvider('invalidValueProvider')] public function testCreateWhenInvalidValueGivenThenThrows(string $value): void { - /** @Then an exception indicating the value is invalid is thrown */ - $this->expectException(CookieValueIsInvalid::class); + $this->expectException(InvalidArgumentException::class); - /** @When creating a cookie with the invalid value */ Cookie::create(name: 'session', value: $value); } - /** @return array */ public static function invalidNameProvider(): array { return [ @@ -229,7 +251,6 @@ public static function invalidNameProvider(): array ]; } - /** @return array */ public static function invalidValueProvider(): array { return [ diff --git a/tests/Unit/Exceptions/HttpConfigurationInvalidTest.php b/tests/Unit/Exceptions/HttpConfigurationInvalidTest.php deleted file mode 100644 index 919136d..0000000 --- a/tests/Unit/Exceptions/HttpConfigurationInvalidTest.php +++ /dev/null @@ -1,32 +0,0 @@ -getMessage()); - self::assertInstanceOf(HttpException::class, $exception); - } - - public function testMissingBaseUrlWhenInvokedThenMessageDescribesMissingBaseUrl(): void - { - /** @When creating the exception for missing base URL */ - $exception = HttpConfigurationInvalid::missingBaseUrl(); - - /** @Then the message describes the missing base URL and is recognized as HttpException */ - self::assertSame('Base URL is required to build Http.', $exception->getMessage()); - self::assertInstanceOf(HttpException::class, $exception); - } -} diff --git a/tests/Unit/Exceptions/HttpNetworkFailedTest.php b/tests/Unit/Exceptions/HttpNetworkFailedTest.php deleted file mode 100644 index 29b58aa..0000000 --- a/tests/Unit/Exceptions/HttpNetworkFailedTest.php +++ /dev/null @@ -1,76 +0,0 @@ -url()); - self::assertSame($method, $exception->method()); - self::assertSame($reason, $exception->reason()); - self::assertStringContainsString($reason, $exception->getMessage()); - self::assertStringContainsString('GET', $exception->getMessage()); - self::assertStringContainsString($url, $exception->getMessage()); - self::assertSame(0, $exception->getCode()); - } - - public function testFromWhenPreviousGivenThenPreservesChain(): void - { - /** @Given a previous throwable */ - $previous = new RuntimeException('socket error'); - - /** @When constructing with a previous */ - $exception = HttpNetworkFailed::from( - url: 'https://api.example.com', - method: Method::DELETE, - reason: 'Network failed.', - previous: $previous - ); - - /** @Then the chain is preserved */ - self::assertSame($previous, $exception->getPrevious()); - } - - public function testFromClientExceptionWhenNetworkExceptionGivenThenWrapsOriginal(): void - { - /** @Given a request and a network exception */ - $request = Request::create(url: 'https://api.example.com/dragons'); - $networkException = new class ('DNS failure') extends RuntimeException implements NetworkExceptionInterface { - public function getRequest(): RequestInterface - { - return (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); - } - }; - - /** @When constructing from a client exception */ - $exception = HttpNetworkFailed::fromClientException(request: $request, exception: $networkException); - - /** @Then the exception wraps the original and implements HttpException */ - self::assertSame('https://api.example.com/dragons', $exception->url()); - self::assertSame($networkException, $exception->getPrevious()); - self::assertInstanceOf(HttpException::class, $exception); - } -} diff --git a/tests/Unit/Exceptions/HttpRequestFailedTest.php b/tests/Unit/Exceptions/HttpRequestFailedTest.php deleted file mode 100644 index eba7714..0000000 --- a/tests/Unit/Exceptions/HttpRequestFailedTest.php +++ /dev/null @@ -1,81 +0,0 @@ -url()); - self::assertSame($method, $exception->method()); - self::assertSame($reason, $exception->reason()); - self::assertStringContainsString($reason, $exception->getMessage()); - self::assertNull($exception->getPrevious()); - } - - public function testFromWhenPreviousGivenThenPreservesChain(): void - { - /** @Given a previous throwable */ - $previous = new RuntimeException('root cause'); - - /** @When constructing the exception with a previous */ - $exception = HttpRequestFailed::from( - url: 'https://api.example.com', - method: Method::GET, - reason: 'Failed.', - previous: $previous - ); - - /** @Then the previous is preserved in the chain */ - self::assertSame($previous, $exception->getPrevious()); - } - - public function testFromClientExceptionWhenRequestGivenThenWrapsOriginal(): void - { - /** @Given a request and a client exception */ - $request = Request::create(url: 'https://api.example.com/dragons', method: Method::DELETE); - $clientException = new class ('PSR-18 error') extends RuntimeException implements ClientExceptionInterface { - }; - - /** @When constructing from a client exception */ - $exception = HttpRequestFailed::fromClientException(request: $request, exception: $clientException); - - /** @Then the exception reflects the request and wraps the original */ - self::assertSame('https://api.example.com/dragons', $exception->url()); - self::assertSame(Method::DELETE, $exception->method()); - self::assertSame($clientException, $exception->getPrevious()); - self::assertInstanceOf(HttpException::class, $exception); - } - - public function testGetCodeWhenExceptionBuiltThenReturnsZero(): void - { - /** @When building an HttpRequestFailed */ - $exception = HttpRequestFailed::from( - url: 'https://api.example.com', - method: Method::GET, - reason: 'Failure.' - ); - - /** @Then the exception code is zero */ - self::assertSame(0, $exception->getCode()); - } -} diff --git a/tests/Unit/Exceptions/HttpRequestInvalidTest.php b/tests/Unit/Exceptions/HttpRequestInvalidTest.php deleted file mode 100644 index 0ddf56c..0000000 --- a/tests/Unit/Exceptions/HttpRequestInvalidTest.php +++ /dev/null @@ -1,76 +0,0 @@ -url()); - self::assertSame($method, $exception->method()); - self::assertSame($reason, $exception->reason()); - self::assertStringContainsString($reason, $exception->getMessage()); - self::assertStringContainsString('PATCH', $exception->getMessage()); - self::assertStringContainsString($url, $exception->getMessage()); - self::assertSame(0, $exception->getCode()); - } - - public function testFromWhenPreviousGivenThenPreservesChain(): void - { - /** @Given a previous throwable */ - $previous = new RuntimeException('bad request object'); - - /** @When constructing with a previous */ - $exception = HttpRequestInvalid::from( - url: 'https://api.example.com', - method: Method::PUT, - reason: 'Request is invalid.', - previous: $previous - ); - - /** @Then the chain is preserved */ - self::assertSame($previous, $exception->getPrevious()); - } - - public function testFromClientExceptionWhenRequestExceptionGivenThenWrapsOriginal(): void - { - /** @Given a request and a request exception */ - $request = Request::create(url: 'https://api.example.com/dragons', method: Method::POST); - $requestException = new class ('bad URI') extends RuntimeException implements RequestExceptionInterface { - public function getRequest(): RequestInterface - { - return (new Psr17Factory())->createRequest('POST', 'https://api.example.com'); - } - }; - - /** @When constructing from a client exception */ - $exception = HttpRequestInvalid::fromClientException(request: $request, exception: $requestException); - - /** @Then the exception reflects the request and wraps the original */ - self::assertSame('https://api.example.com/dragons', $exception->url()); - self::assertSame($requestException, $exception->getPrevious()); - self::assertInstanceOf(HttpException::class, $exception); - } -} diff --git a/tests/Unit/Exceptions/MalformedPathTest.php b/tests/Unit/Exceptions/MalformedPathTest.php deleted file mode 100644 index eb59c9c..0000000 --- a/tests/Unit/Exceptions/MalformedPathTest.php +++ /dev/null @@ -1,53 +0,0 @@ -url()); - self::assertSame(Method::GET, $exception->method()); - self::assertStringContainsString('//evil.example.com/attack', $exception->reason()); - self::assertStringContainsString('//evil.example.com/attack', $exception->getMessage()); - } - - public function testFromRequestWhenAnyMalformedPathGivenThenImplementsHttpException(): void - { - /** @Given a MalformedPath exception built from a scheme-containing path */ - $exception = MalformedPath::fromRequest( - request: Request::create(url: 'javascript:alert(1)') - ); - - /** @Then it is catchable as HttpException */ - self::assertInstanceOf(HttpException::class, $exception); - } - - public function testFromRequestWhenSchemePathGivenThenReasonDescribesPath(): void - { - /** @Given a request with a scheme-containing path */ - $request = Request::create(url: 'https://attacker.com/steal'); - - /** @When constructing from the request */ - $exception = MalformedPath::fromRequest(request: $request); - - /** @Then the reason message references the malformed path */ - self::assertStringContainsString('https://attacker.com/steal', $exception->reason()); - self::assertStringContainsString('malformed', $exception->reason()); - } -} diff --git a/tests/Unit/Exceptions/NoMoreResponsesTest.php b/tests/Unit/Exceptions/NoMoreResponsesTest.php deleted file mode 100644 index a307fed..0000000 --- a/tests/Unit/Exceptions/NoMoreResponsesTest.php +++ /dev/null @@ -1,32 +0,0 @@ -getMessage()); - self::assertInstanceOf(HttpException::class, $exception); - } - - public function testAtIndexWhenIndexZeroGivenThenMessageReferencesIndexAndTransport(): void - { - /** @When creating the exception at index 0 */ - $exception = NoMoreResponses::atIndex(index: 0); - - /** @Then the message references index 0 and the InMemoryTransport */ - self::assertStringContainsString('0', $exception->getMessage()); - self::assertStringContainsString('InMemoryTransport', $exception->getMessage()); - } -} diff --git a/tests/Unit/FailingTransport.php b/tests/Unit/FailingTransport.php new file mode 100644 index 0000000..df9208b --- /dev/null +++ b/tests/Unit/FailingTransport.php @@ -0,0 +1,63 @@ + HttpNetworkFailed::from( + url: $request->url(), + method: $request->method(), + reason: $reason, + previous: $cause + ); + + return new FailingTransport(factory: $factory); + } + + public static function raisingRequestInvalid(string $reason, RuntimeException $cause): FailingTransport + { + $factory = static fn(Request $request): HttpException => HttpRequestInvalid::from( + url: $request->url(), + method: $request->method(), + reason: $reason, + previous: $cause + ); + + return new FailingTransport(factory: $factory); + } + + public static function raisingRequestFailure(string $reason, RuntimeException $cause): FailingTransport + { + $factory = static fn(Request $request): HttpException => HttpRequestFailed::from( + url: $request->url(), + method: $request->method(), + reason: $reason, + previous: $cause + ); + + return new FailingTransport(factory: $factory); + } + + public function send(Request $request): Response + { + throw ($this->factory)($request); + } +} diff --git a/tests/Unit/HeadersTest.php b/tests/Unit/HeadersTest.php index ee4c562..8f53d6c 100644 --- a/tests/Unit/HeadersTest.php +++ b/tests/Unit/HeadersTest.php @@ -16,8 +16,10 @@ final class HeadersTest extends TestCase public function testConstructorWhenEntriesGivenThenExposesEachEntry(): void { /** @Given an array of headers */ + $entries = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; + /** @When creating Headers from a constructor */ - $headers = new Headers(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); + $headers = new Headers(entries: $entries); /** @Then the entries are accessible */ self::assertSame('application/json', $headers->get('Content-Type')); @@ -26,8 +28,10 @@ public function testConstructorWhenEntriesGivenThenExposesEachEntry(): void public function testFromWhenMultipleHeaderablesGivenThenMergesEntries(): void { - /** @Given two headerable instances */ + /** @Given a Content-Type headerable */ $contentType = ContentType::applicationJson(charset: Charset::UTF_8); + + /** @And a Cookie headerable */ $cookie = Cookie::create(name: 'session', value: 'abc123'); /** @When creating Headers from multiple headerables */ @@ -50,7 +54,7 @@ public function testFromWhenNoArgumentsGivenThenReturnsEmptyHeaders(): void public function testFromMessageWhenEmptyHeadersGivenThenReturnsEmptyHeaders(): void { /** @Given a PSR-7 response with no headers */ - $psrResponse = (new Psr17Factory())->createResponse(200); + $psrResponse = new Psr17Factory()->createResponse(200); /** @When building Headers from the message */ $headers = Headers::fromMessage(message: $psrResponse); @@ -62,7 +66,7 @@ public function testFromMessageWhenEmptyHeadersGivenThenReturnsEmptyHeaders(): v public function testFromMessageWhenMultiValueHeaderGivenThenFoldsWithComma(): void { /** @Given a PSR-7 response with a header that carries multiple values */ - $psrResponse = (new Psr17Factory())->createResponse(200) + $psrResponse = new Psr17Factory()->createResponse(200) ->withHeader('Accept', 'application/json') ->withAddedHeader('Accept', 'text/html'); @@ -79,7 +83,7 @@ public function testApplyToWhenEmptyHeadersGivenThenReturnsMessageUnchanged(): v $headers = new Headers(entries: []); /** @And a PSR-7 request */ - $psrRequest = (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); /** @When applying the empty headers to the request */ $applied = $headers->applyTo(message: $psrRequest); @@ -88,21 +92,33 @@ public function testApplyToWhenEmptyHeadersGivenThenReturnsMessageUnchanged(): v self::assertSame($psrRequest, $applied); } - public function testApplyToWhenEntriesGivenThenAttachesAndLeavesOriginalUnchanged(): void + public function testApplyToWhenEntriesGivenThenAttachesHeaders(): void { /** @Given a Headers instance with one entry */ $headers = new Headers(entries: ['X-Trace' => 'abc']); /** @And a PSR-7 request */ - $psrRequest = (new Psr17Factory())->createRequest('GET', 'https://api.example.com'); + $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); /** @When applying the headers to the request */ $applied = $headers->applyTo(message: $psrRequest); /** @Then the resulting message carries the header */ self::assertSame('abc', $applied->getHeaderLine('X-Trace')); + } + + public function testApplyToWhenEntriesGivenThenLeavesOriginalUnchanged(): void + { + /** @Given a Headers instance with one entry */ + $headers = new Headers(entries: ['X-Trace' => 'abc']); + + /** @And a PSR-7 request */ + $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); + + /** @When applying the headers to the request */ + $headers->applyTo(message: $psrRequest); - /** @And the original request is unchanged */ + /** @Then the original request is unchanged */ self::assertSame('', $psrRequest->getHeaderLine('X-Trace')); } diff --git a/tests/Unit/HttpBuilderTest.php b/tests/Unit/HttpBuilderTest.php index a166a3b..69e2c4b 100644 --- a/tests/Unit/HttpBuilderTest.php +++ b/tests/Unit/HttpBuilderTest.php @@ -6,15 +6,16 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; -use Test\TinyBlocks\Http\Fixtures\Client\CapturingClient; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Client\Response; use TinyBlocks\Http\Client\Transports\InMemoryTransport; use TinyBlocks\Http\Client\Transports\NetworkTransport; use TinyBlocks\Http\Code; use TinyBlocks\Http\Exceptions\HttpConfigurationInvalid; +use TinyBlocks\Http\Headers; use TinyBlocks\Http\Http; use TinyBlocks\Http\HttpBuilder; +use TinyBlocks\Http\Method; final class HttpBuilderTest extends TestCase { @@ -27,7 +28,7 @@ public function testCreateWhenInvokedThenReturnsEmptyBuilder(): void self::assertInstanceOf(HttpBuilder::class, $builder); } - public function testWithTransportWhenInvokedThenReturnsNewBuilderAndOriginalIsUntouched(): void + public function testWithTransportWhenInvokedThenReturnsNewBuilder(): void { /** @Given an empty builder */ $original = Http::create(); @@ -41,13 +42,32 @@ public function testWithTransportWhenInvokedThenReturnsNewBuilderAndOriginalIsUn /** @When calling withTransport */ $updated = $original->withTransport(transport: $transport); - /** @Then a new builder instance is returned and original build still throws */ + /** @Then a new builder instance is returned */ self::assertNotSame($original, $updated); + } + + public function testWithTransportWhenInvokedThenOriginalBuilderStillThrows(): void + { + /** @Given an empty builder */ + $original = Http::create(); + + /** @And a fresh transport */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: new Psr17Factory() + ); + + /** @And the original builder receives a new transport */ + $original->withTransport(transport: $transport); + + /** @Then the original builder still throws on build */ $this->expectException(HttpConfigurationInvalid::class); + + /** @When calling build on the original builder */ $original->build(); } - public function testWithBaseUrlWhenInvokedThenReturnsNewBuilderAndOriginalIsUntouched(): void + public function testWithBaseUrlWhenInvokedThenReturnsNewBuilder(): void { /** @Given an empty builder */ $original = Http::create(); @@ -55,9 +75,22 @@ public function testWithBaseUrlWhenInvokedThenReturnsNewBuilderAndOriginalIsUnto /** @When calling withBaseUrl */ $updated = $original->withBaseUrl(url: 'https://api.example.com'); - /** @Then a new builder instance is returned and original build still throws */ + /** @Then a new builder instance is returned */ self::assertNotSame($original, $updated); + } + + public function testWithBaseUrlWhenInvokedThenOriginalBuilderStillThrows(): void + { + /** @Given an empty builder */ + $original = Http::create(); + + /** @And the original builder receives a new base URL */ + $original->withBaseUrl(url: 'https://api.example.com'); + + /** @Then the original builder still throws on build */ $this->expectException(HttpConfigurationInvalid::class); + + /** @When calling build on the original builder */ $original->build(); } @@ -91,16 +124,23 @@ public function testBuildWhenBaseUrlMissingThenThrowsHttpConfigurationInvalid(): public function testBuildWhenFullyConfiguredThenProducesWorkingHttp(): void { - /** @Given a fully configured builder */ + /** @Given a transport seeded with one response */ $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); + /** @And a fully configured builder */ $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: $transport) ->build(); /** @When sending a request */ - $response = $http->send(request: Request::create(url: '/dragons')); + $response = $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); /** @Then the response is returned correctly */ self::assertSame(Code::OK, $response->code()); @@ -114,7 +154,16 @@ public function testWithWhenInvokedDirectlyThenReturnsWorkingHttp(): void /** @When constructing Http directly via Http::with */ $http = Http::with(baseUrl: 'https://api.example.com', transport: $transport); + /** @And a simple GET request */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + /** @Then the instance can send requests and returns the correct response */ - self::assertSame(Code::OK, $http->send(request: Request::create(url: '/dragons'))->code()); + self::assertSame(Code::OK, $http->send(request: $request)->code()); } } diff --git a/tests/Unit/HttpTest.php b/tests/Unit/HttpTest.php index d8fc183..a22022f 100644 --- a/tests/Unit/HttpTest.php +++ b/tests/Unit/HttpTest.php @@ -6,11 +6,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; -use Test\TinyBlocks\Http\Fixtures\Client\CapturingClient; -use Test\TinyBlocks\Http\Fixtures\Client\ThrowingClient; -use Test\TinyBlocks\Http\Fixtures\Psr18\ClientException; -use Test\TinyBlocks\Http\Fixtures\Psr18\NetworkException; -use Test\TinyBlocks\Http\Fixtures\Psr18\RequestException; +use RuntimeException; use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Client\Transports\NetworkTransport; use TinyBlocks\Http\Code; @@ -18,6 +14,7 @@ use TinyBlocks\Http\Exceptions\HttpRequestFailed; use TinyBlocks\Http\Exceptions\HttpRequestInvalid; use TinyBlocks\Http\Exceptions\MalformedPath; +use TinyBlocks\Http\Headers; use TinyBlocks\Http\Http; use TinyBlocks\Http\Method; @@ -42,7 +39,13 @@ public function testSendWhenTransportRespondsThenReturnsResponseWithMatchingCode ->build(); /** @When sending a valid request */ - $response = $http->send(request: Request::create(url: '/dragons')); + $response = $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); /** @Then the response code is correct */ self::assertSame(Code::OK, $response->code()); @@ -60,7 +63,13 @@ public function testSendWhenBaseUrlEndsWithSlashAndPathLeadsWithSlashThenNoDoubl ->build(); /** @When sending a request whose path starts with a slash */ - $response = $http->send(request: Request::create(url: '/dragons')); + $response = $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); /** @Then the response is returned without double slash in the URL */ self::assertSame(Code::OK, $response->code()); @@ -79,7 +88,13 @@ public function testSendWhenQueryGivenThenAppendsAsRfc3986(): void /** @When sending a request with query parameters */ $response = $http->send( - request: Request::create(url: '/dragons', query: ['sort' => 'name', 'order' => 'asc']) + request: Request::create( + url: '/dragons', + body: null, + query: ['sort' => 'name', 'order' => 'asc'], + method: Method::GET, + headers: Headers::from() + ) ); /** @Then the response code is correct */ @@ -99,7 +114,13 @@ public function testSendWhenBodyGivenThenSendsJsonPayload(): void /** @When sending a request with a JSON body */ $response = $http->send( - request: Request::create(url: '/dragons', body: ['name' => 'Hydra'], method: Method::POST) + request: Request::create( + url: '/dragons', + body: ['name' => 'Hydra'], + query: null, + method: Method::POST, + headers: Headers::from() + ) ); /** @Then the response code is correct */ @@ -112,7 +133,7 @@ public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFai $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: ThrowingClient::throwing(exception: new NetworkException('connection refused')), + client: ThrowingClient::throwing(exception: new PsrNetworkException('connection refused')), factory: $this->factory )) ->build(); @@ -121,7 +142,13 @@ public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFai $this->expectException(HttpNetworkFailed::class); /** @When sending the request */ - $http->send(request: Request::create(url: '/dragons')); + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); } public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInvalid(): void @@ -130,7 +157,7 @@ public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInv $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: ThrowingClient::throwing(exception: new RequestException('bad request')), + client: ThrowingClient::throwing(exception: new PsrRequestException('bad request')), factory: $this->factory )) ->build(); @@ -139,7 +166,13 @@ public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInv $this->expectException(HttpRequestInvalid::class); /** @When sending the request */ - $http->send(request: Request::create(url: '/dragons')); + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); } public function testSendWhenGenericClientExceptionRaisedThenThrowsHttpRequestFailed(): void @@ -148,7 +181,7 @@ public function testSendWhenGenericClientExceptionRaisedThenThrowsHttpRequestFai $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( - client: ThrowingClient::throwing(exception: new ClientException('generic failure')), + client: ThrowingClient::throwing(exception: new PsrClientException('generic failure')), factory: $this->factory )) ->build(); @@ -157,7 +190,13 @@ public function testSendWhenGenericClientExceptionRaisedThenThrowsHttpRequestFai $this->expectException(HttpRequestFailed::class); /** @When sending the request */ - $http->send(request: Request::create(url: '/dragons')); + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); } public function testSendWhenProtocolRelativePathGivenThenThrowsMalformedPath(): void @@ -175,7 +214,13 @@ public function testSendWhenProtocolRelativePathGivenThenThrowsMalformedPath(): $this->expectException(MalformedPath::class); /** @When sending a request whose path is protocol-relative */ - $http->send(request: Request::create(url: '//evil.example.com/attack')); + $http->send(request: Request::create( + url: '//evil.example.com/attack', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); } public function testSendWhenSchemePathGivenThenThrowsMalformedPath(): void @@ -193,7 +238,13 @@ public function testSendWhenSchemePathGivenThenThrowsMalformedPath(): void $this->expectException(MalformedPath::class); /** @When sending a request whose path contains a scheme */ - $http->send(request: Request::create(url: 'javascript:alert(1)')); + $http->send(request: Request::create( + url: 'javascript:alert(1)', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); } public function testSendWhenControlCharsInPathGivenThenThrowsMalformedPath(): void @@ -211,14 +262,21 @@ public function testSendWhenControlCharsInPathGivenThenThrowsMalformedPath(): vo $this->expectException(MalformedPath::class); /** @When sending a request whose path contains control characters */ - $http->send(request: Request::create(url: "/dragons\x00/evil")); + $http->send(request: Request::create( + url: "/dragons\x00/evil", + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); } public function testSendWhenNetworkExceptionRaisedThenPreservesPreviousChain(): void { /** @Given a network exception */ - $networkException = new NetworkException('timeout'); + $networkException = new PsrNetworkException('timeout'); + /** @And an Http instance with a transport that throws it */ $http = Http::create() ->withBaseUrl(url: 'https://api.example.com') ->withTransport(transport: NetworkTransport::with( @@ -229,11 +287,329 @@ public function testSendWhenNetworkExceptionRaisedThenPreservesPreviousChain(): /** @When sending the request */ try { - $http->send(request: Request::create(url: '/dragons')); + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + )); self::fail('HttpNetworkFailed was expected.'); } catch (HttpNetworkFailed $exception) { /** @Then the previous exception is preserved in the chain */ self::assertSame($networkException, $exception->getPrevious()); } } + + public function testSendWhenSchemePathGivenThenChainsPathContainsSchemeAsPrevious(): void + { + /** @Given an Http instance with a base URL */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) + ->build(); + + /** @And a request whose path contains a scheme */ + $request = Request::create( + url: 'https://attacker.com/steal', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + try { + $http->send(request: $request); + self::fail('MalformedPath was expected.'); + } catch (MalformedPath $exception) { + /** @Then the previous exception carries the offending path and a scheme-related reason */ + $previous = $exception->getPrevious(); + self::assertNotNull($previous); + self::assertStringContainsString('https://attacker.com/steal', $previous->getMessage()); + self::assertStringContainsString('scheme', $previous->getMessage()); + } + } + + public function testSendWhenSchemePathGivenThenMalformedPathExposesOffendingPath(): void + { + /** @Given an Http instance with a base URL */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) + ->build(); + + /** @And a request whose path contains a scheme */ + $request = Request::create( + url: 'https://attacker.com/steal', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + try { + /** @When sending the request */ + $http->send(request: $request); + } catch (MalformedPath $exception) { + /** @Then the exception exposes the offending path */ + self::assertSame('https://attacker.com/steal', $exception->path()); + } + } + + public function testSendWhenControlCharPathGivenThenChainsPathContainsControlCharsAsPrevious(): void + { + /** @Given an Http instance with a base URL */ + $http = Http::create() + ->withBaseUrl(url: 'https://api.example.com') + ->withTransport(transport: NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + )) + ->build(); + + /** @And a request whose path contains a control character */ + $request = Request::create( + url: "/dragons\x00/evil", + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + try { + $http->send(request: $request); + self::fail('MalformedPath was expected.'); + } catch (MalformedPath $exception) { + /** @Then the previous exception carries the offending path and a control-character reason */ + $previous = $exception->getPrevious(); + self::assertNotNull($previous); + self::assertStringContainsString("/dragons\x00/evil", $previous->getMessage()); + self::assertStringContainsString('control characters', $previous->getMessage()); + } + } + + public function testSendWhenBaseUrlEmptyAndRelativePathGivenThenUsesPathDirectly(): void + { + /** @Given an Http instance with an empty base URL */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: '', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request with a relative path */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the PSR-7 request URI is the path as-is */ + self::assertNotNull($client->captured); + self::assertSame('/dragons', (string)$client->captured->getUri()); + } + + public function testSendWhenBaseUrlEndsWithSlashAndPathLeadsWithSlashThenSingleSlashJoinsThem(): void + { + /** @Given an Http instance with a trailing slash on the base URL */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: 'https://api.example.com/', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request whose path starts with a slash */ + $request = Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the composed URI joins them with exactly one slash */ + self::assertNotNull($client->captured); + self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); + } + + public function testSendWhenBaseUrlWithoutTrailingSlashAndPathWithoutLeadingSlashThenJoinsWithSingleSlash(): void + { + /** @Given an Http instance without trailing slash on the base URL */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: 'https://api.example.com', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request whose path lacks a leading slash */ + $request = Request::create( + url: 'dragons', + body: null, + query: null, + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the composed URI joins them with exactly one slash */ + self::assertNotNull($client->captured); + self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); + } + + public function testSendWhenQueryProvidedThenAppendsAsQueryString(): void + { + /** @Given an Http instance and a query payload */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: 'https://api.example.com', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request with query parameters */ + $request = Request::create( + url: '/dragons', + body: null, + query: ['sort' => 'name'], + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the composed URI includes the encoded query string */ + self::assertNotNull($client->captured); + self::assertSame('https://api.example.com/dragons?sort=name', (string)$client->captured->getUri()); + } + + public function testSendWhenCustomTransportRaisesNetworkFailureThenExceptionCarriesRequestContext(): void + { + /** @Given a custom transport that wraps a non-PSR network error and re-raises via the documented factory */ + $http = Http::with( + baseUrl: 'https://api.example.com', + transport: FailingTransport::raisingNetworkFailure( + reason: 'DNS resolution failed.', + cause: new RuntimeException('curl: getaddrinfo') + ) + ); + + try { + /** @When sending a request through the custom transport */ + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::HEAD, + headers: Headers::from() + )); + self::fail('HttpNetworkFailed was expected.'); + } catch (HttpNetworkFailed $exception) { + /** @Then the exception carries the originating URL, method, and reason */ + self::assertSame('https://api.example.com/dragons', $exception->url()); + self::assertSame(Method::HEAD, $exception->method()); + self::assertSame('DNS resolution failed.', $exception->reason()); + } + } + + public function testSendWhenCustomTransportRaisesRequestInvalidThenExceptionCarriesRequestContext(): void + { + /** @Given a custom transport that maps an upstream validation error to HttpRequestInvalid */ + $http = Http::with( + baseUrl: 'https://api.example.com', + transport: FailingTransport::raisingRequestInvalid( + reason: 'Upstream validator rejected the payload.', + cause: new RuntimeException('validator: required field missing') + ) + ); + + try { + /** @When sending a request through the custom transport */ + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::PATCH, + headers: Headers::from() + )); + self::fail('HttpRequestInvalid was expected.'); + } catch (HttpRequestInvalid $exception) { + /** @Then the exception carries the originating URL, method, and reason */ + self::assertSame('https://api.example.com/dragons', $exception->url()); + self::assertSame(Method::PATCH, $exception->method()); + self::assertSame('Upstream validator rejected the payload.', $exception->reason()); + } + } + + public function testSendWhenCustomTransportRaisesRequestFailureThenExceptionCarriesRequestContext(): void + { + /** @Given a custom transport that maps an upstream cURL error to HttpRequestFailed */ + $http = Http::with( + baseUrl: 'https://api.example.com', + transport: FailingTransport::raisingRequestFailure( + reason: 'cURL handle exhausted retries.', + cause: new RuntimeException('curl: too many retries') + ) + ); + + try { + /** @When sending a request through the custom transport */ + $http->send(request: Request::create( + url: '/dragons', + body: null, + query: null, + method: Method::PUT, + headers: Headers::from() + )); + self::fail('HttpRequestFailed was expected.'); + } catch (HttpRequestFailed $exception) { + /** @Then the exception carries the originating URL, method, and reason */ + self::assertSame('https://api.example.com/dragons', $exception->url()); + self::assertSame(Method::PUT, $exception->method()); + self::assertSame('cURL handle exhausted retries.', $exception->reason()); + } + } + + public function testSendWhenEmptyQueryArrayGivenThenNoTrailingQuestionMark(): void + { + /** @Given an Http instance and an empty query array */ + $client = CapturingClient::returningStatus(statusCode: 200); + $http = Http::with(baseUrl: 'https://api.example.com', transport: NetworkTransport::with( + client: $client, + factory: $this->factory + )); + + /** @And a request with an empty query array */ + $request = Request::create( + url: '/dragons', + body: null, + query: [], + method: Method::GET, + headers: Headers::from() + ); + + /** @When sending the request */ + $http->send(request: $request); + + /** @Then the composed URI has no trailing question mark */ + self::assertNotNull($client->captured); + self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); + } } diff --git a/tests/Unit/Internal/Client/CursorTest.php b/tests/Unit/Internal/Client/CursorTest.php deleted file mode 100644 index 63d9b84..0000000 --- a/tests/Unit/Internal/Client/CursorTest.php +++ /dev/null @@ -1,50 +0,0 @@ -advance(); - - /** @Then the position is 0 */ - self::assertSame(0, $position); - } - - public function testAdvanceWhenInvokedTwiceThenReturnsOne(): void - { - /** @Given a cursor that has been advanced once */ - $cursor = new Cursor(); - $cursor->advance(); - - /** @When advancing a second time */ - $position = $cursor->advance(); - - /** @Then the position is 1 */ - self::assertSame(1, $position); - } - - public function testAdvanceWhenInvokedThreeTimesThenReturnsTwo(): void - { - /** @Given a cursor that has been advanced twice */ - $cursor = new Cursor(); - $cursor->advance(); - $cursor->advance(); - - /** @When advancing a third time */ - $position = $cursor->advance(); - - /** @Then the position is 2 */ - self::assertSame(2, $position); - } -} diff --git a/tests/Unit/Internal/Client/RequestResolverTest.php b/tests/Unit/Internal/Client/RequestResolverTest.php deleted file mode 100644 index 69fcca5..0000000 --- a/tests/Unit/Internal/Client/RequestResolverTest.php +++ /dev/null @@ -1,88 +0,0 @@ -resolve(request: $request); - - /** @Then the resolved request carries Content-Type and Accept defaults */ - self::assertSame('application/json', $resolved->headers->get('Content-Type')); - self::assertSame('application/json', $resolved->headers->get('Accept')); - } - - public function testResolveWhenExplicitContentTypeGivenThenWinsOverDefault(): void - { - /** @Given a resolver and a request with an explicit Content-Type */ - $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); - $request = Request::create( - url: '/dragons', - method: Method::POST, - headers: ContentType::applicationJson(charset: Charset::UTF_8) - ); - - /** @When resolving the request */ - $resolved = $resolver->resolve(request: $request); - - /** @Then the explicit Content-Type wins over the default */ - self::assertSame('application/json; charset=utf-8', $resolved->headers->get('Content-Type')); - } - - public function testResolveWhenRelativeUrlGivenThenComposesAgainstBaseUrl(): void - { - /** @Given a resolver with a base URL and a request with a relative path */ - $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); - $request = Request::create(url: '/dragons'); - - /** @When resolving the request */ - $resolved = $resolver->resolve(request: $request); - - /** @Then the resolved URL is absolute */ - self::assertSame('https://api.example.com/dragons', $resolved->url); - } - - public function testResolveWhenQueryGivenThenEmbedsInUrlAndClearsRequestQuery(): void - { - /** @Given a resolver and a request with query parameters */ - $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); - $request = Request::create(url: '/dragons', query: ['sort' => 'name', 'order' => 'asc']); - - /** @When resolving the request */ - $resolved = $resolver->resolve(request: $request); - - /** @Then the query is embedded in the URL and cleared from the request object */ - self::assertStringContainsString('sort=name', $resolved->url); - self::assertStringContainsString('order=asc', $resolved->url); - self::assertNull($resolved->query); - } - - public function testResolveWhenMalformedPathGivenThenThrowsMalformedPath(): void - { - /** @Given a resolver and a request with a malformed path */ - $resolver = RequestResolver::withBaseUrl(baseUrl: 'https://api.example.com'); - $request = Request::create(url: '//evil.example.com/attack'); - - /** @Then MalformedPath is thrown */ - $this->expectException(MalformedPath::class); - - /** @When resolving the request */ - $resolver->resolve(request: $request); - } -} diff --git a/tests/Unit/Internal/Client/UrlTest.php b/tests/Unit/Internal/Client/UrlTest.php deleted file mode 100644 index b756120..0000000 --- a/tests/Unit/Internal/Client/UrlTest.php +++ /dev/null @@ -1,125 +0,0 @@ - 'name', 'order' => 'asc'], - baseUrl: 'https://api.example.com' - ); - - /** @Then the query is appended with RFC 3986 encoding */ - self::assertStringContainsString('?sort=name&order=asc', $url); - } - - public function testComposeWhenQueryEmptyThenNoTrailingQuestionMark(): void - { - /** @When composing with an empty query array */ - $url = Url::compose(path: '/dragons', query: [], baseUrl: 'https://api.example.com'); - - /** @Then the URL has no trailing question mark */ - self::assertStringNotContainsString('?', $url); - } - - public function testComposeWhenQueryNullThenNoTrailingQuestionMark(): void - { - /** @When composing with a null query */ - $url = Url::compose(path: '/dragons', query: null, baseUrl: 'https://api.example.com'); - - /** @Then the URL has no trailing question mark */ - self::assertStringNotContainsString('?', $url); - } - - public function testComposeWhenProtocolRelativePathGivenThenThrowsInvalidArgument(): void - { - /** @Then InvalidArgumentException is thrown */ - $this->expectException(InvalidArgumentException::class); - - /** @When composing a path starting with // */ - Url::compose(path: '//evil.example.com/attack', query: null, baseUrl: 'https://api.example.com'); - } - - public function testComposeWhenProtocolRelativePathGivenWithEmptyBaseThenStillThrows(): void - { - /** @Then InvalidArgumentException is thrown */ - $this->expectException(InvalidArgumentException::class); - - /** @When composing a protocol-relative path with an empty base URL */ - Url::compose(path: '//evil.example.com/attack', query: null, baseUrl: ''); - } - - public function testComposeWhenSchemePathGivenThenThrowsInvalidArgument(): void - { - /** @Then InvalidArgumentException is thrown */ - $this->expectException(InvalidArgumentException::class); - - /** @When composing a path with https:// scheme */ - Url::compose(path: 'https://attacker.com/steal', query: null, baseUrl: 'https://api.example.com'); - } - - public function testComposeWhenSchemePathGivenWithEmptyBaseThenStillThrows(): void - { - /** @Then InvalidArgumentException is thrown */ - $this->expectException(InvalidArgumentException::class); - - /** @When composing a path with a scheme and empty base URL */ - Url::compose(path: 'https://attacker.com/steal', query: null, baseUrl: ''); - } - - public function testComposeWhenJavascriptSchemePathGivenThenThrowsInvalidArgument(): void - { - /** @Then InvalidArgumentException is thrown */ - $this->expectException(InvalidArgumentException::class); - - /** @When composing a path with javascript: scheme */ - Url::compose(path: 'javascript:alert(1)', query: null, baseUrl: 'https://api.example.com'); - } - - public function testComposeWhenControlCharactersGivenThenThrowsInvalidArgument(): void - { - /** @Then InvalidArgumentException is thrown */ - $this->expectException(InvalidArgumentException::class); - - /** @When composing a path containing a null byte */ - Url::compose(path: "/dragons\x00/evil", query: null, baseUrl: 'https://api.example.com'); - } -} diff --git a/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php b/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php deleted file mode 100644 index a7460bf..0000000 --- a/tests/Unit/Internal/Server/Request/RouteParameterResolverTest.php +++ /dev/null @@ -1,169 +0,0 @@ -withAttribute('__route__', ['id' => '42', 'slug' => 'test']); - - /** @When resolving parameters */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - $params = $resolver->resolve(attributeName: '__route__'); - - /** @Then the array is returned directly */ - self::assertSame(['id' => '42', 'slug' => 'test'], $params); - } - - public function testResolveWhenObjectExposesGetArgumentsThenUsesThatMethod(): void - { - /** @Given a Slim-style route object */ - $routeObject = new class { - /** @return array */ - public function getArguments(): array - { - return ['id' => '1', 'name' => 'dragon']; - } - }; - - $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) - ->withAttribute('__route__', $routeObject); - - /** @When resolving parameters */ - $params = RouteParameterResolver::from(request: $serverRequest)->resolve(attributeName: '__route__'); - - /** @Then getArguments() result is returned */ - self::assertSame(['id' => '1', 'name' => 'dragon'], $params); - } - - public function testResolveWhenObjectExposesGetMatchedParamsThenUsesThatMethod(): void - { - /** @Given a Mezzio-style route result object */ - $routeResult = new class { - /** @return array */ - public function getMatchedParams(): array - { - return ['id' => '99', 'action' => 'view']; - } - }; - - $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) - ->withAttribute('routeResult', $routeResult); - - /** @When resolving parameters */ - $params = RouteParameterResolver::from(request: $serverRequest)->resolve(attributeName: 'routeResult'); - - /** @Then getMatchedParams() result is returned */ - self::assertSame(['id' => '99', 'action' => 'view'], $params); - } - - public function testResolveWhenObjectExposesPublicPropertyThenReadsIt(): void - { - /** @Given a route object with a public arguments property */ - $routeObject = new class { - /** @var array */ - public array $arguments = ['key' => 'value']; - }; - - $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) - ->withAttribute('__route__', $routeObject); - - /** @When resolving parameters */ - $params = RouteParameterResolver::from(request: $serverRequest)->resolve(attributeName: '__route__'); - - /** @Then the public property value is returned */ - self::assertSame(['key' => 'value'], $params); - } - - public function testResolveWhenAttributeAbsentThenReturnsEmptyArray(): void - { - /** @Given a request with no matching attribute */ - $serverRequest = new ServerRequest(method: 'GET', uri: '/'); - - /** @When resolving parameters */ - $params = RouteParameterResolver::from(request: $serverRequest)->resolve(attributeName: '__route__'); - - /** @Then an empty array is returned */ - self::assertSame([], $params); - } - - public function testResolveWhenObjectExposesNoKnownAccessorThenReturnsEmptyArray(): void - { - /** @Given a route object without known methods or properties */ - $routeObject = new class { - public function unknownMethod(): string - { - return 'not useful'; - } - }; - - $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) - ->withAttribute('__route__', $routeObject); - - /** @When resolving parameters */ - $params = RouteParameterResolver::from(request: $serverRequest)->resolve(attributeName: '__route__'); - - /** @Then an empty array is returned */ - self::assertSame([], $params); - } - - public function testResolveFromKnownAttributesWhenSymfonyKeyGivenThenScanFindsIt(): void - { - /** @Given params stored under _route_params (Symfony-style) */ - $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) - ->withAttribute('_route_params', ['controller' => 'DragonController', 'id' => '5']); - - /** @When scanning known attributes */ - $params = RouteParameterResolver::from(request: $serverRequest)->resolveFromKnownAttributes(); - - /** @Then the Symfony-style params are found */ - self::assertSame(['controller' => 'DragonController', 'id' => '5'], $params); - } - - public function testResolveDirectAttributeWhenKeyPresentThenReturnsValue(): void - { - /** @Given a request with direct attributes (Laravel-style) */ - $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) - ->withAttribute('id', '123'); - - /** @When resolving a direct attribute */ - $resolver = RouteParameterResolver::from(request: $serverRequest); - - /** @Then the direct value is returned */ - self::assertSame('123', $resolver->resolveDirectAttribute(key: 'id')); - self::assertNull($resolver->resolveDirectAttribute(key: 'nonexistent')); - } - - public function testResolveWhenObjectHasBothMethodAndPropertyThenMethodWins(): void - { - /** @Given an object that has both a method and a property */ - $routeObject = new class { - /** @var array */ - public array $arguments = ['source' => 'property']; - - /** @return array */ - public function getArguments(): array - { - return ['source' => 'method']; - } - }; - - $serverRequest = (new ServerRequest(method: 'GET', uri: '/')) - ->withAttribute('__route__', $routeObject); - - /** @When resolving parameters */ - $params = RouteParameterResolver::from(request: $serverRequest)->resolve(attributeName: '__route__'); - - /** @Then the method result takes priority */ - self::assertSame(['source' => 'method'], $params); - } -} diff --git a/tests/Unit/Internal/Server/Stream/StreamFactoryTest.php b/tests/Unit/Internal/Server/Stream/StreamFactoryTest.php deleted file mode 100644 index 7e05b5f..0000000 --- a/tests/Unit/Internal/Server/Stream/StreamFactoryTest.php +++ /dev/null @@ -1,94 +0,0 @@ -write(); - - /** @Then the stream contains the written content */ - self::assertSame($body, $stream->getContents()); - } - - public function testFromStreamWhenSeekableThenRewindsBeforeAndAfterReading(): void - { - /** @Given a seekable stream observed via a stub */ - $stream = $this->createStub(StreamInterface::class); - $stream->method('isSeekable')->willReturn(true); - - /** @And rewind call counter */ - $rewindCalls = 0; - $stream->method('rewind')->willReturnCallback( - static function () use (&$rewindCalls): void { - $rewindCalls++; - } - ); - $stream->method('getContents')->willReturnCallback( - static function () use (&$rewindCalls): string { - self::assertSame(1, $rewindCalls); - return 'body'; - } - ); - - /** @When a StreamFactory is created from the stream */ - StreamFactory::fromStream(stream: $stream); - - /** @Then it rewinds twice (before and after reading) */ - self::assertSame(2, $rewindCalls); - } - - public function testFromStreamWhenNotSeekableThenDoesNotRewind(): void - { - /** @Given a non-seekable stream observed via a stub */ - $stream = $this->createStub(StreamInterface::class); - $stream->method('isSeekable')->willReturn(false); - - /** @And rewind call counter */ - $rewindCalls = 0; - $stream->method('rewind')->willReturnCallback( - static function () use (&$rewindCalls): void { - $rewindCalls++; - } - ); - $stream->method('getContents')->willReturnCallback( - static function () use (&$rewindCalls): string { - self::assertSame(0, $rewindCalls); - return 'body'; - } - ); - - /** @When a StreamFactory is created from the stream */ - StreamFactory::fromStream(stream: $stream); - - /** @Then it does not rewind */ - self::assertSame(0, $rewindCalls); - } - - public function testFromBodyWhenStringGivenThenCarriesBodyVerbatim(): void - { - /** @Given a real seekable stream */ - $stream = new Psr17Factory()->createStream('payload'); - - /** @When wrapping it through StreamFactory::fromStream */ - $factory = StreamFactory::fromStream(stream: $stream); - - /** @Then the factory's content matches the stream */ - self::assertSame('payload', $factory->content()); - } -} diff --git a/tests/Unit/Internal/Server/Stream/StreamTest.php b/tests/Unit/Internal/Server/Stream/StreamTest.php deleted file mode 100644 index e4b4874..0000000 --- a/tests/Unit/Internal/Server/Stream/StreamTest.php +++ /dev/null @@ -1,375 +0,0 @@ -temporary = $temporary; - $this->resource = $resource; - } - - protected function tearDown(): void - { - if (file_exists($this->temporary)) { - unlink($this->temporary); - } - } - - public function testGetMetadataWhenInvokedThenReturnsResourceMetadata(): void - { - /** @Given a stream */ - $stream = Stream::from(resource: $this->resource); - - /** @When retrieving metadata */ - /** @var array $actual */ - $actual = $stream->getMetadata(); - - /** @Then the metadata matches the underlying resource's metadata */ - /** @var array $expected */ - $expected = stream_get_meta_data($this->resource); - - self::assertSame($expected['uri'], $actual['uri']); - self::assertSame($expected['mode'], $actual['mode']); - self::assertSame($expected['seekable'], $actual['seekable']); - self::assertSame($expected['stream_type'], $actual['stream_type']); - } - - public function testCloseWhenAlreadyClosedThenIsNoOp(): void - { - /** @Given a stream that has already been closed */ - $stream = Stream::from(resource: $this->resource); - $stream->close(); - - /** @When closing the stream again */ - $stream->close(); - - /** @Then the stream remains detached */ - self::assertFalse($stream->isReadable()); - self::assertFalse($stream->isWritable()); - self::assertFalse($stream->isSeekable()); - self::assertFalse(is_resource($this->resource)); - } - - public function testCloseWhenInvokedThenDetachesResource(): void - { - /** @Given a stream resource */ - $stream = Stream::from(resource: $this->resource); - - /** @When the stream is closed */ - $stream->close(); - - /** @Then the resource is detached */ - self::assertFalse($stream->isReadable()); - self::assertFalse($stream->isWritable()); - self::assertFalse($stream->isSeekable()); - self::assertFalse(is_resource($this->resource)); - } - - public function testSeekWhenInvokedThenMovesCursorPosition(): void - { - /** @Given a stream with data */ - $stream = Stream::from(resource: $this->resource); - $stream->write(string: 'Hello, world!'); - - /** @When seeking to a specific position */ - $stream->seek(offset: 7); - $tellAfterFirstSeek = $stream->tell(); - $stream->seek(offset: 0, whence: SEEK_END); - - /** @Then the cursor moves correctly */ - self::assertTrue($stream->isWritable()); - self::assertTrue($stream->isSeekable()); - self::assertSame(7, $tellAfterFirstSeek); - self::assertSame(13, $stream->tell()); - } - - public function testGetSizeWhenWritesPerformedThenReflectsContentLength(): void - { - /** @Given a stream */ - $stream = Stream::from(resource: $this->resource); - - /** @When writing to the stream */ - $sizeBeforeWrite = $stream->getSize(); - $stream->write(string: 'Hello, world!'); - - /** @Then the size reflects the bytes written */ - self::assertSame(0, $sizeBeforeWrite); - self::assertSame(13, $stream->getSize()); - } - - public function testIsWritableWhenCreateModeGivenThenReturnsTrue(): void - { - /** @Given a file that does not exist */ - unlink($this->temporary); - - /** @When opening the stream in create mode ('x') */ - $stream = Stream::from(resource: fopen($this->temporary, 'x')); - - /** @Then the stream is writable */ - self::assertTrue($stream->isWritable()); - } - - #[DataProvider('modesDataProvider')] - public function testIsWritableWhenModeGivenThenMatchesExpectation(string $mode, bool $expected): void - { - /** @Given a stream opened in a specific mode */ - $stream = Stream::from(resource: fopen('php://memory', $mode)); - - /** @Then the writable flag matches the expectation */ - self::assertSame($expected, $stream->isWritable()); - } - - public function testRewindWhenInvokedThenResetsCursorPosition(): void - { - /** @Given a stream with data */ - $stream = Stream::from(resource: $this->resource); - $stream->write(string: 'Hello, world!'); - - /** @When rewinding the stream */ - $stream->seek(offset: 7); - $stream->rewind(); - - /** @Then the cursor returns to the beginning */ - self::assertSame(0, $stream->tell()); - } - - public function testEofWhenEndReachedThenReturnsTrue(): void - { - /** @Given a stream with data */ - $stream = Stream::from(resource: $this->resource); - $stream->write(string: 'Hello'); - - /** @When reading every byte */ - $eofBeforeRead = $stream->eof(); - $stream->read(length: 5); - - /** @Then EOF reports true at the end */ - self::assertTrue($stream->eof()); - self::assertTrue($stream->isReadable()); - self::assertFalse($eofBeforeRead); - } - - public function testGetMetadataWhenUnknownKeyGivenThenReturnsNull(): void - { - /** @Given a stream */ - $stream = Stream::from(resource: $this->resource); - - /** @When retrieving metadata for an unknown key */ - $actual = $stream->getMetadata(key: 'UNKNOWN'); - - /** @Then the result is null */ - self::assertNull($actual); - } - - public function testToStringWhenInvokedThenReturnsFullContent(): void - { - /** @Given a stream */ - $stream = Stream::from(resource: $this->resource); - - /** @When writing and converting the stream to string */ - $stream->write(string: 'Hello, world!'); - - /** @Then the content matches the written data */ - self::assertSame('Hello, world!', (string)$stream); - } - - public function testGetSizeWhenStreamClosedThenReturnsNull(): void - { - /** @Given a stream that has been closed */ - $stream = Stream::from(resource: $this->resource); - $stream->close(); - - /** @Then getSize returns null */ - self::assertNull($stream->getSize()); - } - - public function testIsSeekableWhenResourceClosedExternallyThenReturnsFalse(): void - { - /** @Given a stream whose underlying resource was closed outside the stream API */ - $resource = fopen('php://memory', 'w+'); - - if ($resource === false) { - self::fail('Could not open php://memory.'); - } - - $stream = Stream::from(resource: $resource); - fclose($resource); - - /** @When checking if the stream is seekable */ - $actual = $stream->isSeekable(); - - /** @Then it returns false because the resource is no longer valid */ - self::assertFalse($actual); - } - - public function testSeekWhenStreamClosedThenThrowsNonSeekableStream(): void - { - /** @Given a stream */ - $stream = Stream::from(resource: $this->resource); - - /** @Then NonSeekableStream is thrown */ - self::expectException(NonSeekableStream::class); - self::expectExceptionMessage('Stream is not seekable.'); - - /** @When attempting to seek on a closed stream */ - $stream->close(); - $stream->seek(offset: 1); - } - - public function testWriteWhenStreamReadOnlyThenThrowsNonWritableStream(): void - { - /** @Given a read-only stream */ - $stream = Stream::from(resource: fopen($this->temporary, 'r')); - - /** @Then NonWritableStream is thrown */ - self::expectException(NonWritableStream::class); - self::expectExceptionMessage('Stream is not writable.'); - - /** @When attempting to write to the stream */ - $stream->write(string: 'Hello, world!'); - } - - public function testReadWhenStreamWriteOnlyThenThrowsNonReadableStream(): void - { - /** @Given a write-only stream */ - $stream = Stream::from(resource: fopen($this->temporary, 'w')); - - /** @Then NonReadableStream is thrown */ - self::expectException(NonReadableStream::class); - self::expectExceptionMessage('Stream is not readable.'); - - /** @When attempting to read from the stream */ - $stream->read(length: 13); - } - - public function testReadWhenLengthZeroGivenThenThrowsNonReadableStream(): void - { - /** @Given a readable stream */ - $stream = Stream::from(resource: $this->resource); - - /** @Then NonReadableStream is thrown when length is zero */ - self::expectException(NonReadableStream::class); - - /** @When attempting to read with length 0 */ - $stream->read(length: 0); - } - - public function testReadWhenLengthOneGivenThenReturnsSingleByte(): void - { - /** @Given a readable stream with one byte written */ - $stream = Stream::from(resource: $this->resource); - $stream->write(string: 'H'); - $stream->rewind(); - - /** @When reading exactly one byte */ - $chunk = $stream->read(length: 1); - - /** @Then a single-byte chunk is returned */ - self::assertSame('H', $chunk); - } - - public function testWriteWhenInvokedThenReturnsByteCount(): void - { - /** @Given a readable and writable stream */ - $stream = Stream::from(resource: $this->resource); - - /** @When writing a known payload */ - $bytesWritten = $stream->write(string: 'Hello'); - - /** @Then the byte count matches the payload size */ - self::assertSame(5, $bytesWritten); - } - - public function testWriteWhenStreamClosedThenThrowsNonWritableStream(): void - { - /** @Given a closed stream */ - $stream = Stream::from(resource: $this->resource); - $stream->close(); - - /** @Then NonWritableStream is thrown when writing after close */ - self::expectException(NonWritableStream::class); - - /** @When writing to the closed stream */ - $stream->write(string: 'Hello'); - } - - public function testFromWhenInvalidResourceGivenThenThrowsInvalidResource(): void - { - /** @Given an invalid resource */ - $resource = 'not_a_resource'; - - /** @Then InvalidResource is thrown */ - $this->expectException(InvalidResource::class); - $this->expectExceptionMessage('The provided value is not a valid resource.'); - - /** @When calling from() with an invalid resource */ - Stream::from(resource: $resource); - } - - public function testTellWhenStreamClosedThenThrowsMissingResourceStream(): void - { - /** @Given a stream */ - $stream = Stream::from(resource: $this->resource); - - /** @Then MissingResourceStream is thrown */ - self::expectException(MissingResourceStream::class); - self::expectExceptionMessage('No resource available.'); - - /** @When attempting to call tell on a closed stream */ - $stream->close(); - $stream->tell(); - } - - public function testGetContentsWhenStreamWriteOnlyThenThrowsNonReadableStream(): void - { - /** @Given a write-only stream */ - $stream = Stream::from(resource: fopen($this->temporary, 'w')); - - /** @Then NonReadableStream is thrown */ - self::expectException(NonReadableStream::class); - self::expectExceptionMessage('Stream is not readable.'); - - /** @When attempting to get contents of the stream */ - $stream->getContents(); - } - - /** @return array */ - public static function modesDataProvider(): array - { - return [ - 'Read mode (r)' => ['mode' => 'r', 'expected' => false], - 'Write mode (w)' => ['mode' => 'w', 'expected' => true], - 'Append mode (a)' => ['mode' => 'a', 'expected' => true], - 'Mixed read/write mode (r+)' => ['mode' => 'r+', 'expected' => true] - ]; - } -} diff --git a/tests/Unit/PsrClientException.php b/tests/Unit/PsrClientException.php new file mode 100644 index 0000000..ce04926 --- /dev/null +++ b/tests/Unit/PsrClientException.php @@ -0,0 +1,12 @@ + */ public static function sameSiteValueProvider(): array { return [ diff --git a/tests/Unit/Server/HeadersTest.php b/tests/Unit/Server/HeadersTest.php index 6c7e939..4784010 100644 --- a/tests/Unit/Server/HeadersTest.php +++ b/tests/Unit/Server/HeadersTest.php @@ -13,43 +13,30 @@ final class HeadersTest extends TestCase { - public function testWithHeaderWhenInvokedThenAddsCustomHeadersAlongsideDefaultContentType(): void + public function testNoContentWhenInvokedThenCarriesDefaultContentType(): void { - /** @Given an HTTP response */ + /** @When a no-content response is created */ $response = Response::noContent(); - /** @And by default, the response contains the 'Content-Type' header set to 'application/json; charset=utf-8' */ + /** @Then the response carries the default Content-Type header */ self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $response->getHeaders()); + } - /** @When we add custom headers to the response */ + public function testWithHeaderWhenChainedWithDistinctKeysThenBothPresentAlongsideDefault(): void + { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @When two distinct custom headers are added in a chain */ $actual = $response - ->withHeader(name: 'X-ID', value: '100') - ->withHeader(name: 'X-NAME', value: 'Xpto'); + ->withHeader('X-ID', '100') + ->withHeader('X-NAME', 'Xpto'); - /** @Then the response contains the correct headers */ + /** @Then both custom headers are present alongside the default Content-Type */ self::assertSame( ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => ['100'], 'X-NAME' => ['Xpto']], $actual->getHeaders() ); - - /** @And when we update the 'X-ID' header with a new value */ - $actual = $actual->withHeader(name: 'X-ID', value: '200'); - - /** @Then the response contains the updated 'X-ID' header value */ - self::assertSame('200', $actual->withAddedHeader(name: 'X-ID', value: '200')->getHeaderLine(name: 'X-ID')); - self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => ['200'], 'X-NAME' => ['Xpto']], - $actual->getHeaders() - ); - - /** @And when we remove the 'X-NAME' header */ - $actual = $actual->withoutHeader(name: 'X-NAME'); - - /** @Then the response contains only the 'X-ID' header and the default 'Content-Type' header */ - self::assertSame( - ['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => ['200']], - $actual->getHeaders() - ); } public function testWithHeaderWhenSameHeaderSetTwiceThenLastValueWins(): void @@ -59,11 +46,11 @@ public function testWithHeaderWhenSameHeaderSetTwiceThenLastValueWins(): void /** @When we add the 'Content-Type' header twice with different values */ $actual = $response - ->withHeader(name: 'Content-Type', value: 'application/json; charset=utf-8') - ->withHeader(name: 'Content-Type', value: 'application/json; charset=ISO-8859-1'); + ->withHeader('Content-Type', 'application/json; charset=utf-8') + ->withHeader('Content-Type', 'application/json; charset=ISO-8859-1'); /** @Then the response carries the latest 'Content-Type' value */ - self::assertSame('application/json; charset=ISO-8859-1', $actual->getHeaderLine(name: 'Content-Type')); + self::assertSame('application/json; charset=ISO-8859-1', $actual->getHeaderLine('Content-Type')); /** @And only one Content-Type entry exists */ self::assertSame(['Content-Type' => ['application/json; charset=ISO-8859-1']], $actual->getHeaders()); @@ -75,7 +62,7 @@ public function testGetHeaderWhenHeaderMissingThenReturnsEmptyArray(): void $response = Response::noContent(); /** @When we retrieve a missing header */ - $actual = $response->getHeader(name: 'Non-Existent-Header'); + $actual = $response->getHeader('Non-Existent-Header'); /** @Then the header is returned as an empty array */ self::assertSame([], $actual); @@ -84,14 +71,14 @@ public function testGetHeaderWhenHeaderMissingThenReturnsEmptyArray(): void public function testWithAddedHeaderWhenDistinctValueGivenThenAppendsToExistingHeader(): void { /** @Given an HTTP response with a custom header */ - $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'first'); + $response = Response::noContent()->withHeader('X-Trace', 'first'); /** @When a distinct value is added to the same header */ - $actual = $response->withAddedHeader(name: 'X-Trace', value: 'second'); + $actual = $response->withAddedHeader('X-Trace', 'second'); /** @Then both values are preserved in the original order */ - self::assertSame('first, second', $actual->getHeaderLine(name: 'X-Trace')); - self::assertSame(['first', 'second'], $actual->getHeader(name: 'X-Trace')); + self::assertSame('first, second', $actual->getHeaderLine('X-Trace')); + self::assertSame(['first', 'second'], $actual->getHeader('X-Trace')); } public function testWithAddedHeaderWhenHeaderAbsentThenCreatesItWithGivenValue(): void @@ -100,10 +87,10 @@ public function testWithAddedHeaderWhenHeaderAbsentThenCreatesItWithGivenValue() $response = Response::noContent(); /** @When a value is added for the absent header */ - $actual = $response->withAddedHeader(name: 'X-Trace', value: 'only-value'); + $actual = $response->withAddedHeader('X-Trace', 'only-value'); /** @Then the header is created carrying the given value */ - self::assertSame(['only-value'], $actual->getHeader(name: 'X-Trace')); + self::assertSame(['only-value'], $actual->getHeader('X-Trace')); self::assertSame( ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['only-value']], $actual->getHeaders() @@ -113,13 +100,13 @@ public function testWithAddedHeaderWhenHeaderAbsentThenCreatesItWithGivenValue() public function testWithAddedHeaderWhenCaseMismatchedThenMatchesExistingHeader(): void { /** @Given an HTTP response with a custom header */ - $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'first'); + $response = Response::noContent()->withHeader('X-Trace', 'first'); /** @When a value is added using a differently cased name */ - $actual = $response->withAddedHeader(name: 'x-trace', value: 'second'); + $actual = $response->withAddedHeader('x-trace', 'second'); /** @Then the value is appended preserving the original case of the header name */ - self::assertSame(['first', 'second'], $actual->getHeader(name: 'X-Trace')); + self::assertSame(['first', 'second'], $actual->getHeader('X-Trace')); self::assertSame( ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['first', 'second']], $actual->getHeaders() @@ -129,13 +116,13 @@ public function testWithAddedHeaderWhenCaseMismatchedThenMatchesExistingHeader() public function testWithoutHeaderWhenCaseMismatchedThenStillRemovesHeader(): void { /** @Given an HTTP response with a custom header */ - $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'value'); + $response = Response::noContent()->withHeader('X-Trace', 'value'); /** @When the header is removed using a differently cased name */ - $actual = $response->withoutHeader(name: 'x-trace'); + $actual = $response->withoutHeader('x-trace'); /** @Then the header is no longer present */ - self::assertFalse($actual->hasHeader(name: 'X-Trace')); + self::assertFalse($actual->hasHeader('X-Trace')); self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); } @@ -145,7 +132,7 @@ public function testWithoutHeaderWhenAbsentThenIsNoOp(): void $response = Response::noContent(); /** @When the missing header is requested to be removed */ - $actual = $response->withoutHeader(name: 'X-Trace'); + $actual = $response->withoutHeader('X-Trace'); /** @Then the headers remain unchanged */ self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders()); @@ -157,40 +144,56 @@ public function testWithHeaderWhenHeaderAbsentThenCreatesIt(): void $response = Response::noContent(); /** @When the header is replaced (i.e., set) */ - $actual = $response->withHeader(name: 'X-Trace', value: 'value'); + $actual = $response->withHeader('X-Trace', 'value'); /** @Then the header is created with the given value */ - self::assertSame(['value'], $actual->getHeader(name: 'X-Trace')); + self::assertSame(['value'], $actual->getHeader('X-Trace')); } public function testWithHeaderWhenCaseMismatchedThenReplacesExistingHeader(): void { /** @Given an HTTP response with a custom header */ - $response = Response::noContent()->withHeader(name: 'X-Trace', value: 'first'); + $response = Response::noContent()->withHeader('X-Trace', 'first'); /** @When the header is replaced using a differently cased name */ - $actual = $response->withHeader(name: 'x-trace', value: 'second'); + $actual = $response->withHeader('x-trace', 'second'); /** @Then the original casing is preserved and the value replaced */ - self::assertSame(['second'], $actual->getHeader(name: 'X-Trace')); + self::assertSame(['second'], $actual->getHeader('X-Trace')); self::assertSame( ['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['second']], $actual->getHeaders() ); } - public function testNoContentWhenMultipleHeaderablesGivenThenCombinesEntries(): void + public function testNoContentWhenMultipleHeaderablesGivenThenCacheControlIsPresent(): void { - /** @Given a Cache-Control and a Content-Type header */ + /** @Given a Cache-Control header */ $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noStore()); + + /** @And a Content-Type header */ + $contentType = ContentType::textPlain(); + + /** @When a response is created with both */ + $actual = Response::noContent($cacheControl, $contentType); + + /** @Then the Cache-Control header is present */ + self::assertSame(['no-store'], $actual->getHeader('Cache-Control')); + } + + public function testNoContentWhenMultipleHeaderablesGivenThenContentTypeReplacesDefault(): void + { + /** @Given a Cache-Control header */ + $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noStore()); + + /** @And a Content-Type header */ $contentType = ContentType::textPlain(); /** @When a response is created with both */ $actual = Response::noContent($cacheControl, $contentType); - /** @Then both headers are present */ - self::assertSame(['no-store'], $actual->getHeader(name: 'Cache-Control')); - self::assertSame(['text/plain'], $actual->getHeader(name: 'Content-Type')); + /** @Then the Content-Type header replaces the default */ + self::assertSame(['text/plain'], $actual->getHeader('Content-Type')); } public function testNoContentWhenCacheControlWithEveryDirectiveGivenThenHeaderRendersAll(): void @@ -210,14 +213,14 @@ public function testNoContentWhenCacheControlWithEveryDirectiveGivenThenHeaderRe $actual = Response::noContent($cacheControl); /** @And the response includes the Cache-Control header */ - self::assertTrue($actual->hasHeader(name: 'Cache-Control')); + self::assertTrue($actual->hasHeader('Cache-Control')); /** @And the Cache-Control header lists every directive */ $expected = 'max-age=10000, no-cache, no-store, no-transform, stale-if-error, ' . 'must-revalidate, proxy-revalidate'; - self::assertSame($expected, $actual->getHeaderLine(name: 'Cache-Control')); - self::assertSame([$expected], $actual->getHeader(name: 'Cache-Control')); + self::assertSame($expected, $actual->getHeaderLine('Cache-Control')); + self::assertSame([$expected], $actual->getHeader('Cache-Control')); self::assertSame($cacheControl->toArray(), $actual->getHeaders()); } @@ -230,8 +233,8 @@ public function testNoContentWhenContentTypeIsPdfThenHeaderReflectsIt(): void $actual = Response::noContent($contentType); /** @Then the response carries Content-Type: application/pdf */ - self::assertTrue($actual->hasHeader(name: 'Content-Type')); - self::assertSame('application/pdf', $actual->getHeaderLine(name: 'Content-Type')); + self::assertTrue($actual->hasHeader('Content-Type')); + self::assertSame('application/pdf', $actual->getHeaderLine('Content-Type')); } public function testNoContentWhenContentTypeIsHtmlThenHeaderReflectsIt(): void @@ -243,7 +246,7 @@ public function testNoContentWhenContentTypeIsHtmlThenHeaderReflectsIt(): void $actual = Response::noContent($contentType); /** @Then the response carries Content-Type: text/html */ - self::assertSame('text/html', $actual->getHeaderLine(name: 'Content-Type')); + self::assertSame('text/html', $actual->getHeaderLine('Content-Type')); } public function testNoContentWhenContentTypeIsJsonThenHeaderReflectsIt(): void @@ -255,7 +258,7 @@ public function testNoContentWhenContentTypeIsJsonThenHeaderReflectsIt(): void $actual = Response::noContent($contentType); /** @Then the response carries Content-Type: application/json */ - self::assertSame('application/json', $actual->getHeaderLine(name: 'Content-Type')); + self::assertSame('application/json', $actual->getHeaderLine('Content-Type')); } public function testNoContentWhenContentTypeIsPlainTextThenHeaderReflectsIt(): void @@ -267,7 +270,7 @@ public function testNoContentWhenContentTypeIsPlainTextThenHeaderReflectsIt(): v $actual = Response::noContent($contentType); /** @Then the response carries Content-Type: text/plain */ - self::assertSame('text/plain', $actual->getHeaderLine(name: 'Content-Type')); + self::assertSame('text/plain', $actual->getHeaderLine('Content-Type')); } public function testNoContentWhenContentTypeIsOctetStreamThenHeaderReflectsIt(): void @@ -279,7 +282,7 @@ public function testNoContentWhenContentTypeIsOctetStreamThenHeaderReflectsIt(): $actual = Response::noContent($contentType); /** @Then the response carries Content-Type: application/octet-stream */ - self::assertSame('application/octet-stream', $actual->getHeaderLine(name: 'Content-Type')); + self::assertSame('application/octet-stream', $actual->getHeaderLine('Content-Type')); } public function testNoContentWhenHeaderableEmitsStringValueThenWrapsItInList(): void @@ -291,7 +294,7 @@ public function testNoContentWhenHeaderableEmitsStringValueThenWrapsItInList(): $actual = Response::noContent($userAgent); /** @Then the header is preserved as a single-entry list */ - self::assertSame(['MyApp/1.2.3'], $actual->getHeader(name: 'User-Agent')); + self::assertSame(['MyApp/1.2.3'], $actual->getHeader('User-Agent')); } public function testNoContentWhenContentTypeIsFormUrlEncodedThenHeaderReflectsIt(): void @@ -303,6 +306,6 @@ public function testNoContentWhenContentTypeIsFormUrlEncodedThenHeaderReflectsIt $actual = Response::noContent($contentType); /** @Then the response carries Content-Type: application/x-www-form-urlencoded */ - self::assertSame('application/x-www-form-urlencoded', $actual->getHeaderLine(name: 'Content-Type')); + self::assertSame('application/x-www-form-urlencoded', $actual->getHeaderLine('Content-Type')); } } diff --git a/tests/Unit/Server/ProtocolVersionTest.php b/tests/Unit/Server/ProtocolVersionTest.php index f2f4e19..5635075 100644 --- a/tests/Unit/Server/ProtocolVersionTest.php +++ b/tests/Unit/Server/ProtocolVersionTest.php @@ -18,7 +18,7 @@ public function testWithProtocolVersionWhenInvokedThenReturnsResponseWithUpdated self::assertSame('1.1', $response->getProtocolVersion()); /** @When the protocol version is updated to HTTP/3 */ - $actual = $response->withProtocolVersion(version: '3'); + $actual = $response->withProtocolVersion('3'); /** @Then the response uses the updated protocol version 3 */ self::assertSame('3', $actual->getProtocolVersion()); diff --git a/tests/Unit/Server/RequestTest.php b/tests/Unit/Server/RequestTest.php index aa5bcfb..a806d60 100644 --- a/tests/Unit/Server/RequestTest.php +++ b/tests/Unit/Server/RequestTest.php @@ -55,7 +55,7 @@ public function testDecodeWhenBodyGivenThenExposesTypedAccessors(): void public function testDecodeWhenRouteHasSingleAttributeThenExposesIt(): void { /** @Given a route attribute carrying a single id */ - $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com/dragons/dragon-id')) + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com/dragons/dragon-id') ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', 'id' => 'dragon-id']); /** @When decoding the route attribute */ @@ -67,10 +67,11 @@ public function testDecodeWhenRouteHasSingleAttributeThenExposesIt(): void public function testDecodeWhenRouteHasMultipleAttributesThenExposesEach(): void { - /** @Given a route attribute carrying id, skill, and weight */ + /** @Given a set of route attributes */ $attributes = ['id' => 'dragon-id', 'skill' => 'dragon-skill', 'weight' => 6000.00]; - $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + /** @And a server request carrying those attributes under the canonical route key */ + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') ->withAttribute('__route__', ['name' => '/v1/dragons/{id}/skills/{skill}', ...$attributes]); /** @When decoding each attribute */ @@ -90,7 +91,7 @@ public function testDecodeWhenAttributeTypedConversionRequestedThenReturnsExpect mixed $expected ): void { /** @Given a route attribute with the provided value */ - $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') ->withAttribute('__route__', ['name' => '/v1/dragons/{id}', $key => $value]); /** @When converting through the typed accessor */ @@ -103,7 +104,7 @@ public function testDecodeWhenAttributeTypedConversionRequestedThenReturnsExpect public function testDecodeWhenRouteAttributeIsScalarThenExposesIt(): void { /** @Given a scalar route attribute value */ - $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') ->withAttribute('__route__', 'dragon-id'); /** @When decoding the route attribute */ @@ -117,14 +118,13 @@ public function testDecodeWhenSlimStyleRouteObjectGivenThenResolvesArguments(): { /** @Given a Slim-style route object that stores params in getArguments() */ $routeObject = new class { - /** @return array */ public function getArguments(): array { return ['id' => '42', 'email' => 'dragon@fire.com']; } }; - $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + $serverRequest = new ServerRequest(method: 'GET', uri: 'https://api.example.com') ->withAttribute('__route__', $routeObject); /** @When decoding the route */ @@ -140,7 +140,6 @@ public function testDecodeWhenMezzioStyleRouteResultGivenThenResolvesMatchedPara { /** @Given a Mezzio-style route result object that uses getMatchedParams() */ $routeResult = new class { - /** @return array */ public function getMatchedParams(): array { return ['id' => '99', 'slug' => 'fire-dragon']; @@ -218,7 +217,6 @@ public function testDecodeWhenRouteObjectExposesPublicPropertyThenResolvesIt(): { /** @Given a route object exposing public properties */ $routeObject = new class { - /** @var array */ public array $arguments = ['id' => '10', 'name' => 'Hydra']; }; @@ -233,6 +231,29 @@ public function testDecodeWhenRouteObjectExposesPublicPropertyThenResolvesIt(): self::assertSame('Hydra', $route->get(key: 'name')->toString()); } + public function testDecodeWhenRouteObjectExposesNonArrayMethodAndPropertyThenFallsBackToEmpty(): void + { + /** @Given a route object whose matching method and property both return non-array values */ + $routeObject = new class { + public string $arguments = 'not-an-array'; + + public function getArguments(): string + { + return 'not-an-array'; + } + }; + + /** @And a server request carrying that object under the canonical route key */ + $serverRequest = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withAttribute('__route__', $routeObject); + + /** @When decoding any route attribute */ + $route = Request::from(request: $serverRequest)->decode()->uri()->route(); + + /** @Then safe defaults are returned because no array could be extracted */ + self::assertSame('', $route->get(key: 'id')->toString()); + } + public function testDecodeWhenNoRouteAttributesGivenThenSafeDefaultsAreReturned(): void { /** @Given a server request with no route attributes at all */ @@ -351,7 +372,6 @@ public function testMethodWhenAnyHttpVerbGivenThenReturnsMatchingEnum( self::assertSame($methodString, $actual->value); } - /** @return array */ public static function httpMethodsProvider(): array { return [ @@ -367,7 +387,6 @@ public static function httpMethodsProvider(): array ]; } - /** @return array */ public static function attributeConversionsProvider(): array { return [ @@ -421,7 +440,7 @@ public function testDecodeWhenStreamAdvancedThenStillParsesFromStart(): void $stream->getContents(); /** @And a server request using that stream */ - $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) + $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com') ->withBody($stream); /** @When decoding the request body */ @@ -437,7 +456,7 @@ public function testDecodeWhenStreamAdvancedThenStillParsesFromStart(): void public function testDecodeWhenEmptyStreamAndNonArrayParsedBodyThenReturnsEmpty(): void { /** @Given an empty stream and a non-array parsed body */ - $serverRequest = (new ServerRequest(method: 'POST', uri: 'https://api.example.com')) + $serverRequest = new ServerRequest(method: 'POST', uri: 'https://api.example.com') ->withBody($this->factory->createStream('')) ->withParsedBody(null); diff --git a/tests/Unit/Server/ResponseTest.php b/tests/Unit/Server/ResponseTest.php index 6eaac6d..32c31a1 100644 --- a/tests/Unit/Server/ResponseTest.php +++ b/tests/Unit/Server/ResponseTest.php @@ -5,8 +5,11 @@ namespace Test\TinyBlocks\Http\Unit\Server; use DateTime; +use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionClass; +use RuntimeException; use Test\TinyBlocks\Http\Models\Amount; use Test\TinyBlocks\Http\Models\Color; use Test\TinyBlocks\Http\Models\Currency; @@ -16,7 +19,6 @@ use Test\TinyBlocks\Http\Models\Products; use Test\TinyBlocks\Http\Models\Status; use TinyBlocks\Http\Code; -use TinyBlocks\Http\Internal\Server\Stream\StreamFactory; use TinyBlocks\Http\Server\Response; final class ResponseTest extends TestCase @@ -197,7 +199,32 @@ public function testInternalServerErrorWhenBodyGivenThenReturnsResponseWithStatu self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); } - /** @return array */ + public function testBadGatewayWhenBodyGivenThenReturnsResponseWithStatus502(): void + { + /** @Given a body with upstream failure details */ + $body = ['error' => 'Bad Gateway', 'message' => 'The upstream server returned an invalid response.']; + + /** @When the response is created with the body */ + $actual = Response::badGateway(body: $body); + + /** @Then the status is 502 */ + self::assertSame(Code::BAD_GATEWAY->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + } + + public function testServiceUnavailableWhenBodyGivenThenReturnsResponseWithStatus503(): void + { + /** @Given a body with service downtime details */ + $body = ['error' => 'Service Unavailable', 'message' => 'The service is temporarily unavailable.']; + + /** @When the response is created with the body */ + $actual = Response::serviceUnavailable(body: $body); + + /** @Then the status is 503 */ + self::assertSame(Code::SERVICE_UNAVAILABLE->value, $actual->getStatusCode()); + self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); + } + public static function responseFromProvider(): array { return [ @@ -245,15 +272,14 @@ public function testWithBodyWhenInvokedThenReplacesBodyContent(): void /** @Given an HTTP response without body */ $response = Response::ok(body: null); - /** @When the body is initially empty */ - self::assertEmpty($response->getBody()->__toString()); + /** @And a fresh PSR-7 stream carrying the replacement bytes */ + $replacement = new Psr17Factory()->createStream('This is a new body'); - /** @And a new body is set for the response */ - $body = 'This is a new body'; - $actual = $response->withBody(body: StreamFactory::fromBody(body: $body)->write()); + /** @When the body is replaced */ + $actual = $response->withBody($replacement); /** @Then the response body matches the new content */ - self::assertSame($body, $actual->getBody()->__toString()); + self::assertSame('This is a new body', $actual->getBody()->__toString()); } public function testWithStatusWhenInvokedThenReturnsResponseWithUpdatedCode(): void @@ -268,7 +294,6 @@ public function testWithStatusWhenInvokedThenReturnsResponseWithUpdatedCode(): v self::assertSame(Code::OK->value, $updated->getStatusCode()); } - /** @return array */ public static function bodyProviderData(): array { return [ @@ -330,4 +355,431 @@ public static function bodyProviderData(): array ] ]; } + + public function testGetBodyWhenInvokedThenStreamIsReadable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When inspecting the stream */ + $isReadable = $stream->isReadable(); + + /** @Then the stream is readable */ + self::assertTrue($isReadable); + } + + public function testGetBodyWhenInvokedThenStreamIsWritable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When inspecting the stream */ + $isWritable = $stream->isWritable(); + + /** @Then the stream is writable */ + self::assertTrue($isWritable); + } + + public function testGetBodyWhenInvokedThenStreamIsSeekable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When inspecting the stream */ + $isSeekable = $stream->isSeekable(); + + /** @Then the stream is seekable */ + self::assertTrue($isSeekable); + } + + public function testGetBodyWhenContentsReadThenReturnsTheWrittenJsonWithoutRequiringRewind(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When reading the stream contents directly */ + $contents = $stream->getContents(); + + /** @Then the contents match the encoded body without needing a manual rewind */ + self::assertSame('{"name":"Hydra"}', $contents); + } + + public function testGetBodyWhenInvokedThenStreamStartsAtPositionZero(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When inspecting position before reading */ + $tell = $stream->tell(); + + /** @Then the position starts at zero */ + self::assertSame(0, $tell); + } + + public function testGetBodyWhenInvokedThenStreamIsNotAtEofBeforeReading(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When inspecting EOF before reading */ + $eof = $stream->eof(); + + /** @Then EOF is not yet reached */ + self::assertFalse($eof); + } + + public function testGetBodyWhenContentsReadThenStreamReachesEof(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When reading all contents to advance the cursor */ + $stream->getContents(); + + /** @Then EOF is signaled */ + self::assertTrue($stream->eof()); + } + + public function testGetBodyWhenSizeRequestedThenMatchesPayloadLength(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When asking the stream for its size */ + $size = $stream->getSize(); + + /** @Then the size matches the encoded payload length */ + self::assertSame(strlen('{"name":"Hydra"}'), $size); + } + + public function testGetBodyWhenClosedThenReportsNullSize(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When the stream is closed */ + $stream->close(); + + /** @Then the stream reports null size */ + self::assertNull($stream->getSize()); + } + + public function testGetBodyWhenClosedThenIsNotReadable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When the stream is closed */ + $stream->close(); + + /** @Then the stream is no longer readable */ + self::assertFalse($stream->isReadable()); + } + + public function testGetBodyWhenClosedThenIsNotWritable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When the stream is closed */ + $stream->close(); + + /** @Then the stream is no longer writable */ + self::assertFalse($stream->isWritable()); + } + + public function testGetBodyWhenClosedThenIsNotSeekable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When the stream is closed */ + $stream->close(); + + /** @Then the stream is no longer seekable */ + self::assertFalse($stream->isSeekable()); + } + + public function testGetBodyWhenClosedThenEofReturnsFalse(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When the stream is closed */ + $stream->close(); + + /** @Then the detached stream reports it has not reached EOF */ + self::assertFalse($stream->eof()); + } + + public function testGetBodyWhenReadInChunksThenReturnsContentSegments(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When reading a small chunk from the beginning */ + $chunk = $stream->read(4); + + /** @Then the chunk matches the leading bytes of the encoded payload */ + self::assertSame('{"na', $chunk); + } + + public function testGetBodyWhenSeekedToOffsetThenTellMatchesOffset(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When seeking past the opening brace */ + $stream->seek(1); + + /** @Then the position reports the seeked offset */ + self::assertSame(1, $stream->tell()); + } + + public function testGetBodyWhenSeekedToOffsetThenSubsequentReadsResumeFromThatOffset(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When seeking past the opening brace */ + $stream->seek(1); + + /** @Then the next read starts at the seeked offset */ + self::assertSame('"', $stream->read(1)); + } + + public function testGetBodyWhenStreamWrittenAdditionalDataThenReturnsByteCount(): void + { + /** @Given a response stream positioned at end-of-file */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + $stream->seek(0, SEEK_END); + + /** @When appending one byte via the StreamInterface write() */ + $written = $stream->write('+'); + + /** @Then the write returns the byte count */ + self::assertSame(1, $written); + } + + public function testGetBodyWhenStreamWrittenAdditionalDataThenContentsGrowAccordingly(): void + { + /** @Given a response stream positioned at end-of-file */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + $stream->seek(0, SEEK_END); + + /** @When appending one byte via the StreamInterface write() */ + $stream->write('+'); + + /** @Then the stream size grows accordingly */ + self::assertSame(strlen('{"name":"Hydra"}+'), $stream->getSize()); + } + + public function testGetBodyWhenMetadataRequestedWithoutKeyThenReturnsArray(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When asking for the full metadata map */ + $metadata = $stream->getMetadata(); + + /** @Then the metadata is exposed as an array */ + self::assertIsArray($metadata); + } + + public function testGetBodyWhenMetadataRequestedForModeKeyThenExposesUnderlyingResourceMode(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When asking for the stream mode key */ + $mode = $stream->getMetadata('mode'); + + /** @Then the value reflects the in-memory resource mode */ + self::assertSame('w+b', $mode); + } + + public function testGetBodyWhenMetadataRequestedAfterCloseThenReturnsEmptyArray(): void + { + /** @Given a closed response stream */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + $stream->close(); + + /** @When asking for the full metadata map */ + $metadata = $stream->getMetadata(); + + /** @Then an empty array is returned */ + self::assertSame([], $metadata); + } + + public function testGetBodyWhenMetadataKeyRequestedAfterCloseThenReturnsNull(): void + { + /** @Given a closed response stream */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + $stream->close(); + + /** @When asking for a specific metadata key */ + $value = $stream->getMetadata('mode'); + + /** @Then null is returned */ + self::assertNull($value); + } + + public function testGetBodyWhenDetachedThenReturnsUnderlyingResource(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When detaching the underlying resource */ + $resource = $stream->detach(); + + /** @Then the returned value is a resource */ + self::assertIsResource($resource); + } + + public function testGetBodyWhenDetachedThenSizeIsNull(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When detaching the underlying resource */ + $stream->detach(); + + /** @Then the size collapses to null */ + self::assertNull($stream->getSize()); + } + + public function testGetBodyWhenDetachedThenIsNoLongerReadable(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @When detaching the underlying resource */ + $stream->detach(); + + /** @Then the stream is no longer readable */ + self::assertFalse($stream->isReadable()); + } + + public function testGetBodyWhenClosedTwiceThenSecondCloseIsANoOp(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @And the stream is already closed */ + $stream->close(); + + /** @When closing the stream a second time */ + $stream->close(); + + /** @Then the stream remains detached and reports null size */ + self::assertNull($stream->getSize()); + } + + public function testGetBodyWhenClosedThenTellRaisesMissingResourceError(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @And the stream is closed */ + $stream->close(); + + /** @Then telling the position raises a missing-resource error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No resource available.'); + + /** @When asking for the position */ + $stream->tell(); + } + + public function testGetBodyWhenClosedThenSeekRaisesNonSeekableError(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @And the stream is closed */ + $stream->close(); + + /** @Then seeking raises a non-seekable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not seekable.'); + + /** @When seeking on the closed stream */ + $stream->seek(0); + } + + public function testGetBodyWhenClosedThenReadRaisesNonReadableError(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @And the stream is closed */ + $stream->close(); + + /** @Then reading raises a non-readable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); + + /** @When reading from the closed stream */ + $stream->read(1); + } + + public function testGetBodyWhenReadLengthIsZeroThenRaisesNonReadableError(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @Then reading raises a non-readable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); + + /** @When reading with a non-positive length */ + $stream->read(0); + } + + public function testGetBodyWhenClosedThenWriteRaisesNonWritableError(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @And the stream is closed */ + $stream->close(); + + /** @Then writing raises a non-writable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not writable.'); + + /** @When writing to the closed stream */ + $stream->write('payload'); + } + + public function testResponseFacadeForbidsInstantiationThroughAPrivateConstructor(): void + { + /** @Given the reflection of the public Response façade */ + $reflection = new ReflectionClass(Response::class); + + /** @And the constructor reflected from that class */ + $constructor = $reflection->getMethod('__construct'); + + /** @When invoking the empty private constructor on a bare instance */ + $constructor->invoke($reflection->newInstanceWithoutConstructor()); + + /** @Then the constructor is private to prevent direct instantiation */ + self::assertTrue($constructor->isPrivate()); + } + + public function testGetBodyWhenClosedThenGetContentsRaisesNonReadableError(): void + { + /** @Given a response with a body */ + $stream = Response::ok(body: ['name' => 'Hydra'])->getBody(); + + /** @And the stream is closed */ + $stream->close(); + + /** @Then reading the contents raises a non-readable error */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); + + /** @When asking for the full contents */ + $stream->getContents(); + } } diff --git a/tests/Unit/Server/ResponseWithCookiesTest.php b/tests/Unit/Server/ResponseWithCookiesTest.php index 1a0e326..9886dbf 100644 --- a/tests/Unit/Server/ResponseWithCookiesTest.php +++ b/tests/Unit/Server/ResponseWithCookiesTest.php @@ -19,11 +19,11 @@ public function testOkWhenSingleCookieGivenThenSetCookieHeaderReflectsConfigurat { /** @Given a fully configured cookie */ $cookie = Cookie::create(name: 'session', value: 'abc') - ->httpOnly() ->secure() - ->withSameSite(sameSite: SameSite::STRICT) + ->httpOnly() ->withPath(path: '/') - ->withMaxAge(seconds: 604800); + ->withMaxAge(seconds: 604800) + ->withSameSite(sameSite: SameSite::STRICT); /** @When the response is built with the cookie */ $response = Response::ok(['ok' => true], $cookie); diff --git a/tests/Fixtures/Client/ThrowingClient.php b/tests/Unit/ThrowingClient.php similarity index 92% rename from tests/Fixtures/Client/ThrowingClient.php rename to tests/Unit/ThrowingClient.php index 883bd55..bfcb6d8 100644 --- a/tests/Fixtures/Client/ThrowingClient.php +++ b/tests/Unit/ThrowingClient.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Http\Fixtures\Client; +namespace Test\TinyBlocks\Http\Unit; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; diff --git a/tests/Unit/UserAgentTest.php b/tests/Unit/UserAgentTest.php index 33fd4a5..2a46c20 100644 --- a/tests/Unit/UserAgentTest.php +++ b/tests/Unit/UserAgentTest.php @@ -4,8 +4,8 @@ namespace Test\TinyBlocks\Http\Unit; -use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use TinyBlocks\Http\Exceptions\UserAgentProductIsEmpty; use TinyBlocks\Http\UserAgent; final class UserAgentTest extends TestCase @@ -59,10 +59,10 @@ public function testToArrayWhenInvokedRepeatedlyThenReturnsSameValue(): void self::assertSame($first, $second); } - public function testFromWhenEmptyProductGivenThenThrowsInvalidArgumentException(): void + public function testFromWhenEmptyProductGivenThenThrowsUserAgentProductIsEmpty(): void { /** @Then an exception is thrown */ - $this->expectException(InvalidArgumentException::class); + $this->expectException(UserAgentProductIsEmpty::class); $this->expectExceptionMessage('User-Agent product must not be empty.'); /** @When constructing with an empty product token */ From f6d07a121530b51f1798e3ae8e875c1d9b82042c Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 16 May 2026 13:24:50 -0300 Subject: [PATCH 27/27] ci: Enhance GitHub workflows with concurrency settings and timeout adjustments. --- .claude/rules/php-library-code-style.md | 38 ++++++++++++++++++++++--- .github/workflows/auto-assign.yml | 17 +++++++---- .github/workflows/codeql.yml | 6 +++- composer.json | 2 +- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md index 465c32c..8485df7 100644 --- a/.claude/rules/php-library-code-style.md +++ b/.claude/rules/php-library-code-style.md @@ -225,7 +225,10 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali ### When required -- Every method of an interface. +- Every method of an interface, **including interfaces declared inside `src/Internal/`**. + Interfaces define contracts. The contract is documentation by definition, regardless of + namespace. The `Internal/` boundary applies to implementations, not to the contracts that + internal collaborators expose to each other. - Every public method of a concrete class outside `src/Internal/`. Public classes are at the public API boundary by definition. Consumers call every public method directly, and the PHPDoc is the contract for each call. Trivial getters and `with*` methods are not exempt. @@ -241,7 +244,10 @@ are `canceled` (not `cancelled`), `organization` (not `organisation`), `initiali interface. The interface carries the docblock. - Anything inside `src/Internal/`. Internal types are implementation detail and must not carry PHPDoc. The namespace itself is the boundary. See `php-library-architecture.md` for the - architectural meaning of `Internal/`. + architectural meaning of `Internal/`. **Exception**: interfaces and their methods. An + interface declared inside `src/Internal/` still defines a contract, and the contract is + documented per `### When required` regardless of namespace. The prohibition covers concrete + classes, traits, enums, and anonymous classes inside `Internal/`, never interfaces. - Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyGivenThenZzz` naming convention, and the `@Given`/`@When`/`@Then`/`@And` annotation blocks defined in `php-library-testing.md` describe the steps. PHPDoc documentation (summary plus @@ -264,7 +270,10 @@ The PHPDoc prohibitions above take priority over the typed-array case. When PHPS - On a **constructor parameter** → suppress via `ignoreErrors` in `phpstan.neon.dist`. Do not add PHPDoc. -- On anything inside **`src/Internal/`** → suppress via `ignoreErrors`. Do not add PHPDoc. +- On anything inside **`src/Internal/`** (concrete classes, traits, enums) → suppress via + `ignoreErrors`. Do not add PHPDoc. Interfaces inside `src/Internal/` are the exception: + they carry PHPDoc per `### When required`, and the PHPStan errors they raise are resolved + through the PHPDoc, never through `ignoreErrors`. - On anything inside **`tests/`** → suppress via `ignoreErrors`. Do not add PHPDoc. - On a **public method of a public (non-Internal) class** → add full PHPDoc with summary, `@param` descriptions, and the typed-array information. The bare-tag form remains @@ -329,7 +338,8 @@ public function __construct(public array $entries) } ``` -**Prohibited.** PHPDoc on anything inside `src/Internal/`: +**Prohibited.** PHPDoc on a **concrete class** inside `src/Internal/` (the prohibition does +not extend to interfaces; see "Correct" below for an Internal/ interface): ```php namespace TinyBlocks\Http\Internal\Client; @@ -343,6 +353,26 @@ final readonly class Url } ``` +**Correct.** Interface declared **inside `src/Internal/`** still carries PHPDoc on every +method. The Internal/ prohibition covers concrete classes; interfaces are exempt because they +are the contract: + +```php +namespace TinyBlocks\Http\Internal\Client; + +interface RequestResolver +{ + /** + * Resolves the given URL against the configured base URL. + * + * @param string $url The path or absolute URL to resolve. + * @return string The absolute URL to dispatch. + * @throws MalformedPath If the URL violates RFC 3986. + */ + public function resolve(string $url): string; +} +``` + **Correct.** Generic array type with summary and `@param` description: ```php diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index d0ba49e..e87e331 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -8,12 +8,19 @@ on: types: - opened +concurrency: + group: auto-assign-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + issues: write + pull-requests: write + jobs: - run: + auto-assign: + name: Auto assign runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write + timeout-minutes: 5 steps: - name: Assign issues and pull requests uses: gustavofreze/auto-assign@2.1.0 @@ -22,4 +29,4 @@ jobs: github_token: '${{ secrets.GITHUB_TOKEN }}' allow_self_assign: 'true' allow_no_assignees: 'true' - assignment_options: 'ISSUE,PULL_REQUEST' \ No newline at end of file + assignment_options: 'ISSUE,PULL_REQUEST' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4c6d7f7..0634bbf 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,6 +8,10 @@ on: schedule: - cron: "0 0 * * *" +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + permissions: actions: read contents: read @@ -17,11 +21,11 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + timeout-minutes: 30 strategy: fail-fast: false matrix: language: [ "actions" ] - steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/composer.json b/composer.json index 06622c3..9ff38d2 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "psr/http-client": "^1.0", "psr/http-factory": "^1.1", "psr/http-message": "^2.0", - "tiny-blocks/mapper": "^2.0" + "tiny-blocks/mapper": "^2.1" }, "require-dev": { "ergebnis/composer-normalize": "^2.51",