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());
+ }
}