Browse Source

add support for Type::map() to validate key-value objects

phpdevcommunity 7 months ago
parent
commit
ebda9de6e4

+ 7 - 1
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.

+ 5 - 1
src/Hydrator/ObjectHydrator.php

@@ -5,6 +5,8 @@ namespace PhpDevCommunity\RequestKit\Hydrator;
 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 @@ final class ObjectHydrator
                     $value = $elements;
                 }
             }
-
+            if ($value instanceof KeyValueObject) {
+                $value = $value->getArrayCopy();
+            }
             if (in_array( $propertyName, $propertiesPublic)) {
                 $object->$propertyName = $value;
             }elseif (method_exists($object, 'set' . $propertyName)) {

+ 23 - 0
src/Schema/AbstractSchema.php

@@ -38,6 +38,13 @@ abstract class AbstractSchema
         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 @@ abstract class AbstractSchema
         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 @@ abstract class AbstractSchema
         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);

+ 3 - 0
src/Schema/SchemaAccessor.php

@@ -82,6 +82,9 @@ final class SchemaAccessor
             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)) {

+ 6 - 0
src/Type.php

@@ -11,6 +11,7 @@ use PhpDevCommunity\RequestKit\Type\EmailType;
 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 @@ final class Type
         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) {

+ 13 - 1
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 @@ final class ArrayOfType extends AbstractType
         return $this;
     }
 
+    public function acceptCommaSeparatedValues(): self
+    {
+        $this->acceptCommaSeparatedValues = true;
+        return $this;
+    }
+
     public function __construct(AbstractType $type)
     {
         $this->type = $type;
@@ -56,6 +63,11 @@ final class ArrayOfType extends AbstractType
             $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;

+ 92 - 0
src/Type/MapType.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
+use PhpDevCommunity\RequestKit\Schema\Schema;
+use PhpDevCommunity\RequestKit\Utils\KeyValueObject;
+use PhpDevCommunity\RequestKit\ValidationResult;
+
+final class MapType extends AbstractType
+{
+    private AbstractType $type;
+
+    private ?int $min = null;
+    private ?int $max = null;
+
+    public function min(int $min): self
+    {
+        $this->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()));
+    }
+}

+ 11 - 0
src/Utils/KeyValueObject.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Utils;
+
+final class KeyValueObject extends \ArrayObject implements \JsonSerializable
+{
+    public function jsonSerialize() : object
+    {
+        return (object)$this->getArrayCopy();
+    }
+}

+ 52 - 0
tests/SchemaTest.php

@@ -6,6 +6,7 @@ use DateTime;
 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 @@ class SchemaTest extends TestCase
         $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')));
+
     }
 }