diff --git a/README.md b/README.md index a097649..3806a6d 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,31 @@ Response::ok(['ok' => true], $cacheControl, ContentType::applicationJson()) ->withHeader(name: 'X-Trace-Id', value: 'abc-123'); ``` +`withStatus($code, $reasonPhrase)` honors the supplied reason phrase: when a non-empty string is +passed, `getReasonPhrase()` returns it instead of the enum-derived phrase. + +```php +withStatus(Code::OK->value, 'All Good'); +$response->getReasonPhrase(); # "All Good" +``` + #### Setting cookies -`Cookie` implements `Headerable` and composes naturally with `Response`: +`Cookie` implements `Headerable` and composes naturally with `Response`. + +`withSameSite(SameSite::NONE)` automatically enables the `Secure` flag. Browsers reject +`SameSite=None` cookies that lack it. Calling `secure()` separately is not required. + +`withMaxAge(...)` and `withExpires(...)` are mutually exclusive (last-write-wins): setting one +clears the other. This follows RFC 6265 §4.1.2.2, which specifies that `Max-Age` takes precedence +over `Expires` when both are present. ```php true], $session); ``` +Setting `SameSite=None` without calling `secure()` first is safe. Secure is set automatically: + +```php +withSameSite(sameSite: SameSite::NONE); + +Response::ok(['ok' => true], $crossSite); +``` + To expire a cookie, use `Cookie::expire(...)` with the same `Path` and `Domain` used at creation. +The expired cookie carries both `Max-Age=0` and `Expires` set to the Unix epoch: modern browsers +honor `Max-Age`. The `Expires` fallback covers legacy user agents. ```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::CONTINUE->isInformational(); # true +Code::MOVED_PERMANENTLY->isRedirection(); # true +Code::BAD_REQUEST->isClientError(); # true +Code::INTERNAL_SERVER_ERROR->isError(); # true +Code::INTERNAL_SERVER_ERROR->isServerError(); # true Code::isValidCode(code: 200); # true Code::isErrorCode(code: 500); # true @@ -233,12 +279,13 @@ use GuzzleHttp\Psr7\HttpFactory; use TinyBlocks\Http\Client\Transports\NetworkTransport; use TinyBlocks\Http\Http; +$client = new Client(config: ['timeout' => 30, 'connect_timeout' => 5]); $factory = new HttpFactory(); $http = Http::with( baseUrl: 'https://api.example.com', transport: NetworkTransport::with( - client: new Client(config: ['timeout' => 30, 'connect_timeout' => 5]), + client: $client, factory: $factory ) ); @@ -246,9 +293,8 @@ $http = Http::with( #### Making a request -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. +Six shortcut factories cover the most common HTTP methods. Supply only the arguments the request +needs. The `body`, `queryParameters`, and `headers` all default to absent or empty. ```php send(request: Request::get(url: '/v1/charges/abc123')); $response = $http->send( - request: Request::create( + request: Request::post( url: '/v1/charges', body: ['amount' => 1000, 'currency' => 'usd'], - query: null, - method: Method::POST, headers: Headers::from(ContentType::applicationJson()) ) ); + +$response = $http->send(request: Request::delete(url: '/v1/charges/abc123')); ``` -A simple `GET` still passes every parameter, with `Headers::from()` for the empty header set: +For HTTP methods not covered by the six shortcuts (`OPTIONS`, `TRACE`, `CONNECT`, or any custom +method), use `Request::for(...)`, which accepts an explicit `Method` argument: ```php send( - request: Request::create( - url: '/v1/charges/abc123', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ) + request: Request::for(url: '/v1/charges', method: Method::OPTIONS) ); ``` +`Method` also exposes RFC 9110 safety and idempotency predicates: + +```php +isSafe(); # true (RFC 9110 §9.2.1) +Method::POST->isSafe(); # false +Method::PUT->isIdempotent(); # true (RFC 9110 §9.2.2) +Method::POST->isIdempotent(); # false +``` + #### Reading the response ```php @@ -319,7 +375,7 @@ $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 query parameters via `queryParameters:`. The library encodes them in RFC 3986 form. ```php send( - request: Request::create( + request: Request::get( url: '/v1/charges', - body: null, - query: ['status' => 'succeeded', 'limit' => 50], - method: Method::GET, - headers: Headers::from() + queryParameters: ['status' => 'succeeded', 'limit' => 50] ) ); ``` +To replace query parameters on an existing request, use `withQueryParameters(...)`: + +```php +$updated = $request->withQueryParameters(queryParameters: ['limit' => 100]); +``` + #### Custom headers and content type Compose any combination of `Headerable` via `Headers::from(...)`: @@ -354,7 +411,6 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\ContentType; use TinyBlocks\Http\Headerable; use TinyBlocks\Http\Headers; -use TinyBlocks\Http\Method; final readonly class IdempotencyKey implements Headerable { @@ -369,11 +425,9 @@ final readonly class IdempotencyKey implements Headerable } $response = $http->send( - request: Request::create( + request: Request::post( url: '/v1/charges', body: ['amount' => 1000], - query: null, - method: Method::POST, headers: Headers::from( ContentType::applicationJson(), new IdempotencyKey(value: $key) @@ -384,12 +438,25 @@ $response = $http->send( Custom headers always win over the library's JSON defaults. +To add or replace a single header on an existing request, use `withHeader(...)`. The lookup is +case-insensitive: replacing `Content-Type` via `content-type` still finds and replaces the entry. + +```php +withHeader(name: 'X-Trace-Id', value: 'abc-123'); +``` + #### 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. +`User-Agent` header. An absent or empty version is normalized to "no version". The rendered +header carries only the product token in that case. ```php send( - request: Request::create( + request: Request::get( url: '/v1/charges', - body: null, - query: null, - method: Method::GET, headers: Headers::from($userAgent) ) ); @@ -437,15 +500,12 @@ declare(strict_types=1); use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\ContentType; use TinyBlocks\Http\Headers; -use TinyBlocks\Http\Method; use TinyBlocks\Http\UserAgent; $response = $http->send( - request: Request::create( + request: Request::post( url: '/v1/charges', body: ['amount' => 1000], - query: null, - method: Method::POST, headers: Headers::from( UserAgent::from(product: 'MyApp', version: '1.2.3'), ContentType::applicationJson() diff --git a/composer.json b/composer.json index 9ff38d2..731a01d 100644 --- a/composer.json +++ b/composer.json @@ -1,16 +1,19 @@ { "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 facade.", + "description": "Implements PSR-7, PSR-15, PSR-17 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-17", "psr-18", + "cookie", "http-codes", - "tiny-blocks", - "http-client" + "http-client", + "http-server", + "tiny-blocks" ], "authors": [ { @@ -31,9 +34,9 @@ "tiny-blocks/mapper": "^2.1" }, "require-dev": { - "ergebnis/composer-normalize": "^2.51", - "guzzlehttp/guzzle": "^7.9", - "infection/infection": "^0.32", + "ergebnis/composer-normalize": "^2.52", + "guzzlehttp/guzzle": "^7.10", + "infection/infection": "^0.33", "laminas/laminas-httphandlerrunner": "^2.13", "nyholm/psr7": "^1.8", "phpstan/phpstan": "^2.1", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 1e394cf..1653aa8 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -15,4 +15,13 @@ parameters: # PHPDoc is prohibited inside tests/; the closure's typed return cannot be expressed. - identifier: throw.notThrowable path: tests/Unit/FailingTransport.php + # PHPDoc is prohibited inside tests/; Closure return type in DataProvider factories is not statically inferrable. + - identifier: method.nonObject + path: tests/Unit/Client/RequestTest.php + # PHPStan knows all Code enum values are >= 100, making the lower-bound comparison always true. + - identifier: greaterOrEqual.alwaysTrue + path: src/Code.php + # PHPStan knows all Code enum values are <= 511, making the upper-bound comparison always true. + - identifier: smallerOrEqual.alwaysTrue + path: src/Code.php reportUnmatchedIgnoredErrors: true diff --git a/src/Attribute.php b/src/Attribute.php index 47da7a3..b9b8a16 100644 --- a/src/Attribute.php +++ b/src/Attribute.php @@ -4,6 +4,12 @@ namespace TinyBlocks\Http; +/** + * Typed wrapper around a scalar or array value extracted from an HTTP message. + * + * Provides coercion methods that convert the wrapped value to a requested primitive type, + * falling back to a safe zero-value when conversion is not possible. + */ final readonly class Attribute { private function __construct(private mixed $value) diff --git a/src/Body.php b/src/Body.php index 2d5dc36..adf4b38 100644 --- a/src/Body.php +++ b/src/Body.php @@ -9,6 +9,14 @@ use Psr\Http\Message\ServerRequestInterface; use TinyBlocks\Http\Internal\Server\Stream\StreamFactory; +/** + * Decoded payload of an HTTP message, represented as an associative array. + * + * Supports construction from raw arrays, PSR-7 server requests, and PSR-7 responses. + * Individual entries are accessed as typed {@see Attribute} wrappers. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages + */ final readonly class Body { private const int MAX_JSON_DEPTH = 64; @@ -29,17 +37,31 @@ public static function fromArray(array $data): Body } /** - * Creates a Body from a PSR-7 server request, parsing JSON or falling back to the parsed body. + * Creates a Body from a PSR-7 server request, decoding the JSON payload up to 64 levels deep. + * + * When the raw body is empty, falls back to the parsed body and degrades to an empty Body + * when the parsed body is not an array. JSON decoding uses JSON_THROW_ON_ERROR; + * any decoding failure degrades to an empty Body rather than propagating the exception. * * @param ServerRequestInterface $request The incoming PSR-7 server request. - * @return Body A Body carrying the decoded request payload. + * @return Body A Body carrying the decoded payload, or an empty Body when decoding fails or + * the payload is not an array. */ public static function fromServerRequest(ServerRequestInterface $request): Body { $streamFactory = StreamFactory::fromStream(stream: $request->getBody()); if (!$streamFactory->isEmptyContent()) { - $decoded = json_decode($streamFactory->content(), true); + try { + $decoded = json_decode( + $streamFactory->content(), + true, + Body::MAX_JSON_DEPTH, + JSON_THROW_ON_ERROR + ); + } catch (JsonException) { + return new Body(data: []); + } return new Body(data: is_array($decoded) ? $decoded : []); } diff --git a/src/CacheControl.php b/src/CacheControl.php index f1ea89c..7353b84 100644 --- a/src/CacheControl.php +++ b/src/CacheControl.php @@ -4,6 +4,11 @@ namespace TinyBlocks\Http; +/** + * HTTP Cache-Control header value composed of one or more response directives. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + */ final readonly class CacheControl implements Headerable { private function __construct(private array $directives) diff --git a/src/Charset.php b/src/Charset.php index 2b660aa..49345e0 100644 --- a/src/Charset.php +++ b/src/Charset.php @@ -4,6 +4,11 @@ namespace TinyBlocks\Http; +/** + * Character encoding declared in an HTTP Content-Type header. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type + */ enum Charset: string { case BIG5 = 'big5'; diff --git a/src/Client/Request.php b/src/Client/Request.php index 97e0e03..a0392ee 100644 --- a/src/Client/Request.php +++ b/src/Client/Request.php @@ -7,35 +7,178 @@ use TinyBlocks\Http\Headers; use TinyBlocks\Http\Method; +/** + * Immutable outbound HTTP request carrying a URL, optional body, query parameters, method, and headers. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages + */ final readonly class Request { private function __construct( private string $url, private ?array $body, - private ?array $query, private Method $method, - private Headers $headers + private Headers $headers, + private ?array $queryParameters ) { } /** - * Builds an outbound request with the given URL, body, query, method, and headers. + * Builds a GET request targeting the given URL. + * + * @param string $url The URL (relative or absolute) the request targets. + * @param array|null $queryParameters The query string parameters, or null when absent. + * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. + * @return Request A new GET request instance. + */ + public static function get(string $url, ?array $queryParameters = null, ?Headers $headers = null): Request + { + return new Request( + url: $url, + body: null, + method: Method::GET, + headers: $headers ?? Headers::from(), + queryParameters: $queryParameters + ); + } + + /** + * Builds a POST request targeting the given URL. + * + * @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 $queryParameters The query string parameters, or null when absent. + * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. + * @return Request A new POST request instance. + */ + public static function post( + string $url, + ?array $body = null, + ?array $queryParameters = null, + ?Headers $headers = null + ): Request { + return new Request( + url: $url, + body: $body, + method: Method::POST, + headers: $headers ?? Headers::from(), + queryParameters: $queryParameters + ); + } + + /** + * Builds a PUT request targeting the given URL. + * + * @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 $queryParameters The query string parameters, or null when absent. + * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. + * @return Request A new PUT request instance. + */ + public static function put( + string $url, + ?array $body = null, + ?array $queryParameters = null, + ?Headers $headers = null + ): Request { + return new Request( + url: $url, + body: $body, + method: Method::PUT, + headers: $headers ?? Headers::from(), + queryParameters: $queryParameters + ); + } + + /** + * Builds a PATCH request targeting the given URL. * * @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 array|null $queryParameters The query string parameters, or null when absent. + * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. + * @return Request A new PATCH request instance. + */ + public static function patch( + string $url, + ?array $body = null, + ?array $queryParameters = null, + ?Headers $headers = null + ): Request { + return new Request( + url: $url, + body: $body, + method: Method::PATCH, + headers: $headers ?? Headers::from(), + queryParameters: $queryParameters + ); + } + + /** + * Builds a DELETE request targeting the given URL. + * + * @param string $url The URL (relative or absolute) the request targets. + * @param array|null $queryParameters The query string parameters, or null when absent. + * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. + * @return Request A new DELETE request instance. + */ + public static function delete(string $url, ?array $queryParameters = null, ?Headers $headers = null): Request + { + return new Request( + url: $url, + body: null, + method: Method::DELETE, + headers: $headers ?? Headers::from(), + queryParameters: $queryParameters + ); + } + + /** + * Builds a HEAD request targeting the given URL. + * + * @param string $url The URL (relative or absolute) the request targets. + * @param array|null $queryParameters The query string parameters, or null when absent. + * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. + * @return Request A new HEAD request instance. + */ + public static function head(string $url, ?array $queryParameters = null, ?Headers $headers = null): Request + { + return new Request( + url: $url, + body: null, + method: Method::HEAD, + headers: $headers ?? Headers::from(), + queryParameters: $queryParameters + ); + } + + /** + * Builds a Request for any HTTP method, including those not covered by the six shortcut factories. + * + * Use this factory when the target method is OPTIONS, TRACE, + * CONNECT, or any other method not represented by a dedicated shortcut. + * * @param Method $method The HTTP method used by the request. - * @param Headers $headers The headers folded into the request. + * @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 $queryParameters The query string parameters, or null when absent. + * @param Headers|null $headers The headers carried by the request, or null to default to an empty set. * @return Request A new immutable request instance. */ - public static function create( - string $url, - ?array $body, - ?array $query, + public static function for( Method $method, - Headers $headers + string $url, + ?array $body = null, + ?array $queryParameters = null, + ?Headers $headers = null ): Request { - return new Request(url: $url, body: $body, query: $query, method: $method, headers: $headers); + return new Request( + url: $url, + body: $body, + method: $method, + headers: $headers ?? Headers::from(), + queryParameters: $queryParameters + ); } /** @@ -58,16 +201,6 @@ 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. * @@ -88,6 +221,16 @@ public function headers(): Headers return $this->headers; } + /** + * Returns the query parameters. + * + * @return array|null The query string parameters, or null when absent. + */ + public function queryParameters(): ?array + { + return $this->queryParameters; + } + /** * Returns a copy of the Request with the URL replaced. * @@ -99,32 +242,39 @@ public function withUrl(string $url): Request return new Request( url: $url, body: $this->body, - query: $this->query, method: $this->method, - headers: $this->headers + headers: $this->headers, + queryParameters: $this->queryParameters ); } /** - * Returns a copy of the request carrying the given query parameters. + * Returns a copy of the Request with the named header replaced or appended. + * + * The lookup is case-insensitive, delegating to Headers::with: setting + * content-type replaces an existing Content-Type entry. * - * @param array|null $query The query string parameters, or null to clear them. - * @return Request A new instance with the replaced query. + * @param string $name The header name. + * @param string $value The replacement or new header value. + * @return Request A new instance carrying the updated header. */ - public function withQuery(?array $query): Request + public function withHeader(string $name, string $value): Request { return new Request( url: $this->url, body: $this->body, - query: $query, method: $this->method, - headers: $this->headers + headers: $this->headers->with(name: $name, value: $value), + queryParameters: $this->queryParameters ); } /** * Returns a copy of the Request with the given default headers merged in. * + * The merge is case-insensitive, delegating to Headers::mergedWith: default + * headers whose names match existing entries regardless of casing are skipped. + * * @param Headers $defaults The default headers to merge under existing entries. * @return Request A new instance carrying the merged headers. */ @@ -133,9 +283,27 @@ 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(other: $defaults) + headers: $this->headers->mergedWith(other: $defaults), + queryParameters: $this->queryParameters + ); + } + + /** + * Returns a copy of the Request with the query parameters replaced. + * + * @param array|null $queryParameters The replacement query string parameters, + * or null to clear them. + * @return Request A new instance with the replaced query parameters. + */ + public function withQueryParameters(?array $queryParameters): Request + { + return new Request( + url: $this->url, + body: $this->body, + method: $this->method, + headers: $this->headers, + queryParameters: $queryParameters ); } } diff --git a/src/Client/Response.php b/src/Client/Response.php index 2aed34b..6f29fa2 100644 --- a/src/Client/Response.php +++ b/src/Client/Response.php @@ -10,6 +10,14 @@ use TinyBlocks\Http\Exceptions\SynthesizedResponseHasNoRaw; use TinyBlocks\Http\Headers; +/** + * Typed wrapper around an HTTP response carrying a status code, body, and headers. + * + * Can be constructed from a PSR-7 response or synthesized directly from a status code and + * an optional body for use in tests and in-memory transports. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages + */ final readonly class Response { private function __construct( @@ -50,7 +58,7 @@ public static function with(Code $code, ?array $body = null, ?Headers $headers = psr: null, body: Body::fromArray(data: $body ?? []), code: $code, - headers: $headers ?? new Headers(entries: []) + headers: $headers ?? Headers::fromArray(entries: []) ); } diff --git a/src/Client/Transport.php b/src/Client/Transport.php index e54b599..263070e 100644 --- a/src/Client/Transport.php +++ b/src/Client/Transport.php @@ -6,13 +6,20 @@ use TinyBlocks\Http\Exceptions\HttpException; +/** + * Port abstracting outbound HTTP dispatch, decoupling Http from any concrete delivery mechanism. + * + * Built-in: {@see NetworkTransport} (PSR-18 backed) and {@see InMemoryTransport} (testing). + * Implementations may decorate an inner Transport for cross-cutting concerns such as + * retry, logging, or circuit breaking. + */ interface Transport { /** * Sends an outbound HTTP request and returns the response. * - * The request received here is expected to be fully resolved by the caller — - * absolute URL with query embedded, default headers already merged. + * The request received here is expected to be fully resolved by the caller (absolute URL with + * query embedded, default headers already merged). * Transport implementations must translate transport-level failures into HttpException. * * @param Request $request The fully resolved outbound request. diff --git a/src/Client/Transports/InMemoryTransport.php b/src/Client/Transports/InMemoryTransport.php index 79dc97e..ecbcee4 100644 --- a/src/Client/Transports/InMemoryTransport.php +++ b/src/Client/Transports/InMemoryTransport.php @@ -10,6 +10,12 @@ use TinyBlocks\Http\Exceptions\NoMoreResponses; use TinyBlocks\Http\Internal\Client\Cursor; +/** + * In-memory {@see Transport} that serves pre-built responses from a FIFO queue. + * + * Intended for use in tests and local development to avoid real network calls. + * Raises {@see NoMoreResponses} when the queue is exhausted. + */ final readonly class InMemoryTransport implements Transport { private function __construct(private Cursor $cursor, private array $responses) diff --git a/src/Client/Transports/NetworkTransport.php b/src/Client/Transports/NetworkTransport.php index 957fb34..f543a25 100644 --- a/src/Client/Transports/NetworkTransport.php +++ b/src/Client/Transports/NetworkTransport.php @@ -17,6 +17,12 @@ use TinyBlocks\Http\Exceptions\HttpRequestFailed; use TinyBlocks\Http\Exceptions\HttpRequestInvalid; +/** + * PSR-18-backed {@see Transport} that dispatches each request through a real HTTP client. + * + * Builds PSR-7 request messages using a PSR-17 factory, serializes the body as JSON, + * and maps PSR-18 client exceptions to typed library exceptions. + */ final readonly class NetworkTransport implements Transport { private const int JSON_FLAGS = JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE; diff --git a/src/Code.php b/src/Code.php index 37e33e6..7eaacf3 100644 --- a/src/Code.php +++ b/src/Code.php @@ -14,6 +14,9 @@ * Client error (400 – 499) * Server error (500 – 599) * + * The is*() instance helpers validate by enum case, so they cover only the status codes + * represented here. Status codes outside this list (for example 250) return false for every helper. + * * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#information_responses */ enum Code: int @@ -122,9 +125,21 @@ public static function isValidCode(int $code): bool return !is_null(Code::tryFrom($code)); } + /** + * Tells whether the status code falls in the 1xx range. + * + * @return bool True when the code represents an informational response. + */ + public function isInformational(): bool + { + return $this->value >= Code::CONTINUE->value && $this->value <= Code::EARLY_HINTS->value; + } + /** * Tells whether the status code falls in the 4xx or 5xx range. * + * Equivalent to isClientError() || isServerError(). + * * @return bool True when the code represents an error response. */ public function isError(): bool @@ -142,6 +157,37 @@ public function isSuccess(): bool return Code::isSuccessCode(code: $this->value); } + /** + * Tells whether the status code falls in the 3xx range. + * + * @return bool True when the code represents a redirection response. + */ + public function isRedirection(): bool + { + return $this->value >= Code::MULTIPLE_CHOICES->value && $this->value <= Code::PERMANENT_REDIRECT->value; + } + + /** + * Tells whether the status code falls in the 4xx range. + * + * @return bool True when the code represents a client error response. + */ + public function isClientError(): bool + { + return $this->value >= Code::BAD_REQUEST->value && $this->value <= Code::UNAVAILABLE_FOR_LEGAL_REASONS->value; + } + + /** + * Tells whether the status code falls in the 5xx range. + * + * @return bool True when the code represents a server error response. + */ + public function isServerError(): bool + { + return $this->value >= Code::INTERNAL_SERVER_ERROR->value + && $this->value <= Code::NETWORK_AUTHENTICATION_REQUIRED->value; + } + /** * Returns the HTTP status message associated with the enum's code. * diff --git a/src/ContentType.php b/src/ContentType.php index 4a3c137..419b1b5 100644 --- a/src/ContentType.php +++ b/src/ContentType.php @@ -4,6 +4,11 @@ namespace TinyBlocks\Http; +/** + * HTTP Content-Type header combining a MIME type with an optional character encoding. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type + */ 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 6e997ee..1ea581b 100644 --- a/src/Cookie.php +++ b/src/Cookie.php @@ -7,13 +7,26 @@ use DateTimeImmutable; use DateTimeInterface; use DateTimeZone; +use TinyBlocks\Http\Internal\Server\Cookies\CookieDomain; use TinyBlocks\Http\Internal\Server\Cookies\CookieName; +use TinyBlocks\Http\Internal\Server\Cookies\CookiePath; use TinyBlocks\Http\Internal\Server\Cookies\CookieValue; -use TinyBlocks\Http\Internal\Server\Exceptions\ConflictingLifetimeAttributes; +use TinyBlocks\Http\Internal\Server\Exceptions\CookieDomainIsInvalid; use TinyBlocks\Http\Internal\Server\Exceptions\CookieNameIsInvalid; +use TinyBlocks\Http\Internal\Server\Exceptions\CookiePathIsInvalid; use TinyBlocks\Http\Internal\Server\Exceptions\CookieValueIsInvalid; -use TinyBlocks\Http\Internal\Server\Exceptions\SameSiteNoneRequiresSecure; +/** + * HTTP Set-Cookie header value carrying a name, value, and optional attributes. + * + * Instances are immutable; each mutating operation returns a new Cookie with the replaced + * attribute. Invariants are enforced by construction: withSameSite(SameSite::NONE) + * automatically enables Secure. Max-Age and Expires are mutually exclusive (last-write-wins), + * with the deliberate exception of expire(), which emits both for legacy + * user-agent compatibility. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies + */ final readonly class Cookie implements Headerable { private const string EXPIRES_FORMAT = 'D, d M Y H:i:s \G\M\T'; @@ -60,9 +73,15 @@ 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. * + * The returned cookie carries both Max-Age=0 and Expires set to the Unix epoch. Modern + * browsers honor Max-Age; the Expires fallback ensures correct behavior on legacy user + * agents that pre-date RFC 6265's Max-Age support. + * * @param string $name The cookie name being expired. - * @return Cookie A Cookie with an empty value and Max-Age=0 set. + * @return Cookie A Cookie with an empty value, Max-Age=0, and + * Expires set to the Unix epoch. * @throws CookieNameIsInvalid If the name is empty or contains forbidden characters. + * @see https://www.rfc-editor.org/rfc/rfc6265#section-5.3 */ public static function expire(string $name): Cookie { @@ -73,7 +92,7 @@ public static function expire(string $name): Cookie domain: null, maxAge: 0, secure: false, - expires: null, + expires: new DateTimeImmutable('@0'), httpOnly: false, sameSite: null, partitioned: false @@ -148,12 +167,13 @@ public function partitioned(): Cookie * * @param string $path The replacement path. * @return Cookie A new instance carrying the replaced path. + * @throws CookiePathIsInvalid If the path contains forbidden characters. */ public function withPath(string $path): Cookie { return new Cookie( name: $this->name, - path: $path, + path: CookiePath::from(value: $path)->toString(), value: $this->value, domain: $this->domain, maxAge: $this->maxAge, @@ -193,6 +213,7 @@ public function withValue(string $value): Cookie * * @param string $domain The replacement domain. * @return Cookie A new instance carrying the replaced domain. + * @throws CookieDomainIsInvalid If the domain contains forbidden characters. */ public function withDomain(string $domain): Cookie { @@ -200,7 +221,7 @@ public function withDomain(string $domain): Cookie name: $this->name, path: $this->path, value: $this->value, - domain: $domain, + domain: CookieDomain::from(value: $domain)->toString(), maxAge: $this->maxAge, secure: $this->secure, expires: $this->expires, @@ -211,10 +232,15 @@ public function withDomain(string $domain): Cookie } /** - * Returns a copy of the Cookie with the Max-Age replaced. + * Returns a copy of the Cookie with Max-Age replaced and Expires cleared. + * + * Max-Age and Expires are mutually exclusive in this library: setting one clears the other + * (last-write-wins). RFC 6265 §4.1.2.2 specifies that Max-Age takes precedence over Expires + * when both are present, so emitting both is redundant at best and conflicting at worst. * * @param int $seconds The replacement lifetime in seconds. - * @return Cookie A new instance carrying the replaced Max-Age. + * @return Cookie A new instance carrying the replaced Max-Age and no Expires. + * @see https://www.rfc-editor.org/rfc/rfc6265#section-4.1.2.2 */ public function withMaxAge(int $seconds): Cookie { @@ -225,7 +251,7 @@ public function withMaxAge(int $seconds): Cookie domain: $this->domain, maxAge: $seconds, secure: $this->secure, - expires: $this->expires, + expires: null, httpOnly: $this->httpOnly, sameSite: $this->sameSite, partitioned: $this->partitioned @@ -233,10 +259,15 @@ public function withMaxAge(int $seconds): Cookie } /** - * Returns a copy of the Cookie with the Expires replaced and normalized to UTC. + * Returns a copy of the Cookie with Expires replaced (normalized to UTC) and Max-Age cleared. + * + * Max-Age and Expires are mutually exclusive in this library: setting one clears the other + * (last-write-wins). RFC 6265 §4.1.2.2 specifies that Max-Age takes precedence over Expires + * when both are present, so emitting both is redundant at best and conflicting at worst. * * @param DateTimeInterface $expires The replacement expiration timestamp; normalized to UTC. - * @return Cookie A new instance carrying the replaced Expires. + * @return Cookie A new instance carrying the replaced Expires and no Max-Age. + * @see https://www.rfc-editor.org/rfc/rfc6265#section-4.1.2.2 */ public function withExpires(DateTimeInterface $expires): Cookie { @@ -245,7 +276,7 @@ public function withExpires(DateTimeInterface $expires): Cookie path: $this->path, value: $this->value, domain: $this->domain, - maxAge: $this->maxAge, + maxAge: null, secure: $this->secure, expires: DateTimeImmutable::createFromInterface($expires)->setTimezone(new DateTimeZone('UTC')), httpOnly: $this->httpOnly, @@ -255,10 +286,16 @@ public function withExpires(DateTimeInterface $expires): Cookie } /** - * Returns a copy of the Cookie with the SameSite attribute replaced. + * Returns a copy of the Cookie with the SameSite attribute replaced. + * + * When the supplied SameSite value is None, the returned Cookie also has Secure + * set automatically. Browsers reject SameSite=None cookies that lack the Secure + * flag, so enforcing it here removes a common footgun without changing the + * cookie's effective behavior. * * @param SameSite $sameSite The replacement SameSite attribute. * @return Cookie A new instance carrying the replaced SameSite attribute. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value */ public function withSameSite(SameSite $sameSite): Cookie { @@ -268,7 +305,7 @@ public function withSameSite(SameSite $sameSite): Cookie value: $this->value, domain: $this->domain, maxAge: $this->maxAge, - secure: $this->secure, + secure: $sameSite === SameSite::NONE ? true : $this->secure, expires: $this->expires, httpOnly: $this->httpOnly, sameSite: $sameSite, @@ -278,16 +315,6 @@ 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 - }; - - if (!is_null($invariantViolation)) { - throw $invariantViolation; - } - $nameValueTemplate = '%s=%s'; $parts = [sprintf($nameValueTemplate, $this->name->toString(), $this->value->toString())]; diff --git a/src/Exceptions/BaseUrlIsInvalid.php b/src/Exceptions/BaseUrlIsInvalid.php new file mode 100644 index 0000000..55c2f57 --- /dev/null +++ b/src/Exceptions/BaseUrlIsInvalid.php @@ -0,0 +1,39 @@ +Http::with() or HttpBuilder::withBaseUrl() + * fails validation. + * + * Accepted forms are the empty string and absolute URLs beginning with http:// or + * https://. Protocol-relative URLs, non-HTTP schemes, and URLs carrying control + * characters are rejected. + */ +final class BaseUrlIsInvalid extends InvalidArgumentException implements HttpException +{ + private const string REASON_TEMPLATE = 'Base URL <%s> is invalid. Only empty string, http://, and https:// ' + . 'base URLs are accepted.'; + + private function __construct(string $url) + { + $template = BaseUrlIsInvalid::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $url)); + } + + /** + * Creates a BaseUrlIsInvalid signaling that the given URL does not satisfy the accepted forms. + * + * @param string $url The offending base URL. + * @return BaseUrlIsInvalid The composed exception describing the invalid base URL. + */ + public static function for(string $url): BaseUrlIsInvalid + { + return new BaseUrlIsInvalid(url: $url); + } +} diff --git a/src/Exceptions/BodyTypeIsUnsupported.php b/src/Exceptions/BodyTypeIsUnsupported.php new file mode 100644 index 0000000..2d16a2f --- /dev/null +++ b/src/Exceptions/BodyTypeIsUnsupported.php @@ -0,0 +1,39 @@ +Mapper, BackedEnum, UnitEnum, scalar types, arrays, + * and null are accepted. Passing a generic object (such as a domain entity or a value + * object that does not implement Mapper) is rejected to prevent unintentional leakage + * of object internals as JSON. + */ +final class BodyTypeIsUnsupported extends InvalidArgumentException implements HttpException +{ + private const string REASON_TEMPLATE = 'Response body type <%s> is not supported. Use a Mapper, BackedEnum, ' + . 'UnitEnum, scalar, array, or null.'; + + private function __construct(string $class) + { + $template = BodyTypeIsUnsupported::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $class)); + } + + /** + * Creates a BodyTypeIsUnsupported signaling that the given class is not an accepted body type. + * + * @param string $class The fully qualified class name of the unsupported object. + * @return BodyTypeIsUnsupported The composed exception describing the unsupported body type. + */ + public static function for(string $class): BodyTypeIsUnsupported + { + return new BodyTypeIsUnsupported(class: $class); + } +} diff --git a/src/Exceptions/HeaderNameIsInvalid.php b/src/Exceptions/HeaderNameIsInvalid.php new file mode 100644 index 0000000..0468f74 --- /dev/null +++ b/src/Exceptions/HeaderNameIsInvalid.php @@ -0,0 +1,38 @@ + is invalid. Names must match the RFC 7230 token ' + . 'production: non-empty, no control characters, no whitespace, no separator characters.'; + + private function __construct(string $name) + { + $template = HeaderNameIsInvalid::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $name)); + } + + /** + * Creates a HeaderNameIsInvalid signaling that the given header name violates RFC 7230 token rules. + * + * @param string $name The offending header name. + * @return HeaderNameIsInvalid The composed exception describing the invalid header name. + */ + public static function for(string $name): HeaderNameIsInvalid + { + return new HeaderNameIsInvalid(name: $name); + } +} diff --git a/src/Exceptions/HeaderValueIsInvalid.php b/src/Exceptions/HeaderValueIsInvalid.php new file mode 100644 index 0000000..501d55d --- /dev/null +++ b/src/Exceptions/HeaderValueIsInvalid.php @@ -0,0 +1,38 @@ + is invalid. Values must not contain control characters ' + . 'other than horizontal tab (0x09).'; + + private function __construct(string $value) + { + $template = HeaderValueIsInvalid::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $value)); + } + + /** + * Creates a HeaderValueIsInvalid signaling that the given value contains a forbidden control character. + * + * @param string $value The offending header value. + * @return HeaderValueIsInvalid The composed exception describing the invalid header value. + */ + public static function for(string $value): HeaderValueIsInvalid + { + return new HeaderValueIsInvalid(value: $value); + } +} diff --git a/src/Exceptions/HttpConfigurationInvalid.php b/src/Exceptions/HttpConfigurationInvalid.php index e8fde12..cd080d3 100644 --- a/src/Exceptions/HttpConfigurationInvalid.php +++ b/src/Exceptions/HttpConfigurationInvalid.php @@ -6,6 +6,10 @@ use LogicException; +/** + * Raised when HttpBuilder::build() is called without all required dependencies configured + * (base URL and Transport). + */ final class HttpConfigurationInvalid extends LogicException implements HttpException { private const string MISSING_BASE_URL_REASON = 'Base URL is required to build Http.'; diff --git a/src/Exceptions/HttpException.php b/src/Exceptions/HttpException.php index c3c99d6..bd0ef93 100644 --- a/src/Exceptions/HttpException.php +++ b/src/Exceptions/HttpException.php @@ -6,6 +6,13 @@ use Throwable; +/** + * Common contract implemented by every exception raised by this library. + * + * Allows a single catch clause to handle any failure originating from the HTTP layer, regardless + * of whether it stems from configuration, request resolution, transport dispatch, or library + * invariant violations. + */ interface HttpException extends Throwable { } diff --git a/src/Exceptions/HttpNetworkFailed.php b/src/Exceptions/HttpNetworkFailed.php index 15d0874..51f179a 100644 --- a/src/Exceptions/HttpNetworkFailed.php +++ b/src/Exceptions/HttpNetworkFailed.php @@ -10,6 +10,12 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Method; +/** + * Raised when the transport could not deliver a request due to a network-level failure such as + * DNS resolution failure, connection refused, or timeout. + * + * Wraps the underlying PSR-18 NetworkExceptionInterface when produced by the network transport. + */ final class HttpNetworkFailed extends RuntimeException implements TransportFailure { private const string REASON_TEMPLATE = 'Network failure for %s %s: %s'; diff --git a/src/Exceptions/HttpRequestFailed.php b/src/Exceptions/HttpRequestFailed.php index dae12ff..2ea277c 100644 --- a/src/Exceptions/HttpRequestFailed.php +++ b/src/Exceptions/HttpRequestFailed.php @@ -10,6 +10,12 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Method; +/** + * Raised when the underlying PSR-18 client fails for a reason not classified as a network failure + * or request-invalid error. + * + * Wraps the originating PSR-18 ClientExceptionInterface. + */ final class HttpRequestFailed extends RuntimeException implements TransportFailure { private const string REASON_TEMPLATE = 'PSR-18 client failed for %s %s: %s'; diff --git a/src/Exceptions/HttpRequestInvalid.php b/src/Exceptions/HttpRequestInvalid.php index 6f3f5f5..7696a1f 100644 --- a/src/Exceptions/HttpRequestInvalid.php +++ b/src/Exceptions/HttpRequestInvalid.php @@ -10,6 +10,11 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Method; +/** + * Raised when a request is malformed and rejected by the transport before dispatch. + * + * Wraps the underlying PSR-18 RequestExceptionInterface when produced by the network transport. + */ final class HttpRequestInvalid extends RuntimeException implements TransportFailure { private const string REASON_TEMPLATE = 'Request is invalid for %s %s: %s'; diff --git a/src/Exceptions/MalformedPath.php b/src/Exceptions/MalformedPath.php index 9f6475f..fd57118 100644 --- a/src/Exceptions/MalformedPath.php +++ b/src/Exceptions/MalformedPath.php @@ -8,6 +8,12 @@ use Throwable; use TinyBlocks\Http\Client\Request; +/** + * Raised when a request path would escape the configured base URL. + * + * Triggered by paths containing a scheme, paths that are protocol-relative, or paths that carry + * control characters. Raised by the request resolver before the transport is invoked. + */ 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.'; diff --git a/src/Exceptions/NoMoreResponses.php b/src/Exceptions/NoMoreResponses.php index cc5a0fb..019288a 100644 --- a/src/Exceptions/NoMoreResponses.php +++ b/src/Exceptions/NoMoreResponses.php @@ -6,6 +6,11 @@ use LogicException; +/** + * Raised when InMemoryTransport is asked to deliver a response beyond the end of its seeded queue. + * + * Always indicates a programmer error. The test or scenario consumed more responses than were preloaded. + */ final class NoMoreResponses extends LogicException implements HttpException { private const string REASON_TEMPLATE = 'InMemoryTransport has no response queued at index %d.'; diff --git a/src/Exceptions/SynthesizedResponseHasNoRaw.php b/src/Exceptions/SynthesizedResponseHasNoRaw.php index 8af8430..d6770d4 100644 --- a/src/Exceptions/SynthesizedResponseHasNoRaw.php +++ b/src/Exceptions/SynthesizedResponseHasNoRaw.php @@ -6,6 +6,12 @@ use LogicException; +/** + * Raised when Response::raw() is called on a response synthesized via Response::with(). + * + * Synthesized responses exist only for in-process scenarios (tests, in-memory transports) and have + * no backing PSR-7 message to expose. + */ final class SynthesizedResponseHasNoRaw extends LogicException implements HttpException { private const string REASON = 'Response was synthesized via Response::with(...) and has no underlying PSR-7 raw ' diff --git a/src/Exceptions/TransportFailure.php b/src/Exceptions/TransportFailure.php index f396576..830ca17 100644 --- a/src/Exceptions/TransportFailure.php +++ b/src/Exceptions/TransportFailure.php @@ -6,6 +6,13 @@ use TinyBlocks\Http\Method; +/** + * Specialization of HttpException for failures that occur during transport dispatch. + * + * Implementations carry the URL, HTTP method, and transport-level reason associated with the + * failed request, allowing callers to inspect what was attempted and why without unwrapping + * the previous exception chain. + */ interface TransportFailure extends HttpException { /** diff --git a/src/Exceptions/UserAgentProductIsEmpty.php b/src/Exceptions/UserAgentProductIsEmpty.php index b5a6d97..f9da3d7 100644 --- a/src/Exceptions/UserAgentProductIsEmpty.php +++ b/src/Exceptions/UserAgentProductIsEmpty.php @@ -6,6 +6,9 @@ use InvalidArgumentException; +/** + * Raised when UserAgent::from() receives an empty product token. + */ final class UserAgentProductIsEmpty extends InvalidArgumentException implements HttpException { private const string REASON = 'User-Agent product must not be empty.'; @@ -15,6 +18,11 @@ private function __construct() parent::__construct(message: UserAgentProductIsEmpty::REASON); } + /** + * Creates a UserAgentProductIsEmpty signaling that the product token is empty. + * + * @return UserAgentProductIsEmpty The composed exception describing the empty-product-token state. + */ public static function create(): UserAgentProductIsEmpty { return new UserAgentProductIsEmpty(); diff --git a/src/Exceptions/UserAgentValueIsInvalid.php b/src/Exceptions/UserAgentValueIsInvalid.php new file mode 100644 index 0000000..a7e5a41 --- /dev/null +++ b/src/Exceptions/UserAgentValueIsInvalid.php @@ -0,0 +1,38 @@ +UserAgent::from() contains + * characters that would corrupt the rendered header line. + * + * The product token must not contain control characters or a forward slash (the product-version + * separator in the rendered header). The version token must not contain control characters. + */ +final class UserAgentValueIsInvalid extends InvalidArgumentException implements HttpException +{ + private const string REASON_TEMPLATE = 'User-Agent token <%s> is invalid. Tokens must not contain control ' + . 'characters (0x00-0x1F, 0x7F). The product token also must not contain a forward slash.'; + + private function __construct(string $value) + { + $template = UserAgentValueIsInvalid::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $value)); + } + + /** + * Creates a UserAgentValueIsInvalid signaling that the given token is invalid. + * + * @param string $value The offending product or version token. + * @return UserAgentValueIsInvalid The composed exception describing the invalid token. + */ + public static function for(string $value): UserAgentValueIsInvalid + { + return new UserAgentValueIsInvalid(value: $value); + } +} diff --git a/src/Headers.php b/src/Headers.php index 77cba96..9adc960 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -5,13 +5,22 @@ namespace TinyBlocks\Http; use Psr\Http\Message\MessageInterface; - +use TinyBlocks\Http\Exceptions\HeaderNameIsInvalid; +use TinyBlocks\Http\Exceptions\HeaderValueIsInvalid; + +/** + * Case-insensitive collection of HTTP headers represented as a name-to-value map. + * + * Multi-value header lines are folded into a single comma-separated string on construction. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers + */ final readonly class Headers { private array $entries; private array $lowerIndex; - public function __construct(array $entries) + private function __construct(array $entries) { $lowerIndex = []; @@ -23,6 +32,30 @@ public function __construct(array $entries) $this->lowerIndex = $lowerIndex; } + /** + * Creates a Headers from a name-to-value map. + * + * @param array $entries The header name to single folded value map; multi-value entries + * must be pre-folded by the caller. + * @return Headers A Headers wrapping the supplied entries. + * @throws HeaderNameIsInvalid If any entry key violates RFC 7230 token rules. + * @throws HeaderValueIsInvalid If any entry value contains a forbidden control character. + */ + public static function fromArray(array $entries): Headers + { + foreach ($entries as $name => $value) { + if (!preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) { + throw HeaderNameIsInvalid::for(name: $name); + } + + if (preg_match('/[\x00-\x08\x0A-\x1F\x7F]/', $value) === 1) { + throw HeaderValueIsInvalid::for(value: $value); + } + } + + return new Headers(entries: $entries); + } + /** * Creates a Headers from a PSR-7 message, folding multi-value headers with commas. * @@ -36,7 +69,7 @@ public static function fromMessage(MessageInterface $message): Headers $message->getHeaders() ); - return new Headers(entries: $entries); + return Headers::fromArray(entries: $entries); } /** @@ -55,7 +88,7 @@ public static function from(Headerable ...$headers): Headers } } - return new Headers(entries: $entries); + return Headers::fromArray(entries: $entries); } /** @@ -114,6 +147,33 @@ public function applyTo(MessageInterface $message): MessageInterface return $applied; } + /** + * Returns a copy of these Headers with the named entry replaced or appended. + * + * The lookup is case-insensitive: with('content-type', 'text/plain') replaces + * an existing Content-Type entry regardless of how it was originally cased. + * When no entry matches, the new header is appended under the supplied name. + * + * @param string $name The header name. + * @param string $value The replacement or new header value. + * @return Headers A new instance carrying the updated header. + * @throws HeaderNameIsInvalid If the name violates RFC 7230 token rules. + * @throws HeaderValueIsInvalid If the value contains a forbidden control character. + */ + public function with(string $name, string $value): Headers + { + $key = strtolower($name); + $entries = $this->entries; + + if (isset($this->lowerIndex[$key])) { + $entries[$this->lowerIndex[$key]] = $value; + } else { + $entries[$name] = $value; + } + + return Headers::fromArray(entries: $entries); + } + /** * Returns a copy of these Headers merged with another instance, with existing entries winning on collision. * @@ -132,6 +192,6 @@ public function mergedWith(Headers $other): Headers $merged[$name] = $value; } - return new Headers(entries: $merged); + return Headers::fromArray(entries: $merged); } } diff --git a/src/Http.php b/src/Http.php index e90408a..4492d6f 100644 --- a/src/Http.php +++ b/src/Http.php @@ -7,16 +7,29 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Client\Response; use TinyBlocks\Http\Client\Transport; +use TinyBlocks\Http\Exceptions\BaseUrlIsInvalid; use TinyBlocks\Http\Exceptions\HttpException; +use TinyBlocks\Http\Internal\Client\BaseUrl; use TinyBlocks\Http\Internal\Client\RequestResolver; +/** + * Facade for sending outbound HTTP requests through a configured Transport. + * + * Resolves each Request against the configured base URL and JSON defaults before delegating to + * the Transport. Constructed via the fluent builder returned by Http::create() + * or the explicit factory Http::with(). + * + * Path normalization of the remote URL (for example collapsing ... segments) is the + * responsibility of the remote server. + */ final readonly class Http { private RequestResolver $resolver; private function __construct(string $baseUrl, private Transport $transport) { - $this->resolver = RequestResolver::withBaseUrl(baseUrl: $baseUrl); + $baseUrl = BaseUrl::from(value: $baseUrl); + $this->resolver = RequestResolver::withBaseUrl(baseUrl: $baseUrl->toString()); } /** @@ -41,6 +54,7 @@ public static function create(): HttpBuilder * @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 facade. + * @throws BaseUrlIsInvalid If the base URL is not an accepted form. */ public static function with(string $baseUrl, Transport $transport): Http { @@ -50,8 +64,9 @@ public static function with(string $baseUrl, Transport $transport): Http /** * 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 + * The request is first resolved against the configured base URL and the JSON defaults + * (Accept: application/json, Content-Type: application/json), which custom headers in the + * request override. A path that escapes the base URL raises * MalformedPath before the transport is invoked. Transport-level failures * surface as HttpException subclasses. * diff --git a/src/HttpBuilder.php b/src/HttpBuilder.php index a3ffcca..b175e69 100644 --- a/src/HttpBuilder.php +++ b/src/HttpBuilder.php @@ -5,8 +5,16 @@ namespace TinyBlocks\Http; use TinyBlocks\Http\Client\Transport; +use TinyBlocks\Http\Exceptions\BaseUrlIsInvalid; use TinyBlocks\Http\Exceptions\HttpConfigurationInvalid; +use TinyBlocks\Http\Internal\Client\BaseUrl; +/** + * Fluent builder for Http instances. + * + * Accepts a base URL and Transport in any order; the configuration is validated only when + * HttpBuilder::build() is called. + */ final readonly class HttpBuilder { public function __construct(private ?string $baseUrl, private ?Transport $transport) @@ -18,10 +26,13 @@ public function __construct(private ?string $baseUrl, private ?Transport $transp * * @param string $url The absolute base URL prepended to every request path. * @return HttpBuilder A new builder instance. + * @throws BaseUrlIsInvalid If the URL is not empty, http://, or https://. Validation happens immediately, + * before build() is called. */ public function withBaseUrl(string $url): HttpBuilder { - return new HttpBuilder(baseUrl: $url, transport: $this->transport); + $baseUrl = BaseUrl::from($url); + return new HttpBuilder(baseUrl: $baseUrl->toString(), transport: $this->transport); } /** diff --git a/src/Internal/Client/BaseUrl.php b/src/Internal/Client/BaseUrl.php new file mode 100644 index 0000000..7fc1ca3 --- /dev/null +++ b/src/Internal/Client/BaseUrl.php @@ -0,0 +1,32 @@ +value; + } +} diff --git a/src/Internal/Client/RequestResolver.php b/src/Internal/Client/RequestResolver.php index f9c2c88..06dfcc9 100644 --- a/src/Internal/Client/RequestResolver.php +++ b/src/Internal/Client/RequestResolver.php @@ -29,14 +29,18 @@ 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(), + baseUrl: $this->baseUrl, + queryParameters: $request->queryParameters() + ); } catch (PathContainsScheme | PathContainsControlChars $exception) { throw MalformedPath::fromRequest(request: $request, previous: $exception); } return $request ->withUrl(url: $url) - ->withQuery(query: null) - ->withMergedHeaders(defaults: new Headers(entries: RequestResolver::JSON_DEFAULTS)); + ->withMergedHeaders(defaults: Headers::fromArray(entries: RequestResolver::JSON_DEFAULTS)) + ->withQueryParameters(queryParameters: null); } } diff --git a/src/Internal/Client/Url.php b/src/Internal/Client/Url.php index e331598..78a06f0 100644 --- a/src/Internal/Client/Url.php +++ b/src/Internal/Client/Url.php @@ -12,7 +12,7 @@ final class Url private const string CONTROL_CHARS_PATTERN = '/[\x00-\x1F\x7F]/'; private const string SCHEME_OR_PROTOCOL_RELATIVE_PATTERN = '#^(?://|\\\\\\\\|[a-z][a-z0-9+.-]*:)#i'; - public static function compose(string $path, ?array $query, string $baseUrl): string + public static function compose(string $path, string $baseUrl, ?array $queryParameters): string { if (preg_match(Url::SCHEME_OR_PROTOCOL_RELATIVE_PATTERN, $path) === 1) { throw PathContainsScheme::create(path: $path); @@ -27,12 +27,12 @@ public static function compose(string $path, ?array $query, string $baseUrl): st ? $path : sprintf($joinTemplate, rtrim($baseUrl, '/'), ltrim($path, '/')); - if (is_null($query) || $query === []) { + if (is_null($queryParameters) || $queryParameters === []) { return $absolute; } $queryTemplate = '%s?%s'; - return sprintf($queryTemplate, $absolute, http_build_query($query, '', '&', PHP_QUERY_RFC3986)); + return sprintf($queryTemplate, $absolute, http_build_query($queryParameters, '', '&', PHP_QUERY_RFC3986)); } } diff --git a/src/Internal/Server/CacheControl/CacheControlDirective.php b/src/Internal/Server/CacheControl/CacheControlDirective.php deleted file mode 100644 index 7e73cd8..0000000 --- a/src/Internal/Server/CacheControl/CacheControlDirective.php +++ /dev/null @@ -1,42 +0,0 @@ -toHeaderValue(value: $maxAgeInWholeSeconds)); - } - - public static function noCache(): static - { - return new static(value: Directives::NO_CACHE->toHeaderValue()); - } - - public static function noStore(): static - { - return new static(value: Directives::NO_STORE->toHeaderValue()); - } - - public static function noTransform(): static - { - return new static(value: Directives::NO_TRANSFORM->toHeaderValue()); - } - - public static function staleIfError(): static - { - return new static(value: Directives::STALE_IF_ERROR->toHeaderValue()); - } - - public function toString(): string - { - return $this->value; - } -} diff --git a/src/Internal/Server/Cookies/CookieDomain.php b/src/Internal/Server/Cookies/CookieDomain.php new file mode 100644 index 0000000..f714c9a --- /dev/null +++ b/src/Internal/Server/Cookies/CookieDomain.php @@ -0,0 +1,34 @@ +value; + } +} diff --git a/src/Internal/Server/Cookies/CookiePath.php b/src/Internal/Server/Cookies/CookiePath.php new file mode 100644 index 0000000..e3afaca --- /dev/null +++ b/src/Internal/Server/Cookies/CookiePath.php @@ -0,0 +1,34 @@ +value; + } +} diff --git a/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php b/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php deleted file mode 100644 index 634a93a..0000000 --- a/src/Internal/Server/Exceptions/ConflictingLifetimeAttributes.php +++ /dev/null @@ -1,18 +0,0 @@ - is invalid. A domain must not contain control ' + . 'characters, whitespace, semicolons, commas, double quotes, or backslashes.'; + + public function __construct(string $domain) + { + $template = CookieDomainIsInvalid::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $domain)); + } +} diff --git a/src/Internal/Server/Exceptions/CookiePathIsInvalid.php b/src/Internal/Server/Exceptions/CookiePathIsInvalid.php new file mode 100644 index 0000000..a5ce604 --- /dev/null +++ b/src/Internal/Server/Exceptions/CookiePathIsInvalid.php @@ -0,0 +1,20 @@ + is invalid. A path must not contain control characters, ' + . 'semicolons, or commas.'; + + public function __construct(string $path) + { + $template = CookiePathIsInvalid::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $path)); + } +} diff --git a/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php b/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php deleted file mode 100644 index dd90f80..0000000 --- a/src/Internal/Server/Exceptions/SameSiteNoneRequiresSecure.php +++ /dev/null @@ -1,18 +0,0 @@ -write(), code: $code, headers: ResponseHeaders::fromOrDefault(...$headers), - protocolVersion: ProtocolVersion::default() + protocolVersion: ProtocolVersion::default(), + customReasonPhrase: null ); } @@ -37,7 +39,8 @@ public static function createWithoutBody(Code $code, Headerable ...$headers): Re body: StreamFactory::fromEmptyBody()->write(), code: $code, headers: ResponseHeaders::fromOrDefault(...$headers), - protocolVersion: ProtocolVersion::default() + protocolVersion: ProtocolVersion::default(), + customReasonPhrase: null ); } @@ -73,7 +76,7 @@ public function getHeaderLine(string $name): string public function getReasonPhrase(): string { - return $this->code->message(); + return $this->customReasonPhrase ?? $this->code->message(); } public function getProtocolVersion(): string @@ -87,7 +90,8 @@ public function withBody(StreamInterface $body): MessageInterface body: $body, code: $this->code, headers: $this->headers, - protocolVersion: $this->protocolVersion + protocolVersion: $this->protocolVersion, + customReasonPhrase: $this->customReasonPhrase ); } @@ -97,7 +101,8 @@ public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterf body: $this->body, code: Code::from($code), headers: $this->headers, - protocolVersion: $this->protocolVersion + protocolVersion: $this->protocolVersion, + customReasonPhrase: $reasonPhrase !== '' ? $reasonPhrase : null ); } @@ -107,7 +112,8 @@ public function withHeader(string $name, mixed $value): MessageInterface body: $this->body, code: $this->code, headers: $this->headers->withReplaced(name: $name, value: $value), - protocolVersion: $this->protocolVersion + protocolVersion: $this->protocolVersion, + customReasonPhrase: $this->customReasonPhrase ); } @@ -117,7 +123,8 @@ public function withoutHeader(string $name): MessageInterface body: $this->body, code: $this->code, headers: $this->headers->removeByName(name: $name), - protocolVersion: $this->protocolVersion + protocolVersion: $this->protocolVersion, + customReasonPhrase: $this->customReasonPhrase ); } @@ -127,7 +134,8 @@ public function withAddedHeader(string $name, mixed $value): MessageInterface body: $this->body, code: $this->code, headers: $this->headers->withAdded(name: $name, value: $value), - protocolVersion: $this->protocolVersion + protocolVersion: $this->protocolVersion, + customReasonPhrase: $this->customReasonPhrase ); } @@ -139,7 +147,8 @@ public function withProtocolVersion(string $version): MessageInterface body: $this->body, code: $this->code, headers: $this->headers, - protocolVersion: $protocolVersion + protocolVersion: $protocolVersion, + customReasonPhrase: $this->customReasonPhrase ); } } diff --git a/src/Internal/Server/Stream/StreamFactory.php b/src/Internal/Server/Stream/StreamFactory.php index 0ada976..d0457f5 100644 --- a/src/Internal/Server/Stream/StreamFactory.php +++ b/src/Internal/Server/Stream/StreamFactory.php @@ -6,6 +6,7 @@ use BackedEnum; use Psr\Http\Message\StreamInterface; +use TinyBlocks\Http\Exceptions\BodyTypeIsUnsupported; use TinyBlocks\Mapper\Mapper; use UnitEnum; @@ -52,7 +53,7 @@ public static function fromBody(mixed $body): StreamFactory $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_object($body) => throw BodyTypeIsUnsupported::for(class: $body::class), is_string($body) => $body, is_scalar($body) || is_array($body) => StreamFactory::toJsonFrom(body: $body), default => '' diff --git a/src/Method.php b/src/Method.php index 77387a1..0ea35a7 100644 --- a/src/Method.php +++ b/src/Method.php @@ -20,4 +20,48 @@ enum Method: string case DELETE = 'DELETE'; case OPTIONS = 'OPTIONS'; case CONNECT = 'CONNECT'; + + /** + * Tells whether the method is safe per RFC 9110 §9.2.1. + * + * Safe methods do not alter the state of the server. A request is safe when it is + * read-only from the server's perspective. Safe methods are GET, + * HEAD, OPTIONS, and TRACE. + * + * @see https://www.rfc-editor.org/rfc/rfc9110#section-9.2.1 + * @return bool True when the method is safe, otherwise false. + */ + public function isSafe(): bool + { + return match ($this) { + Method::GET, + Method::HEAD, + Method::OPTIONS, + Method::TRACE => true, + default => false + }; + } + + /** + * Tells whether the method is idempotent per RFC 9110 §9.2.2. + * + * An idempotent method produces the same server state when applied one or more times. + * All safe methods are idempotent. Additionally, PUT and DELETE + * are idempotent but not safe. + * + * @see https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2 + * @return bool True when the method is idempotent, otherwise false. + */ + public function isIdempotent(): bool + { + return match ($this) { + Method::GET, + Method::PUT, + Method::HEAD, + Method::TRACE, + Method::DELETE, + Method::OPTIONS => true, + default => false + }; + } } diff --git a/src/ResponseCacheDirectives.php b/src/ResponseCacheDirectives.php index cbda0d1..50046df 100644 --- a/src/ResponseCacheDirectives.php +++ b/src/ResponseCacheDirectives.php @@ -4,7 +4,6 @@ namespace TinyBlocks\Http; -use TinyBlocks\Http\Internal\Server\CacheControl\CacheControlDirective; use TinyBlocks\Http\Internal\Server\CacheControl\Directives; /** @@ -14,7 +13,21 @@ */ final readonly class ResponseCacheDirectives { - use CacheControlDirective; + private function __construct(private string $value) + { + } + + /** + * Builds a ResponseCacheDirectives with the max-age directive. + * + * @param int $maxAgeInWholeSeconds The maximum time in whole seconds a response may be cached. + * @return ResponseCacheDirectives A directive instructing caches to store the response for at most the given + * number of seconds. + */ + public static function maxAge(int $maxAgeInWholeSeconds): ResponseCacheDirectives + { + return new ResponseCacheDirectives(value: Directives::MAX_AGE->toHeaderValue(value: $maxAgeInWholeSeconds)); + } /** * Builds a ResponseCacheDirectives with the must-revalidate directive. @@ -26,6 +39,37 @@ public static function mustRevalidate(): ResponseCacheDirectives return new ResponseCacheDirectives(value: Directives::MUST_REVALIDATE->toHeaderValue()); } + /** + * Builds a ResponseCacheDirectives with the no-cache directive. + * + * @return ResponseCacheDirectives A directive that requires caches to validate the response with the origin + * before serving it. + */ + public static function noCache(): ResponseCacheDirectives + { + return new ResponseCacheDirectives(value: Directives::NO_CACHE->toHeaderValue()); + } + + /** + * Builds a ResponseCacheDirectives with the no-store directive. + * + * @return ResponseCacheDirectives A directive that forbids caches from storing any part of the response. + */ + public static function noStore(): ResponseCacheDirectives + { + return new ResponseCacheDirectives(value: Directives::NO_STORE->toHeaderValue()); + } + + /** + * Builds a ResponseCacheDirectives with the no-transform directive. + * + * @return ResponseCacheDirectives A directive that forbids intermediaries from transforming the response. + */ + public static function noTransform(): ResponseCacheDirectives + { + return new ResponseCacheDirectives(value: Directives::NO_TRANSFORM->toHeaderValue()); + } + /** * Builds a ResponseCacheDirectives with the proxy-revalidate directive. * @@ -35,4 +79,25 @@ public static function proxyRevalidate(): ResponseCacheDirectives { return new ResponseCacheDirectives(value: Directives::PROXY_REVALIDATE->toHeaderValue()); } + + /** + * Builds a ResponseCacheDirectives with the stale-if-error directive. + * + * @return ResponseCacheDirectives A directive that allows caches to serve a stale response when an error + * is encountered. + */ + public static function staleIfError(): ResponseCacheDirectives + { + return new ResponseCacheDirectives(value: Directives::STALE_IF_ERROR->toHeaderValue()); + } + + /** + * Returns the ResponseCacheDirectives as a string. + * + * @return string The Cache-Control directive header value. + */ + public function toString(): string + { + return $this->value; + } } diff --git a/src/SameSite.php b/src/SameSite.php index 7edd6b2..7ffa8b9 100644 --- a/src/SameSite.php +++ b/src/SameSite.php @@ -4,6 +4,11 @@ namespace TinyBlocks\Http; +/** + * SameSite attribute for an HTTP Set-Cookie header controlling cross-site request behavior. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + */ enum SameSite: string { case LAX = 'Lax'; diff --git a/src/Server/Decoded/DecodedRequest.php b/src/Server/Decoded/DecodedRequest.php index 071accf..29e3f43 100644 --- a/src/Server/Decoded/DecodedRequest.php +++ b/src/Server/Decoded/DecodedRequest.php @@ -6,6 +6,11 @@ use TinyBlocks\Http\Body; +/** + * Typed view of an incoming HTTP request decoded into its URI and body. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages + */ final readonly class DecodedRequest { private function __construct(private Uri $uri, private Body $body) diff --git a/src/Server/Decoded/QueryParameters.php b/src/Server/Decoded/QueryParameters.php index a9bea41..045c767 100644 --- a/src/Server/Decoded/QueryParameters.php +++ b/src/Server/Decoded/QueryParameters.php @@ -7,6 +7,11 @@ use Psr\Http\Message\ServerRequestInterface; use TinyBlocks\Http\Attribute; +/** + * Typed collection of query string parameters extracted from an HTTP request URI. + * + * @see https://developer.mozilla.org/en-US/docs/Web/URI + */ final readonly class QueryParameters { private function __construct(private array $data) diff --git a/src/Server/Decoded/Uri.php b/src/Server/Decoded/Uri.php index 82f0b31..e16e4e3 100644 --- a/src/Server/Decoded/Uri.php +++ b/src/Server/Decoded/Uri.php @@ -8,6 +8,11 @@ use TinyBlocks\Http\Attribute; use TinyBlocks\Http\Internal\Server\Request\RouteParameterResolver; +/** + * Typed accessor for the URI of an incoming HTTP request, including route attributes and query parameters. + * + * @see https://developer.mozilla.org/en-US/docs/Web/URI + */ final readonly class Uri { private const string ROUTE = '__route__'; @@ -74,6 +79,9 @@ public function get(string $key): Attribute /** * Returns a copy of the Uri scoped to a different route attribute name. * + * When $name is omitted, the Uri is re-scoped to the library's default route + * attribute key (__route__). + * * @param string $name The route attribute name to scope the Uri to. * @return Uri A new Uri scoped to the supplied attribute name. */ diff --git a/src/Server/Request.php b/src/Server/Request.php index 2ab716f..7747662 100644 --- a/src/Server/Request.php +++ b/src/Server/Request.php @@ -9,6 +9,11 @@ use TinyBlocks\Http\Method; use TinyBlocks\Http\Server\Decoded\DecodedRequest; +/** + * Typed wrapper around an incoming PSR-7 server request. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages + */ final readonly class Request { private function __construct(private ServerRequestInterface $request) diff --git a/src/Server/Response.php b/src/Server/Response.php index c45cb16..b224295 100644 --- a/src/Server/Response.php +++ b/src/Server/Response.php @@ -9,6 +9,11 @@ use TinyBlocks\Http\Headerable; use TinyBlocks\Http\Internal\Server\Response\InternalResponse; +/** + * Factory class for building PSR-7 server responses with a typed status code and optional headers. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages + */ final class Response implements Responses { private function __construct() diff --git a/src/Server/Responses.php b/src/Server/Responses.php index cb31cd5..8f8bda4 100644 --- a/src/Server/Responses.php +++ b/src/Server/Responses.php @@ -9,7 +9,9 @@ use TinyBlocks\Http\Headerable; /** - * Define standard HTTP response methods. + * Contract for factories that build PSR-7 responses with semantic HTTP status helpers. + * + * Implemented by Server\Response. * * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status */ diff --git a/src/UserAgent.php b/src/UserAgent.php index 8db0330..9ce3563 100644 --- a/src/UserAgent.php +++ b/src/UserAgent.php @@ -5,7 +5,13 @@ namespace TinyBlocks\Http; use TinyBlocks\Http\Exceptions\UserAgentProductIsEmpty; +use TinyBlocks\Http\Exceptions\UserAgentValueIsInvalid; +/** + * HTTP User-Agent header value composed of a product token and an optional version. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent + */ final readonly class UserAgent implements Headerable { private function __construct(private string $product, private ?string $version) @@ -15,24 +21,33 @@ private function __construct(private string $product, private ?string $version) /** * Builds a User-Agent header value from a product token and an optional version. * - * An empty version is normalized to "no version" — the rendered header - * carries only the product token in that case. The product token must not - * be empty. + * An absent or empty version is normalized to no version (the rendered header carries only + * the product token in that case). The product token must not be empty. * * @param string $product The mandatory product token (e.g., "MyApp"). - * @param string $version The optional version. Empty string means "absent". + * @param string|null $version The optional version, or null when absent. Empty string is treated as absent. * @return UserAgent A new immutable value object. * @throws UserAgentProductIsEmpty When the product token is empty. + * @throws UserAgentValueIsInvalid When the product or version contains control characters or a forward slash + * (product only). */ - public static function from(string $product, string $version = ''): UserAgent + public static function from(string $product, ?string $version = null): UserAgent { if ($product === '') { throw UserAgentProductIsEmpty::create(); } + if (preg_match('/[\x00-\x1F\x7F\/]/', $product) === 1) { + throw UserAgentValueIsInvalid::for(value: $product); + } + + if (!is_null($version) && $version !== '' && preg_match('/[\x00-\x1F\x7F]/', $version) === 1) { + throw UserAgentValueIsInvalid::for(value: $version); + } + return new UserAgent( product: $product, - version: $version === '' ? null : $version + version: ($version === null || $version === '') ? null : $version ); } diff --git a/tests/Unit/Client/RequestTest.php b/tests/Unit/Client/RequestTest.php index bfa49ad..bde36fd 100644 --- a/tests/Unit/Client/RequestTest.php +++ b/tests/Unit/Client/RequestTest.php @@ -4,6 +4,8 @@ namespace Test\TinyBlocks\Http\Unit\Client; +use Closure; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\CacheControl; use TinyBlocks\Http\Charset; @@ -15,41 +17,29 @@ final class RequestTest extends TestCase { - public function testCreateWhenMinimalParametersGivenThenAccessorsReturnSuppliedValues(): void + public function testForWhenMethodAndUrlGivenThenAccessorsReturnSuppliedValues(): void { - /** @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() - ); + /** @When creating a request for a specific method and URL */ + $request = Request::for(method: Method::GET, url: 'https://api.example.com/dragons'); /** @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::assertNull($request->queryParameters()); self::assertSame([], $request->headers()->toArray()); } - public function testCreateWhenNullBodyGivenThenCarriesNoBody(): void + public function testForWhenNullBodyGivenThenCarriesNoBody(): void { /** @When creating a request with an explicit null body */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ); + $request = Request::for(method: Method::GET, url: '/dragons'); /** @Then the body is null */ self::assertNull($request->body()); } - public function testCreateWhenMultipleHeadersGivenThenMergesEntries(): void + public function testForWhenMultipleHeadersGivenThenMergesEntries(): void { /** @Given a Content-Type header with charset */ $contentType = ContentType::applicationJson(charset: Charset::UTF_8); @@ -58,11 +48,9 @@ public function testCreateWhenMultipleHeadersGivenThenMergesEntries(): void $accept = ContentType::applicationJson(); /** @When creating a request with both headers merged */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, + $request = Request::for( method: Method::POST, + url: '/dragons', headers: Headers::from($contentType, $accept) ); @@ -70,7 +58,7 @@ public function testCreateWhenMultipleHeadersGivenThenMergesEntries(): void self::assertTrue($request->headers()->has('Content-Type')); } - public function testCreateWhenSameHeaderProvidedTwiceThenLastOneWins(): void + public function testForWhenSameHeaderProvidedTwiceThenLastOneWins(): void { /** @Given a Content-Type header with charset */ $first = ContentType::applicationJson(charset: Charset::UTF_8); @@ -79,11 +67,9 @@ public function testCreateWhenSameHeaderProvidedTwiceThenLastOneWins(): void $second = ContentType::applicationJson(); /** @When creating the request with both (last one wins) */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, + $request = Request::for( method: Method::POST, + url: '/dragons', headers: Headers::from($first, $second) ); @@ -91,34 +77,22 @@ public function testCreateWhenSameHeaderProvidedTwiceThenLastOneWins(): void self::assertSame('application/json', $request->headers()->get('Content-Type')); } - public function testCreateWhenQueryGivenThenPreservesArrayInProperty(): void + public function testForWhenQueryParametersGivenThenPreservesArrayInProperty(): void { /** @Given query parameters */ - $query = ['sort' => 'name', 'order' => 'asc']; + $queryParameters = ['sort' => 'name', 'order' => 'asc']; - /** @When creating the request with query */ - $request = Request::create( - url: '/dragons', - body: null, - query: $query, - method: Method::GET, - headers: Headers::from() - ); + /** @When creating the request with query parameters */ + $request = Request::for(method: Method::GET, url: '/dragons', queryParameters: $queryParameters); - /** @Then the query is preserved */ - self::assertSame($query, $request->query()); + /** @Then the query parameters are preserved */ + self::assertSame($queryParameters, $request->queryParameters()); } public function testWithUrlWhenInvokedThenReturnsNewInstanceWithReplacedUrl(): void { /** @Given a request with an original URL */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: '/dragons'); /** @When calling withUrl */ $updated = $request->withUrl(url: '/dragons/42'); @@ -129,27 +103,70 @@ public function testWithUrlWhenInvokedThenReturnsNewInstanceWithReplacedUrl(): v self::assertSame('/dragons', $request->url()); } - public function testWithQueryWhenInvokedThenReturnsNewInstanceWithReplacedQuery(): void + public function testWithQueryParametersWhenInvokedThenReturnsNewInstanceWithReplacedQueryParameters(): void { - /** @Given a request with an original query */ - $request = Request::create( + /** @Given a request with original query parameters */ + $request = Request::get(url: '/dragons', queryParameters: ['sort' => 'name']); + + /** @When calling withQueryParameters */ + $updated = $request->withQueryParameters(queryParameters: ['order' => 'asc']); + + /** @Then a new instance is returned with the query parameters replaced */ + self::assertNotSame($request, $updated); + self::assertSame(['order' => 'asc'], $updated->queryParameters()); + self::assertSame(['sort' => 'name'], $request->queryParameters()); + } + + public function testWithHeaderWhenNewNameGivenThenAppendsHeader(): void + { + /** @Given a request with no custom headers */ + $request = Request::get(url: '/dragons'); + + /** @When adding a new header */ + $updated = $request->withHeader(name: 'X-Trace-Id', value: 'abc-123'); + + /** @Then the new header is present on the updated instance */ + self::assertSame('abc-123', $updated->headers()->get('X-Trace-Id')); + + /** @And the original instance is unchanged */ + self::assertNull($request->headers()->get('X-Trace-Id')); + } + + public function testWithHeaderWhenExistingNameGivenThenReplacesHeader(): void + { + /** @Given a request with a Content-Type header */ + $request = Request::post( url: '/dragons', - body: null, - query: ['sort' => 'name'], - method: Method::GET, - headers: Headers::from() + headers: Headers::from(ContentType::applicationJson()) ); - /** @When calling withQuery */ - $updated = $request->withQuery(query: ['order' => 'asc']); + /** @When replacing the Content-Type header */ + $updated = $request->withHeader(name: 'Content-Type', value: 'text/plain'); - /** @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()); + /** @Then the new value replaces the original */ + self::assertSame('text/plain', $updated->headers()->get('Content-Type')); + + /** @And the original instance retains its original value */ + self::assertSame('application/json', $request->headers()->get('Content-Type')); } - public function testCreateWhenDistinctKeyHeadersGivenThenBothPresent(): void + public function testWithHeaderWhenCasingDiffersThenReplacesExistingEntry(): void + { + /** @Given a request with a Content-Type header stored under mixed case */ + $request = Request::post( + url: '/dragons', + headers: Headers::from(ContentType::applicationJson()) + ); + + /** @When replacing using a different casing */ + $updated = $request->withHeader(name: 'content-type', value: 'text/plain'); + + /** @Then only one Content-Type entry exists and it carries the new value */ + self::assertSame('text/plain', $updated->headers()->get('Content-Type')); + self::assertCount(1, $updated->headers()->toArray()); + } + + public function testForWhenDistinctKeyHeadersGivenThenBothPresent(): void { /** @Given a Content-Type header */ $contentType = ContentType::applicationJson(); @@ -158,11 +175,9 @@ public function testCreateWhenDistinctKeyHeadersGivenThenBothPresent(): void $cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::mustRevalidate()); /** @When creating a request with both headers */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, + $request = Request::for( method: Method::GET, + url: '/dragons', headers: Headers::from($contentType, $cacheControl) ); @@ -173,16 +188,13 @@ public function testCreateWhenDistinctKeyHeadersGivenThenBothPresent(): void public function testWithMergedHeadersWhenCustomConflictsWithDefaultThenCustomWins(): void { /** @Given a request with a custom Content-Type header */ - $request = Request::create( + $request = Request::post( url: '/dragons', - body: null, - query: null, - method: Method::POST, headers: Headers::from(ContentType::applicationJson(charset: Charset::UTF_8)) ); /** @And defaults that include the same header */ - $defaults = new Headers(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); + $defaults = Headers::fromArray(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); /** @When merging defaults under the existing headers */ $resolved = $request->withMergedHeaders(defaults: $defaults); @@ -195,13 +207,7 @@ public function testWithMergedHeadersWhenCustomConflictsWithDefaultThenCustomWin public function testHeadersWhenMixedCaseGivenThenLookupIsCaseInsensitive(): void { /** @Given a request with a Content-Type header */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from(ContentType::applicationJson()) - ); + $request = Request::get(url: '/dragons', headers: Headers::from(ContentType::applicationJson())); /** @When looking up the header with different casing */ /** @Then the lookup succeeds regardless of case */ @@ -212,13 +218,7 @@ public function testHeadersWhenMixedCaseGivenThenLookupIsCaseInsensitive(): void public function testHeadersGetWhenMissingKeyGivenThenReturnsNull(): void { /** @Given a request with no headers */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: '/dragons'); /** @When looking up a non-existent header */ /** @Then null is returned */ @@ -228,16 +228,204 @@ public function testHeadersGetWhenMissingKeyGivenThenReturnsNull(): void public function testHeadersWhenRequestCreatedThenExposesHeadersInstance(): void { /** @Given a request */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: '/dragons'); /** @When accessing headers */ /** @Then a Headers instance is returned */ self::assertInstanceOf(Headers::class, $request->headers()); } + + public function testForWhenNonStandardMethodGivenThenMethodIsPreserved(): void + { + /** @When creating a request for a non-standard method */ + $request = Request::for(method: Method::OPTIONS, url: '/dragons'); + + /** @Then the method is preserved */ + self::assertSame(Method::OPTIONS, $request->method()); + } + + #[DataProvider('shortcutMethodCases')] + public function testShortcutWhenInvokedThenMethodMatchesExpected(Method $expected, Closure $factory): void + { + /** @Given a shortcut factory for a specific HTTP method */ + + /** @When the factory is called */ + $request = $factory(); + + /** @Then the method matches the expected enum case */ + self::assertSame($expected, $request->method()); + } + + #[DataProvider('noBodyShortcutCases')] + public function testBodylessShortcutWhenInvokedThenBodyIsNull(Closure $factory): void + { + /** @Given a shortcut that does not accept a body */ + + /** @When the factory is called */ + $request = $factory(); + + /** @Then the body is null */ + self::assertNull($request->body()); + } + + #[DataProvider('bodyShortcutWithBodyCases')] + public function testBodyShortcutWhenBodyGivenThenBodyIsPropagated(Closure $factory, array $body): void + { + /** @Given a shortcut that accepts a body */ + + /** @When the factory is called with a body */ + $request = $factory(); + + /** @Then the body is propagated */ + self::assertSame($body, $request->body()); + } + + #[DataProvider('bodyShortcutNullBodyCases')] + public function testBodyShortcutWhenBodyOmittedThenBodyIsNull(Closure $factory): void + { + /** @Given a shortcut that accepts a body */ + + /** @When the factory is called without a body */ + $request = $factory(); + + /** @Then the body is null */ + self::assertNull($request->body()); + } + + #[DataProvider('shortcutWithQueryCases')] + public function testShortcutWhenQueryParametersGivenThenQueryParametersArePropagated( + Closure $factory, + array $queryParameters + ): void { + /** @Given a shortcut factory called with query parameters */ + + /** @When the factory is called with query parameters */ + $request = $factory(); + + /** @Then the query parameters are propagated */ + self::assertSame($queryParameters, $request->queryParameters()); + } + + #[DataProvider('shortcutWithDefaultHeadersCases')] + public function testShortcutWhenHeadersOmittedThenHeadersDefaultsToEmptySet(Closure $factory): void + { + /** @Given a shortcut factory called without headers */ + + /** @When the factory is called without headers */ + $request = $factory(); + + /** @Then the headers default to an empty set */ + self::assertSame([], $request->headers()->toArray()); + } + + #[DataProvider('shortcutWithHeadersCases')] + public function testShortcutWhenHeadersGivenThenHeadersIsPropagated(Closure $factory, Headers $headers): void + { + /** @Given a shortcut factory called with specific headers */ + + /** @When the factory is called with headers */ + $request = $factory(); + + /** @Then the headers are propagated unchanged */ + self::assertSame($headers, $request->headers()); + } + + public static function bodyShortcutNullBodyCases(): array + { + return [ + 'POST' => [static fn(): Request => Request::post(url: '/dragons')], + 'PUT' => [static fn(): Request => Request::put(url: '/dragons')], + 'PATCH' => [static fn(): Request => Request::patch(url: '/dragons')] + ]; + } + + public static function noBodyShortcutCases(): array + { + return [ + 'GET' => [static fn(): Request => Request::get(url: '/dragons')], + 'DELETE' => [static fn(): Request => Request::delete(url: '/dragons')], + 'HEAD' => [static fn(): Request => Request::head(url: '/dragons')] + ]; + } + + public static function bodyShortcutWithBodyCases(): array + { + $body = ['name' => 'Smaug', 'type' => 'fire']; + + return [ + 'POST' => [static fn(): Request => Request::post(url: '/dragons', body: $body), $body], + 'PUT' => [static fn(): Request => Request::put(url: '/dragons', body: $body), $body], + 'PATCH' => [static fn(): Request => Request::patch(url: '/dragons', body: $body), $body] + ]; + } + + public static function shortcutMethodCases(): array + { + return [ + 'GET' => [Method::GET, static fn(): Request => Request::get(url: '/dragons')], + 'POST' => [Method::POST, static fn(): Request => Request::post(url: '/dragons')], + 'PUT' => [Method::PUT, static fn(): Request => Request::put(url: '/dragons')], + 'PATCH' => [Method::PATCH, static fn(): Request => Request::patch(url: '/dragons')], + 'DELETE' => [Method::DELETE, static fn(): Request => Request::delete(url: '/dragons')], + 'HEAD' => [Method::HEAD, static fn(): Request => Request::head(url: '/dragons')] + ]; + } + + public static function shortcutWithDefaultHeadersCases(): array + { + return [ + 'GET' => [static fn(): Request => Request::get(url: '/dragons')], + 'POST' => [static fn(): Request => Request::post(url: '/dragons')], + 'PUT' => [static fn(): Request => Request::put(url: '/dragons')], + 'PATCH' => [static fn(): Request => Request::patch(url: '/dragons')], + 'DELETE' => [static fn(): Request => Request::delete(url: '/dragons')], + 'HEAD' => [static fn(): Request => Request::head(url: '/dragons')] + ]; + } + + public static function shortcutWithHeadersCases(): array + { + $headers = Headers::from(ContentType::applicationJson()); + + return [ + 'GET' => [static fn(): Request => Request::get(url: '/dragons', headers: $headers), $headers], + 'POST' => [static fn(): Request => Request::post(url: '/dragons', headers: $headers), $headers], + 'PUT' => [static fn(): Request => Request::put(url: '/dragons', headers: $headers), $headers], + 'PATCH' => [static fn(): Request => Request::patch(url: '/dragons', headers: $headers), $headers], + 'DELETE' => [static fn(): Request => Request::delete(url: '/dragons', headers: $headers), $headers], + 'HEAD' => [static fn(): Request => Request::head(url: '/dragons', headers: $headers), $headers] + ]; + } + + public static function shortcutWithQueryCases(): array + { + $queryParameters = ['sort' => 'name', 'order' => 'asc']; + + return [ + 'GET' => [ + static fn(): Request => Request::get(url: '/dragons', queryParameters: $queryParameters), + $queryParameters + ], + 'POST' => [ + static fn(): Request => Request::post(url: '/dragons', queryParameters: $queryParameters), + $queryParameters + ], + 'PUT' => [ + static fn(): Request => Request::put(url: '/dragons', queryParameters: $queryParameters), + $queryParameters + ], + 'PATCH' => [ + static fn(): Request => Request::patch(url: '/dragons', queryParameters: $queryParameters), + $queryParameters + ], + 'DELETE' => [ + static fn(): Request => Request::delete(url: '/dragons', queryParameters: $queryParameters), + $queryParameters + ], + 'HEAD' => [ + static fn(): Request => Request::head(url: '/dragons', queryParameters: $queryParameters), + $queryParameters + ] + ]; + } } diff --git a/tests/Unit/Client/ResponseTest.php b/tests/Unit/Client/ResponseTest.php index b16b3ef..c213996 100644 --- a/tests/Unit/Client/ResponseTest.php +++ b/tests/Unit/Client/ResponseTest.php @@ -182,7 +182,7 @@ public function testWithWhenNullBodyGivenThenReturnsEmptyArray(): void public function testWithWhenHeadersGivenThenExposesViaHeadersAccessor(): void { /** @Given a Headers instance with one entry */ - $headers = new Headers(entries: ['X-Trace' => 'abc']); + $headers = Headers::fromArray(entries: ['X-Trace' => 'abc']); /** @When synthesizing a response with the headers */ $response = Response::with(code: Code::OK, headers: $headers); diff --git a/tests/Unit/Client/Transports/InMemoryTransportTest.php b/tests/Unit/Client/Transports/InMemoryTransportTest.php index 34fdb50..cc0f2b5 100644 --- a/tests/Unit/Client/Transports/InMemoryTransportTest.php +++ b/tests/Unit/Client/Transports/InMemoryTransportTest.php @@ -10,8 +10,6 @@ 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 { @@ -27,13 +25,7 @@ public function testSendWhenMultipleResponsesQueuedThenServesInFifoOrder(): void $transport = InMemoryTransport::with(responses: [$first, $second]); /** @And a request to dispatch */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: '/dragons'); /** @When the queue is drained twice */ $drained = [ @@ -52,13 +44,7 @@ public function testSendWhenQueueExhaustedThenThrowsNoMoreResponses(): void $transport = InMemoryTransport::with(responses: [Response::with(code: Code::OK)]); /** @And a request to dispatch */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: '/dragons'); /** @And the seeded response is already consumed */ $transport->send(request: $request); @@ -76,13 +62,7 @@ public function testSendWhenQueueEmptyThenThrowsNoMoreResponsesImmediately(): vo $transport = InMemoryTransport::with(responses: []); /** @And a request to dispatch */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: '/dragons'); /** @Then NoMoreResponses is thrown immediately */ $this->expectException(NoMoreResponses::class); @@ -97,13 +77,7 @@ public function testSendWhenQueueEmptyThenExceptionMessageReferencesExhaustedInd $transport = InMemoryTransport::with(responses: []); /** @And a request to dispatch */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: '/dragons'); /** @Then the raised exception message references the exhausted index */ $this->expectException(NoMoreResponses::class); @@ -119,13 +93,7 @@ public function testSendWhenSingleResponseQueuedThenReturnsTheQueuedResponse(): $transport = InMemoryTransport::with(responses: [Response::with(code: Code::CREATED)]); /** @And a request to dispatch */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: '/dragons'); /** @When the request is sent */ $response = $transport->send(request: $request); diff --git a/tests/Unit/Client/Transports/NetworkTransportTest.php b/tests/Unit/Client/Transports/NetworkTransportTest.php index bb45568..18e6752 100644 --- a/tests/Unit/Client/Transports/NetworkTransportTest.php +++ b/tests/Unit/Client/Transports/NetworkTransportTest.php @@ -18,7 +18,6 @@ use TinyBlocks\Http\Exceptions\HttpRequestFailed; use TinyBlocks\Http\Exceptions\HttpRequestInvalid; use TinyBlocks\Http\Headers; -use TinyBlocks\Http\Method; final class NetworkTransportTest extends TestCase { @@ -37,13 +36,8 @@ public function testSendWhenBodyGivenThenForwardsJsonAndContentTypeHeader(): voi /** @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'], - query: null, - method: Method::POST, - headers: Headers::from() - )->withMergedHeaders(defaults: new Headers(entries: ['Content-Type' => 'application/json'])) + request: Request::post(url: 'https://api.example.com/dragons', body: ['name' => 'Hydra']) + ->withMergedHeaders(defaults: Headers::fromArray(entries: ['Content-Type' => 'application/json'])) ); /** @Then the PSR-7 request carries JSON and the Content-Type header */ @@ -59,13 +53,7 @@ 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', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $transport->send(request: Request::get(url: 'https://api.example.com/dragons')); /** @Then the PSR-7 request body is empty */ self::assertNotNull($client->captured); @@ -80,13 +68,8 @@ 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', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )->withMergedHeaders(defaults: new Headers(entries: ['X-Correlation-ID' => 'abc-123'])) + request: Request::get(url: 'https://api.example.com/dragons') + ->withMergedHeaders(defaults: Headers::fromArray(entries: ['X-Correlation-ID' => 'abc-123'])) ); /** @Then the PSR-7 request carries the custom header */ @@ -106,13 +89,7 @@ public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFai $this->expectException(HttpNetworkFailed::class); /** @When sending the request */ - $transport->send(request: Request::create( - url: 'https://api.example.com/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $transport->send(request: Request::get(url: 'https://api.example.com/dragons')); } public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInvalid(): void @@ -127,13 +104,7 @@ public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInv $this->expectException(HttpRequestInvalid::class); /** @When sending the request */ - $transport->send(request: Request::create( - url: 'https://api.example.com/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $transport->send(request: Request::get(url: 'https://api.example.com/dragons')); } public function testSendWhenClientRaisesGenericClientExceptionThenThrowsHttpRequestFailed(): void @@ -148,13 +119,7 @@ public function testSendWhenClientRaisesGenericClientExceptionThenThrowsHttpRequ $this->expectException(HttpRequestFailed::class); /** @When sending the request */ - $transport->send(request: Request::create( - url: 'https://api.example.com/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $transport->send(request: Request::get(url: 'https://api.example.com/dragons')); } public function testSendWhenClientRaisesRequestExceptionThenExceptionMessageDescribesInvalidRequest(): void @@ -167,13 +132,7 @@ public function testSendWhenClientRaisesRequestExceptionThenExceptionMessageDesc 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() - )); + $transport->send(request: Request::post(url: 'https://api.example.com/dragons')); self::fail('HttpRequestInvalid was expected.'); } catch (HttpRequestInvalid $exception) { /** @Then the message names the method, the URL, and the client-supplied reason */ @@ -193,13 +152,7 @@ public function testSendWhenClientRaisesGenericClientExceptionThenExceptionMessa 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() - )); + $transport->send(request: Request::delete(url: 'https://api.example.com/dragons')); self::fail('HttpRequestFailed was expected.'); } catch (HttpRequestFailed $exception) { /** @Then the message names the method, the URL, and the client-supplied reason */ @@ -216,13 +169,7 @@ 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', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $response = $transport->send(request: Request::get(url: 'https://api.example.com/dragons')); /** @Then the response code is correct */ self::assertSame(Code::OK, $response->code()); @@ -236,13 +183,7 @@ public function testSendWhenBodyHasInvalidUtf8ThenSubstitutesAndStillSends(): vo /** @When sending a request whose body contains a non-UTF-8 byte sequence */ $transport->send( - request: Request::create( - url: 'https://api.example.com/dragons', - body: ['value' => "\xB0\xB1\xB2"], - query: null, - method: Method::POST, - headers: Headers::from() - ) + request: Request::post(url: 'https://api.example.com/dragons', body: ['value' => "\xB0\xB1\xB2"]) ); /** @Then the PSR-7 request body carries the JSON-escaped replacement character */ diff --git a/tests/Unit/CodeTest.php b/tests/Unit/CodeTest.php index 5b8e713..9b4b7ab 100644 --- a/tests/Unit/CodeTest.php +++ b/tests/Unit/CodeTest.php @@ -102,6 +102,162 @@ public function testIsSuccessWhenCodeInternalServerErrorGivenThenReturnsFalse(): self::assertFalse($actual); } + public function testIsInformationalWhenCodeContinueGivenThenReturnsTrue(): void + { + /** @Given Code::CONTINUE */ + $code = Code::CONTINUE; + + /** @When invoking isInformational */ + $actual = $code->isInformational(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsInformationalWhenCodeEarlyHintsGivenThenReturnsTrue(): void + { + /** @Given Code::EARLY_HINTS */ + $code = Code::EARLY_HINTS; + + /** @When invoking isInformational */ + $actual = $code->isInformational(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsInformationalWhenCodeOkGivenThenReturnsFalse(): void + { + /** @Given Code::OK */ + $code = Code::OK; + + /** @When invoking isInformational */ + $actual = $code->isInformational(); + + /** @Then the result is false */ + self::assertFalse($actual); + } + + public function testIsRedirectionWhenCodeMovedPermanentlyGivenThenReturnsTrue(): void + { + /** @Given Code::MOVED_PERMANENTLY */ + $code = Code::MOVED_PERMANENTLY; + + /** @When invoking isRedirection */ + $actual = $code->isRedirection(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsRedirectionWhenCodeMultipleChoicesGivenThenReturnsTrue(): void + { + /** @Given Code::MULTIPLE_CHOICES */ + $code = Code::MULTIPLE_CHOICES; + + /** @When invoking isRedirection */ + $actual = $code->isRedirection(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsRedirectionWhenCodePermanentRedirectGivenThenReturnsTrue(): void + { + /** @Given Code::PERMANENT_REDIRECT */ + $code = Code::PERMANENT_REDIRECT; + + /** @When invoking isRedirection */ + $actual = $code->isRedirection(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsRedirectionWhenCodeOkGivenThenReturnsFalse(): void + { + /** @Given Code::OK */ + $code = Code::OK; + + /** @When invoking isRedirection */ + $actual = $code->isRedirection(); + + /** @Then the result is false */ + self::assertFalse($actual); + } + + public function testIsClientErrorWhenCodeBadRequestGivenThenReturnsTrue(): void + { + /** @Given Code::BAD_REQUEST */ + $code = Code::BAD_REQUEST; + + /** @When invoking isClientError */ + $actual = $code->isClientError(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsClientErrorWhenCodeUnavailableForLegalReasonsGivenThenReturnsTrue(): void + { + /** @Given Code::UNAVAILABLE_FOR_LEGAL_REASONS */ + $code = Code::UNAVAILABLE_FOR_LEGAL_REASONS; + + /** @When invoking isClientError */ + $actual = $code->isClientError(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsClientErrorWhenCodeInternalServerErrorGivenThenReturnsFalse(): void + { + /** @Given Code::INTERNAL_SERVER_ERROR */ + $code = Code::INTERNAL_SERVER_ERROR; + + /** @When invoking isClientError */ + $actual = $code->isClientError(); + + /** @Then the result is false */ + self::assertFalse($actual); + } + + public function testIsServerErrorWhenCodeInternalServerErrorGivenThenReturnsTrue(): void + { + /** @Given Code::INTERNAL_SERVER_ERROR */ + $code = Code::INTERNAL_SERVER_ERROR; + + /** @When invoking isServerError */ + $actual = $code->isServerError(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsServerErrorWhenCodeNetworkAuthenticationRequiredGivenThenReturnsTrue(): void + { + /** @Given Code::NETWORK_AUTHENTICATION_REQUIRED */ + $code = Code::NETWORK_AUTHENTICATION_REQUIRED; + + /** @When invoking isServerError */ + $actual = $code->isServerError(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + public function testIsServerErrorWhenCodeBadRequestGivenThenReturnsFalse(): void + { + /** @Given Code::BAD_REQUEST */ + $code = Code::BAD_REQUEST; + + /** @When invoking isServerError */ + $actual = $code->isServerError(); + + /** @Then the result is false */ + self::assertFalse($actual); + } + public static function messagesDataProvider(): array { return [ diff --git a/tests/Unit/CookieTest.php b/tests/Unit/CookieTest.php index 3bdd14e..4b08a01 100644 --- a/tests/Unit/CookieTest.php +++ b/tests/Unit/CookieTest.php @@ -6,7 +6,6 @@ use DateTimeImmutable; use DateTimeZone; -use DomainException; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -48,7 +47,7 @@ public function testCreateWhenAllAttributesAppliedThenSerializesInCanonicalOrder self::assertSame(['Set-Cookie' => [$expected]], $actual); } - public function testExpireWhenInvokedThenEmitsEmptyValueAndMaxAgeZero(): void + public function testExpireWhenInvokedThenEmitsEmptyValueMaxAgeZeroAndExpiresEpoch(): void { /** @Given a cookie deletion bound to the path used when the cookie was issued */ $cookie = Cookie::expire(name: 'refresh_token')->withPath(path: '/v1/sessions'); @@ -56,8 +55,9 @@ public function testExpireWhenInvokedThenEmitsEmptyValueAndMaxAgeZero(): void /** @When the header is serialized */ $actual = $cookie->toArray(); - /** @Then the header instructs the browser to discard the cookie */ - self::assertSame(['Set-Cookie' => ['refresh_token=; Max-Age=0; Path=/v1/sessions']], $actual); + /** @Then the header instructs the browser to discard the cookie via both Max-Age and Expires */ + $expected = 'refresh_token=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/v1/sessions'; + self::assertSame(['Set-Cookie' => [$expected]], $actual); } public function testWithValueWhenInvokedThenLeavesOriginalUntouched(): void @@ -125,25 +125,24 @@ public function testSecureWhenInvokedThenReturnsNewInstanceWithFlag(): void self::assertSame(['Set-Cookie' => ['session=abc; Secure']], $secured->toArray()); } - public function testToArrayWhenSameSiteNoneWithoutSecureGivenThenThrows(): void + public function testWithSameSiteWhenNoneGivenThenAutomaticallyEnablesSecure(): void { - /** @Given a cookie set to SameSite=None without the Secure flag */ - $cookie = Cookie::create(name: 'session', value: 'abc')->withSameSite(sameSite: SameSite::NONE); + /** @Given a cookie without the Secure flag */ + $cookie = Cookie::create(name: 'session', value: 'abc'); - /** @Then an exception indicating the missing Secure flag is thrown */ - $this->expectException(DomainException::class); - $this->expectExceptionMessage('SameSite=None require the Secure flag'); + /** @When SameSite=None is set */ + $updated = $cookie->withSameSite(sameSite: SameSite::NONE); - /** @When the header is serialized */ - $cookie->toArray(); + /** @Then both Secure and SameSite=None are present */ + self::assertSame(['Set-Cookie' => ['session=abc; Secure; SameSite=None']], $updated->toArray()); } - public function testToArrayWhenSameSiteNoneWithSecureGivenThenSerializesBothAttributes(): void + public function testWithSameSiteWhenNoneGivenOnAlreadySecureCookieThenSerializesBothAttributes(): void { /** @Given a cookie with SameSite=None combined with Secure */ $cookie = Cookie::create(name: 'session', value: 'abc') - ->withSameSite(sameSite: SameSite::NONE) - ->secure(); + ->secure() + ->withSameSite(sameSite: SameSite::NONE); /** @When the header is serialized */ $actual = $cookie->toArray(); @@ -152,19 +151,29 @@ public function testToArrayWhenSameSiteNoneWithSecureGivenThenSerializesBothAttr self::assertSame(['Set-Cookie' => ['session=abc; Secure; SameSite=None']], $actual); } - public function testToArrayWhenBothMaxAgeAndExpiresGivenThenThrows(): void + public function testWithMaxAgeWhenInvokedAfterWithExpiresThenOnlyMaxAgeIsEmitted(): void { - /** @Given a cookie with both Max-Age and Expires assigned */ + /** @Given a cookie with an Expires attribute */ $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 is thrown */ - $this->expectException(DomainException::class); - $this->expectExceptionMessage('Cookie lifetime attributes are conflicting'); + /** @When Max-Age is set afterwards */ + $updated = $cookie->withMaxAge(seconds: 3600); - /** @When the header is serialized */ - $cookie->toArray(); + /** @Then only Max-Age is emitted; Expires is cleared */ + self::assertSame(['Set-Cookie' => ['session=abc; Max-Age=3600']], $updated->toArray()); + } + + public function testWithExpiresWhenInvokedAfterWithMaxAgeThenOnlyExpiresIsEmitted(): void + { + /** @Given a cookie with a Max-Age attribute */ + $cookie = Cookie::create(name: 'session', value: 'abc')->withMaxAge(seconds: 3600); + + /** @When Expires is set afterwards */ + $updated = $cookie->withExpires(expires: new DateTimeImmutable('2030-01-15 12:00:00 UTC')); + + /** @Then only Expires is emitted; Max-Age is cleared */ + self::assertSame(['Set-Cookie' => ['session=abc; Expires=Tue, 15 Jan 2030 12:00:00 GMT']], $updated->toArray()); } public function testCreateWhenEmptyValueGivenThenRendersEmpty(): void @@ -237,6 +246,34 @@ public function testCreateWhenInvalidValueGivenThenThrows(string $value): void Cookie::create(name: 'session', value: $value); } + #[DataProvider('invalidDomainProvider')] + public function testWithDomainWhenInvalidDomainGivenThenThrows(string $domain): void + { + /** @Given a valid cookie */ + $cookie = Cookie::create(name: 'session', value: 'abc'); + + /** @Then an exception indicating the domain is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is invalid'); + + /** @When setting the domain to an invalid value */ + $cookie->withDomain(domain: $domain); + } + + #[DataProvider('invalidPathProvider')] + public function testWithPathWhenInvalidPathGivenThenThrows(string $path): void + { + /** @Given a valid cookie */ + $cookie = Cookie::create(name: 'session', value: 'abc'); + + /** @Then an exception indicating the path is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is invalid'); + + /** @When setting the path to an invalid value */ + $cookie->withPath(path: $path); + } + public static function invalidNameProvider(): array { return [ @@ -263,4 +300,26 @@ public static function invalidValueProvider(): array 'Value with control character' => ["abc\x00def"] ]; } + + public static function invalidDomainProvider(): array + { + return [ + 'Domain with space' => ['example .com'], + 'Domain with tab' => ["example\tcom"], + 'Domain with semicolon' => ['example.com;evil'], + 'Domain with comma' => ['example.com,evil'], + 'Domain with double quote' => ['"example.com"'], + 'Domain with backslash' => ['example\\com'], + 'Domain with control char' => ["example\x00.com"] + ]; + } + + public static function invalidPathProvider(): array + { + return [ + 'Path with semicolon' => ['/api;evil'], + 'Path with comma' => ['/api,evil'], + 'Path with control char' => ["/api\x00evil"] + ]; + } } diff --git a/tests/Unit/HeadersTest.php b/tests/Unit/HeadersTest.php index 8f53d6c..42510c6 100644 --- a/tests/Unit/HeadersTest.php +++ b/tests/Unit/HeadersTest.php @@ -4,7 +4,9 @@ namespace Test\TinyBlocks\Http\Unit; +use InvalidArgumentException; use Nyholm\Psr7\Factory\Psr17Factory; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\Charset; use TinyBlocks\Http\ContentType; @@ -18,8 +20,8 @@ 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: $entries); + /** @When creating Headers from an array */ + $headers = Headers::fromArray(entries: $entries); /** @Then the entries are accessible */ self::assertSame('application/json', $headers->get('Content-Type')); @@ -80,7 +82,7 @@ public function testFromMessageWhenMultiValueHeaderGivenThenFoldsWithComma(): vo public function testApplyToWhenEmptyHeadersGivenThenReturnsMessageUnchanged(): void { /** @Given an empty Headers instance */ - $headers = new Headers(entries: []); + $headers = Headers::fromArray(entries: []); /** @And a PSR-7 request */ $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); @@ -95,7 +97,7 @@ public function testApplyToWhenEmptyHeadersGivenThenReturnsMessageUnchanged(): v public function testApplyToWhenEntriesGivenThenAttachesHeaders(): void { /** @Given a Headers instance with one entry */ - $headers = new Headers(entries: ['X-Trace' => 'abc']); + $headers = Headers::fromArray(entries: ['X-Trace' => 'abc']); /** @And a PSR-7 request */ $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); @@ -110,7 +112,7 @@ public function testApplyToWhenEntriesGivenThenAttachesHeaders(): void public function testApplyToWhenEntriesGivenThenLeavesOriginalUnchanged(): void { /** @Given a Headers instance with one entry */ - $headers = new Headers(entries: ['X-Trace' => 'abc']); + $headers = Headers::fromArray(entries: ['X-Trace' => 'abc']); /** @And a PSR-7 request */ $psrRequest = new Psr17Factory()->createRequest('GET', 'https://api.example.com'); @@ -125,7 +127,7 @@ public function testApplyToWhenEntriesGivenThenLeavesOriginalUnchanged(): void public function testGetWhenMixedCaseKeyGivenThenLookupIsCaseInsensitive(): void { /** @Given headers with a mixed-case key */ - $headers = new Headers(entries: ['Content-Type' => 'application/json']); + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); /** @When looking up with different casing */ /** @Then the lookup succeeds */ @@ -137,7 +139,7 @@ public function testGetWhenMixedCaseKeyGivenThenLookupIsCaseInsensitive(): void public function testGetWhenMissingKeyGivenThenReturnsNull(): void { /** @Given headers with one entry */ - $headers = new Headers(entries: ['Content-Type' => 'application/json']); + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); /** @When looking up a non-existent header */ /** @Then null is returned */ @@ -147,7 +149,7 @@ public function testGetWhenMissingKeyGivenThenReturnsNull(): void public function testHasWhenMixedCaseKeyGivenThenIsCaseInsensitive(): void { /** @Given headers with a mixed-case key */ - $headers = new Headers(entries: ['X-Trace' => 'abc']); + $headers = Headers::fromArray(entries: ['X-Trace' => 'abc']); /** @When checking existence with different casing */ /** @Then has() returns true regardless of case */ @@ -159,7 +161,7 @@ public function testHasWhenMixedCaseKeyGivenThenIsCaseInsensitive(): void public function testHasWhenMissingKeyGivenThenReturnsFalse(): void { /** @Given empty headers */ - $headers = new Headers(entries: []); + $headers = Headers::fromArray(entries: []); /** @When checking for a non-existent header */ /** @Then has() returns false */ @@ -169,10 +171,10 @@ public function testHasWhenMissingKeyGivenThenReturnsFalse(): void public function testMergedWithWhenOtherHasNewEntriesThenBothAppearInResult(): void { /** @Given headers with one entry */ - $headers = new Headers(entries: ['Accept' => 'application/json']); + $headers = Headers::fromArray(entries: ['Accept' => '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'])); + $merged = $headers->mergedWith(other: Headers::fromArray(entries: ['Content-Type' => 'application/json'])); /** @Then both entries are present */ self::assertSame('application/json', $merged->get('Accept')); @@ -182,10 +184,10 @@ public function testMergedWithWhenOtherHasNewEntriesThenBothAppearInResult(): vo public function testMergedWithWhenOtherCollidesThenExistingEntryWins(): void { /** @Given headers with a Content-Type entry */ - $headers = new Headers(entries: ['Content-Type' => 'application/json; charset=utf-8']); + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json; charset=utf-8']); /** @When merging with a Headers carrying a default Content-Type */ - $merged = $headers->mergedWith(other: new Headers(entries: ['Content-Type' => 'application/json'])); + $merged = $headers->mergedWith(other: Headers::fromArray(entries: ['Content-Type' => 'application/json'])); /** @Then the existing header wins */ self::assertSame('application/json; charset=utf-8', $merged->get('Content-Type')); @@ -197,10 +199,10 @@ public function testMergedWithWhenOtherCollidesThenExistingEntryWins(): void public function testMergedWithWhenCasingDiffersThenStillTreatsAsCollision(): void { /** @Given headers with a lowercase key */ - $headers = new Headers(entries: ['content-type' => 'application/json; charset=utf-8']); + $headers = Headers::fromArray(entries: ['content-type' => 'application/json; charset=utf-8']); /** @When merging with a Headers using mixed casing */ - $merged = $headers->mergedWith(other: new Headers(entries: ['Content-Type' => 'application/json'])); + $merged = $headers->mergedWith(other: Headers::fromArray(entries: ['Content-Type' => 'application/json'])); /** @Then the existing header wins despite different casing */ self::assertSame('application/json; charset=utf-8', $merged->get('content-type')); @@ -209,10 +211,81 @@ public function testMergedWithWhenCasingDiffersThenStillTreatsAsCollision(): voi self::assertCount(1, $merged->toArray()); } + public function testWithWhenNewNameGivenThenAppendsHeader(): void + { + /** @Given headers with one entry */ + $headers = Headers::fromArray(entries: ['Accept' => 'application/json']); + + /** @When adding a header with a name not already present */ + $updated = $headers->with(name: 'X-Trace-Id', value: 'abc-123'); + + /** @Then the new header is appended and the original is preserved */ + self::assertSame('application/json', $updated->get('Accept')); + self::assertSame('abc-123', $updated->get('X-Trace-Id')); + + /** @And the original instance is unchanged */ + self::assertNull($headers->get('X-Trace-Id')); + } + + public function testWithWhenExistingNameGivenThenReplacesEntry(): void + { + /** @Given headers with a Content-Type entry */ + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); + + /** @When replacing the Content-Type value */ + $updated = $headers->with(name: 'Content-Type', value: 'text/plain'); + + /** @Then the value is replaced on the updated instance */ + self::assertSame('text/plain', $updated->get('Content-Type')); + + /** @And the original instance retains its original value */ + self::assertSame('application/json', $headers->get('Content-Type')); + } + + public function testWithWhenCasingDiffersThenReplacesExistingEntry(): void + { + /** @Given headers with a Content-Type entry stored under mixed case */ + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); + + /** @When replacing using a different casing */ + $updated = $headers->with(name: 'content-type', value: 'text/plain'); + + /** @Then only one Content-Type entry exists and it carries the new value */ + self::assertSame('text/plain', $updated->get('Content-Type')); + self::assertCount(1, $updated->toArray()); + } + + public function testWithWhenUpperCaseNameGivenThenReplacesExistingEntry(): void + { + /** @Given headers with a Content-Type entry stored under mixed case */ + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json']); + + /** @When replacing using an entirely uppercase name */ + $updated = $headers->with(name: 'CONTENT-TYPE', value: 'text/plain'); + + /** @Then only one Content-Type entry exists and it carries the new value */ + self::assertSame('text/plain', $updated->get('Content-Type')); + self::assertCount(1, $updated->toArray()); + } + + public function testWithWhenMultipleHeadersExistThenOtherHeadersArePreserved(): void + { + /** @Given headers with two entries */ + $headers = Headers::fromArray(entries: ['Content-Type' => 'application/json', 'Accept' => 'application/json']); + + /** @When replacing the Content-Type header */ + $updated = $headers->with(name: 'Content-Type', value: 'text/plain'); + + /** @Then the replaced entry is updated and the other entry is unchanged */ + self::assertSame('text/plain', $updated->get('Content-Type')); + self::assertSame('application/json', $updated->get('Accept')); + self::assertCount(2, $updated->toArray()); + } + public function testToArrayWhenMultipleEntriesGivenThenReturnsAll(): void { /** @Given headers with two entries */ - $headers = new Headers(entries: ['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(); @@ -222,4 +295,122 @@ public function testToArrayWhenMultipleEntriesGivenThenReturnsAll(): void self::assertSame('123', $array['X-Request-ID']); self::assertCount(2, $array); } + + #[DataProvider('validHeaderNameProvider')] + public function testFromArrayWhenValidHeaderNameGivenThenAccepts(string $name): void + { + /** @Given a valid header name */ + + /** @When creating Headers from an array with that name */ + $headers = Headers::fromArray(entries: [$name => 'value']); + + /** @Then the header is present */ + self::assertTrue($headers->has($name)); + } + + #[DataProvider('invalidHeaderNameProvider')] + public function testFromArrayWhenInvalidHeaderNameGivenThenThrows(string $name): void + { + /** @Given an invalid header name */ + + /** @Then an exception indicating the name is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is invalid'); + + /** @When creating Headers from an array with that name */ + Headers::fromArray(entries: [$name => 'value']); + } + + #[DataProvider('validHeaderValueProvider')] + public function testFromArrayWhenValidHeaderValueGivenThenAccepts(string $value): void + { + /** @Given a valid header value */ + + /** @When creating Headers from an array with that value */ + $headers = Headers::fromArray(entries: ['X-Custom' => $value]); + + /** @Then the header value is present */ + self::assertSame($value, $headers->get('X-Custom')); + } + + #[DataProvider('invalidHeaderValueProvider')] + public function testFromArrayWhenInvalidHeaderValueGivenThenThrows(string $value): void + { + /** @Given an invalid header value */ + + /** @Then an exception indicating the value is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is invalid'); + + /** @When creating Headers from an array with that value */ + Headers::fromArray(entries: ['X-Custom' => $value]); + } + + #[DataProvider('invalidHeaderNameProvider')] + public function testWithWhenInvalidHeaderNameGivenThenThrows(string $name): void + { + /** @Given a valid Headers instance */ + $headers = Headers::fromArray(entries: []); + + /** @Then an exception indicating the name is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + + /** @When adding a header with an invalid name */ + $headers->with(name: $name, value: 'value'); + } + + #[DataProvider('invalidHeaderValueProvider')] + public function testWithWhenInvalidHeaderValueGivenThenThrows(string $value): void + { + /** @Given a valid Headers instance */ + $headers = Headers::fromArray(entries: []); + + /** @Then an exception indicating the value is invalid is thrown */ + $this->expectException(InvalidArgumentException::class); + + /** @When adding a header with an invalid value */ + $headers->with(name: 'X-Custom', value: $value); + } + + public static function validHeaderNameProvider(): array + { + return [ + 'Content-Type' => ['Content-Type'], + 'X-Trace-Id' => ['X-Trace-Id'], + 'Accept' => ['Accept'], + 'Authorization' => ['Authorization'] + ]; + } + + public static function invalidHeaderNameProvider(): array + { + return [ + 'Empty name' => [''], + 'Name with space' => ['foo bar'], + 'Name with colon' => ['foo:bar'], + 'Name with CR' => ["foo\r"], + 'Name with CRLF' => ["foo\r\nbar"], + 'Name with tab' => ["foo\tbar"], + 'Name with null byte' => ["foo\x00bar"] + ]; + } + + public static function validHeaderValueProvider(): array + { + return [ + 'application/json' => ['application/json'], + 'text/plain with charset' => ['text/plain; charset=utf-8'], + 'Value with horizontal tab' => ["has\ttab"] + ]; + } + + public static function invalidHeaderValueProvider(): array + { + return [ + 'Value with LF' => ["foo\nbar"], + 'Value with CR' => ["foo\rbar"], + 'CRLF injection' => ["foo\r\nbar: injected"], + 'Value with null byte' => ["foo\x00bar"] + ]; + } } diff --git a/tests/Unit/HttpBuilderTest.php b/tests/Unit/HttpBuilderTest.php index 69e2c4b..50e436d 100644 --- a/tests/Unit/HttpBuilderTest.php +++ b/tests/Unit/HttpBuilderTest.php @@ -11,11 +11,10 @@ use TinyBlocks\Http\Client\Transports\InMemoryTransport; use TinyBlocks\Http\Client\Transports\NetworkTransport; use TinyBlocks\Http\Code; +use TinyBlocks\Http\Exceptions\BaseUrlIsInvalid; 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 { @@ -134,13 +133,7 @@ public function testBuildWhenFullyConfiguredThenProducesWorkingHttp(): void ->build(); /** @When sending a request */ - $response = $http->send(request: Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $response = $http->send(request: Request::get(url: '/dragons')); /** @Then the response is returned correctly */ self::assertSame(Code::OK, $response->code()); @@ -155,15 +148,118 @@ public function testWithWhenInvokedDirectlyThenReturnsWorkingHttp(): void $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() - ); + $request = Request::get(url: '/dragons'); /** @Then the instance can send requests and returns the correct response */ self::assertSame(Code::OK, $http->send(request: $request)->code()); } + + public function testWithBaseUrlWhenJavascriptSchemeGivenThenThrowsBaseUrlIsInvalid(): void + { + /** @Given an empty builder */ + $builder = Http::create(); + + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + $this->expectExceptionMessage('Base URL is invalid'); + + /** @When setting a javascript: scheme base URL */ + $builder->withBaseUrl(url: 'javascript:alert(1)'); + } + + public function testWithBaseUrlWhenFtpSchemeGivenThenThrowsBaseUrlIsInvalid(): void + { + /** @Given an empty builder */ + $builder = Http::create(); + + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + + /** @When setting an ftp:// base URL */ + $builder->withBaseUrl(url: 'ftp://example.com'); + } + + public function testWithBaseUrlWhenProtocolRelativeGivenThenThrowsBaseUrlIsInvalid(): void + { + /** @Given an empty builder */ + $builder = Http::create(); + + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + + /** @When setting a protocol-relative base URL */ + $builder->withBaseUrl(url: '//host'); + } + + public function testWithBaseUrlWhenControlCharGivenThenThrowsBaseUrlIsInvalid(): void + { + /** @Given an empty builder */ + $builder = Http::create(); + + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + + /** @When setting a base URL containing a control character */ + $builder->withBaseUrl(url: "https://api.example.com\x00"); + } + + public function testWithBaseUrlWhenHttpsGivenThenAccepts(): void + { + /** @Given an empty builder */ + $builder = Http::create(); + + /** @When setting a valid https:// base URL */ + $updated = $builder->withBaseUrl(url: 'https://api.example.com'); + + /** @Then a new builder instance is returned without throwing */ + self::assertNotSame($builder, $updated); + } + + public function testWithBaseUrlWhenHttpGivenThenAccepts(): void + { + /** @Given an empty builder */ + $builder = Http::create(); + + /** @When setting a valid http:// base URL */ + $updated = $builder->withBaseUrl(url: 'http://localhost:8080'); + + /** @Then a new builder instance is returned without throwing */ + self::assertNotSame($builder, $updated); + } + + public function testWithBaseUrlWhenEmptyStringGivenThenAccepts(): void + { + /** @Given an empty builder */ + $builder = Http::create(); + + /** @When setting an empty base URL */ + $updated = $builder->withBaseUrl(url: ''); + + /** @Then a new builder instance is returned without throwing */ + self::assertNotSame($builder, $updated); + } + + public function testWithBaseUrlWhenUppercaseHttpsGivenThenAccepts(): void + { + /** @Given an empty builder */ + $builder = Http::create(); + + /** @When setting a base URL with uppercase scheme */ + $updated = $builder->withBaseUrl(url: 'HTTPS://api.example.com'); + + /** @Then a new builder instance is returned without throwing */ + self::assertNotSame($builder, $updated); + } + + public function testWithBaseUrlWhenSchemeEmbeddedInPathGivenThenThrowsBaseUrlIsInvalid(): void + { + /** @Given an empty builder */ + $builder = Http::create(); + + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + + /** @When setting a base URL with the scheme embedded mid-string */ + $builder->withBaseUrl(url: 'example.com?redirect=https://api.example.com'); + } } diff --git a/tests/Unit/HttpTest.php b/tests/Unit/HttpTest.php index a22022f..3429842 100644 --- a/tests/Unit/HttpTest.php +++ b/tests/Unit/HttpTest.php @@ -10,11 +10,11 @@ use TinyBlocks\Http\Client\Request; use TinyBlocks\Http\Client\Transports\NetworkTransport; use TinyBlocks\Http\Code; +use TinyBlocks\Http\Exceptions\BaseUrlIsInvalid; use TinyBlocks\Http\Exceptions\HttpNetworkFailed; 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; @@ -39,13 +39,7 @@ public function testSendWhenTransportRespondsThenReturnsResponseWithMatchingCode ->build(); /** @When sending a valid request */ - $response = $http->send(request: Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $response = $http->send(request: Request::get(url: '/dragons')); /** @Then the response code is correct */ self::assertSame(Code::OK, $response->code()); @@ -63,13 +57,7 @@ public function testSendWhenBaseUrlEndsWithSlashAndPathLeadsWithSlashThenNoDoubl ->build(); /** @When sending a request whose path starts with a slash */ - $response = $http->send(request: Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $response = $http->send(request: Request::get(url: '/dragons')); /** @Then the response is returned without double slash in the URL */ self::assertSame(Code::OK, $response->code()); @@ -88,13 +76,7 @@ public function testSendWhenQueryGivenThenAppendsAsRfc3986(): void /** @When sending a request with query parameters */ $response = $http->send( - request: Request::create( - url: '/dragons', - body: null, - query: ['sort' => 'name', 'order' => 'asc'], - method: Method::GET, - headers: Headers::from() - ) + request: Request::get(url: '/dragons', queryParameters: ['sort' => 'name', 'order' => 'asc']) ); /** @Then the response code is correct */ @@ -114,13 +96,7 @@ public function testSendWhenBodyGivenThenSendsJsonPayload(): void /** @When sending a request with a JSON body */ $response = $http->send( - request: Request::create( - url: '/dragons', - body: ['name' => 'Hydra'], - query: null, - method: Method::POST, - headers: Headers::from() - ) + request: Request::post(url: '/dragons', body: ['name' => 'Hydra']) ); /** @Then the response code is correct */ @@ -142,13 +118,7 @@ public function testSendWhenClientRaisesNetworkExceptionThenThrowsHttpNetworkFai $this->expectException(HttpNetworkFailed::class); /** @When sending the request */ - $http->send(request: Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $http->send(request: Request::get(url: '/dragons')); } public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInvalid(): void @@ -166,13 +136,7 @@ public function testSendWhenClientRaisesRequestExceptionThenThrowsHttpRequestInv $this->expectException(HttpRequestInvalid::class); /** @When sending the request */ - $http->send(request: Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $http->send(request: Request::get(url: '/dragons')); } public function testSendWhenGenericClientExceptionRaisedThenThrowsHttpRequestFailed(): void @@ -190,13 +154,7 @@ public function testSendWhenGenericClientExceptionRaisedThenThrowsHttpRequestFai $this->expectException(HttpRequestFailed::class); /** @When sending the request */ - $http->send(request: Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $http->send(request: Request::get(url: '/dragons')); } public function testSendWhenProtocolRelativePathGivenThenThrowsMalformedPath(): void @@ -214,13 +172,7 @@ 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', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $http->send(request: Request::get(url: '//evil.example.com/attack')); } public function testSendWhenSchemePathGivenThenThrowsMalformedPath(): void @@ -238,13 +190,7 @@ 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)', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $http->send(request: Request::get(url: 'javascript:alert(1)')); } public function testSendWhenControlCharsInPathGivenThenThrowsMalformedPath(): void @@ -262,13 +208,7 @@ 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", - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $http->send(request: Request::get(url: "/dragons\x00/evil")); } public function testSendWhenNetworkExceptionRaisedThenPreservesPreviousChain(): void @@ -287,13 +227,7 @@ public function testSendWhenNetworkExceptionRaisedThenPreservesPreviousChain(): /** @When sending the request */ try { - $http->send(request: Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - )); + $http->send(request: Request::get(url: '/dragons')); self::fail('HttpNetworkFailed was expected.'); } catch (HttpNetworkFailed $exception) { /** @Then the previous exception is preserved in the chain */ @@ -313,13 +247,7 @@ public function testSendWhenSchemePathGivenThenChainsPathContainsSchemeAsPreviou ->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() - ); + $request = Request::get(url: 'https://attacker.com/steal'); /** @When sending the request */ try { @@ -346,13 +274,7 @@ public function testSendWhenSchemePathGivenThenMalformedPathExposesOffendingPath ->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() - ); + $request = Request::get(url: 'https://attacker.com/steal'); try { /** @When sending the request */ @@ -375,13 +297,7 @@ public function testSendWhenControlCharPathGivenThenChainsPathContainsControlCha ->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() - ); + $request = Request::get(url: "/dragons\x00/evil"); /** @When sending the request */ try { @@ -406,13 +322,7 @@ public function testSendWhenBaseUrlEmptyAndRelativePathGivenThenUsesPathDirectly )); /** @And a request with a relative path */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: '/dragons'); /** @When sending the request */ $http->send(request: $request); @@ -432,13 +342,7 @@ public function testSendWhenBaseUrlEndsWithSlashAndPathLeadsWithSlashThenSingleS )); /** @And a request whose path starts with a slash */ - $request = Request::create( - url: '/dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: '/dragons'); /** @When sending the request */ $http->send(request: $request); @@ -458,13 +362,7 @@ public function testSendWhenBaseUrlWithoutTrailingSlashAndPathWithoutLeadingSlas )); /** @And a request whose path lacks a leading slash */ - $request = Request::create( - url: 'dragons', - body: null, - query: null, - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: 'dragons'); /** @When sending the request */ $http->send(request: $request); @@ -484,13 +382,7 @@ public function testSendWhenQueryProvidedThenAppendsAsQueryString(): void )); /** @And a request with query parameters */ - $request = Request::create( - url: '/dragons', - body: null, - query: ['sort' => 'name'], - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: '/dragons', queryParameters: ['sort' => 'name']); /** @When sending the request */ $http->send(request: $request); @@ -513,13 +405,7 @@ public function testSendWhenCustomTransportRaisesNetworkFailureThenExceptionCarr 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() - )); + $http->send(request: Request::head(url: '/dragons')); self::fail('HttpNetworkFailed was expected.'); } catch (HttpNetworkFailed $exception) { /** @Then the exception carries the originating URL, method, and reason */ @@ -542,13 +428,7 @@ public function testSendWhenCustomTransportRaisesRequestInvalidThenExceptionCarr 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() - )); + $http->send(request: Request::patch(url: '/dragons')); self::fail('HttpRequestInvalid was expected.'); } catch (HttpRequestInvalid $exception) { /** @Then the exception carries the originating URL, method, and reason */ @@ -571,13 +451,7 @@ public function testSendWhenCustomTransportRaisesRequestFailureThenExceptionCarr 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() - )); + $http->send(request: Request::put(url: '/dragons')); self::fail('HttpRequestFailed was expected.'); } catch (HttpRequestFailed $exception) { /** @Then the exception carries the originating URL, method, and reason */ @@ -597,13 +471,7 @@ public function testSendWhenEmptyQueryArrayGivenThenNoTrailingQuestionMark(): vo )); /** @And a request with an empty query array */ - $request = Request::create( - url: '/dragons', - body: null, - query: [], - method: Method::GET, - headers: Headers::from() - ); + $request = Request::get(url: '/dragons', queryParameters: []); /** @When sending the request */ $http->send(request: $request); @@ -612,4 +480,109 @@ public function testSendWhenEmptyQueryArrayGivenThenNoTrailingQuestionMark(): vo self::assertNotNull($client->captured); self::assertSame('https://api.example.com/dragons', (string)$client->captured->getUri()); } + + public function testWithWhenJavascriptSchemeGivenThenThrowsBaseUrlIsInvalid(): void + { + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + ); + + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + + /** @When constructing Http with a javascript: scheme base URL */ + Http::with(baseUrl: 'javascript:alert(1)', transport: $transport); + } + + public function testWithWhenFtpSchemeGivenThenThrowsBaseUrlIsInvalid(): void + { + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + ); + + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + + /** @When constructing Http with an ftp:// base URL */ + Http::with(baseUrl: 'ftp://example.com', transport: $transport); + } + + public function testWithWhenProtocolRelativeGivenThenThrowsBaseUrlIsInvalid(): void + { + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + ); + + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + + /** @When constructing Http with a protocol-relative base URL */ + Http::with(baseUrl: '//host', transport: $transport); + } + + public function testWithWhenControlCharGivenThenThrowsBaseUrlIsInvalid(): void + { + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + ); + + /** @Then an exception indicating the base URL is invalid is thrown */ + $this->expectException(BaseUrlIsInvalid::class); + + /** @When constructing Http with a base URL containing a control character */ + Http::with(baseUrl: "https://api.example.com\x00", transport: $transport); + } + + public function testWithWhenHttpsGivenThenAcceptsWithoutThrowing(): void + { + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + ); + + /** @When constructing Http with a valid https:// base URL */ + $http = Http::with(baseUrl: 'https://api.example.com', transport: $transport); + + /** @Then an Http instance is returned without throwing */ + self::assertInstanceOf(Http::class, $http); + } + + public function testWithWhenHttpGivenThenAcceptsWithoutThrowing(): void + { + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + ); + + /** @When constructing Http with a valid http:// base URL */ + $http = Http::with(baseUrl: 'http://localhost:8080', transport: $transport); + + /** @Then an Http instance is returned without throwing */ + self::assertInstanceOf(Http::class, $http); + } + + public function testWithWhenEmptyStringGivenThenAcceptsWithoutThrowing(): void + { + /** @Given a transport seeded with a response */ + $transport = NetworkTransport::with( + client: CapturingClient::returningStatus(statusCode: 200), + factory: $this->factory + ); + + /** @When constructing Http with an empty base URL */ + $http = Http::with(baseUrl: '', transport: $transport); + + /** @Then an Http instance is returned without throwing */ + self::assertInstanceOf(Http::class, $http); + } } diff --git a/tests/Unit/MethodTest.php b/tests/Unit/MethodTest.php new file mode 100644 index 0000000..d6113da --- /dev/null +++ b/tests/Unit/MethodTest.php @@ -0,0 +1,102 @@ +isSafe(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + #[DataProvider('unsafeMethodCases')] + public function testIsSafeWhenUnsafeMethodGivenThenReturnsFalse(Method $method): void + { + /** @Given an unsafe HTTP method */ + + /** @When checking isSafe */ + $actual = $method->isSafe(); + + /** @Then the result is false */ + self::assertFalse($actual); + } + + #[DataProvider('idempotentMethodCases')] + public function testIsIdempotentWhenIdempotentMethodGivenThenReturnsTrue(Method $method): void + { + /** @Given an idempotent HTTP method */ + + /** @When checking isIdempotent */ + $actual = $method->isIdempotent(); + + /** @Then the result is true */ + self::assertTrue($actual); + } + + #[DataProvider('nonIdempotentMethodCases')] + public function testIsIdempotentWhenNonIdempotentMethodGivenThenReturnsFalse(Method $method): void + { + /** @Given a non-idempotent HTTP method */ + + /** @When checking isIdempotent */ + $actual = $method->isIdempotent(); + + /** @Then the result is false */ + self::assertFalse($actual); + } + + public static function safeMethodCases(): array + { + return [ + 'GET' => ['method' => Method::GET], + 'HEAD' => ['method' => Method::HEAD], + 'OPTIONS' => ['method' => Method::OPTIONS], + 'TRACE' => ['method' => Method::TRACE] + ]; + } + + public static function unsafeMethodCases(): array + { + return [ + 'POST' => ['method' => Method::POST], + 'PUT' => ['method' => Method::PUT], + 'PATCH' => ['method' => Method::PATCH], + 'DELETE' => ['method' => Method::DELETE], + 'CONNECT' => ['method' => Method::CONNECT] + ]; + } + + public static function idempotentMethodCases(): array + { + return [ + 'GET' => ['method' => Method::GET], + 'PUT' => ['method' => Method::PUT], + 'HEAD' => ['method' => Method::HEAD], + 'TRACE' => ['method' => Method::TRACE], + 'DELETE' => ['method' => Method::DELETE], + 'OPTIONS' => ['method' => Method::OPTIONS] + ]; + } + + public static function nonIdempotentMethodCases(): array + { + return [ + 'POST' => ['method' => Method::POST], + 'PATCH' => ['method' => Method::PATCH], + 'CONNECT' => ['method' => Method::CONNECT] + ]; + } +} diff --git a/tests/Unit/Server/ResponseTest.php b/tests/Unit/Server/ResponseTest.php index 32c31a1..be29935 100644 --- a/tests/Unit/Server/ResponseTest.php +++ b/tests/Unit/Server/ResponseTest.php @@ -4,7 +4,6 @@ namespace Test\TinyBlocks\Http\Unit\Server; -use DateTime; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -19,6 +18,7 @@ use Test\TinyBlocks\Http\Models\Products; use Test\TinyBlocks\Http\Models\Status; use TinyBlocks\Http\Code; +use TinyBlocks\Http\Exceptions\BodyTypeIsUnsupported; use TinyBlocks\Http\Server\Response; final class ResponseTest extends TestCase @@ -225,37 +225,6 @@ public function testServiceUnavailableWhenBodyGivenThenReturnsResponseWithStatus self::assertTrue(Code::isErrorCode(code: $actual->getStatusCode())); } - public static function responseFromProvider(): array - { - return [ - 'I am a teapot' => [ - 'code' => Code::IM_A_TEAPOT, - 'body' => 'Short and stout', - 'expectedBody' => 'Short and stout' - ], - 'OK with array body' => [ - 'code' => Code::OK, - 'body' => ['status' => 'success'], - 'expectedBody' => '{"status":"success"}' - ], - 'Accepted with null body' => [ - 'code' => Code::ACCEPTED, - 'body' => null, - 'expectedBody' => '' - ], - 'Not Found with string body' => [ - 'code' => Code::NOT_FOUND, - 'body' => 'Resource not found', - 'expectedBody' => 'Resource not found' - ], - 'Internal Server Error with complex body' => [ - 'code' => Code::INTERNAL_SERVER_ERROR, - 'body' => ['error' => ['code' => 500, 'message' => 'Crash']], - 'expectedBody' => '{"error":{"code":500,"message":"Crash"}}' - ] - ]; - } - #[DataProvider('bodyProviderData')] public function testOkWhenAnyBodyShapeGivenThenSerializesToExpectedString(mixed $body, string $expected): void { @@ -294,66 +263,40 @@ public function testWithStatusWhenInvokedThenReturnsResponseWithUpdatedCode(): v self::assertSame(Code::OK->value, $updated->getStatusCode()); } - public static function bodyProviderData(): array + public function testWithStatusWhenCustomReasonPhraseGivenThenReasonPhraseIsHonored(): void { - return [ - 'UnitEnum' => [ - 'body' => Color::RED, - 'expected' => 'RED' - ], - 'BackedEnum' => [ - 'body' => Status::PAID, - 'expected' => '1' - ], - 'Null value' => [ - 'body' => null, - 'expected' => '' - ], - 'Empty string' => [ - 'body' => '', - 'expected' => '' - ], - 'Simple object' => [ - 'body' => new Dragon(name: 'Drakengard Firestorm', weight: 6000.0), - 'expected' => '{"name":"Drakengard Firestorm","weight":6000.0}' - ], - 'Non-empty string' => [ - 'body' => 'Hello, World!', - 'expected' => 'Hello, World!' - ], - 'Serializer object' => [ - 'body' => new Order( - id: 1, - products: new Products(elements: [ - new Product(name: 'Product One', amount: new Amount(value: 100.50, currency: Currency::USD)), - new Product(name: 'Product Two', amount: new Amount(value: 200.75, currency: Currency::BRL)) - ]) - ), - 'expected' => json_encode([ - 'id' => 1, - 'products' => [ - ['name' => 'Product One', 'amount' => ['value' => 100.50, 'currency' => 'USD']], - ['name' => 'Product Two', 'amount' => ['value' => 200.75, 'currency' => 'BRL']] - ] - ], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) - ], - 'Boolean true value' => [ - 'body' => true, - 'expected' => 'true' - ], - 'Boolean false value' => [ - 'body' => false, - 'expected' => 'false' - ], - 'Large integer value' => [ - 'body' => PHP_INT_MAX, - 'expected' => (string)PHP_INT_MAX - ], - 'DateTimeInterface value' => [ - 'body' => new DateTime('2024-12-16'), - 'expected' => '[]' - ] - ]; + /** @Given an HTTP response */ + $response = Response::ok(body: null); + + /** @When calling withStatus with a custom reason phrase */ + $updated = $response->withStatus(Code::OK->value, 'All Good'); + + /** @Then the custom reason phrase is returned */ + self::assertSame('All Good', $updated->getReasonPhrase()); + } + + public function testWithStatusWhenEmptyReasonPhraseGivenThenEnumDerivedPhraseIsUsed(): void + { + /** @Given an HTTP response */ + $response = Response::ok(body: null); + + /** @When calling withStatus with an empty reason phrase */ + $updated = $response->withStatus(Code::OK->value); + + /** @Then the enum-derived phrase is returned */ + self::assertSame(Code::OK->message(), $updated->getReasonPhrase()); + } + + public function testWithStatusWhenCustomPhraseSetThenSubsequentWithHeaderPreservesIt(): void + { + /** @Given an HTTP response with a custom reason phrase */ + $response = Response::ok(body: null)->withStatus(Code::OK->value, 'All Good'); + + /** @When adding a header to that response */ + $updated = $response->withHeader('X-Trace-Id', 'abc'); + + /** @Then the custom reason phrase is still returned */ + self::assertSame('All Good', $updated->getReasonPhrase()); } public function testGetBodyWhenInvokedThenStreamIsReadable(): void @@ -782,4 +725,102 @@ public function testGetBodyWhenClosedThenGetContentsRaisesNonReadableError(): vo /** @When asking for the full contents */ $stream->getContents(); } + + public function testOkWhenArbitraryObjectGivenThenThrowsBodyTypeIsUnsupported(): void + { + /** @Given an arbitrary object that is not a Mapper, BackedEnum, or UnitEnum */ + $body = new Dragon(name: 'Drakengard Firestorm', weight: 6000.0); + + /** @Then an exception indicating the body type is unsupported is thrown */ + $this->expectException(BodyTypeIsUnsupported::class); + $this->expectExceptionMessage('Response body type is not supported'); + + /** @When creating a response with the arbitrary object */ + Response::ok(body: $body); + } + + public static function responseFromProvider(): array + { + return [ + 'I am a teapot' => [ + 'code' => Code::IM_A_TEAPOT, + 'body' => 'Short and stout', + 'expectedBody' => 'Short and stout' + ], + 'OK with array body' => [ + 'code' => Code::OK, + 'body' => ['status' => 'success'], + 'expectedBody' => '{"status":"success"}' + ], + 'Accepted with null body' => [ + 'code' => Code::ACCEPTED, + 'body' => null, + 'expectedBody' => '' + ], + 'Not Found with string body' => [ + 'code' => Code::NOT_FOUND, + 'body' => 'Resource not found', + 'expectedBody' => 'Resource not found' + ], + 'Internal Server Error with complex body' => [ + 'code' => Code::INTERNAL_SERVER_ERROR, + 'body' => ['error' => ['code' => 500, 'message' => 'Crash']], + 'expectedBody' => '{"error":{"code":500,"message":"Crash"}}' + ] + ]; + } + + public static function bodyProviderData(): array + { + return [ + 'UnitEnum' => [ + 'body' => Color::RED, + 'expected' => 'RED' + ], + 'BackedEnum' => [ + 'body' => Status::PAID, + 'expected' => '1' + ], + 'Null value' => [ + 'body' => null, + 'expected' => '' + ], + 'Empty string' => [ + 'body' => '', + 'expected' => '' + ], + 'Non-empty string' => [ + 'body' => 'Hello, World!', + 'expected' => 'Hello, World!' + ], + 'Serializer object' => [ + 'body' => new Order( + id: 1, + products: new Products(elements: [ + new Product(name: 'Product One', amount: new Amount(value: 100.50, currency: Currency::USD)), + new Product(name: 'Product Two', amount: new Amount(value: 200.75, currency: Currency::BRL)) + ]) + ), + 'expected' => json_encode([ + 'id' => 1, + 'products' => [ + ['name' => 'Product One', 'amount' => ['value' => 100.50, 'currency' => 'USD']], + ['name' => 'Product Two', 'amount' => ['value' => 200.75, 'currency' => 'BRL']] + ] + ], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) + ], + 'Boolean true value' => [ + 'body' => true, + 'expected' => 'true' + ], + 'Boolean false value' => [ + 'body' => false, + 'expected' => 'false' + ], + 'Large integer value' => [ + 'body' => PHP_INT_MAX, + 'expected' => (string)PHP_INT_MAX + ] + ]; + } } diff --git a/tests/Unit/Server/ResponseWithCookiesTest.php b/tests/Unit/Server/ResponseWithCookiesTest.php index 9886dbf..b422010 100644 --- a/tests/Unit/Server/ResponseWithCookiesTest.php +++ b/tests/Unit/Server/ResponseWithCookiesTest.php @@ -98,7 +98,8 @@ public function testNoContentWhenExpireCookieGivenThenInstructsBrowserToDiscard( /** @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'], + ['refresh_token=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; ' + . 'Path=/v1/sessions; Secure; HttpOnly; SameSite=Strict'], $response->getHeader('Set-Cookie') ); } diff --git a/tests/Unit/UserAgentTest.php b/tests/Unit/UserAgentTest.php index 2a46c20..00f62d8 100644 --- a/tests/Unit/UserAgentTest.php +++ b/tests/Unit/UserAgentTest.php @@ -4,8 +4,10 @@ namespace Test\TinyBlocks\Http\Unit; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; use TinyBlocks\Http\Exceptions\UserAgentProductIsEmpty; +use TinyBlocks\Http\Exceptions\UserAgentValueIsInvalid; use TinyBlocks\Http\UserAgent; final class UserAgentTest extends TestCase @@ -68,4 +70,61 @@ public function testFromWhenEmptyProductGivenThenThrowsUserAgentProductIsEmpty() /** @When constructing with an empty product token */ UserAgent::from(product: ''); } + + public function testFromWhenProductWithControlCharGivenThenThrowsUserAgentValueIsInvalid(): void + { + /** @Then an exception indicating the product token is invalid is thrown */ + $this->expectException(UserAgentValueIsInvalid::class); + $this->expectExceptionMessage('is invalid'); + + /** @When constructing with a product containing a control character */ + UserAgent::from(product: "MyApp\x00"); + } + + public function testFromWhenProductWithSlashGivenThenThrowsUserAgentValueIsInvalid(): void + { + /** @Then an exception indicating the product token is invalid is thrown */ + $this->expectException(UserAgentValueIsInvalid::class); + + /** @When constructing with a product containing a forward slash */ + UserAgent::from(product: 'My/App'); + } + + public function testFromWhenProductWithLfGivenThenThrowsUserAgentValueIsInvalid(): void + { + /** @Then an exception indicating the product token is invalid is thrown */ + $this->expectException(UserAgentValueIsInvalid::class); + + /** @When constructing with a product containing a line feed */ + UserAgent::from(product: "My\nApp"); + } + + public function testFromWhenVersionWithControlCharGivenThenThrowsUserAgentValueIsInvalid(): void + { + /** @Given a valid product token */ + + /** @Then an exception indicating the version token is invalid is thrown */ + $this->expectException(UserAgentValueIsInvalid::class); + + /** @When constructing with a version containing a control character */ + UserAgent::from(product: 'MyApp', version: "1\x002"); + } + + public function testFromWhenValidProductOnlyGivenThenNoExceptionIsThrown(): void + { + /** @When constructing with a valid product token */ + $userAgent = UserAgent::from(product: 'ValidApp'); + + /** @Then the header is rendered without error */ + self::assertSame(['User-Agent' => 'ValidApp'], $userAgent->toArray()); + } + + public function testFromWhenValidProductAndVersionGivenThenNoExceptionIsThrown(): void + { + /** @When constructing with a valid product and version */ + $userAgent = UserAgent::from(product: 'ValidApp', version: '2.0'); + + /** @Then the header is rendered without error */ + self::assertSame(['User-Agent' => 'ValidApp/2.0'], $userAgent->toArray()); + } }