Skip to content

Commit ebda9de

Browse files
add support for Type::map() to validate key-value objects
1 parent c4e8c7a commit ebda9de

File tree

9 files changed

+212
-3
lines changed

9 files changed

+212
-3
lines changed

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ class UserController
9494
'email' => Type::email()->required(),
9595
'age' => Type::int()->min(18),
9696
'roles' => Type::arrayOf(Type::string())->required(),
97+
'metadata' => Type::map(Type::string())->required(),
9798
'address' => Type::item([
9899
'street' => Type::string()->length(5, 100),
99100
'city' => Type::string()->allowed('Paris', 'London'),
@@ -108,6 +109,7 @@ class UserController
108109
$email = $validatedData->get('user.email');
109110
$age = $validatedData->get('user.age');
110111
$roles = $validatedData->get('user.roles');
112+
$metadata = $validatedData->get('user.metadata'); // <-- map : Instance Of KeyValueObject
111113
$street = $validatedData->get('user.address.street');
112114
$city = $validatedData->get('user.address.city');
113115

@@ -130,6 +132,10 @@ $requestData = [
130132
'email' => '[email protected]',
131133
'age' => 30,
132134
'roles' => ['admin', 'user'],
135+
'metadata' => [
136+
'department' => 'IT',
137+
'level' => 'senior',
138+
],
133139
'address' => [
134140
'street' => 'Main Street',
135141
'city' => 'London',
@@ -360,7 +366,7 @@ Use `toResponse()` to get a pre-formatted associative array suitable for returni
360366
* **`Type::item(array $schema)`:** For nested objects/items. Defines a schema for a nested object within the main schema.
361367

362368
* **`Type::arrayOf(Type $type)`:** For collections/arrays. Defines that a field should be an array of items, each validated against the provided `Type`.
363-
369+
* **`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.
364370
## Extending Schemas
365371

366372
You can extend existing schemas to reuse and build upon validation logic.

src/Hydrator/ObjectHydrator.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use LogicException;
66
use PhpDevCommunity\RequestKit\Type\ArrayOfType;
77
use PhpDevCommunity\RequestKit\Type\ItemType;
8+
use PhpDevCommunity\RequestKit\Type\MapType;
9+
use PhpDevCommunity\RequestKit\Utils\KeyValueObject;
810
use ReflectionClass;
911

1012
final class ObjectHydrator
@@ -52,7 +54,9 @@ private function hydrateObject($objectClass, array $data, array $definitions): o
5254
$value = $elements;
5355
}
5456
}
55-
57+
if ($value instanceof KeyValueObject) {
58+
$value = $value->getArrayCopy();
59+
}
5660
if (in_array( $propertyName, $propertiesPublic)) {
5761
$object->$propertyName = $value;
5862
}elseif (method_exists($object, 'set' . $propertyName)) {

src/Schema/AbstractSchema.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ final public function version(string $version): self
3838
return $this;
3939
}
4040

41+
/**
42+
* @param string $json
43+
* @param int $depth
44+
* @param int $flags
45+
* @return SchemaAccessor
46+
* @throws InvalidDataException
47+
*/
4148
final public function processJsonInput(string $json, int $depth = 512, int $flags = 0): SchemaAccessor
4249
{
4350
$data = json_decode($json, true, $depth , $flags);
@@ -48,6 +55,11 @@ final public function processJsonInput(string $json, int $depth = 512, int $flag
4855
return $this->process($data);
4956
}
5057

58+
/**
59+
* @param ServerRequestInterface $request
60+
* @return SchemaAccessor
61+
* @throws InvalidDataException
62+
*/
5163
final public function processHttpRequest(ServerRequestInterface $request): SchemaAccessor
5264
{
5365
if (in_array('application/json', $request->getHeader('Content-Type'))) {
@@ -56,11 +68,22 @@ final public function processHttpRequest(ServerRequestInterface $request): Schem
5668
return $this->process($request->getParsedBody());
5769
}
5870

71+
/**
72+
* @param ServerRequestInterface $request
73+
* @return SchemaAccessor
74+
* @throws InvalidDataException
75+
*/
5976
final public function processHttpQuery(ServerRequestInterface $request): SchemaAccessor
6077
{
6178
return $this->process($request->getQueryParams(), true);
6279
}
6380

81+
/**
82+
* @param array $data
83+
* @param bool $allowEmptyData
84+
* @return SchemaAccessor
85+
* @throws InvalidDataException
86+
*/
6487
final public function process(array $data, bool $allowEmptyData = false): SchemaAccessor
6588
{
6689
$accessor = new SchemaAccessor($data, $this, $allowEmptyData);

src/Schema/SchemaAccessor.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ public function get(string $key)
8282
throw new InvalidArgumentException('Schema not executed, call execute() first');
8383
}
8484
$current = $this->toArray();
85+
if (array_key_exists($key, $current)) {
86+
return $current[$key];
87+
}
8588
$pointer = strtok($key, '.');
8689
while ($pointer !== false) {
8790
if (!array_key_exists($pointer, $current)) {

src/Type.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use PhpDevCommunity\RequestKit\Type\FloatType;
1212
use PhpDevCommunity\RequestKit\Type\IntType;
1313
use PhpDevCommunity\RequestKit\Type\ItemType;
14+
use PhpDevCommunity\RequestKit\Type\MapType;
1415
use PhpDevCommunity\RequestKit\Type\NumericType;
1516
use PhpDevCommunity\RequestKit\Type\StringType;
1617
use PhpDevCommunity\RequestKit\Utils\DateOnly;
@@ -67,6 +68,11 @@ public static function arrayOf(AbstractType $type) : ArrayOfType
6768
return new ArrayOfType($type);
6869
}
6970

71+
public static function map(AbstractType $type) : MapType
72+
{
73+
return new MapType($type);
74+
}
75+
7076
public static function typeObject(string $type): ?AbstractType
7177
{
7278
if ($type=== DateOnly::class) {

src/Type/ArrayOfType.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ final class ArrayOfType extends AbstractType
1212

1313
private ?int $min = null;
1414
private ?int $max = null;
15-
private ?bool $acceptStringKeys = false;
15+
private bool $acceptStringKeys = false;
16+
private bool $acceptCommaSeparatedValues = false;
1617

1718
public function min(int $min): self
1819
{
@@ -32,6 +33,12 @@ public function acceptStringKeys(): self
3233
return $this;
3334
}
3435

36+
public function acceptCommaSeparatedValues(): self
37+
{
38+
$this->acceptCommaSeparatedValues = true;
39+
return $this;
40+
}
41+
3542
public function __construct(AbstractType $type)
3643
{
3744
$this->type = $type;
@@ -56,6 +63,11 @@ protected function validateValue(ValidationResult $result): void
5663
$this->min = 1;
5764
}
5865
$values = $result->getValue();
66+
if (is_string($values) && $this->acceptCommaSeparatedValues) {
67+
$values = explode(',', $values);
68+
$values = array_map('trim', $values);
69+
$values = array_filter($values, fn($v) => $v !== '');
70+
}
5971
if (!is_array($values)) {
6072
$result->setError('Value must be an array');
6173
return;

src/Type/MapType.php

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
namespace PhpDevCommunity\RequestKit\Type;
4+
5+
use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
6+
use PhpDevCommunity\RequestKit\Schema\Schema;
7+
use PhpDevCommunity\RequestKit\Utils\KeyValueObject;
8+
use PhpDevCommunity\RequestKit\ValidationResult;
9+
10+
final class MapType extends AbstractType
11+
{
12+
private AbstractType $type;
13+
14+
private ?int $min = null;
15+
private ?int $max = null;
16+
17+
public function min(int $min): self
18+
{
19+
$this->min = $min;
20+
return $this;
21+
}
22+
23+
public function max(int $max): self
24+
{
25+
$this->max = $max;
26+
return $this;
27+
}
28+
29+
public function __construct(AbstractType $type)
30+
{
31+
$this->type = $type;
32+
$this->default(new KeyValueObject());
33+
}
34+
35+
public function getCopyType(): AbstractType
36+
{
37+
return clone $this->type;
38+
}
39+
40+
protected function forceDefaultValue(ValidationResult $result): void
41+
{
42+
if ($result->getValue() === null) {
43+
$result->setValue(new KeyValueObject());
44+
}
45+
}
46+
47+
protected function validateValue(ValidationResult $result): void
48+
{
49+
if ($this->isRequired() && empty($this->min)) {
50+
$this->min = 1;
51+
}
52+
$values = $result->getValue();
53+
if (!is_array($values) && !$values instanceof KeyValueObject) {
54+
$result->setError('Value must be an array or KeyValueObject');
55+
return;
56+
}
57+
58+
$count = count($values);
59+
if ($this->min && $count < $this->min) {
60+
$result->setError("Value must have at least $this->min item(s)");
61+
return;
62+
}
63+
if ($this->max && $count > $this->max) {
64+
$result->setError("Value must have at most $this->max item(s)");
65+
return;
66+
}
67+
68+
$definitions = [];
69+
foreach ($values as $key => $value) {
70+
if (!is_string($key)) {
71+
$result->setError(sprintf( 'Key "%s" must be a string, got %s', $key, gettype($key)));
72+
return;
73+
}
74+
$key = trim($key);
75+
$definitions[$key] = $this->type;
76+
}
77+
if (empty($definitions)) {
78+
$result->setValue(new KeyValueObject());
79+
return;
80+
}
81+
82+
$schema = Schema::create($definitions);
83+
try {
84+
$values = $schema->process($values);
85+
} catch (InvalidDataException $e) {
86+
$result->setErrors($e->getErrors(), false);
87+
return;
88+
}
89+
90+
$result->setValue(new KeyValueObject($values->toArray()));
91+
}
92+
}

src/Utils/KeyValueObject.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace PhpDevCommunity\RequestKit\Utils;
4+
5+
final class KeyValueObject extends \ArrayObject implements \JsonSerializable
6+
{
7+
public function jsonSerialize() : object
8+
{
9+
return (object)$this->getArrayCopy();
10+
}
11+
}

tests/SchemaTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
77
use PhpDevCommunity\RequestKit\Schema\Schema;
88
use PhpDevCommunity\RequestKit\Type;
9+
use PhpDevCommunity\RequestKit\Utils\KeyValueObject;
910
use PhpDevCommunity\UniTester\TestCase;
1011

1112
class SchemaTest extends TestCase
@@ -397,5 +398,56 @@ private function testArray()
397398
$this->assertStrictEquals('admin', $result->get('roles.0'));
398399
$this->assertStrictEquals('value1', $result->get('dependencies.key1'));
399400
$this->assertStrictEquals('value2', $result->get('dependencies.key2'));
401+
402+
403+
$schema = Schema::create([
404+
'roles' => Type::arrayOf(Type::string()->strict())->required()->example('admin')->acceptCommaSeparatedValues(),
405+
]);
406+
407+
$data = [
408+
'roles' => 'admin,user,manager',
409+
];
410+
$result = $schema->process($data);
411+
$this->assertStrictEquals('admin', $result->get('roles.0'));
412+
$this->assertStrictEquals('user', $result->get('roles.1'));
413+
$this->assertStrictEquals('manager', $result->get('roles.2'));
414+
415+
416+
$schema = Schema::create([
417+
'autoload.psr-4' => Type::map(Type::string()->strict()->trim())->required(),
418+
'dependencies' => Type::map(Type::string()->strict()->trim())
419+
]);
420+
421+
$data = [
422+
'autoload.psr-4' => [
423+
'App\\' => 'app/',
424+
],
425+
'dependencies' => [
426+
'key1' => 'value1',
427+
'key2' => 'value2',
428+
],
429+
];
430+
$result = $schema->process($data);
431+
$this->assertInstanceOf( KeyValueObject::class, $result->get('autoload.psr-4'));
432+
$this->assertInstanceOf( KeyValueObject::class, $result->get('dependencies'));
433+
$this->assertEquals(1, count($result->get('autoload.psr-4')));
434+
$this->assertEquals(2, count($result->get('dependencies')));
435+
436+
437+
$schema = Schema::create([
438+
'autoload.psr-4' => Type::map(Type::string()->strict()->trim()),
439+
'dependencies' => Type::map(Type::string()->strict()->trim())
440+
]);
441+
442+
$data = [
443+
'autoload.psr-4' => [
444+
],
445+
];
446+
$result = $schema->process($data);
447+
$this->assertInstanceOf( KeyValueObject::class, $result->get('autoload.psr-4'));
448+
$this->assertInstanceOf( KeyValueObject::class, $result->get('dependencies'));
449+
$this->assertEquals(0, count($result->get('autoload.psr-4')));
450+
$this->assertEquals(0, count($result->get('dependencies')));
451+
400452
}
401453
}

0 commit comments

Comments
 (0)