From 034c69758eabbb48c385a0528a29196ad6808a8f Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Sat, 26 Sep 2026 18:51:54 +0100 Subject: [PATCH 1/3] Fill entity fields from path and provided values, build value objects, enums and dates, add Conflict --- docs/api/functions.md | 2 +- docs/core/entity-decoding.md | 16 +++++- src/Decoder/EntityDecoder.php | 85 ++++++++++++++++++++++++++------ src/Functions/decoding.php | 8 ++- src/Functions/response.php | 14 ++++++ tests/Unit/ConflictTest.php | 27 ++++++++++ tests/Unit/DecodingTest.php | 92 ++++++++++++++++++++++++++++++++--- 7 files changed, 218 insertions(+), 26 deletions(-) create mode 100644 tests/Unit/ConflictTest.php diff --git a/docs/api/functions.md b/docs/api/functions.md index 1f756c9..c3a9f10 100644 --- a/docs/api/functions.md +++ b/docs/api/functions.md @@ -6,7 +6,7 @@ Every helper lives under `Phunkie\Http4p\Functions`. Import what you use with `u |-----------|-----------| | `Phunkie\Http4p\Functions` | `HttpRoutes`, `Request`, `Response`, `Headers`, `Status`, `StatusOk` and the other status constructors, `decode`, `FileResponse` | | `Phunkie\Http4p\Functions\routes` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` | -| `Phunkie\Http4p\Functions\response` | `Ok`, `Created`, `Accepted`, `NoContent`, `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `InternalServerError` | +| `Phunkie\Http4p\Functions\response` | `Ok`, `Created`, `Accepted`, `NoContent`, `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `Conflict`, `InternalServerError` | | `Phunkie\Http4p\Functions\decoding` | `json` | | `Phunkie\Http4p\Functions\middleware` | `Through`, `Logger`, `Cors` | | `Phunkie\Http4p\Functions\headers` | `get`, `put`, `remove` | diff --git a/docs/core/entity-decoding.md b/docs/core/entity-decoding.md index 3f25911..9e731c9 100644 --- a/docs/core/entity-decoding.md +++ b/docs/core/entity-decoding.md @@ -20,8 +20,22 @@ POST('/authors', fn(Request $req) => - A key matches a parameter by its exact name or by its snake_case form, so `published_year` fills `$publishedYear`. - Parameters marked `#[Generated]` (the attribute from phunkie/phetch, read by name so http4p does not depend on it) are never expected and are dropped if sent; so are unknown keys. +- Values the route already knows fill the parameter of the same name ahead of the body and are never required from it: the request's path parameters, so `POST /authors/:authorId/books` fills `$authorId`, and anything passed as the third argument, `decode($req, Book::class, ['ownerId' => $user->id])`. - On `POST` and `PUT` every parameter without a default that is not nullable is required. A `PATCH` may carry any subset, as long as one field is known. -- Values must match the declared types as JSON provides them: no coercion, so `"1843"` is not an `int`. Nested entities are accepted as JSON objects. +- Scalars must match the declared types as JSON provides them: no coercion, so `"1843"` is not an `int`. Nested entities are accepted as JSON objects. +- A parameter typed with a backed enum is built with `tryFrom`, one typed with a date class from the string, and one typed with any other class from the scalar through its constructor. An `InvalidArgumentException` thrown by that constructor becomes the field's error, so a value object such as `Isbn` or `Email` carries its own validation: + +```php +final readonly class Isbn +{ + public function __construct(public string $value) + { + if (!preg_match('/^\d{13}$/', $value)) { + throw new InvalidArgumentException('must be 13 digits'); + } + } +} +``` A body that does not fit fails with a `DecodeFailure` carrying one message per field, and the router answers with a `400`: diff --git a/src/Decoder/EntityDecoder.php b/src/Decoder/EntityDecoder.php index 5a7b62b..9a34249 100644 --- a/src/Decoder/EntityDecoder.php +++ b/src/Decoder/EntityDecoder.php @@ -11,6 +11,11 @@ namespace Phunkie\Http4p\Decoder; +use BackedEnum; +use DateTimeImmutable; +use DateTimeInterface; +use Exception; +use InvalidArgumentException; use Phunkie\Http4p\DecodeFailure; use Phunkie\Http4p\Method; use ReflectionClass; @@ -25,9 +30,14 @@ * Decodes a JSON object into the fields of an entity, keyed by constructor parameter name. * * A key matches a parameter by its exact name or by its snake_case form. Unknown keys and - * parameters marked #[Generated] are dropped. On POST and PUT every parameter without a default - * that is not nullable is required; a PATCH may carry any subset, as long as one field is known. - * Values must match the declared parameter types as JSON gives them, with no coercion. + * parameters marked #[Generated] are dropped. Values the route already knows, the request's path + * parameters and anything the caller provides, fill the parameter of the same name ahead of the body. + * On POST and PUT every parameter without a default that is not nullable is required; a PATCH may + * carry any subset, as long as one field is known. + * + * Scalars must match the declared type as JSON gives them, with no coercion. A parameter typed with + * a backed enum, a date or any class with a single-argument constructor is built from the scalar, + * and an InvalidArgumentException thrown by that constructor becomes the field's error. */ final class EntityDecoder { @@ -35,10 +45,12 @@ final class EntityDecoder /** * @param class-string $class + * @param array $known values the route already has, keyed by parameter name */ public function __construct( private string $class, private Method $method, + private array $known = [], ) { } @@ -68,23 +80,27 @@ private function fields(array $json): array continue; } + $name = $param->getName(); + if (array_key_exists($name, $this->known)) { + $fields[$name] = $this->known[$name]; + + continue; + } + $key = $this->keyFor($param, $json); if (null === $key) { if ($this->isRequired($param)) { - $errors[$param->getName()] = 'missing'; + $errors[$name] = 'missing'; } continue; } - $mismatch = $this->typeMismatch($param, $json[$key]); - if (null !== $mismatch) { - $errors[$param->getName()] = $mismatch; - - continue; + try { + $fields[$name] = $this->valueFor($param, $json[$key]); + } catch (InvalidArgumentException $e) { + $errors[$name] = $e->getMessage(); } - - $fields[$param->getName()] = $json[$key]; } if ([] !== $errors) { @@ -134,25 +150,62 @@ private function keyFor(ReflectionParameter $param, array $json): ?string return null; } - private function typeMismatch(ReflectionParameter $param, mixed $value): ?string + /** + * The value to hand to the parameter, or an InvalidArgumentException saying why the JSON value will not do. + */ + private function valueFor(ReflectionParameter $param, mixed $value): mixed { $type = $param->getType(); if (null === $type) { - return null; + return $value; } if (null === $value) { - return $type->allowsNull() ? null : sprintf('expected %s, got null', $type); + if ($type->allowsNull()) { + return null; + } + + throw new InvalidArgumentException(sprintf('expected %s, got null', $type)); } $names = $this->typeNames($type); foreach ($names as $name) { if ($this->accepts($name, $value)) { - return null; + return $value; + } + } + + foreach ($names as $name) { + if (is_scalar($value) && class_exists($name)) { + return $this->construct($name, $value); + } + } + + throw new InvalidArgumentException(sprintf('expected %s, got %s', implode('|', $names), get_debug_type($value))); + } + + /** + * @param class-string $class + */ + private function construct(string $class, int|float|string|bool $value): object + { + if (is_subclass_of($class, BackedEnum::class)) { + return $class::tryFrom($value) ?? throw new InvalidArgumentException(sprintf( + 'expected one of %s, got %s', + implode(', ', array_map(fn (BackedEnum $case) => $case->value, $class::cases())), + json_encode($value), + )); + } + + if (is_a($class, DateTimeInterface::class, true)) { + try { + return new ($class === DateTimeInterface::class ? DateTimeImmutable::class : $class)((string) $value); + } catch (Exception) { + throw new InvalidArgumentException(sprintf('expected a date, got %s', json_encode($value))); } } - return sprintf('expected %s, got %s', implode('|', $names), get_debug_type($value)); + return new $class($value); } /** diff --git a/src/Functions/decoding.php b/src/Functions/decoding.php index 4153f23..cceb632 100644 --- a/src/Functions/decoding.php +++ b/src/Functions/decoding.php @@ -24,14 +24,18 @@ * * The IO fails with a DecodeFailure when the body cannot be decoded; the router turns that into a 400. * + * When decoding into an entity, the request's path parameters and $known fill the constructor + * parameters of the same name ahead of the body, so a route can supply what the client must not. + * * @param class-string|callable(string): mixed|null $decoder + * @param array $known values keyed by constructor parameter name * @return IO */ - function decode(Request $request, string|callable|null $decoder = null): IO + function decode(Request $request, string|callable|null $decoder = null, array $known = []): IO { $decoder = match (true) { null === $decoder => json(), - is_string($decoder) => new EntityDecoder($decoder, $request->method), + is_string($decoder) => new EntityDecoder($decoder, $request->method, $known + $request->pathParams), default => $decoder, }; diff --git a/src/Functions/response.php b/src/Functions/response.php index 498d123..1cb039a 100644 --- a/src/Functions/response.php +++ b/src/Functions/response.php @@ -48,6 +48,7 @@ function Response(Status $status, ?Headers $headers = null, mixed $body = ''): R use function Phunkie\Http4p\Functions\StatusAccepted; use function Phunkie\Http4p\Functions\StatusBadRequest; + use function Phunkie\Http4p\Functions\StatusConflict; use function Phunkie\Http4p\Functions\StatusCreated; use function Phunkie\Http4p\Functions\StatusForbidden; use function Phunkie\Http4p\Functions\StatusInternalServerError; @@ -157,6 +158,19 @@ function NotFound(mixed $body = null, ?EntityEncoder $encoder = null): IO return createResponse(StatusNotFound(), $body, $encoder); } + /** + * Create a 409 Conflict response. + * + * @template A + * @param A $body + * @param EntityEncoder|null $encoder + * @return IO + */ + function Conflict(mixed $body = null, ?EntityEncoder $encoder = null): IO + { + return createResponse(StatusConflict(), $body, $encoder); + } + /** * Create a 500 Internal Server Error response. * diff --git a/tests/Unit/ConflictTest.php b/tests/Unit/ConflictTest.php new file mode 100644 index 0000000..ad1bea6 --- /dev/null +++ b/tests/Unit/ConflictTest.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit; + +use PHPUnit\Framework\TestCase; + +use function Phunkie\Http4p\Functions\response\Conflict; + +final class ConflictTest extends TestCase +{ + public function testConflictIsA409WithAJsonBody(): void + { + $response = Conflict(['error' => 'Email already taken.'])->unsafeRun(); + + $this->assertSame(409, $response->status->code); + $this->assertSame('{"error":"Email already taken."}', implode('', $response->body->toArray())); + } +} diff --git a/tests/Unit/DecodingTest.php b/tests/Unit/DecodingTest.php index 5dd5f53..eda525b 100644 --- a/tests/Unit/DecodingTest.php +++ b/tests/Unit/DecodingTest.php @@ -11,6 +11,8 @@ namespace Tests\Unit; +use DateTimeImmutable; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Phunkie\Http4p\DecodeFailure; use Phunkie\Http4p\Method; @@ -18,12 +20,41 @@ use function Phunkie\Http4p\Functions\decode; use function Phunkie\Http4p\Functions\Request; -final readonly class Book +enum Format: string +{ + case Hardcover = 'hardcover'; + case Ebook = 'ebook'; +} + +final readonly class Isbn +{ + public function __construct(public string $value) + { + if (! preg_match('/^\d{13}$/', $value)) { + throw new InvalidArgumentException('must be 13 digits'); + } + } +} + +final readonly class Edition { public function __construct( #[\Phunkie\Phetch\Attributes\Generated] public int $id, + public int $bookId, + public Isbn $isbn, + public Format $format, + public DateTimeImmutable $publishedOn, + public ?float $price = null, + ) { + } +} + +final readonly class Book +{ + public function __construct( #[\Phunkie\Phetch\Attributes\Generated] + public int $id, public int $authorId, public string $title, public int $publishedYear, @@ -60,7 +91,7 @@ public function testDecodesAPostBodyIntoTheEntityFieldsKeyedByParameterName(): v { $request = Request(Method::POST, '/books', null, '{"title":"Notes","published_year":1843,"id":9,"author_id":3,"rating":5}'); - $this->assertSame(['title' => 'Notes', 'publishedYear' => 1843], decode($request, Book::class)->unsafeRun()); + $this->assertSame(['authorId' => 3, 'title' => 'Notes', 'publishedYear' => 1843], decode($request, Book::class)->unsafeRun()); } public function testAPostBodyMustCarryEveryFieldThatIsNeitherGeneratedNorDefaulted(): void @@ -70,7 +101,7 @@ public function testAPostBodyMustCarryEveryFieldThatIsNeitherGeneratedNorDefault $this->fail('Expected a DecodeFailure.'); } catch (DecodeFailure $failure) { $this->assertSame('Body does not describe Book.', $failure->getMessage()); - $this->assertSame(['publishedYear' => 'missing'], $failure->errors()); + $this->assertSame(['authorId' => 'missing', 'publishedYear' => 'missing'], $failure->errors()); } } @@ -98,7 +129,7 @@ public function testAPatchBodyMustCarryAtLeastOneKnownField(): void public function testValuesMustMatchTheParameterTypes(): void { try { - decode(Request(Method::POST, '/books', null, '{"title":7,"published_year":"1843","in_print":"yes"}'), Book::class)->unsafeRun(); + decode(Request(Method::POST, '/books', null, '{"author_id":1,"title":7,"published_year":"1843","in_print":"yes"}'), Book::class)->unsafeRun(); $this->fail('Expected a DecodeFailure.'); } catch (DecodeFailure $failure) { $this->assertSame([ @@ -111,10 +142,10 @@ public function testValuesMustMatchTheParameterTypes(): void public function testNullableAndDefaultedParametersAreOptionalAndKeptWhenSent(): void { - $request = Request(Method::POST, '/books', null, '{"title":"Notes","published_year":1843,"subtitle":null,"in_print":false}'); + $request = Request(Method::POST, '/books', null, '{"author_id":1,"title":"Notes","published_year":1843,"subtitle":null,"in_print":false}'); $this->assertSame( - ['title' => 'Notes', 'publishedYear' => 1843, 'subtitle' => null, 'inPrint' => false], + ['authorId' => 1, 'title' => 'Notes', 'publishedYear' => 1843, 'subtitle' => null, 'inPrint' => false], decode($request, Book::class)->unsafeRun() ); } @@ -125,4 +156,53 @@ public function testTheBodyMustBeAJsonObject(): void decode(Request(Method::POST, '/books', null, '[1,2]'), Book::class)->unsafeRun(); } + + public function testPathParametersFillMatchingConstructorParameters(): void + { + $request = Request(Method::POST, '/authors/3/books', null, '{"title":"Notes","published_year":1843}')->withPathParams(['authorId' => 3]); + + $this->assertSame(['authorId' => 3, 'title' => 'Notes', 'publishedYear' => 1843], decode($request, Book::class)->unsafeRun()); + } + + public function testAPathParameterWinsOverTheBody(): void + { + $request = Request(Method::POST, '/authors/3/books', null, '{"author_id":9,"title":"Notes","published_year":1843}')->withPathParams(['authorId' => 3]); + + $this->assertSame(3, decode($request, Book::class)->unsafeRun()['authorId']); + } + + public function testProvidedValuesFillParametersAndAreNotRequired(): void + { + $request = Request(Method::POST, '/books', null, '{"title":"Notes","published_year":1843}'); + + $this->assertSame(['authorId' => 3, 'title' => 'Notes', 'publishedYear' => 1843], decode($request, Book::class, ['authorId' => 3])->unsafeRun()); + } + + public function testValueObjectsEnumsAndDatesAreBuiltFromScalars(): void + { + $request = Request(Method::POST, '/books/1/editions', null, '{"isbn":"9780000000002","format":"ebook","published_on":"2020-01-02","price":9.5}') + ->withPathParams(['bookId' => 1]); + + $this->assertEquals( + ['bookId' => 1, 'isbn' => new Isbn('9780000000002'), 'format' => Format::Ebook, 'publishedOn' => new DateTimeImmutable('2020-01-02'), 'price' => 9.5], + decode($request, Edition::class)->unsafeRun() + ); + } + + public function testInvalidValueObjectsEnumsAndDatesAreReportedPerField(): void + { + $request = Request(Method::POST, '/books/1/editions', null, '{"isbn":"12","format":"vinyl","published_on":"never"}') + ->withPathParams(['bookId' => 1]); + + try { + decode($request, Edition::class)->unsafeRun(); + $this->fail('Expected a DecodeFailure.'); + } catch (DecodeFailure $failure) { + $this->assertSame([ + 'isbn' => 'must be 13 digits', + 'format' => 'expected one of hardcover, ebook, got "vinyl"', + 'publishedOn' => 'expected a date, got "never"', + ], $failure->errors()); + } + } } From 73b4e1db8f13733e1cd10e7b3d0c891dedaa3f40 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Sat, 26 Sep 2026 18:59:09 +0100 Subject: [PATCH 2/3] Read query parameters from the request and encode dates as ISO 8601 --- src/Encoder/JsonEncoder.php | 20 +++++++++++++++++++- src/Request.php | 22 ++++++++++++++++++++++ tests/Unit/JsonEncoderTest.php | 24 ++++++++++++++++++++++++ tests/Unit/RequestTest.php | 11 +++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/Encoder/JsonEncoder.php b/src/Encoder/JsonEncoder.php index 4f6fc4d..08626b4 100644 --- a/src/Encoder/JsonEncoder.php +++ b/src/Encoder/JsonEncoder.php @@ -11,6 +11,9 @@ namespace Phunkie\Http4p\Encoder; +use BackedEnum; +use DateTimeInterface; +use JsonSerializable; use Phunkie\Http4p\EntityEncoder; /** @@ -26,7 +29,22 @@ public function encode(mixed $value): string return $value; } - return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return json_encode($this->normalized($value), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** + * The value with every date replaced by its ISO 8601 form and every backed enum by its value, however deep. + */ + private function normalized(mixed $value): mixed + { + return match (true) { + $value instanceof BackedEnum => $value->value, + $value instanceof DateTimeInterface => $value->format(DateTimeInterface::ATOM), + $value instanceof JsonSerializable => $this->normalized($value->jsonSerialize()), + is_array($value) => array_map(fn ($item) => $this->normalized($item), $value), + is_object($value) => array_map(fn ($item) => $this->normalized($item), get_object_vars($value)), + default => $value, + }; } public function contentType(): string diff --git a/src/Request.php b/src/Request.php index d3ab270..444b356 100644 --- a/src/Request.php +++ b/src/Request.php @@ -35,6 +35,28 @@ public function __construct( public array $pathParams = [] ) {} + /** + * The value of one query string parameter, or null when absent. + */ + public function query(string $name): ?string + { + $value = $this->queryParams()[$name] ?? null; + + return is_string($value) ? $value : null; + } + + /** + * The query string parsed into an array, empty when there is none. + * + * @return array + */ + public function queryParams(): array + { + parse_str((string) parse_url($this->uri, PHP_URL_QUERY), $params); + + return $params; + } + public function withPathParams(array $params): self { return new self($this->method, $this->uri, $this->headers, $this->body, $params); diff --git a/tests/Unit/JsonEncoderTest.php b/tests/Unit/JsonEncoderTest.php index 479b185..ab80c7c 100644 --- a/tests/Unit/JsonEncoderTest.php +++ b/tests/Unit/JsonEncoderTest.php @@ -176,4 +176,28 @@ public function test_encode_preserves_string_numeric_keys() // PHP will treat these as an indexed array $this->assertIsArray($decoded); } + + public function testEncodesDatesAsIso8601WhereverTheyAppear(): void + { + $at = new \DateTimeImmutable('2020-01-02 03:04:05', new \DateTimeZone('UTC')); + $entity = new class($at) { + public function __construct(public \DateTimeImmutable $createdAt) {} + }; + + $this->assertSame('{"at":"2020-01-02T03:04:05+00:00","rows":[{"createdAt":"2020-01-02T03:04:05+00:00"}]}', (new JsonEncoder())->encode(['at' => $at, 'rows' => [$entity]])); + } + + public function testEncodesBackedEnumsByValueWhereverTheyAppear(): void + { + $entity = new class(Suit::Hearts) { + public function __construct(public Suit $suit) {} + }; + + $this->assertSame('{"suit":"hearts","rows":[{"suit":"hearts"}]}', (new JsonEncoder())->encode(['suit' => Suit::Hearts, 'rows' => [$entity]])); + } +} + +enum Suit: string +{ + case Hearts = 'hearts'; } diff --git a/tests/Unit/RequestTest.php b/tests/Unit/RequestTest.php index 983c673..120d9dc 100644 --- a/tests/Unit/RequestTest.php +++ b/tests/Unit/RequestTest.php @@ -73,4 +73,15 @@ public function test_request_immutability() $this->assertEquals([''], $original->body->toArray()); $this->assertEquals([['foo' => 'bar']], $modified->body->toArray()); } + + public function test_query_parameters_are_read_from_the_uri() + { + $request = Request(Method::GET, '/books?tag=maths&page=2'); + + $this->assertSame('maths', $request->query('tag')); + $this->assertSame('2', $request->query('page')); + $this->assertNull($request->query('sort')); + $this->assertSame(['tag' => 'maths', 'page' => '2'], $request->queryParams()); + $this->assertSame([], Request(Method::GET, '/books')->queryParams()); + } } From 939ef0f68c306895825a546becc83bd3884a5ce1 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Sat, 26 Sep 2026 19:34:42 +0100 Subject: [PATCH 3/3] Update dependencies --- composer.json | 4 ++-- composer.lock | 28 ++++++++++++++-------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/composer.json b/composer.json index 6c28bf4..e0f1724 100644 --- a/composer.json +++ b/composer.json @@ -12,8 +12,8 @@ "require": { "php": "^8.2 || ^8.3 || ^8.4", "phunkie/phunkie": "^1.5", - "phunkie/effect": "^1.3", - "phunkie/streams": "^1.2" + "phunkie/effect": "^1.4", + "phunkie/streams": "^1.3" }, "require-dev": { "phpunit/phpunit": "^10.0 || ^11.0", diff --git a/composer.lock b/composer.lock index b687dd8..a9f571a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3cdfdef427b012e349fdfb1b8b375cbb", + "content-hash": "ee096c644f8d65cb4abc78c134d44aca", "packages": [ { "name": "phunkie/effect", - "version": "1.3.1", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/phunkie/effect.git", - "reference": "1b3ed22d182fac17e80b7cfa3f5a5717e0a159f6" + "reference": "de055de98cfa1e3f349636fba89ae211a26c14b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phunkie/effect/zipball/1b3ed22d182fac17e80b7cfa3f5a5717e0a159f6", - "reference": "1b3ed22d182fac17e80b7cfa3f5a5717e0a159f6", + "url": "https://api.github.com/repos/phunkie/effect/zipball/de055de98cfa1e3f349636fba89ae211a26c14b0", + "reference": "de055de98cfa1e3f349636fba89ae211a26c14b0", "shasum": "" }, "require": { @@ -61,9 +61,9 @@ "description": "A functional effects library for PHP inspired by Scala's cats-effect", "support": { "issues": "https://github.com/phunkie/effect/issues", - "source": "https://github.com/phunkie/effect/tree/1.3.1" + "source": "https://github.com/phunkie/effect/tree/1.4.0" }, - "time": "2026-08-05T09:49:14+00:00" + "time": "2026-09-26T18:12:49+00:00" }, { "name": "phunkie/phunkie", @@ -120,21 +120,21 @@ }, { "name": "phunkie/streams", - "version": "1.2.2", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/phunkie/streams.git", - "reference": "df9eeb370328db546f4970f31681fd013fc05f4d" + "reference": "e4cbb2c5bfd75b1ec6bee28d1c4789c0df259263" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phunkie/streams/zipball/df9eeb370328db546f4970f31681fd013fc05f4d", - "reference": "df9eeb370328db546f4970f31681fd013fc05f4d", + "url": "https://api.github.com/repos/phunkie/streams/zipball/e4cbb2c5bfd75b1ec6bee28d1c4789c0df259263", + "reference": "e4cbb2c5bfd75b1ec6bee28d1c4789c0df259263", "shasum": "" }, "require": { "php": "^8.2 || ^8.3 || ^8.4 || ^8.5", - "phunkie/effect": "^1.2.0", + "phunkie/effect": "^1.4", "phunkie/phunkie": "^1.1.0" }, "require-dev": { @@ -175,9 +175,9 @@ "description": "Functional Streams for PHP", "support": { "issues": "https://github.com/phunkie/streams/issues", - "source": "https://github.com/phunkie/streams/tree/1.2.2" + "source": "https://github.com/phunkie/streams/tree/1.3.0" }, - "time": "2026-09-26T15:57:32+00:00" + "time": "2026-09-26T18:21:12+00:00" } ], "packages-dev": [