Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 106 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php

declare(strict_types=1);

use TinyBlocks\Http\Code;
use TinyBlocks\Http\Server\Response;

$response = Response::ok(body: null)->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
<?php
Expand All @@ -155,7 +177,27 @@ $session = Cookie::create(name: 'session', value: $token)
Response::ok(['ok' => true], $session);
```

Setting `SameSite=None` without calling `secure()` first is safe. Secure is set automatically:

```php
<?php

declare(strict_types=1);

use TinyBlocks\Http\Cookie;
use TinyBlocks\Http\SameSite;
use TinyBlocks\Http\Server\Response;

# Secure is applied automatically when SameSite=None is set.
$crossSite = Cookie::create(name: 'session', value: $token)
->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
<?php
Expand Down Expand Up @@ -186,10 +228,14 @@ declare(strict_types=1);

use TinyBlocks\Http\Code;

Code::OK->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
Expand Down Expand Up @@ -233,22 +279,22 @@ 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
)
);
```

#### 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
<?php
Expand All @@ -258,41 +304,51 @@ declare(strict_types=1);
use TinyBlocks\Http\Client\Request;
use TinyBlocks\Http\ContentType;
use TinyBlocks\Http\Headers;
use TinyBlocks\Http\Method;

$response = $http->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
<?php

declare(strict_types=1);

use TinyBlocks\Http\Client\Request;
use TinyBlocks\Http\Headers;
use TinyBlocks\Http\Method;

$response = $http->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
<?php

declare(strict_types=1);

use TinyBlocks\Http\Method;

Method::GET->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
Expand All @@ -319,28 +375,29 @@ $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
<?php

declare(strict_types=1);

use TinyBlocks\Http\Client\Request;
use TinyBlocks\Http\Headers;
use TinyBlocks\Http\Method;

$response = $http->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(...)`:
Expand All @@ -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
{
Expand All @@ -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)
Expand All @@ -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
<?php

declare(strict_types=1);

use TinyBlocks\Http\Client\Request;

$updated = Request::get(url: '/v1/charges')
->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
<?php
Expand All @@ -398,17 +465,13 @@ declare(strict_types=1);

use TinyBlocks\Http\Client\Request;
use TinyBlocks\Http\Headers;
use TinyBlocks\Http\Method;
use TinyBlocks\Http\UserAgent;

$userAgent = UserAgent::from(product: 'MyApp', version: '1.2.3');

$response = $http->send(
request: Request::create(
request: Request::get(
url: '/v1/charges',
body: null,
query: null,
method: Method::GET,
headers: Headers::from($userAgent)
)
);
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 9 additions & 6 deletions composer.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand All @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions src/Attribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading