From 365f5aa58d7db8fd2fabde9047d3a97e1cde6f3a Mon Sep 17 00:00:00 2001 From: Bert Ramakers Date: Fri, 10 Nov 2023 09:35:52 +0100 Subject: [PATCH 01/10] Add array field with min/max/unique constraints --- src/Schema/Field/ArrayField.php | 59 +++++++++++++ tests/feature/ArrayFieldTest.php | 140 +++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 src/Schema/Field/ArrayField.php create mode 100644 tests/feature/ArrayFieldTest.php diff --git a/src/Schema/Field/ArrayField.php b/src/Schema/Field/ArrayField.php new file mode 100644 index 0000000..26a88af --- /dev/null +++ b/src/Schema/Field/ArrayField.php @@ -0,0 +1,59 @@ +validate(function (mixed $value, callable $fail): void { + if ($value === null) { + return; + } + + if (!is_array($value)) { + $fail('must be an array'); + return; + } + + if (count($value) < $this->minItems) { + $fail(sprintf('must contain at least %d values', $this->minItems)); + } + + if ($this->maxItems !== null && count($value) > $this->maxItems) { + $fail(sprintf('must contain no more than %d values', $this->maxItems)); + } + + if ($this->uniqueItems && $value !== array_unique($value)) { + $fail('must contain unique values'); + } + }); + } + + public function minItems(int $minItems): static + { + $this->minItems = $minItems; + + return $this; + } + + public function maxItems(?int $maxItems): static + { + $this->maxItems = $maxItems; + + return $this; + } + + public function uniqueItems(bool $uniqueItems = true): static + { + $this->uniqueItems = $uniqueItems; + + return $this; + } +} diff --git a/tests/feature/ArrayFieldTest.php b/tests/feature/ArrayFieldTest.php new file mode 100644 index 0000000..45e9e52 --- /dev/null +++ b/tests/feature/ArrayFieldTest.php @@ -0,0 +1,140 @@ +api = new JsonApi(); + } + + public function test_validates_array() + { + $this->api->resource( + new MockResource( + 'customers', + endpoints: [Create::make()], + fields: [ + ArrayField::make('featureToggles') + ->writable(), + ], + ), + ); + + $this->expectException(UnprocessableEntityException::class); + + $this->api->handle( + $this->buildRequest('POST', '/customers')->withParsedBody([ + 'data' => ['type' => 'customers', 'attributes' => ['featureToggles' => 1]], + ]), + ); + } + + public function test_invalid_min_length() + { + $this->api->resource( + new MockResource( + 'customers', + endpoints: [Create::make()], + fields: [ + ArrayField::make('featureToggles') + ->minItems(1) + ->writable(), + ], + ), + ); + + $this->expectException(UnprocessableEntityException::class); + + $this->api->handle( + $this->buildRequest('POST', '/customers')->withParsedBody([ + 'data' => ['type' => 'customers', 'attributes' => ['featureToggles' => []]], + ]), + ); + } + + public function test_invalid_max_length() + { + $this->api->resource( + new MockResource( + 'customers', + endpoints: [Create::make()], + fields: [ + ArrayField::make('featureToggles') + ->maxItems(1) + ->writable(), + ], + ), + ); + + $this->expectException(UnprocessableEntityException::class); + + $this->api->handle( + $this->buildRequest('POST', '/customers')->withParsedBody([ + 'data' => ['type' => 'customers', 'attributes' => ['featureToggles' => [1, 2]]], + ]), + ); + } + + public function test_invalid_uniqueness() + { + $this->api->resource( + new MockResource( + 'customers', + endpoints: [Create::make()], + fields: [ + ArrayField::make('featureToggles') + ->uniqueItems() + ->writable(), + ], + ), + ); + + $this->expectException(UnprocessableEntityException::class); + + $this->api->handle( + $this->buildRequest('POST', '/customers')->withParsedBody([ + 'data' => ['type' => 'customers', 'attributes' => ['featureToggles' => [1, 1]]], + ]), + ); + } + + public function test_valid_items_constraints() + { + $this->api->resource( + new MockResource( + 'customers', + endpoints: [Create::make()], + fields: [ + ArrayField::make('featureToggles') + ->minItems(2) + ->maxItems(4) + ->uniqueItems() + ->writable(), + ], + ), + ); + + $response = $this->api->handle( + $this->buildRequest('POST', '/customers')->withParsedBody([ + 'data' => ['type' => 'customers', 'attributes' => ['featureToggles' => [1, 2, 3]]], + ]), + ); + + $this->assertJsonApiDocumentSubset( + ['data' => ['attributes' => ['featureToggles' => [1, 2, 3]]]], + $response->getBody(), + true, + ); + } +} From 4c0ef0db8bf3b6eb4cb342aafe40e74986809299 Mon Sep 17 00:00:00 2001 From: bertramakers Date: Fri, 10 Nov 2023 09:35:51 +0000 Subject: [PATCH 02/10] Run Prettier --- tests/feature/ArrayFieldTest.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/feature/ArrayFieldTest.php b/tests/feature/ArrayFieldTest.php index 45e9e52..8e4e065 100644 --- a/tests/feature/ArrayFieldTest.php +++ b/tests/feature/ArrayFieldTest.php @@ -24,10 +24,7 @@ public function test_validates_array() new MockResource( 'customers', endpoints: [Create::make()], - fields: [ - ArrayField::make('featureToggles') - ->writable(), - ], + fields: [ArrayField::make('featureToggles')->writable()], ), ); From 8ed89a4da2db5824688d0d32e7905b40220847f5 Mon Sep 17 00:00:00 2001 From: Bert Ramakers Date: Fri, 10 Nov 2023 10:37:22 +0100 Subject: [PATCH 03/10] Add schema validation for array items --- src/Schema/Field/ArrayField.php | 22 +++++++++++++- tests/feature/ArrayFieldTest.php | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/Schema/Field/ArrayField.php b/src/Schema/Field/ArrayField.php index 26a88af..5250b1e 100644 --- a/src/Schema/Field/ArrayField.php +++ b/src/Schema/Field/ArrayField.php @@ -2,17 +2,20 @@ namespace Tobyz\JsonApiServer\Schema\Field; +use Tobyz\JsonApiServer\Context; + class ArrayField extends Field { private int $minItems = 0; private ?int $maxItems = null; private bool $uniqueItems = false; + private ?Attribute $items = null; public function __construct(string $name) { parent::__construct($name); - $this->validate(function (mixed $value, callable $fail): void { + $this->validate(function (mixed $value, callable $fail, Context $context): void { if ($value === null) { return; } @@ -33,6 +36,16 @@ public function __construct(string $name) if ($this->uniqueItems && $value !== array_unique($value)) { $fail('must contain unique values'); } + + if ($this->items) { + foreach ($value as $position => $item) { + $itemFail = function ($detail = null) use ($fail, $position) { + $fail('item at position ' . $position . ' ' . $detail); + }; + + $this->items->validateValue($itemFail, $fail, $context); + } + } }); } @@ -56,4 +69,11 @@ public function uniqueItems(bool $uniqueItems = true): static return $this; } + + public function items(Attribute $schema): static + { + $this->items = $schema; + + return $this; + } } diff --git a/tests/feature/ArrayFieldTest.php b/tests/feature/ArrayFieldTest.php index 45e9e52..a2871dc 100644 --- a/tests/feature/ArrayFieldTest.php +++ b/tests/feature/ArrayFieldTest.php @@ -6,6 +6,7 @@ use Tobyz\JsonApiServer\Exception\UnprocessableEntityException; use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\Schema\Field\ArrayField; +use Tobyz\JsonApiServer\Schema\Field\Str; use Tobyz\Tests\JsonApiServer\AbstractTestCase; use Tobyz\Tests\JsonApiServer\MockResource; @@ -137,4 +138,54 @@ public function test_valid_items_constraints() true, ); } + + public function test_invalid_item_schema() + { + $this->api->resource( + new MockResource( + 'customers', + endpoints: [Create::make()], + fields: [ + ArrayField::make('featureToggles') + ->items(Str::make('')->enum(['valid'])) + ->writable(), + ], + ), + ); + + $this->expectException(UnprocessableEntityException::class); + + $this->api->handle( + $this->buildRequest('POST', '/customers')->withParsedBody([ + 'data' => ['type' => 'customers', 'attributes' => ['featureToggles' => ['valid','invalid']]], + ]), + ); + } + + public function test_valid_item_schema() + { + $this->api->resource( + new MockResource( + 'customers', + endpoints: [Create::make()], + fields: [ + ArrayField::make('featureToggles') + ->items(Str::make('')->enum(['valid1','valid2'])) + ->writable(), + ], + ), + ); + + $response = $this->api->handle( + $this->buildRequest('POST', '/customers')->withParsedBody([ + 'data' => ['type' => 'customers', 'attributes' => ['featureToggles' => ['valid1', 'valid2']]], + ]), + ); + + $this->assertJsonApiDocumentSubset( + ['data' => ['attributes' => ['featureToggles' => ['valid1', 'valid2']]]], + $response->getBody(), + true, + ); + } } From f33c7da069b002ee1c620ba40c1fcb8b4f50a717 Mon Sep 17 00:00:00 2001 From: bertramakers Date: Fri, 10 Nov 2023 09:37:56 +0000 Subject: [PATCH 04/10] Run Prettier --- tests/feature/ArrayFieldTest.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/feature/ArrayFieldTest.php b/tests/feature/ArrayFieldTest.php index d5c0938..8fbff44 100644 --- a/tests/feature/ArrayFieldTest.php +++ b/tests/feature/ArrayFieldTest.php @@ -154,7 +154,10 @@ public function test_invalid_item_schema() $this->api->handle( $this->buildRequest('POST', '/customers')->withParsedBody([ - 'data' => ['type' => 'customers', 'attributes' => ['featureToggles' => ['valid','invalid']]], + 'data' => [ + 'type' => 'customers', + 'attributes' => ['featureToggles' => ['valid', 'invalid']], + ], ]), ); } @@ -167,7 +170,7 @@ public function test_valid_item_schema() endpoints: [Create::make()], fields: [ ArrayField::make('featureToggles') - ->items(Str::make('')->enum(['valid1','valid2'])) + ->items(Str::make('')->enum(['valid1', 'valid2'])) ->writable(), ], ), @@ -175,7 +178,10 @@ public function test_valid_item_schema() $response = $this->api->handle( $this->buildRequest('POST', '/customers')->withParsedBody([ - 'data' => ['type' => 'customers', 'attributes' => ['featureToggles' => ['valid1', 'valid2']]], + 'data' => [ + 'type' => 'customers', + 'attributes' => ['featureToggles' => ['valid1', 'valid2']], + ], ]), ); From 3528310415d21a60fa2d32e5915e10e64fe8c89e Mon Sep 17 00:00:00 2001 From: Bert Ramakers Date: Fri, 10 Nov 2023 14:11:03 +0100 Subject: [PATCH 05/10] Fix typo in item schema validation --- src/Schema/Field/ArrayField.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Schema/Field/ArrayField.php b/src/Schema/Field/ArrayField.php index 5250b1e..c22d41f 100644 --- a/src/Schema/Field/ArrayField.php +++ b/src/Schema/Field/ArrayField.php @@ -43,7 +43,7 @@ public function __construct(string $name) $fail('item at position ' . $position . ' ' . $detail); }; - $this->items->validateValue($itemFail, $fail, $context); + $this->items->validateValue($item, $itemFail, $context); } } }); From 44c24e97a78adf04536faa55c5d1eeee11ff90ce Mon Sep 17 00:00:00 2001 From: Bert Ramakers Date: Fri, 10 Nov 2023 14:39:34 +0100 Subject: [PATCH 06/10] Don't prefix error detail --- src/Schema/Field/ArrayField.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Schema/Field/ArrayField.php b/src/Schema/Field/ArrayField.php index c22d41f..a8950b2 100644 --- a/src/Schema/Field/ArrayField.php +++ b/src/Schema/Field/ArrayField.php @@ -38,12 +38,8 @@ public function __construct(string $name) } if ($this->items) { - foreach ($value as $position => $item) { - $itemFail = function ($detail = null) use ($fail, $position) { - $fail('item at position ' . $position . ' ' . $detail); - }; - - $this->items->validateValue($item, $itemFail, $context); + foreach ($value as $item) { + $this->items->validateValue($item, $fail, $context); } } }); From 753a3f5de5dfd4c0c1d2792fb8d4944dd236b0dd Mon Sep 17 00:00:00 2001 From: Bert Ramakers Date: Fri, 10 Nov 2023 14:49:08 +0100 Subject: [PATCH 07/10] Implement getSchema() --- src/Schema/Field/ArrayField.php | 11 +++++++++ tests/feature/ArrayFieldTest.php | 41 ++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/Schema/Field/ArrayField.php b/src/Schema/Field/ArrayField.php index a8950b2..ab9c610 100644 --- a/src/Schema/Field/ArrayField.php +++ b/src/Schema/Field/ArrayField.php @@ -72,4 +72,15 @@ public function items(Attribute $schema): static return $this; } + + public function getSchema(): array + { + return parent::getSchema() + [ + 'type' => 'array', + 'minItems' => $this->minItems, + 'maxItems' => $this->maxItems, + 'uniqueItems' => $this->uniqueItems, + 'items' => $this->items?->getSchema(), + ]; + } } diff --git a/tests/feature/ArrayFieldTest.php b/tests/feature/ArrayFieldTest.php index 8fbff44..ca9f37a 100644 --- a/tests/feature/ArrayFieldTest.php +++ b/tests/feature/ArrayFieldTest.php @@ -136,7 +136,7 @@ public function test_valid_items_constraints() ); } - public function test_invalid_item_schema() + public function test_invalid_items() { $this->api->resource( new MockResource( @@ -162,7 +162,7 @@ public function test_invalid_item_schema() ); } - public function test_valid_item_schema() + public function test_valid_items() { $this->api->resource( new MockResource( @@ -191,4 +191,41 @@ public function test_valid_item_schema() true, ); } + + public function test_schema() + { + $this->assertEquals( + [ + 'type' => 'array', + 'minItems' => 0, + 'maxItems' => null, + 'uniqueItems' => false, + 'items' => null, + ], + ArrayField::make('featureToggles') + ->getSchema(), + ); + + $this->assertEquals( + [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => 10, + 'uniqueItems' => true, + 'items' => [ + 'type' => 'string', + 'enum' => ['valid1', 'valid2'], + ], + ], + ArrayField::make('featureToggles') + ->minItems(1) + ->maxItems(10) + ->uniqueItems() + ->items( + Str::make('') + ->enum(['valid1', 'valid2']) + ) + ->getSchema(), + ); + } } From d4c2c632baf02120acc88d4dc55e05eb72356952 Mon Sep 17 00:00:00 2001 From: bertramakers Date: Fri, 10 Nov 2023 13:49:35 +0000 Subject: [PATCH 08/10] Run Prettier --- tests/feature/ArrayFieldTest.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/feature/ArrayFieldTest.php b/tests/feature/ArrayFieldTest.php index ca9f37a..296ba8a 100644 --- a/tests/feature/ArrayFieldTest.php +++ b/tests/feature/ArrayFieldTest.php @@ -202,8 +202,7 @@ public function test_schema() 'uniqueItems' => false, 'items' => null, ], - ArrayField::make('featureToggles') - ->getSchema(), + ArrayField::make('featureToggles')->getSchema(), ); $this->assertEquals( @@ -221,10 +220,7 @@ public function test_schema() ->minItems(1) ->maxItems(10) ->uniqueItems() - ->items( - Str::make('') - ->enum(['valid1', 'valid2']) - ) + ->items(Str::make('')->enum(['valid1', 'valid2'])) ->getSchema(), ); } From 3329071bf7920c5196755f37f85b1f084b131cfe Mon Sep 17 00:00:00 2001 From: Bert Ramakers Date: Fri, 10 Nov 2023 15:06:33 +0100 Subject: [PATCH 09/10] Add missing schema properties to test --- tests/feature/ArrayFieldTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/feature/ArrayFieldTest.php b/tests/feature/ArrayFieldTest.php index ca9f37a..995cf3b 100644 --- a/tests/feature/ArrayFieldTest.php +++ b/tests/feature/ArrayFieldTest.php @@ -201,6 +201,8 @@ public function test_schema() 'maxItems' => null, 'uniqueItems' => false, 'items' => null, + 'description' => null, + 'nullable' => false, ], ArrayField::make('featureToggles') ->getSchema(), @@ -216,6 +218,8 @@ public function test_schema() 'type' => 'string', 'enum' => ['valid1', 'valid2'], ], + 'description' => null, + 'nullable' => false, ], ArrayField::make('featureToggles') ->minItems(1) From 79c887544d487dc97db1dce8fe411d941c10cffe Mon Sep 17 00:00:00 2001 From: Bert Ramakers Date: Fri, 10 Nov 2023 15:12:31 +0100 Subject: [PATCH 10/10] Add missing schema properties of items schema in test --- tests/feature/ArrayFieldTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/feature/ArrayFieldTest.php b/tests/feature/ArrayFieldTest.php index 2dbbd0e..19cd33a 100644 --- a/tests/feature/ArrayFieldTest.php +++ b/tests/feature/ArrayFieldTest.php @@ -216,6 +216,12 @@ public function test_schema() 'items' => [ 'type' => 'string', 'enum' => ['valid1', 'valid2'], + 'description' => null, + 'nullable' => false, + 'minLength' => 0, + 'maxLength' => null, + 'pattern' => null, + 'format' => null, ], 'description' => null, 'nullable' => false,