From c4e8c7ae83d0f343ebfb9a5e1af63ebe5c01d1f6 Mon Sep 17 00:00:00 2001 From: phpdevcommunity Date: Fri, 25 Apr 2025 09:31:51 +0000 Subject: [PATCH 1/3] Added support for string keys in ArrayOf types via acceptStringKeys() method. --- src/Schema/AbstractSchema.php | 2 +- src/Type/ArrayOfType.php | 12 +++++++++++- tests/SchemaTest.php | 22 ++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Schema/AbstractSchema.php b/src/Schema/AbstractSchema.php index 05ce019..e63d1f5 100755 --- a/src/Schema/AbstractSchema.php +++ b/src/Schema/AbstractSchema.php @@ -50,7 +50,7 @@ final public function processJsonInput(string $json, int $depth = 512, int $flag final public function processHttpRequest(ServerRequestInterface $request): SchemaAccessor { - if ($request->getHeader('Content-Type')[0] === 'application/json') { + if (in_array('application/json', $request->getHeader('Content-Type'))) { return $this->processJsonInput($request->getBody()->getContents()); } return $this->process($request->getParsedBody()); diff --git a/src/Type/ArrayOfType.php b/src/Type/ArrayOfType.php index c94ac3e..25ed0fd 100755 --- a/src/Type/ArrayOfType.php +++ b/src/Type/ArrayOfType.php @@ -12,6 +12,7 @@ final class ArrayOfType extends AbstractType private ?int $min = null; private ?int $max = null; + private ?bool $acceptStringKeys = false; public function min(int $min): self { @@ -25,6 +26,12 @@ public function max(int $max): self return $this; } + public function acceptStringKeys(): self + { + $this->acceptStringKeys = true; + return $this; + } + public function __construct(AbstractType $type) { $this->type = $type; @@ -66,10 +73,13 @@ protected function validateValue(ValidationResult $result): void } foreach ($values as $key => $value) { - if (!is_int($key)) { + if ($this->acceptStringKeys === false && !is_int($key)) { $result->setError('All keys must be integers'); return; } + if (is_string($key)) { + $key = trim($key); + } $definitions[$key] = $this->type; } if (empty($definitions)) { diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index a78a0fd..eda7154 100755 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -41,6 +41,7 @@ protected function execute(): void $this->testMultipleValidationErrors(); $this->testNestedData(); $this->testCollection(); + $this->testArray(); $this->testExtend(); $this->testExampleData(); @@ -376,4 +377,25 @@ private function testExampleData() ] ]); } + + private function testArray() + { + + $schema = Schema::create([ + 'roles' => Type::arrayOf(Type::string()->strict())->required()->example('admin'), + 'dependencies' => Type::arrayOf(Type::string()->strict())->acceptStringKeys() + ]); + + $data = [ + 'roles' => ['admin'], + 'dependencies' => [ + 'key1' => 'value1', + 'key2' => 'value2', + ], + ]; + $result = $schema->process($data); + $this->assertStrictEquals('admin', $result->get('roles.0')); + $this->assertStrictEquals('value1', $result->get('dependencies.key1')); + $this->assertStrictEquals('value2', $result->get('dependencies.key2')); + } } From ebda9de6e44547ebcee2f26b7a53f15a21dd1a9d Mon Sep 17 00:00:00 2001 From: phpdevcommunity Date: Mon, 12 May 2025 10:43:02 +0000 Subject: [PATCH 2/3] add support for Type::map() to validate key-value objects --- README.md | 8 ++- src/Hydrator/ObjectHydrator.php | 6 ++- src/Schema/AbstractSchema.php | 23 +++++++++ src/Schema/SchemaAccessor.php | 3 ++ src/Type.php | 6 +++ src/Type/ArrayOfType.php | 14 ++++- src/Type/MapType.php | 92 +++++++++++++++++++++++++++++++++ src/Utils/KeyValueObject.php | 11 ++++ tests/SchemaTest.php | 52 +++++++++++++++++++ 9 files changed, 212 insertions(+), 3 deletions(-) create mode 100755 src/Type/MapType.php create mode 100755 src/Utils/KeyValueObject.php diff --git a/README.md b/README.md index e8d673d..446ceee 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ class UserController 'email' => Type::email()->required(), 'age' => Type::int()->min(18), 'roles' => Type::arrayOf(Type::string())->required(), + 'metadata' => Type::map(Type::string())->required(), 'address' => Type::item([ 'street' => Type::string()->length(5, 100), 'city' => Type::string()->allowed('Paris', 'London'), @@ -108,6 +109,7 @@ class UserController $email = $validatedData->get('user.email'); $age = $validatedData->get('user.age'); $roles = $validatedData->get('user.roles'); + $metadata = $validatedData->get('user.metadata'); // <-- map : Instance Of KeyValueObject $street = $validatedData->get('user.address.street'); $city = $validatedData->get('user.address.city'); @@ -130,6 +132,10 @@ $requestData = [ 'email' => 'john.doe@example.com', 'age' => 30, 'roles' => ['admin', 'user'], + 'metadata' => [ + 'department' => 'IT', + 'level' => 'senior', + ], 'address' => [ 'street' => 'Main Street', 'city' => 'London', @@ -360,7 +366,7 @@ Use `toResponse()` to get a pre-formatted associative array suitable for returni * **`Type::item(array $schema)`:** For nested objects/items. Defines a schema for a nested object within the main schema. * **`Type::arrayOf(Type $type)`:** For collections/arrays. Defines that a field should be an array of items, each validated against the provided `Type`. - +* **`Type::map(Type $type)`:** For key-value objects (associative arrays). Defines that a field should be an object where each value is validated against the provided Type, and keys must be strings. ## Extending Schemas You can extend existing schemas to reuse and build upon validation logic. diff --git a/src/Hydrator/ObjectHydrator.php b/src/Hydrator/ObjectHydrator.php index 601007b..3dee9a3 100755 --- a/src/Hydrator/ObjectHydrator.php +++ b/src/Hydrator/ObjectHydrator.php @@ -5,6 +5,8 @@ use LogicException; use PhpDevCommunity\RequestKit\Type\ArrayOfType; use PhpDevCommunity\RequestKit\Type\ItemType; +use PhpDevCommunity\RequestKit\Type\MapType; +use PhpDevCommunity\RequestKit\Utils\KeyValueObject; use ReflectionClass; final class ObjectHydrator @@ -52,7 +54,9 @@ private function hydrateObject($objectClass, array $data, array $definitions): o $value = $elements; } } - + if ($value instanceof KeyValueObject) { + $value = $value->getArrayCopy(); + } if (in_array( $propertyName, $propertiesPublic)) { $object->$propertyName = $value; }elseif (method_exists($object, 'set' . $propertyName)) { diff --git a/src/Schema/AbstractSchema.php b/src/Schema/AbstractSchema.php index e63d1f5..f93c819 100755 --- a/src/Schema/AbstractSchema.php +++ b/src/Schema/AbstractSchema.php @@ -38,6 +38,13 @@ final public function version(string $version): self return $this; } + /** + * @param string $json + * @param int $depth + * @param int $flags + * @return SchemaAccessor + * @throws InvalidDataException + */ final public function processJsonInput(string $json, int $depth = 512, int $flags = 0): SchemaAccessor { $data = json_decode($json, true, $depth , $flags); @@ -48,6 +55,11 @@ final public function processJsonInput(string $json, int $depth = 512, int $flag return $this->process($data); } + /** + * @param ServerRequestInterface $request + * @return SchemaAccessor + * @throws InvalidDataException + */ final public function processHttpRequest(ServerRequestInterface $request): SchemaAccessor { if (in_array('application/json', $request->getHeader('Content-Type'))) { @@ -56,11 +68,22 @@ final public function processHttpRequest(ServerRequestInterface $request): Schem return $this->process($request->getParsedBody()); } + /** + * @param ServerRequestInterface $request + * @return SchemaAccessor + * @throws InvalidDataException + */ final public function processHttpQuery(ServerRequestInterface $request): SchemaAccessor { return $this->process($request->getQueryParams(), true); } + /** + * @param array $data + * @param bool $allowEmptyData + * @return SchemaAccessor + * @throws InvalidDataException + */ final public function process(array $data, bool $allowEmptyData = false): SchemaAccessor { $accessor = new SchemaAccessor($data, $this, $allowEmptyData); diff --git a/src/Schema/SchemaAccessor.php b/src/Schema/SchemaAccessor.php index ea7da3c..3b0d5ef 100755 --- a/src/Schema/SchemaAccessor.php +++ b/src/Schema/SchemaAccessor.php @@ -82,6 +82,9 @@ public function get(string $key) throw new InvalidArgumentException('Schema not executed, call execute() first'); } $current = $this->toArray(); + if (array_key_exists($key, $current)) { + return $current[$key]; + } $pointer = strtok($key, '.'); while ($pointer !== false) { if (!array_key_exists($pointer, $current)) { diff --git a/src/Type.php b/src/Type.php index 18c4d9b..3bfc1d5 100755 --- a/src/Type.php +++ b/src/Type.php @@ -11,6 +11,7 @@ use PhpDevCommunity\RequestKit\Type\FloatType; use PhpDevCommunity\RequestKit\Type\IntType; use PhpDevCommunity\RequestKit\Type\ItemType; +use PhpDevCommunity\RequestKit\Type\MapType; use PhpDevCommunity\RequestKit\Type\NumericType; use PhpDevCommunity\RequestKit\Type\StringType; use PhpDevCommunity\RequestKit\Utils\DateOnly; @@ -67,6 +68,11 @@ public static function arrayOf(AbstractType $type) : ArrayOfType return new ArrayOfType($type); } + public static function map(AbstractType $type) : MapType + { + return new MapType($type); + } + public static function typeObject(string $type): ?AbstractType { if ($type=== DateOnly::class) { diff --git a/src/Type/ArrayOfType.php b/src/Type/ArrayOfType.php index 25ed0fd..5c21bcd 100755 --- a/src/Type/ArrayOfType.php +++ b/src/Type/ArrayOfType.php @@ -12,7 +12,8 @@ final class ArrayOfType extends AbstractType private ?int $min = null; private ?int $max = null; - private ?bool $acceptStringKeys = false; + private bool $acceptStringKeys = false; + private bool $acceptCommaSeparatedValues = false; public function min(int $min): self { @@ -32,6 +33,12 @@ public function acceptStringKeys(): self return $this; } + public function acceptCommaSeparatedValues(): self + { + $this->acceptCommaSeparatedValues = true; + return $this; + } + public function __construct(AbstractType $type) { $this->type = $type; @@ -56,6 +63,11 @@ protected function validateValue(ValidationResult $result): void $this->min = 1; } $values = $result->getValue(); + if (is_string($values) && $this->acceptCommaSeparatedValues) { + $values = explode(',', $values); + $values = array_map('trim', $values); + $values = array_filter($values, fn($v) => $v !== ''); + } if (!is_array($values)) { $result->setError('Value must be an array'); return; diff --git a/src/Type/MapType.php b/src/Type/MapType.php new file mode 100755 index 0000000..50ba384 --- /dev/null +++ b/src/Type/MapType.php @@ -0,0 +1,92 @@ +min = $min; + return $this; + } + + public function max(int $max): self + { + $this->max = $max; + return $this; + } + + public function __construct(AbstractType $type) + { + $this->type = $type; + $this->default(new KeyValueObject()); + } + + public function getCopyType(): AbstractType + { + return clone $this->type; + } + + protected function forceDefaultValue(ValidationResult $result): void + { + if ($result->getValue() === null) { + $result->setValue(new KeyValueObject()); + } + } + + protected function validateValue(ValidationResult $result): void + { + if ($this->isRequired() && empty($this->min)) { + $this->min = 1; + } + $values = $result->getValue(); + if (!is_array($values) && !$values instanceof KeyValueObject) { + $result->setError('Value must be an array or KeyValueObject'); + return; + } + + $count = count($values); + if ($this->min && $count < $this->min) { + $result->setError("Value must have at least $this->min item(s)"); + return; + } + if ($this->max && $count > $this->max) { + $result->setError("Value must have at most $this->max item(s)"); + return; + } + + $definitions = []; + foreach ($values as $key => $value) { + if (!is_string($key)) { + $result->setError(sprintf( 'Key "%s" must be a string, got %s', $key, gettype($key))); + return; + } + $key = trim($key); + $definitions[$key] = $this->type; + } + if (empty($definitions)) { + $result->setValue(new KeyValueObject()); + return; + } + + $schema = Schema::create($definitions); + try { + $values = $schema->process($values); + } catch (InvalidDataException $e) { + $result->setErrors($e->getErrors(), false); + return; + } + + $result->setValue(new KeyValueObject($values->toArray())); + } +} diff --git a/src/Utils/KeyValueObject.php b/src/Utils/KeyValueObject.php new file mode 100755 index 0000000..9c86f54 --- /dev/null +++ b/src/Utils/KeyValueObject.php @@ -0,0 +1,11 @@ +getArrayCopy(); + } +} diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index eda7154..e2bc492 100755 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -6,6 +6,7 @@ use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException; use PhpDevCommunity\RequestKit\Schema\Schema; use PhpDevCommunity\RequestKit\Type; +use PhpDevCommunity\RequestKit\Utils\KeyValueObject; use PhpDevCommunity\UniTester\TestCase; class SchemaTest extends TestCase @@ -397,5 +398,56 @@ private function testArray() $this->assertStrictEquals('admin', $result->get('roles.0')); $this->assertStrictEquals('value1', $result->get('dependencies.key1')); $this->assertStrictEquals('value2', $result->get('dependencies.key2')); + + + $schema = Schema::create([ + 'roles' => Type::arrayOf(Type::string()->strict())->required()->example('admin')->acceptCommaSeparatedValues(), + ]); + + $data = [ + 'roles' => 'admin,user,manager', + ]; + $result = $schema->process($data); + $this->assertStrictEquals('admin', $result->get('roles.0')); + $this->assertStrictEquals('user', $result->get('roles.1')); + $this->assertStrictEquals('manager', $result->get('roles.2')); + + + $schema = Schema::create([ + 'autoload.psr-4' => Type::map(Type::string()->strict()->trim())->required(), + 'dependencies' => Type::map(Type::string()->strict()->trim()) + ]); + + $data = [ + 'autoload.psr-4' => [ + 'App\\' => 'app/', + ], + 'dependencies' => [ + 'key1' => 'value1', + 'key2' => 'value2', + ], + ]; + $result = $schema->process($data); + $this->assertInstanceOf( KeyValueObject::class, $result->get('autoload.psr-4')); + $this->assertInstanceOf( KeyValueObject::class, $result->get('dependencies')); + $this->assertEquals(1, count($result->get('autoload.psr-4'))); + $this->assertEquals(2, count($result->get('dependencies'))); + + + $schema = Schema::create([ + 'autoload.psr-4' => Type::map(Type::string()->strict()->trim()), + 'dependencies' => Type::map(Type::string()->strict()->trim()) + ]); + + $data = [ + 'autoload.psr-4' => [ + ], + ]; + $result = $schema->process($data); + $this->assertInstanceOf( KeyValueObject::class, $result->get('autoload.psr-4')); + $this->assertInstanceOf( KeyValueObject::class, $result->get('dependencies')); + $this->assertEquals(0, count($result->get('autoload.psr-4'))); + $this->assertEquals(0, count($result->get('dependencies'))); + } } From ff13aff0d1cc09a2a1f3431d9725a32a525ed3b1 Mon Sep 17 00:00:00 2001 From: phpdevcommunity Date: Mon, 12 May 2025 10:49:10 +0000 Subject: [PATCH 3/3] add support for Type::map() to validate key-value objects and requiredIf method --- src/Type/AbstractType.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Type/AbstractType.php b/src/Type/AbstractType.php index 57424b3..6883de1 100755 --- a/src/Type/AbstractType.php +++ b/src/Type/AbstractType.php @@ -24,6 +24,21 @@ final public function required(): self return $this; } + final public function requiredIf($condition) : self + { + if (!is_bool($condition) && !is_callable($condition)) { + throw new \InvalidArgumentException('condition must be boolean or callable'); + } + + if (is_callable($condition)) { + $condition = $condition(); + } + if ($condition) { + $this->required(); + } + return $this; + } + final public function optional(): self { $this->required = false;