Skip to content

Commit ea6f268

Browse files
committed
Release/6.0.0 (#57)
1 parent 5fcaf2b commit ea6f268

68 files changed

Lines changed: 2459 additions & 728 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 106 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,31 @@ Response::ok(['ok' => true], $cacheControl, ContentType::applicationJson())
132132
->withHeader(name: 'X-Trace-Id', value: 'abc-123');
133133
```
134134

135+
`withStatus($code, $reasonPhrase)` honors the supplied reason phrase: when a non-empty string is
136+
passed, `getReasonPhrase()` returns it instead of the enum-derived phrase.
137+
138+
```php
139+
<?php
140+
141+
declare(strict_types=1);
142+
143+
use TinyBlocks\Http\Code;
144+
use TinyBlocks\Http\Server\Response;
145+
146+
$response = Response::ok(body: null)->withStatus(Code::OK->value, 'All Good');
147+
$response->getReasonPhrase(); # "All Good"
148+
```
149+
135150
#### Setting cookies
136151

137-
`Cookie` implements `Headerable` and composes naturally with `Response`:
152+
`Cookie` implements `Headerable` and composes naturally with `Response`.
153+
154+
`withSameSite(SameSite::NONE)` automatically enables the `Secure` flag. Browsers reject
155+
`SameSite=None` cookies that lack it. Calling `secure()` separately is not required.
156+
157+
`withMaxAge(...)` and `withExpires(...)` are mutually exclusive (last-write-wins): setting one
158+
clears the other. This follows RFC 6265 §4.1.2.2, which specifies that `Max-Age` takes precedence
159+
over `Expires` when both are present.
138160

139161
```php
140162
<?php
@@ -155,7 +177,27 @@ $session = Cookie::create(name: 'session', value: $token)
155177
Response::ok(['ok' => true], $session);
156178
```
157179

180+
Setting `SameSite=None` without calling `secure()` first is safe. Secure is set automatically:
181+
182+
```php
183+
<?php
184+
185+
declare(strict_types=1);
186+
187+
use TinyBlocks\Http\Cookie;
188+
use TinyBlocks\Http\SameSite;
189+
use TinyBlocks\Http\Server\Response;
190+
191+
# Secure is applied automatically when SameSite=None is set.
192+
$crossSite = Cookie::create(name: 'session', value: $token)
193+
->withSameSite(sameSite: SameSite::NONE);
194+
195+
Response::ok(['ok' => true], $crossSite);
196+
```
197+
158198
To expire a cookie, use `Cookie::expire(...)` with the same `Path` and `Domain` used at creation.
199+
The expired cookie carries both `Max-Age=0` and `Expires` set to the Unix epoch: modern browsers
200+
honor `Max-Age`. The `Expires` fallback covers legacy user agents.
159201

160202
```php
161203
<?php
@@ -186,10 +228,14 @@ declare(strict_types=1);
186228

187229
use TinyBlocks\Http\Code;
188230

189-
Code::OK->value; # 200
190-
Code::OK->message(); # "OK"
191-
Code::OK->isSuccess(); # true
192-
Code::INTERNAL_SERVER_ERROR->isError(); # true
231+
Code::OK->value; # 200
232+
Code::OK->message(); # "OK"
233+
Code::OK->isSuccess(); # true
234+
Code::CONTINUE->isInformational(); # true
235+
Code::MOVED_PERMANENTLY->isRedirection(); # true
236+
Code::BAD_REQUEST->isClientError(); # true
237+
Code::INTERNAL_SERVER_ERROR->isError(); # true
238+
Code::INTERNAL_SERVER_ERROR->isServerError(); # true
193239

194240
Code::isValidCode(code: 200); # true
195241
Code::isErrorCode(code: 500); # true
@@ -233,22 +279,22 @@ use GuzzleHttp\Psr7\HttpFactory;
233279
use TinyBlocks\Http\Client\Transports\NetworkTransport;
234280
use TinyBlocks\Http\Http;
235281

282+
$client = new Client(config: ['timeout' => 30, 'connect_timeout' => 5]);
236283
$factory = new HttpFactory();
237284

238285
$http = Http::with(
239286
baseUrl: 'https://api.example.com',
240287
transport: NetworkTransport::with(
241-
client: new Client(config: ['timeout' => 30, 'connect_timeout' => 5]),
288+
client: $client,
242289
factory: $factory
243290
)
244291
);
245292
```
246293

247294
#### Making a request
248295

249-
Every parameter on `Request::create(...)` is explicit. Pass `null` for `body` and `query` when absent. Pass
250-
`Method::GET` (or another method) for `method`. Build `headers` from one or more `Headerable` instances via
251-
`Headers::from(...)`, or call `Headers::from()` with no arguments when no headers apply.
296+
Six shortcut factories cover the most common HTTP methods. Supply only the arguments the request
297+
needs. The `body`, `queryParameters`, and `headers` all default to absent or empty.
252298

253299
```php
254300
<?php
@@ -258,41 +304,51 @@ declare(strict_types=1);
258304
use TinyBlocks\Http\Client\Request;
259305
use TinyBlocks\Http\ContentType;
260306
use TinyBlocks\Http\Headers;
261-
use TinyBlocks\Http\Method;
307+
308+
$response = $http->send(request: Request::get(url: '/v1/charges/abc123'));
262309

263310
$response = $http->send(
264-
request: Request::create(
311+
request: Request::post(
265312
url: '/v1/charges',
266313
body: ['amount' => 1000, 'currency' => 'usd'],
267-
query: null,
268-
method: Method::POST,
269314
headers: Headers::from(ContentType::applicationJson())
270315
)
271316
);
317+
318+
$response = $http->send(request: Request::delete(url: '/v1/charges/abc123'));
272319
```
273320

274-
A simple `GET` still passes every parameter, with `Headers::from()` for the empty header set:
321+
For HTTP methods not covered by the six shortcuts (`OPTIONS`, `TRACE`, `CONNECT`, or any custom
322+
method), use `Request::for(...)`, which accepts an explicit `Method` argument:
275323

276324
```php
277325
<?php
278326

279327
declare(strict_types=1);
280328

281329
use TinyBlocks\Http\Client\Request;
282-
use TinyBlocks\Http\Headers;
283330
use TinyBlocks\Http\Method;
284331

285332
$response = $http->send(
286-
request: Request::create(
287-
url: '/v1/charges/abc123',
288-
body: null,
289-
query: null,
290-
method: Method::GET,
291-
headers: Headers::from()
292-
)
333+
request: Request::for(url: '/v1/charges', method: Method::OPTIONS)
293334
);
294335
```
295336

337+
`Method` also exposes RFC 9110 safety and idempotency predicates:
338+
339+
```php
340+
<?php
341+
342+
declare(strict_types=1);
343+
344+
use TinyBlocks\Http\Method;
345+
346+
Method::GET->isSafe(); # true (RFC 9110 §9.2.1)
347+
Method::POST->isSafe(); # false
348+
Method::PUT->isIdempotent(); # true (RFC 9110 §9.2.2)
349+
Method::POST->isIdempotent(); # false
350+
```
351+
296352
#### Reading the response
297353

298354
```php
@@ -319,28 +375,29 @@ $hasTrace = $response->headers()->has(name: 'X-Trace-Id'); # true
319375

320376
#### Query parameters
321377

322-
Pass the query as a named parameter - the library encodes it in RFC3986 form.
378+
Pass query parameters via `queryParameters:`. The library encodes them in RFC 3986 form.
323379

324380
```php
325381
<?php
326382

327383
declare(strict_types=1);
328384

329385
use TinyBlocks\Http\Client\Request;
330-
use TinyBlocks\Http\Headers;
331-
use TinyBlocks\Http\Method;
332386

333387
$response = $http->send(
334-
request: Request::create(
388+
request: Request::get(
335389
url: '/v1/charges',
336-
body: null,
337-
query: ['status' => 'succeeded', 'limit' => 50],
338-
method: Method::GET,
339-
headers: Headers::from()
390+
queryParameters: ['status' => 'succeeded', 'limit' => 50]
340391
)
341392
);
342393
```
343394

395+
To replace query parameters on an existing request, use `withQueryParameters(...)`:
396+
397+
```php
398+
$updated = $request->withQueryParameters(queryParameters: ['limit' => 100]);
399+
```
400+
344401
#### Custom headers and content type
345402

346403
Compose any combination of `Headerable` via `Headers::from(...)`:
@@ -354,7 +411,6 @@ use TinyBlocks\Http\Client\Request;
354411
use TinyBlocks\Http\ContentType;
355412
use TinyBlocks\Http\Headerable;
356413
use TinyBlocks\Http\Headers;
357-
use TinyBlocks\Http\Method;
358414

359415
final readonly class IdempotencyKey implements Headerable
360416
{
@@ -369,11 +425,9 @@ final readonly class IdempotencyKey implements Headerable
369425
}
370426

371427
$response = $http->send(
372-
request: Request::create(
428+
request: Request::post(
373429
url: '/v1/charges',
374430
body: ['amount' => 1000],
375-
query: null,
376-
method: Method::POST,
377431
headers: Headers::from(
378432
ContentType::applicationJson(),
379433
new IdempotencyKey(value: $key)
@@ -384,12 +438,25 @@ $response = $http->send(
384438

385439
Custom headers always win over the library's JSON defaults.
386440

441+
To add or replace a single header on an existing request, use `withHeader(...)`. The lookup is
442+
case-insensitive: replacing `Content-Type` via `content-type` still finds and replaces the entry.
443+
444+
```php
445+
<?php
446+
447+
declare(strict_types=1);
448+
449+
use TinyBlocks\Http\Client\Request;
450+
451+
$updated = Request::get(url: '/v1/charges')
452+
->withHeader(name: 'X-Trace-Id', value: 'abc-123');
453+
```
454+
387455
#### Setting the User-Agent
388456

389457
The `UserAgent` value object implements `Headerable` and renders the standard
390-
`User-Agent` header. Empty version is normalized to "no version" - the rendered
391-
header carries only the product token in that case, so configuration with an
392-
optional version flows in directly.
458+
`User-Agent` header. An absent or empty version is normalized to "no version". The rendered
459+
header carries only the product token in that case.
393460

394461
```php
395462
<?php
@@ -398,17 +465,13 @@ declare(strict_types=1);
398465

399466
use TinyBlocks\Http\Client\Request;
400467
use TinyBlocks\Http\Headers;
401-
use TinyBlocks\Http\Method;
402468
use TinyBlocks\Http\UserAgent;
403469

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

406472
$response = $http->send(
407-
request: Request::create(
473+
request: Request::get(
408474
url: '/v1/charges',
409-
body: null,
410-
query: null,
411-
method: Method::GET,
412475
headers: Headers::from($userAgent)
413476
)
414477
);
@@ -437,15 +500,12 @@ declare(strict_types=1);
437500
use TinyBlocks\Http\Client\Request;
438501
use TinyBlocks\Http\ContentType;
439502
use TinyBlocks\Http\Headers;
440-
use TinyBlocks\Http\Method;
441503
use TinyBlocks\Http\UserAgent;
442504

443505
$response = $http->send(
444-
request: Request::create(
506+
request: Request::post(
445507
url: '/v1/charges',
446508
body: ['amount' => 1000],
447-
query: null,
448-
method: Method::POST,
449509
headers: Headers::from(
450510
UserAgent::from(product: 'MyApp', version: '1.2.3'),
451511
ContentType::applicationJson()

composer.json

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
{
22
"name": "tiny-blocks/http",
3-
"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.",
3+
"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.",
44
"license": "MIT",
55
"type": "library",
66
"keywords": [
77
"http",
88
"psr-7",
99
"psr-15",
10+
"psr-17",
1011
"psr-18",
12+
"cookie",
1113
"http-codes",
12-
"tiny-blocks",
13-
"http-client"
14+
"http-client",
15+
"http-server",
16+
"tiny-blocks"
1417
],
1518
"authors": [
1619
{
@@ -31,9 +34,9 @@
3134
"tiny-blocks/mapper": "^2.1"
3235
},
3336
"require-dev": {
34-
"ergebnis/composer-normalize": "^2.51",
35-
"guzzlehttp/guzzle": "^7.9",
36-
"infection/infection": "^0.32",
37+
"ergebnis/composer-normalize": "^2.52",
38+
"guzzlehttp/guzzle": "^7.10",
39+
"infection/infection": "^0.33",
3740
"laminas/laminas-httphandlerrunner": "^2.13",
3841
"nyholm/psr7": "^1.8",
3942
"phpstan/phpstan": "^2.1",

phpstan.neon.dist

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,13 @@ parameters:
1515
# PHPDoc is prohibited inside tests/; the closure's typed return cannot be expressed.
1616
- identifier: throw.notThrowable
1717
path: tests/Unit/FailingTransport.php
18+
# PHPDoc is prohibited inside tests/; Closure return type in DataProvider factories is not statically inferrable.
19+
- identifier: method.nonObject
20+
path: tests/Unit/Client/RequestTest.php
21+
# PHPStan knows all Code enum values are >= 100, making the lower-bound comparison always true.
22+
- identifier: greaterOrEqual.alwaysTrue
23+
path: src/Code.php
24+
# PHPStan knows all Code enum values are <= 511, making the upper-bound comparison always true.
25+
- identifier: smallerOrEqual.alwaysTrue
26+
path: src/Code.php
1827
reportUnmatchedIgnoredErrors: true

src/Attribute.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
namespace TinyBlocks\Http;
66

7+
/**
8+
* Typed wrapper around a scalar or array value extracted from an HTTP message.
9+
*
10+
* Provides coercion methods that convert the wrapped value to a requested primitive type,
11+
* falling back to a safe zero-value when conversion is not possible.
12+
*/
713
final readonly class Attribute
814
{
915
private function __construct(private mixed $value)

0 commit comments

Comments
 (0)