Browse Source

Initial commit for php-requestkit library

phpdevcommunity 8 months ago
commit
8b55a14339

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+/.idea
+/vendor

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 F. Michel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 387 - 0
README.md

@@ -0,0 +1,387 @@
+# php-requestkit
+
+**Lightweight and efficient PHP library for robust request data validation and transformation.**
+
+Simplify your request processing with `php-requestkit`. This library allows you to define schemas for your incoming HTTP requests (both form submissions and JSON payloads) and effortlessly validate and transform the data. Ensure data integrity and streamline your application logic with schema-based validation, input sanitization, and flexible transformation methods.
+
+## Key Features
+
+* **Schema-based validation:** Define clear and concise validation rules for your request data.
+* **Data transformation:**  Automatically transform and sanitize input data based on your schema.
+* **Multiple data types:** Supports strings, integers, booleans, dates, date-times, and numeric types with various constraints.
+* **Nested data and collections:** Validate complex data structures, including nested objects and arrays.
+* **Error handling:** Provides detailed error messages for easy debugging and user feedback.
+* **Extensible:**  Easily extend schemas and create reusable validation logic.
+
+## Installation
+
+```bash
+composer require phpdevcommunity/php-requestkit
+```
+
+## Basic Usage
+
+1.  **Create a Schema:** Define your data structure and validation rules using `Schema::create()` and `Type` classes.
+
+```php
+<?php
+
+use PhpDevCommunity\RequestKit\Schema\Schema;
+use PhpDevCommunity\RequestKit\Type;
+
+$userSchema = Schema::create([
+    'username' => Type::string()->length(5, 20)->required(),
+    'email' => Type::email()->required(),
+    'age' => Type::int()->min(18)->optional(), // Optional field
+]);
+```
+
+2.  **Process Request Data:** Use the `process()` method of your schema to validate and transform incoming data.
+
+```php
+<?php
+
+use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
+
+// ... (Schema creation from step 1) ...
+
+$requestData = [
+    'username' => 'john_doe',
+    'email' => 'john.doe@example.com',
+    'age' => '30', // Can be string, will be cast to int
+];
+
+try {
+    $validatedData = $userSchema->process($requestData);
+    // Access validated data as an array-like object
+    $username = $validatedData->get('username');
+    $email = $validatedData->get('email');
+    $age = $validatedData->get('age');
+
+    // ... continue processing with validated data ...
+
+    print_r($validatedData->toArray()); // Output validated data as array
+
+} catch (InvalidDataException $e) {
+    // Handle validation errors
+    $errors = $e->getErrors();
+    print_r($errors);
+}
+```
+
+## Usage Examples
+
+### Validating REST API Request Body (JSON or Form Data)
+
+This example demonstrates validating data from a REST API endpoint (e.g., POST, PUT, PATCH requests).
+
+```php
+<?php
+
+namespace MonApi\Controller;
+
+use PhpDevCommunity\RequestKit\Schema\Schema;
+use PhpDevCommunity\RequestKit\Type;
+use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
+
+class UserController
+{
+    public function createUser(array $requestData)
+    {
+        $schema = Schema::create([
+            'user' => Type::item([
+                'username' => Type::string()->length(5, 20)->required(),
+                'email' => Type::email()->required(),
+                'age' => Type::int()->min(18),
+                'roles' => Type::arrayOf(Type::string())->required(),
+                'address' => Type::item([
+                    'street' => Type::string()->length(5, 100),
+                    'city' => Type::string()->allowed('Paris', 'London'),
+                ]),
+            ]),
+        ]);
+
+        try {
+            $validatedData = $schema->process($requestData);
+            // Access validated data directly using dot notation
+            $username = $validatedData->get('user.username');
+            $email = $validatedData->get('user.email');
+            $age = $validatedData->get('user.age');
+            $roles = $validatedData->get('user.roles');
+            $street = $validatedData->get('user.address.street');
+            $city = $validatedData->get('user.address.city');
+
+            // Process validated data (e.g., save to database)
+            // ...
+            return $validatedData; // Or return a JSON response
+        } catch (InvalidDataException $e) {
+            // Handle validation errors and return an appropriate error response
+            http_response_code(400); // Bad Request
+            return ['errors' => $e->getErrors()]; // Or return a JSON error response
+        }
+    }
+}
+
+// Usage example (assuming $requestData is the parsed request body)
+$controller = new UserController();
+$requestData = [
+    'user' => [
+        'username' => 'john_doe',
+        'email' => 'john.doe@example.com',
+        'age' => 30,
+        'roles' => ['admin', 'user'],
+        'address' => [
+            'street' => 'Main Street',
+            'city' => 'London',
+        ],
+    ],
+];
+
+$result = $controller->createUser($requestData);
+print_r($result);
+```
+
+### Validating URL Parameters (Query String)
+
+Validate parameters passed in the URL's query string.
+
+```php
+<?php
+
+namespace MonApi\Controller;
+
+use PhpDevCommunity\RequestKit\Schema\Schema;
+use PhpDevCommunity\RequestKit\Type;
+use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
+
+class ProductController
+{
+    public function getProduct(array $urlParams)
+    {
+        $schema = Schema::create([
+            'id' => Type::int()->required()->min(1),
+            'category' => Type::string()->allowed('electronics', 'clothing')->required(),
+            'page' => Type::int()->min(1)->default(1), // Optional with default value
+        ]);
+
+        try {
+            $validatedParams = $schema->process($urlParams);
+            $id = $validatedParams->get('id');
+            $category = $validatedParams->get('category');
+            $page = $validatedParams->get('page'); // Will be 1 if 'page' is not in $urlParams
+
+            // Retrieve product using validated parameters
+            // ...
+            return $validatedParams; // Or return a JSON response
+        } catch (InvalidDataException $e) {
+            // Handle validation errors
+            http_response_code(400); // Bad Request
+            return ['errors' => $e->getErrors()]; // Or return a JSON error response
+        }
+    }
+}
+
+// Usage example (assuming $urlParams is extracted from $_GET)
+$controller = new ProductController();
+$urlParams = ['id' => 123, 'category' => 'electronics'];
+$result = $controller->getProduct($urlParams);
+print_r($result);
+```
+
+### Validating Collections of Data (Arrays)
+
+Validate arrays of data, especially useful for batch operations or list endpoints.
+
+```php
+<?php
+
+namespace MonApi\Controller;
+
+use PhpDevCommunity\RequestKit\Schema\Schema;
+use PhpDevCommunity\RequestKit\Type;
+use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
+
+class OrderController
+{
+    public function createOrders(array $ordersData)
+    {
+        $orderSchema = Type::item([
+            'product_id' => Type::int()->required(),
+            'quantity' => Type::int()->required()->min(1),
+        ]);
+
+        $schema = Schema::create(['orders' => Type::arrayOf($orderSchema)->required()]);
+
+        try {
+            $validatedOrders = $schema->process($ordersData);
+
+            $orders = $validatedOrders->get('orders'); // Array of validated order items
+            $firstProductId = $validatedOrders->get('orders.0.product_id');
+
+            // Process validated orders
+            // ...
+            return $validatedOrders; // Or return a JSON response
+        } catch (InvalidDataException $e) {
+            // Handle validation errors
+            http_response_code(400); // Bad Request
+            return ['errors' => $e->getErrors()]; // Or return a JSON error response
+        }
+    }
+}
+
+// Usage example (assuming $ordersData is the parsed request body)
+$controller = new OrderController();
+$ordersData = [
+    'orders' => [
+        ['product_id' => 1, 'quantity' => 2],
+        ['product_id' => 2, 'quantity' => 1],
+        ['product_id' => 'invalid', 'quantity' => 0], // Will trigger validation errors
+    ],
+];
+
+$result = $controller->createOrders($ordersData);
+print_r($result); // Will print error array if validation fails
+```
+
+## Error Handling with `InvalidDataException`
+
+When validation fails, the `Schema::process()` method throws an `InvalidDataException`.  This exception provides methods to access detailed error information.
+
+### Retrieving All Errors
+
+Use `getErrors()` to get an associative array where keys are field paths and values are error messages.
+
+```php
+<?php
+// ... inside a catch block for InvalidDataException ...
+
+    } catch (InvalidDataException $e) {
+        $errors = $e->getErrors();
+        // $errors will be like:
+        // [
+        //    'orders.2.product_id' => 'Value must be an integer, got: string',
+        //    'orders.2.quantity' => 'quantity must be at least 1',
+        // ]
+        return ['errors' => $errors];
+    }
+```
+
+### Retrieving a Specific Error
+
+Use `getError(string $key)` to get the error message for a specific field path. Returns `null` if no error exists for that path.
+
+```php
+<?php
+// ... inside a catch block for InvalidDataException ...
+
+    } catch (InvalidDataException $e) {
+        $productIdError = $e->getError('orders.2.product_id');
+        if ($productIdError) {
+            // $productIdError will be: 'Value must be an integer, got: string'
+            return ['errors' => ['product_id_error' => $productIdError]]; // Structure error response as needed
+        }
+    }
+```
+
+### Formatting Error Response with `toResponse()`
+
+Use `toResponse()` to get a pre-formatted associative array suitable for returning as an API error response. This includes status, a general error message, and detailed validation errors.
+
+```php
+<?php
+// ... inside a catch block for InvalidDataException ...
+
+    } catch (InvalidDataException $e) {
+        $response = $e->toResponse();
+        // $response will be like:
+        // [
+        //     'status' => 'error',
+        //     'message' => 'Validation failed',
+        //     'errors' => [ /* ... detailed errors from getErrors() ... */ ],
+        // ]
+        http_response_code($response['code']); // Set appropriate HTTP status code (e.g., 400)
+        return $response;
+    }
+```
+
+### Accessing Exception `message` and `code`
+
+`InvalidDataException` extends PHP's base `\Exception`, allowing access to standard exception properties.
+
+```php
+<?php
+// ... inside a catch block for InvalidDataException ...
+
+    } catch (InvalidDataException $e) {
+        $message = $e->getMessage(); // General error message (e.g., "Validation failed")
+        $code = $e->getCode();       //  Error code (you can customize this)
+
+        return [
+            'message' => $message,
+            'code' => $code,
+            'errors' => $e->getErrors(), // Detailed errors
+        ];
+    }
+```
+
+## Available Validation Types and Rules
+
+`php-requestkit` provides a variety of built-in data types with a rich set of validation rules.
+
+*   **`Type::string()`:**
+    *   `required()`: Field is mandatory.
+    *   `optional()`: Field is optional.
+    *   `length(min, max)`:  String length constraints.
+    *   `trim()`: Trim whitespace from both ends.
+    *   `lowercase()`: Convert to lowercase.
+    *   `uppercase()`: Convert to uppercase.
+    *   `email()`: Validate email format.
+    *   `allowed(...values)`:  Value must be one of the allowed values.
+    *   `removeSpaces()`: Remove all spaces.
+    *   `padLeft(length, char)`: Pad string to the left with a character.
+    *   `removeChars(...chars)`: Remove specific characters.
+    *   `strict()`: Strict type validation (only accepts strings).
+
+*   **`Type::int()`:**
+    *   `required()`, `optional()`, `strict()`: Same as StringType.
+    *   `min(value)`: Minimum value.
+    *   `max(value)`: Maximum value.
+
+*   **`Type::bool()`:**
+    *   `required()`, `optional()`, `strict()`: Same as StringType.
+
+*   **`Type::date()` and `Type::datetime()`:**
+    *   `required()`, `optional()`: Same as StringType.
+    *   `format(format)`: Specify the date/datetime format (using PHP date formats).
+
+*   **`Type::numeric()`:**
+    *   `required()`, `optional()`: Same as StringType.  Validates any numeric value (integer or float).
+
+*   **`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`.
+
+## Extending Schemas
+
+You can extend existing schemas to reuse and build upon validation logic.
+
+```php
+<?php
+use PhpDevCommunity\RequestKit\Schema\Schema;
+use PhpDevCommunity\RequestKit\Type;
+
+$baseUserSchema = Schema::create([
+    'name' => Type::string()->required(),
+    'email' => Type::email()->required(),
+]);
+
+$extendedUserSchema = $baseUserSchema->extend([
+    'password' => Type::string()->length(8)->required(),
+    'address' => Type::item([
+        'city' => Type::string(),
+        'zip' => Type::string()->length(5,10),
+    ])
+]);
+
+// $extendedUserSchema now includes 'name', 'email', 'password', and 'address' fields
+```

+ 30 - 0
composer.json

@@ -0,0 +1,30 @@
+{
+  "name": "phpdevcommunity/php-requestkit",
+  "description": "A lightweight and efficient PHP library for handling, validating, and transforming incoming HTTP requests, supporting both form submissions and JSON API payloads.",
+  "type": "library",
+  "license": "MIT",
+  "authors": [
+    {
+      "name": "F. Michel",
+      "homepage": "https://www.phpdevcommunity.com"
+    }
+  ],
+  "autoload": {
+    "psr-4": {
+      "PhpDevCommunity\\RequestKit\\": "src",
+      "Test\\PhpDevCommunity\\RequestKit\\": "tests"
+    },
+    "files": [
+      "functions/helpers.php"
+    ]
+  },
+  "require": {
+    "php": ">=7.4",
+    "ext-json": "*",
+    "phpdevcommunity/php-validator": "^1.1",
+    "psr/http-message": "^2.0"
+  },
+  "require-dev": {
+    "phpdevcommunity/unitester": "^0.1.0@alpha"
+  }
+}

+ 39 - 0
functions/helpers.php

@@ -0,0 +1,39 @@
+<?php
+
+if (!function_exists('array_dot')) {
+
+    /**
+     * Flatten a multi-dimensional associative array with dots.
+     *
+     * @param array $array The array to flatten.
+     * @param string $rootKey The base key prefix (used internally for recursion).
+     * @return array The flattened array with dot notation keys.
+     */
+    function array_dot(array $array, string $rootKey = ''): array
+    {
+        $result = [];
+        foreach ($array as $key => $value) {
+            $key = strval($key);
+            $key = $rootKey !== '' ? ($rootKey . '.' . $key) : $key;
+            if (is_array($value)) {
+                $result = $result + array_dot($value, $key);
+                continue;
+            }
+            $result[$key] = $value;
+        }
+
+        return $result;
+    }
+}
+if (!function_exists('str_starts_with')) {
+
+    /**
+     * @param string $haystack
+     * @param string $needle
+     * @return bool
+     */
+    function str_starts_with(string $haystack, string $needle): bool
+    {
+        return substr($haystack, 0, strlen($needle)) === $needle;
+    }
+}

+ 24 - 0
src/Builder/SchemaObjectFactory.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Builder;
+
+use PhpDevCommunity\RequestKit\Schema\Schema;
+
+final class SchemaObjectFactory
+{
+    private ?string $cacheDir;
+    public function __construct(string $cacheDir = null)
+    {
+        $this->cacheDir = $cacheDir;
+    }
+
+    public function createSchemaFromObject($object): Schema
+    {
+        return Schema::createFromObject($object, $this);
+    }
+
+    public function getCacheDir(): ?string
+    {
+        return $this->cacheDir;
+    }
+}

+ 20 - 0
src/Builder/TypeBuilder.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Builder;
+
+use PhpDevCommunity\RequestKit\Type;
+use PhpDevCommunity\RequestKit\Type\AbstractType;
+
+final class TypeBuilder
+{
+    private ?string $cacheDir;
+    public function __construct(string $cacheDir = null)
+    {
+        $this->cacheDir = $cacheDir;
+    }
+
+    public function build(string $type): AbstractType
+    {
+        return Type::typeObject($type, $this->cacheDir) ?? Type::type($type);
+    }
+}

+ 33 - 0
src/Exceptions/InvalidDataException.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Exceptions;
+
+class InvalidDataException extends \Exception
+{
+    private array $errors;
+
+    public function __construct(string $message = 'Invalid request data', int $code = 400, array $errors = [])
+    {
+        parent::__construct($message, $code);
+        $this->errors = $errors;
+    }
+
+    public function getErrors(): array
+    {
+        return $this->errors;
+    }
+
+    public function getError(string $key): ?string
+    {
+        return $this->errors[$key] ?? null;
+    }
+
+    public function toResponse(): array
+    {
+        return [
+            'status' => 'error',
+            'message' => $this->getMessage(),
+            'errors' => $this->getErrors(),
+        ];
+    }
+}

+ 199 - 0
src/Generator/DefinitionGenerator.php

@@ -0,0 +1,199 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Generator;
+
+use PhpDevCommunity\RequestKit\Builder\SchemaObjectFactory;
+use PhpDevCommunity\RequestKit\Type;
+use ReflectionClass;
+use ReflectionException;
+use ReflectionProperty;
+
+final class DefinitionGenerator
+{
+    private SchemaObjectFactory $factory;
+    public function __construct(SchemaObjectFactory $factory)
+    {
+        $this->factory = $factory;
+    }
+
+    /**
+     * @param string|object $objectClass
+     * @return array
+     */
+    public function generateFromObject($objectClass): array
+    {
+        if (is_object($objectClass)) {
+            $object = $objectClass;
+        }else {
+            $object = new $objectClass();
+        }
+
+        $metadata = $this->cacheGet($object);
+        if (!empty($metadata)) {
+            return $this->generateFromMetadata($object, $metadata);
+        }
+
+        $reflection = new ReflectionClass($object);
+        $metadata['object_class'] = get_class($object);
+        $metadata['php_class'] = $reflection->getExtensionName() === false;
+        $metadata['properties'] = [];
+
+        foreach ($reflection->getProperties() as $property) {
+
+            $type = $property->getType();
+            $propertyName = self::camelCaseToSnakeCase($property->getName());
+            $phpDoc = $property->getDocComment();
+            $example = self::parsePhpDocTag($phpDoc, 'example')[0] ?? null;
+            $required = false;
+
+            if ($type) {
+                $name = $type->getName();
+                $propertyType = $name;
+                if (in_array($name, ['array', 'iterable'], true)) {
+                    $arrayType = self::extractArrayType(self::parsePhpDocTag($phpDoc, 'var')[0] ?? '', $property);
+                    $propertyType = class_exists($arrayType) ? "array_of_item:$arrayType" : "array_of_$arrayType";
+                }
+
+                if (!$type->allowsNull() && !str_starts_with($propertyType, 'array_of_')) {
+                    $required = true;
+                }
+            } else {
+                $propertyType = 'string';
+            }
+
+            $metadata['properties'][$propertyName]['type'] = $propertyType;
+            $metadata['properties'][$propertyName]['public'] = $property->isPublic();
+            $metadata['properties'][$propertyName]['name'] = $property->getName();
+            $metadata['properties'][$propertyName]['required'] = $required;
+            $metadata['properties'][$propertyName]['example'] = $example;
+        }
+
+        $this->cacheSet($object, $metadata);
+        return $this->generateFromMetadata($object, $metadata);
+    }
+
+    private function generateFromMetadata(object $object, array $metadata): array
+    {
+        $definitions = [];
+        foreach ($metadata['properties'] as $name => $property) {
+            $type = $property['type'];
+            $example = $property['example'];
+            $propertyName = $property['name'];
+            $required = $property['required'];
+            $defaultValue = null;
+
+            if ($property['public'] && isset($object->$propertyName)) {
+                $defaultValue = $object->$propertyName;
+            } elseif (method_exists($object, 'get' . ucfirst($propertyName))) {
+                $defaultValue = $object->{'get' . ucfirst($propertyName)}();
+            }elseif (method_exists($object, 'is' . ucfirst($propertyName))) {
+                $defaultValue = $object->{'is' . ucfirst($propertyName)}();
+            }
+
+            if (str_starts_with( $type, 'array_of_item:')) {
+                $class = substr($type, 14);
+                $definitionType =  Type::typeObject($class);
+                if ($definitionType === null) {
+                    $definitionType = new Type\ItemType($this->factory->createSchemaFromObject($class));
+                }
+                $definition = Type::arrayOf($definitionType);
+            }elseif (class_exists($type)) {
+                $definition =  Type::typeObject($type);
+                if ($definition === null) {
+                    $definition = new Type\ItemType($this->factory->createSchemaFromObject($type));
+                }
+            } else {
+                $definition = Type::type($type);
+            }
+
+            $definition->example($example);
+            $definition->default($defaultValue);
+            if ($required) {
+                $definition->required();
+            }
+            $definitions[$name] = $definition;
+        }
+
+        return $definitions;
+    }
+
+    private function cacheGet(object $object)
+    {
+        $key = md5(get_class($object));
+        if ($this->factory->getCacheDir()) {
+            $file = $this->factory->getCacheDir() . DIRECTORY_SEPARATOR . $key . '.definition.json';
+            if (file_exists($file)) {
+                return unserialize(file_get_contents($file));
+            }
+        }
+        return [];
+    }
+
+    private function cacheSet(object $object, array $metadata): void
+    {
+        $key = md5(get_class($object));
+        if ($this->factory->getCacheDir()) {
+            $file = $this->factory->getCacheDir() . DIRECTORY_SEPARATOR . $key . '.definition.json';
+            file_put_contents($file, serialize($metadata));
+        }
+    }
+
+    private static function camelCaseToSnakeCase(string $camelCaseString): string
+    {
+        return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $camelCaseString));
+    }
+
+    private static function parsePhpDocTag($phpDoc, string $tag): array
+    {
+        if (!is_string($phpDoc) || empty($phpDoc)) {
+            return [];
+        }
+
+        $matches = [];
+        $pattern = '/\*\s*@' . preg_quote($tag, '/') . '\s+([^\n]+)/';
+
+        preg_match_all($pattern, $phpDoc, $matches);
+
+        return $matches[1] ?? [];
+    }
+
+    private static function extractArrayType(string $type, ReflectionProperty $property): ?string
+    {
+        if (preg_match('/array<([^>]+)>/', $type, $matches)) {
+            $typeParsed = trim($matches[1]);
+            if (self::isNativeType($typeParsed)) {
+                return $typeParsed;
+            }
+
+            $classname = $typeParsed;
+            if (class_exists($classname)) {
+                return $classname;
+            }
+
+            $declaringClass = $property->getDeclaringClass();
+            $namespace = $declaringClass->getNamespaceName();
+            $fullClassName = $namespace ? "$namespace\\$classname" : $classname;
+
+            return class_exists($fullClassName) ? $fullClassName : null;
+        }
+        return null;
+    }
+
+    private static function isNativeType(string $type): bool
+    {
+        $nativeTypes = [
+            'int', 'integer',
+            'float', 'double',
+            'string',
+            'bool', 'boolean',
+            'array',
+            'object',
+            'callable',
+            'iterable',
+            'resource',
+            'null',
+        ];
+        return in_array($type, $nativeTypes, true);
+    }
+
+}

+ 83 - 0
src/Hydrator/ObjectHydrator.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Hydrator;
+
+use LogicException;
+use PhpDevCommunity\RequestKit\Type\ArrayOfType;
+use PhpDevCommunity\RequestKit\Type\ItemType;
+use ReflectionClass;
+
+final class ObjectHydrator
+{
+    private $object;
+    private array $data;
+    private array $definitions;
+
+    public function __construct($object, array $data, array $definitions)
+    {
+        $this->object = $object;
+        $this->data = $data;
+        $this->definitions = $definitions;
+    }
+
+    public function hydrate(): object
+    {
+        return self::hydrateObject($this->object, $this->data, $this->definitions);
+    }
+    private function hydrateObject($objectClass, array $data, array $definitions): object
+    {
+        if (is_object($objectClass)) {
+            $object = $objectClass;
+        }else {
+            $object = new $objectClass();
+        }
+
+        $propertiesPublic = array_keys(get_class_vars(get_class($object)));
+        foreach ($definitions as $key => $definition) {
+            if (!array_key_exists($key, $data)) {
+                continue;
+            }
+            $value = $data[$key];
+            $propertyName = self::snakeCaseToCamelCase($key);
+
+            if (is_array($value) && $definition instanceof ItemType) {
+                $value = self::hydrateFromItemType( $definition, $value);
+            }elseif (is_array($value) && $definition instanceof ArrayOfType) {
+                $type = $definition->getCopyType();
+                if ($type instanceof ItemType) {
+                    $elements = [];
+                    foreach ($value as $element) {
+                        $elements[] = self::hydrateFromItemType($type, $element);
+                    }
+                    $value = $elements;
+                }
+            }
+
+            if (in_array( $propertyName, $propertiesPublic)) {
+                $object->$propertyName = $value;
+            }elseif (method_exists($object, 'set' . $propertyName)) {
+                $object->{'set' . $propertyName}($value);
+            }else {
+                throw new LogicException('Can not set property ' . $propertyName . ' on object ' . get_class($object));
+            }
+        }
+
+        return $object;
+    }
+
+    private function hydrateFromItemType(ItemType $definition, array $data): object
+    {
+        $objectToHydrate = $definition->getObject();
+        if ($objectToHydrate === null) {
+            throw new LogicException('No object to hydrate, can not hydrate');
+        }
+        return $this->hydrateObject($objectToHydrate, $data, $definition->copyDefinitions());
+    }
+
+    private static function snakeCaseToCamelCase(string $snakeCaseString): string
+    {
+        $camelCaseString = str_replace('_', '', ucwords($snakeCaseString, '_'));
+        return lcfirst($camelCaseString);
+    }
+
+}

+ 111 - 0
src/Schema/AbstractSchema.php

@@ -0,0 +1,111 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Schema;
+
+use Exception;
+use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
+use PhpDevCommunity\RequestKit\Type\AbstractType;
+use PhpDevCommunity\RequestKit\Type\ArrayOfType;
+use PhpDevCommunity\RequestKit\Type\ItemType;
+use Psr\Http\Message\ServerRequestInterface;
+
+abstract class AbstractSchema
+{
+    protected bool $patchMode = false;
+    protected string $title = '';
+    protected string $version = '1.0';
+
+    final public function patch(): self
+    {
+        $this->patchMode = true;
+        return $this;
+    }
+
+    final public function isPatchMode(): bool
+    {
+        return $this->patchMode;
+    }
+
+    final public function title(string $title): self
+    {
+        $this->title = $title;
+        return $this;
+    }
+
+    final public function version(string $version): self
+    {
+        $this->version = $version;
+        return $this;
+    }
+
+    final public function processJsonInput(string $json, int $depth = 512, int $flags = 0): SchemaAccessor
+    {
+        $data = json_decode($json, true, $depth , $flags);
+        if (json_last_error() !== JSON_ERROR_NONE) {
+            $errorMessage = json_last_error_msg();
+            throw new InvalidDataException($errorMessage);
+        }
+        return $this->process($data);
+    }
+
+    final public function processHttpRequest(ServerRequestInterface $request): SchemaAccessor
+    {
+        return $this->process($request->getParsedBody());
+    }
+    final public function processHttpQuery(ServerRequestInterface $request): SchemaAccessor
+    {
+        return $this->process($request->getQueryParams());
+    }
+
+    final public function process(array $data): SchemaAccessor
+    {
+        $accessor = new SchemaAccessor($data, $this);
+        $accessor->execute();
+        return $accessor;
+    }
+
+    /**
+     * @return array<string, AbstractType>
+     */
+    abstract protected function definitions(): array;
+
+    final private function getDefinitions(): array
+    {
+        $definitions = $this->definitions();
+        foreach ($definitions as $definition) {
+            if (!$definition instanceof AbstractType) {
+                throw new \InvalidArgumentException('Definition must be an instance of AbstractType');
+            }
+        }
+        return $definitions;
+    }
+
+    /**
+     * @return array<string, AbstractType>
+     */
+    final public function copyDefinitions() : array
+    {
+        $definitions = [];
+        foreach ($this->getDefinitions() as $key => $definition) {
+            $definitions[$key] = clone $definition;
+        }
+        return $definitions;
+    }
+
+    final public function generateExampleData(): array
+    {
+        $data = [];
+        foreach ($this->getDefinitions() as $key => $definition) {
+            if ($definition instanceof ItemType) {
+                $data[$key] = $definition->getExample() ?: $definition->copySchema()->generateExampleData();
+                continue;
+            }
+            if ($definition instanceof ArrayOfType) {
+                $data[$key][] = $definition->getExample();
+                continue;
+            }
+            $data[$key] = $definition->getExample();
+        }
+        return $data;
+    }
+}

+ 73 - 0
src/Schema/Schema.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Schema;
+
+use PhpDevCommunity\RequestKit\Builder\SchemaObjectFactory;
+use PhpDevCommunity\RequestKit\Generator\DefinitionGenerator;
+use PhpDevCommunity\RequestKit\Type\AbstractType;
+use ReflectionException;
+
+final class Schema extends AbstractSchema
+{
+    /**
+     * @var null|string|object
+     */
+    private $object = null;
+
+    /**
+     * @var AbstractType[]
+     */
+    private array $definitions;
+
+
+    public static function create(array $definitions): Schema
+    {
+        return (new self())->setDefinitions($definitions);
+    }
+
+    /**
+     * @param string|object $object
+     * @param SchemaObjectFactory $factory
+     * @return Schema
+     */
+    public static function createFromObject($object, SchemaObjectFactory $factory): Schema
+    {
+        return (new self())->generateDefinitionFromObject($object,$factory);
+    }
+
+    final public function extend(array $definitions): Schema
+    {
+        $schema = clone $this;
+        $schema->setDefinitions($definitions + $this->definitions());
+        return $schema;
+    }
+
+    /**
+     * @param string|object $object
+     * @param SchemaObjectFactory $factory
+     * @return self
+     */
+    private function generateDefinitionFromObject($object, SchemaObjectFactory $factory): self
+    {
+        $definitionGenerator = new DefinitionGenerator($factory);
+        $this->setDefinitions($definitionGenerator->generateFromObject($object));
+        $this->object = $object;
+        return $this;
+    }
+
+    private function setDefinitions(array $definitions): self
+    {
+        $this->definitions = $definitions;
+        return $this;
+    }
+
+    public function getObject()
+    {
+        return $this->object;
+    }
+
+    protected function definitions(): array
+    {
+        return $this->definitions;
+    }
+}

+ 115 - 0
src/Schema/SchemaAccessor.php

@@ -0,0 +1,115 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Schema;
+
+use InvalidArgumentException;
+use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
+use PhpDevCommunity\RequestKit\Hydrator\ObjectHydrator;
+use PhpDevCommunity\RequestKit\Type\ItemType;
+
+final class SchemaAccessor
+{
+    private array $initialData;
+    private AbstractSchema $schema;
+    private ?\ArrayObject $data = null;
+    private bool $executed = false;
+
+    public function __construct(array $initialData, Schema $schema)
+    {
+        $this->initialData = $initialData;
+        $this->schema = $schema;
+    }
+
+    public function execute(): void
+    {
+        $data = $this->initialData;
+        if (empty($data)) {
+            throw new InvalidDataException('No data provided', 0);
+        }
+
+        $errors = [];
+        $dataFiltered = [];
+        foreach ($this->schema->copyDefinitions() as $key => $definition) {
+            $aliases = array_merge([$key], $definition->getAliases());
+            $keyToUse = null;
+            foreach ($aliases as $alias) {
+                if (array_key_exists($alias, $data)) {
+                    $keyToUse = $alias;
+                    break;
+                }
+            }
+
+            if ($this->schema->isPatchMode() && $keyToUse === null) {
+                continue;
+            }
+
+            if ($this->schema->isPatchMode() && $definition instanceof ItemType) {
+                $definition->patch();
+            }
+
+            if (array_key_exists( $keyToUse, $data)) {
+                $value = $data[$keyToUse];
+            }else {
+                $value = $definition->getDefault();
+            }
+
+            $result = $definition->validate($value);
+            if (!$result->isValid()) {
+                if (!$result->isGlobalError()) {
+                    $errors[$key] = $result->getErrors();
+                } else {
+                    $errors[$key] = $result->getError();
+                }
+                continue;
+            }
+            $dataFiltered[$key] = $result->getValue();
+        }
+        $errors = array_dot($errors);
+        if (!empty($errors)) {
+            throw new InvalidDataException('Validation failed', 0, $errors);
+        }
+
+        $this->data = new \ArrayObject($dataFiltered);
+        $this->executed = true;
+    }
+
+    public function get(string $key)
+    {
+        if (!$this->executed) {
+            throw new InvalidArgumentException('Schema not executed, call execute() first');
+        }
+        $current = $this->toArray();
+        $pointer = strtok($key, '.');
+        while ($pointer !== false) {
+            if (!array_key_exists($pointer, $current)) {
+                throw new InvalidArgumentException('Key ' . $key . ' not found');
+            }
+            $current = $current[$pointer];
+            $pointer = strtok('.');
+        }
+        return $current;
+    }
+
+    public function toArray(): array
+    {
+        if (!$this->executed) {
+            throw new InvalidArgumentException('Schema not executed, call execute() first');
+        }
+
+        return $this->data->getIterator()->getArrayCopy();
+    }
+
+    public function toObject(): object
+    {
+        if (!$this->executed) {
+            throw new InvalidArgumentException('Schema not executed, call execute() first');
+        }
+
+        if ($this->schema->getObject() === null) {
+            throw new InvalidArgumentException('Schema does not have an object, cannot hydrate');
+        }
+
+        return (new ObjectHydrator($this->schema->getObject(), $this->toArray(), $this->schema->copyDefinitions()))->hydrate();
+    }
+
+}

+ 125 - 0
src/Type.php

@@ -0,0 +1,125 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit;
+use PhpDevCommunity\RequestKit\Schema\Schema;
+use PhpDevCommunity\RequestKit\Type\AbstractType;
+use PhpDevCommunity\RequestKit\Type\ArrayOfType;
+use PhpDevCommunity\RequestKit\Type\BoolType;
+use PhpDevCommunity\RequestKit\Type\DateTimeType;
+use PhpDevCommunity\RequestKit\Type\DateType;
+use PhpDevCommunity\RequestKit\Type\EmailType;
+use PhpDevCommunity\RequestKit\Type\FloatType;
+use PhpDevCommunity\RequestKit\Type\IntType;
+use PhpDevCommunity\RequestKit\Type\ItemType;
+use PhpDevCommunity\RequestKit\Type\NumericType;
+use PhpDevCommunity\RequestKit\Type\StringType;
+use PhpDevCommunity\RequestKit\Utils\DateOnly;
+
+final class Type
+{
+
+    public static function int(): IntType
+    {
+        return new IntType();
+    }
+
+    public static function string(): StringType
+    {
+        return new StringType();
+    }
+
+    public static function numeric(): NumericType
+    {
+        return new NumericType();
+    }
+
+    public static function bool(): BoolType
+    {
+        return new BoolType();
+    }
+
+    public static function date(): DateType
+    {
+        return new DateType();
+    }
+
+    public static function datetime(): DateTimeType
+    {
+        return new DateTimeType();
+    }
+
+    public static function float(): FloatType
+    {
+        return new FloatType();
+    }
+
+    public static function email(): EmailType
+    {
+        return new EmailType();
+    }
+
+    public static function item(array $definitions) : ItemType
+    {
+        return new ItemType(Schema::create($definitions));
+    }
+    public static function arrayOf(AbstractType $type) : ArrayOfType
+    {
+        return new ArrayOfType($type);
+    }
+
+    public static function typeObject(string $type): ?AbstractType
+    {
+        if ($type=== DateOnly::class) {
+            return self::date();
+        }
+
+        if ($type === \DateTimeInterface::class || is_subclass_of( $type, \DateTimeInterface::class)) {
+            return self::datetime();
+        }
+
+        return null;
+    }
+
+    public static function type(string $type): AbstractType
+    {
+        $definition = self::typeObject($type);
+        if ($definition) {
+            return $definition;
+        }
+        switch ($type) {
+            case 'array_of_string':
+                return self::arrayOf(self::type('string'));
+            case 'array_of_int':
+                return self::arrayOf(self::type('int'));
+            case 'array_of_numeric':
+                return self::arrayOf(self::type('numeric'));
+            case 'array_of_date':
+                return self::arrayOf(self::type('date'));
+            case 'array_of_datetime':
+                return self::arrayOf(self::type('datetime'));
+            case 'array_of_float':
+                return self::arrayOf(self::type('float'));
+            case 'array_of_email':
+                return self::arrayOf(self::type('email'));
+            case 'int':
+                return self::int();
+            case 'string':
+                return self::string();
+            case 'numeric':
+                return self::numeric();
+            case 'bool':
+                return self::bool();
+            case 'date':
+                return self::date();
+            case 'datetime':
+                return self::datetime();
+            case 'float':
+                return self::float();
+            case 'email':
+                return self::email();
+            default:
+                throw new \LogicException('Unknown type ' . $type);
+        }
+
+    }
+}

+ 89 - 0
src/Type/AbstractStringType.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use PhpDevCommunity\RequestKit\ValidationResult;
+use PhpDevCommunity\Validator\Assert\StringLength;
+
+abstract class AbstractStringType extends AbstractType
+{
+
+    public function padLeft(int $length, string $pad = ' '): self
+    {
+        $this->transform(function ($value) use ($length, $pad) {
+            if (empty($value) || !is_string($value)) {
+                return $value;
+            }
+            return str_pad($value, $length, $pad, STR_PAD_LEFT);
+        });
+        return $this;
+    }
+
+    public function padRight(int $length, string $pad = ' '): self
+    {
+        $this->transform(function ($value) use ($length, $pad) {
+            if (empty($value) || !is_string($value)) {
+                return $value;
+            }
+            return str_pad($value, $length, $pad, STR_PAD_RIGHT);
+        });
+        return $this;
+    }
+
+    public function removeSpaces(): self
+    {
+        $this->transform(function ($value) {
+            if (empty($value) || !is_string($value)) {
+                return $value;
+            }
+            return str_replace(' ', '', $value);
+        });
+        return $this;
+    }
+
+    public function removeChars(string ... $chars): self
+    {
+        $this->transform(function ($value) use ($chars) {
+            if (empty($value) || !is_string($value)) {
+                return $value;
+            }
+            return str_replace($chars, '', $value);
+        });
+        return $this;
+
+    }
+
+    public function trim(): self
+    {
+        $this->transform(function ($value) {
+            if (empty($value) || !is_string($value)) {
+                return $value;
+            }
+            return trim($value);
+        });
+
+        return $this;
+    }
+
+    public function uppercase(): self
+    {
+        $this->transform(function ($value) {
+            if (empty($value) || !is_string($value)) {
+                return $value;
+            }
+            return mb_strtoupper($value);
+        });
+        return $this;
+    }
+
+    public function lowercase(): self
+    {
+        $this->transform(function ($value) {
+            if (empty($value) || !is_string($value)) {
+                return $value;
+            }
+            return mb_strtolower($value);
+        });
+        return $this;
+    }
+}

+ 118 - 0
src/Type/AbstractType.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use Closure;
+use PhpDevCommunity\RequestKit\ValidationResult;
+
+abstract class AbstractType
+{
+    protected $example = null;
+    protected bool $required = false;
+    protected array $aliases = [];
+
+    /**
+     * @var mixed
+     */
+    protected $default = null;
+    protected ?array $transformers = null;
+
+
+    final public function required(): self
+    {
+        $this->required = true;
+        return $this;
+    }
+
+    final public function optional(): self
+    {
+        $this->required = false;
+        return $this;
+    }
+
+    final public function default($value): self
+    {
+        $this->default = $value;
+        return $this;
+    }
+
+    final public function alias(string ...$aliases): self
+    {
+        foreach ($aliases as $alias) {
+            $this->aliases[$alias] = $alias;
+        }
+        return $this;
+    }
+
+    final public function example($value): self
+    {
+        $this->example = $value;
+        return $this;
+    }
+
+    final public function transform(Closure $transform): self
+    {
+        $this->transformers[] = $transform;
+        return $this;
+    }
+
+    final protected function isRequired(): bool
+    {
+        return $this->required;
+    }
+
+    final  public function getDefault()
+    {
+        $default = $this->default;
+        if (is_callable($default)) {
+            $default = $default();
+        }
+        return $default;
+    }
+
+    final public function getAliases(): array
+    {
+        return $this->aliases;
+    }
+
+    final public function getExample()
+    {
+        return $this->example;
+    }
+
+    final protected function transformValue(ValidationResult $result): void
+    {
+        if (empty($this->transformers)) {
+            return;
+        }
+        foreach ($this->transformers as $transformer) {
+            $value = $result->getValue();
+            $value = $transformer($value);
+            $result->setValue($value);
+        }
+    }
+
+    final public function validate($value): ValidationResult
+    {
+        $result = new ValidationResult($value);
+        $this->forceDefaultValue($result);
+
+        if ($result->getValue() === null || (is_string($result->getValue())) && trim($result->getValue()) === '') {
+            if ($this->isRequired()) {
+                $result->setError("Value is required, but got null or empty string");
+            }
+            return $result;
+        }
+
+        $this->transformValue($result);
+        $this->validateValue($result);
+
+        return $result;
+    }
+
+    abstract protected function validateValue(ValidationResult $result): void;
+
+    protected function forceDefaultValue(ValidationResult $result): void
+    {
+    }
+}

+ 90 - 0
src/Type/ArrayOfType.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
+use PhpDevCommunity\RequestKit\Schema\Schema;
+use PhpDevCommunity\RequestKit\ValidationResult;
+
+final class ArrayOfType 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([]);
+    }
+
+    public function getCopyType(): AbstractType
+    {
+        return clone $this->type;
+    }
+
+    protected function forceDefaultValue( ValidationResult $result): void
+    {
+        if ($result->getValue() === null) {
+            $result->setValue([]);
+        }
+    }
+
+    protected function validateValue(ValidationResult $result): void
+    {
+        if ($this->isRequired() && empty($this->min)) {
+            $this->min = 1;
+        }
+        $values = $result->getValue();
+        if (!is_array($values)) {
+            $result->setError('Value must be an array');
+            return;
+        }
+
+        $definitions = [];
+        $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;
+        }
+
+        foreach ($values as $key => $value) {
+            if (!is_int($key)) {
+                $result->setError('All keys must be integers');
+                return;
+            }
+            $definitions[$key] = $this->type;
+        }
+        if (empty($definitions)) {
+            $result->setValue([]);
+            return;
+        }
+
+        $schema = Schema::create($definitions);
+        try {
+            $values = $schema->process($values);
+        } catch (InvalidDataException $e) {
+            $result->setErrors($e->getErrors(), false);
+            return;
+        }
+
+        $result->setValue($values);
+    }
+}

+ 37 - 0
src/Type/BoolType.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use PhpDevCommunity\RequestKit\Type\Traits\StrictTrait;
+use PhpDevCommunity\RequestKit\ValidationResult;
+use PhpDevCommunity\Validator\Assert\Boolean;
+
+final class BoolType extends AbstractType
+{
+
+    use StrictTrait;
+
+    protected function validateValue(ValidationResult $result): void
+    {
+        if ($this->isStrict() && !is_bool($result->getValue())) {
+            $result->setError("Value must be a boolean, got: " . gettype($result->getValue()));
+            return;
+        }
+
+        if ($this->isStrict() === false && !is_bool($result->getValue())) {
+            if (in_array($result->getValue(), [1, '1', 'true', 'on', 'TRUE', 'ON'], true)) {
+                $result->setValue(true);
+            }elseif (in_array($result->getValue(), [0, '0', 'false', 'off', 'FALSE', 'OFF'], true)) {
+                $result->setValue(false);
+            }else {
+                $result->setError("Value must be a boolean, got: " . gettype($result->getValue()));
+                return;
+            }
+        }
+
+        $validator = new Boolean();
+        if ($validator->validate($result->getValue()) === false) {
+            $result->setError($validator->getError());
+        }
+    }
+}

+ 33 - 0
src/Type/DateTimeType.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use PhpDevCommunity\RequestKit\ValidationResult;
+use PhpDevCommunity\Validator\Assert\Email;
+use PhpDevCommunity\Validator\Assert\StringLength;
+
+final class DateTimeType extends AbstractType
+{
+    private string $format = 'Y-m-d H:i:s';
+    public function format(string $format): self
+    {
+        $this->format = $format;
+        return $this;
+    }
+
+    protected function validateValue(ValidationResult $result): void
+    {
+        if (is_string($result->getValue())) {
+            $datetime = \DateTime::createFromFormat($this->format, $result->getValue());
+            if ($datetime === false) {
+                $result->setError("Value must be a valid datetime for format: " . $this->format);
+                return;
+            }
+            $result->setValue($datetime);
+        }elseif (is_int($result->getValue())) {
+            $datetime = new \DateTime();
+            $datetime->setTimestamp($result->getValue());
+            $result->setValue($datetime);
+        }
+    }
+}

+ 33 - 0
src/Type/DateType.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use PhpDevCommunity\RequestKit\Utils\DateOnly;
+use PhpDevCommunity\RequestKit\ValidationResult;
+use PhpDevCommunity\Validator\Assert\Email;
+use PhpDevCommunity\Validator\Assert\StringLength;
+
+final class DateType extends AbstractType
+{
+    private string $format = 'Y-m-d';
+    public function format(string $format): self
+    {
+        $this->format = $format;
+        return $this;
+    }
+    protected function validateValue(ValidationResult $result): void
+    {
+        if (is_string($result->getValue())) {
+            $datetime = DateOnly::createFromFormat($this->format, $result->getValue());
+            if ($datetime === false) {
+                $result->setError("Value must be a valid date for format: " . $this->format);
+                return;
+            }
+            $result->setValue($datetime);
+        }elseif (is_int($result->getValue())) {
+            $datetime = new DateOnly();
+            $datetime->setTimestamp($result->getValue());
+            $result->setValue($datetime);
+        }
+    }
+}

+ 18 - 0
src/Type/EmailType.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use PhpDevCommunity\RequestKit\ValidationResult;
+use PhpDevCommunity\Validator\Assert\Email;
+use PhpDevCommunity\Validator\Assert\StringLength;
+
+final class EmailType extends AbstractStringType
+{
+    protected function validateValue(ValidationResult $result): void
+    {
+        $validator = new Email();
+        if ($validator->validate($result->getValue()) === false) {
+            $result->setError($validator->getError());
+        }
+    }
+}

+ 44 - 0
src/Type/FloatType.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use PhpDevCommunity\RequestKit\Type\Traits\StrictTrait;
+use PhpDevCommunity\RequestKit\ValidationResult;
+use PhpDevCommunity\Validator\Assert\Numeric;
+
+final class FloatType extends AbstractType
+{
+    use StrictTrait;
+    private ?float $min = null;
+    private ?float $max = null;
+
+    public function min(float $min): self
+    {
+        $this->min = $min;
+        return $this;
+    }
+
+    public function max(float $max): self
+    {
+        $this->max = $max;
+        return $this;
+    }
+
+    protected function validateValue(ValidationResult $result): void
+    {
+        if ($this->isStrict() && !is_float($result->getValue())) {
+            $result->setError("Value must be a float, got: " . gettype($result->getValue()));
+            return;
+        }
+
+        if ($this->isStrict() === false && is_numeric($result->getValue())) {
+            $value = floatval($result->getValue());
+            $result->setValue($value);
+        }
+
+        $validator = new Numeric();
+        if ($validator->validate($result->getValue()) === false) {
+            $result->setError($validator->getError());
+        }
+    }
+}

+ 51 - 0
src/Type/IntType.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use PhpDevCommunity\RequestKit\Type\Traits\StrictTrait;
+use PhpDevCommunity\RequestKit\ValidationResult;
+use PhpDevCommunity\Validator\Assert\Integer;
+
+final class IntType extends AbstractType
+{
+    use StrictTrait;
+
+    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;
+    }
+
+    protected function validateValue(ValidationResult $result): void
+    {
+        if ($this->isStrict() && !is_int($result->getValue())) {
+            $result->setError("Value must be a int, got: " . gettype($result->getValue()));
+            return;
+        }
+
+        if ($this->isStrict() === false && is_numeric($result->getValue())) {
+            $value = intval($result->getValue());
+            $result->setValue($value);
+        }
+
+        $validator = new Integer();
+        if ($this->min) {
+            $validator->min($this->min);
+        }
+        if ($this->max) {
+            $validator->max($this->max);
+        }
+        if ($validator->validate($result->getValue()) === false) {
+            $result->setError($validator->getError());
+        }
+    }
+}

+ 47 - 0
src/Type/ItemType.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
+use PhpDevCommunity\RequestKit\Schema\Schema;
+use PhpDevCommunity\RequestKit\ValidationResult;
+
+final class ItemType extends AbstractType
+{
+    private Schema $schema;
+
+    public function __construct(Schema $schema)
+    {
+        $this->schema = $schema;
+    }
+
+    public function patch(): self
+    {
+        $this->schema->patch();
+        return $this;
+    }
+
+    public function getObject() : ?string
+    {
+        return $this->schema->getObject();
+    }
+
+    public function copyDefinitions(): array
+    {
+        return $this->schema->copyDefinitions();
+    }
+
+    public function copySchema(): Schema
+    {
+        return clone $this->schema;
+    }
+
+    protected function validateValue(ValidationResult $result): void
+    {
+        try {
+            $result->setValue($this->schema->process($result->getValue()));
+        } catch (InvalidDataException $e) {
+            $result->setErrors($e->getErrors(), false);
+        }
+    }
+}

+ 23 - 0
src/Type/NumericType.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use PhpDevCommunity\RequestKit\Type\Traits\StrictTrait;
+use PhpDevCommunity\RequestKit\ValidationResult;
+use PhpDevCommunity\Validator\Assert\Numeric;
+
+final class NumericType extends AbstractType
+{
+    protected function validateValue(ValidationResult $result): void
+    {
+        if (is_numeric($result->getValue())) {
+            $value = strval($result->getValue());
+            $result->setValue($value);
+        }
+
+        $validator = new Numeric();
+        if ($validator->validate($result->getValue()) === false) {
+            $result->setError($validator->getError());
+        }
+    }
+}

+ 63 - 0
src/Type/StringType.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type;
+
+use PhpDevCommunity\RequestKit\Type\Traits\StrictTrait;
+use PhpDevCommunity\RequestKit\ValidationResult;
+use PhpDevCommunity\Validator\Assert\StringLength;
+
+final class StringType extends AbstractStringType
+{
+    use StrictTrait;
+    private array $allowed = [];
+    private ?int $min = null;
+    private ?int $max = null;
+
+    public function allowed(string ...$allowed): self
+    {
+        $this->allowed = $allowed;
+        return $this;
+    }
+
+    public function length(int $min, ?int $max = null): self
+    {
+        $this->min = $min;
+        $this->max = $max;
+        return $this;
+    }
+
+    protected function validateValue(ValidationResult $result): void
+    {
+        if ($this->isStrict() && !is_string($result->getValue())) {
+            $result->setError("Value must be a string, got: " . gettype($result->getValue()));
+            return;
+        }
+
+        if ($this->isStrict() === false && !is_string($result->getValue())) {
+
+            if (is_array($result->getValue())) {
+                $result->setError("Value must be a string, got: array");
+                return;
+            }
+
+            $value = strval($result->getValue());
+            $result->setValue($value);
+        }
+
+        if (!empty($this->allowed) && !in_array($result->getValue(), $this->allowed, $this->isStrict())) {
+            $result->setError("Value is not allowed, allowed values are: " . implode(", ", $this->allowed));
+            return;
+        }
+
+        $validator = new StringLength();
+        if ($this->min) {
+            $validator->min($this->min);
+        }
+        if ($this->max) {
+            $validator->max($this->max);
+        }
+        if ($validator->validate($result->getValue()) === false) {
+            $result->setError($validator->getError());
+        }
+    }
+}

+ 26 - 0
src/Type/Traits/StrictTrait.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Type\Traits;
+
+trait StrictTrait
+{
+
+    protected bool $strict = false;
+
+    final public function strict()
+    {
+        $this->strict = true;
+        return $this;
+    }
+
+    final public function notStrict()
+    {
+        $this->strict = false;
+        return $this;
+    }
+
+    final protected function isStrict(): bool
+    {
+        return $this->strict;
+    }
+}

+ 35 - 0
src/Utils/DateOnly.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit\Utils;
+
+final class DateOnly extends \DateTime
+{
+    public function __construct(string $date = "now", \DateTimeZone $timezone = null)
+    {
+        parent::__construct($date, $timezone);
+        $this->setTime(0, 0, 0);
+    }
+
+    /**
+     * @param $format
+     * @param $datetime
+     * @param $timezone
+     * @return \DateTime|false
+     * @throws \Exception
+     */
+    public static function createFromFormat($format, $datetime, $timezone = null)
+    {
+        $date = \DateTime::createFromFormat($format, $datetime, $timezone);
+        if ($date === false) {
+            return false;
+        }
+        return new self($date->format('Y-m-d'));
+    }
+
+    public function setTimestamp( $timestamp ): \DateTime
+    {
+        parent::setTimestamp($timestamp);
+        $this->setTime(0, 0, 0);
+        return $this;
+    }
+}

+ 82 - 0
src/ValidationResult.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace PhpDevCommunity\RequestKit;
+
+use PhpDevCommunity\RequestKit\Schema\SchemaAccessor;
+
+final class ValidationResult
+{
+    private $rawValue;
+    private array $values;
+    private bool $isValid = true;
+    private bool $isGlobal = true;
+    private ?array $errors = null;
+    private ?string $globalError = null;
+
+    public function __construct($value)
+    {
+        $this->rawValue = $value;
+        $this->values[] = $value;
+    }
+
+    /**
+     * @return mixed
+     */
+    public function getRawValue()
+    {
+        return $this->rawValue;
+    }
+
+    /**
+     * @return mixed
+     */
+    public function getValue()
+    {
+        $values = $this->values;
+        return end($values);
+    }
+
+    public function setValue($value)
+    {
+        $this->values[] = $value instanceof SchemaAccessor ? $value->toArray() : $value;
+    }
+
+    public function isValid(): bool
+    {
+        return $this->isValid;
+    }
+
+    public function isGlobalError(): bool
+    {
+        return $this->isGlobal;
+    }
+
+    public function getError(): ?string
+    {
+        $errors = $this->getErrors();
+        if (empty($errors)) {
+            return null;
+        }
+        return $this->globalError;
+    }
+
+    public function getErrors(): ?array
+    {
+        return $this->errors;
+    }
+    public function setErrors(array $errors, bool $global = true) : self
+    {
+        if (empty($this->errors)) {
+            $this->globalError = $errors[0] ?? null;
+        }
+        $this->errors = $errors;
+        $this->isValid = false;
+        $this->isGlobal = $global;
+        return $this;
+    }
+
+    public function setError(string $error): self
+    {
+        return $this->setErrors([$error] , true);
+    }
+}

+ 239 - 0
tests/HydratorTest.php

@@ -0,0 +1,239 @@
+<?php
+
+namespace Test\PhpDevCommunity\RequestKit;
+
+use DateTime;
+use PhpDevCommunity\RequestKit\Builder\RequestKitBuilderFactory;
+use PhpDevCommunity\RequestKit\Builder\SchemaObjectFactory;
+use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
+use PhpDevCommunity\RequestKit\Schema\Schema;
+use PhpDevCommunity\RequestKit\Type;
+use PhpDevCommunity\UniTester\TestCase;
+use Test\PhpDevCommunity\RequestKit\Model\AddressTest;
+use Test\PhpDevCommunity\RequestKit\Model\UserModelTest;
+
+class HydratorTest extends TestCase
+{
+
+    private ?Schema $schema = null;
+
+    protected function setUp(): void
+    {
+        $user = new UserModelTest();
+        $user->setName('John Doe 3');
+        $user->setActive(false);
+        $this->schema = (new SchemaObjectFactory(sys_get_temp_dir()))->createSchemaFromObject($user);
+        $this->schema  = $this->schema->extend([
+            'email' => Type::email()->lowercase(),
+        ]);
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testHydratorSimple();
+        $this->testAddingNewProperty();
+        $this->testForceNullOnDefaultValue();
+        $this->testMissingNestedProperty();
+        $this->testEmptyArrayForAddresses();
+        $this->testInvalidTypeForName();
+        $this->testInvalidDate();
+
+    }
+
+    public function testHydratorSimple(): void
+    {
+        $data = [
+            'name' => 'John Doe 3',
+            'active' => true,
+//            'created_at' => null,
+            'address' => [
+                'street' => '10 rue de la paix',
+                'city' => 'Paris',
+            ],
+        ];
+        $result = $this->schema->process($data);
+        $result = $result->toObject();
+
+        $this->assertInstanceOf(UserModelTest::class, $result);
+        $this->assertStrictEquals('John Doe 3', $result->getName());
+        $this->assertStrictEquals(null, $result->getAge());
+        $this->assertStrictEquals(null, $result->getEmail());
+        $this->assertInstanceOf(AddressTest::class, $result->getAddress());
+        $this->assertStrictEquals('10 rue de la paix', $result->getAddress()->getStreet());
+        $this->assertStrictEquals('Paris', $result->getAddress()->getCity());
+        $this->assertInstanceOf(DateTime::class, $result->getCreatedAt());
+        $this->assertStrictEquals(true, $result->isActive());
+
+
+        $data = [
+            'name' => 'John Doe',
+            'age' => 25,
+            'email' => 'JOHN@EXAMPLE.COM',
+            'date_of_birth' => '1990-01-01',
+            'created_at' => '2023-01-01 12:00:00',
+            'active' => 'off',
+            'address' => null,
+            'addresses' => null,
+        ];
+
+        $result = $this->schema->process($data);
+        $result = $result->toObject();
+
+        $this->assertInstanceOf(UserModelTest::class, $result);
+        $this->assertStrictEquals('John Doe', $result->getName());
+        $this->assertStrictEquals(25, $result->getAge());
+        $this->assertStrictEquals('john@example.com', $result->getEmail());
+        $this->assertInstanceOf(DateTime::class, $result->getDateOfBirth());
+        $this->assertInstanceOf(DateTime::class, $result->getCreatedAt());
+        $this->assertStrictEquals(null, $result->getAddress());
+        $this->assertStrictEquals(false, $result->isActive());
+        $this->assertStrictEquals([], $result->getAddresses());
+
+        $data = [
+            'name' => 'John Doe',
+            'age' => 25,
+            'email' => 'JOHN@EXAMPLE.COM',
+            'date_of_birth' => '1990-01-01',
+            'created_at' => '2023-01-01 12:00:00',
+            'active' => true,
+            'address' => null,
+            'addresses' => [
+                [
+                    'street' => '10 rue de la paix',
+                    'city' => 'Paris',
+                    'tags' => [
+                        'tag1',
+                        'tag2',
+                        1
+                    ],
+                ],
+                [
+                    'street' => 'Marsupilami',
+                    'city' => 'Paris',
+                ],
+            ],
+        ];
+
+        $result = $this->schema->process($data);
+        $result = $result->toObject();
+
+        $this->assertInstanceOf(UserModelTest::class, $result);
+        $this->assertStrictEquals('John Doe', $result->getName());
+        $this->assertStrictEquals(25, $result->getAge());
+        $this->assertStrictEquals('john@example.com', $result->getEmail());
+        $this->assertInstanceOf(DateTime::class, $result->getDateOfBirth());
+        $this->assertInstanceOf(DateTime::class, $result->getCreatedAt());
+        $this->assertStrictEquals(null, $result->getAddress());
+        $this->assertStrictEquals(2, count($result->getAddresses()));
+
+        $this->assertStrictEquals('10 rue de la paix', $result->getAddresses()[0]->getStreet());
+        $this->assertStrictEquals('Paris', $result->getAddresses()[0]->getCity());
+        $this->assertStrictEquals(['tag1', 'tag2', '1'], $result->getAddresses()[0]->getTags());
+
+        $this->assertStrictEquals('Marsupilami', $result->getAddresses()[1]->getStreet());
+        $this->assertStrictEquals('Paris', $result->getAddresses()[1]->getCity());
+        $this->assertStrictEquals([], $result->getAddresses()[1]->getTags());
+
+    }
+
+    public function testAddingNewProperty(): void
+    {
+        $data = [
+            'phone' => '0606060606',
+        ];
+
+        $result = $this->schema->process($data);
+        $result = $result->toArray();
+        $this->assertTrue(!isset($result['phone']));
+
+    }
+
+    public function testForceNullOnDefaultValue(): void
+    {
+        $data = [
+            'email' => null,
+        ];
+
+        $result = $this->schema->process($data);
+        $result = $result->toObject();
+
+        $this->assertNull($result->getEmail()); // Attendu : null ou "John Doe 3" si l'hydrateur ignore les nulls
+    }
+
+    public function testMissingNestedProperty(): void
+    {
+        $data = [
+            'address' => [
+                'street' => '10 rue de la paix',
+                // 'city' est manquant
+            ],
+        ];
+
+        $this->expectException(InvalidDataException::class, function () use ($data) {
+            try {
+                $this->schema->process($data);
+            } catch (InvalidDataException $e) {
+                $this->assertNotEmpty($e->getErrors());
+                $this->assertEquals(1, count($e->getErrors()));
+                $this->assertNotEmpty($e->getError('address.city'));
+                throw $e;
+            }
+        });
+    }
+
+    public function testEmptyArrayForAddresses(): void
+    {
+        $data = [
+            'addresses' => [], // Devrait être une liste d'adresses, mais on met un tableau vide
+        ];
+
+        $result = $this->schema->process($data);
+        $result = $result->toObject();
+
+        $this->assertTrue(is_array($result->getAddresses()));
+        $this->assertStrictEquals(0, count($result->getAddresses())); // Doit retourner un tableau vide
+    }
+
+    public function testInvalidTypeForName(): void
+    {
+        $data = [
+            'name' => ['John', 'Doe'], // Mauvais type (tableau au lieu de string)
+        ];
+
+        $this->expectException(InvalidDataException::class, function () use ($data) {
+            try {
+                $this->schema->process($data);
+            } catch (InvalidDataException $e) {
+                $this->assertNotEmpty($e->getErrors());
+                $this->assertEquals(1, count($e->getErrors()));
+                $this->assertNotEmpty($e->getError('name'));
+                throw $e;
+            }
+        });
+    }
+
+    public function testInvalidDate(): void
+    {
+        $data = [
+            'date_of_birth' => '99-99-9999', // Date invalide
+        ];
+        $this->expectException(InvalidDataException::class, function () use ($data) {
+            try {
+                $this->schema->process($data);
+            } catch (InvalidDataException $e) {
+                $this->assertNotEmpty($e->getErrors());
+                $this->assertEquals(1, count($e->getErrors()));
+                $this->assertNotEmpty($e->getError('date_of_birth'));
+                throw $e;
+            }
+        });
+    }
+
+
+
+}

+ 51 - 0
tests/Model/AddressTest.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Test\PhpDevCommunity\RequestKit\Model;
+
+class AddressTest
+{
+    /**
+     * @var string
+     * @example toto
+     */
+    public string $street = '';
+    public string $city = '';
+
+    /**
+     * @var array<string>
+     */
+    private array $tags = [];
+
+    public function getStreet(): string
+    {
+        return $this->street;
+    }
+
+    public function setStreet(string $street): AddressTest
+    {
+        $this->street = $street;
+        return $this;
+    }
+
+    public function getCity(): string
+    {
+        return $this->city;
+    }
+
+    public function setCity(string $city): AddressTest
+    {
+        $this->city = $city;
+        return $this;
+    }
+
+    public function getTags(): array
+    {
+        return $this->tags;
+    }
+
+    public function setTags(array $tags): AddressTest
+    {
+        $this->tags = $tags;
+        return $this;
+    }
+}

+ 113 - 0
tests/Model/UserModelTest.php

@@ -0,0 +1,113 @@
+<?php
+
+namespace Test\PhpDevCommunity\RequestKit\Model;
+
+use PhpDevCommunity\RequestKit\Utils\DateOnly;
+
+class UserModelTest
+{
+    private string $name = '';
+    private ?int $age = null; // Nullable as it's not required
+    private ?string $email = null; // Nullable as it's not required
+    private ?DateOnly $dateOfBirth = null; // Nullable as it's not required
+    private ?\DateTimeInterface $createdAt = null;
+    private ?AddressTest $address = null;
+
+    /**
+     * @var array<AddressTest>
+     */
+    private array $addresses = [];
+    private bool $active = false;
+
+    public function __construct()
+    {
+        $this->createdAt = new \DateTime();
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function setName(string $name): UserModelTest
+    {
+        $this->name = $name;
+        return $this;
+    }
+
+    public function getAge(): ?int
+    {
+        return $this->age;
+    }
+
+    public function setAge(?int $age): UserModelTest
+    {
+        $this->age = $age;
+        return $this;
+    }
+
+    public function getEmail(): ?string
+    {
+        return $this->email;
+    }
+
+    public function setEmail(?string $email): UserModelTest
+    {
+        $this->email = $email;
+        return $this;
+    }
+
+    public function getDateOfBirth(): ?\DateTimeInterface
+    {
+        return $this->dateOfBirth;
+    }
+
+    public function setDateOfBirth(?\DateTimeInterface $dateOfBirth): UserModelTest
+    {
+        $this->dateOfBirth = $dateOfBirth;
+        return $this;
+    }
+
+    public function getCreatedAt(): ?\DateTimeInterface
+    {
+        return $this->createdAt;
+    }
+
+    public function setCreatedAt(?\DateTimeInterface $createdAt): UserModelTest
+    {
+        $this->createdAt = $createdAt;
+        return $this;
+    }
+
+    public function getAddress(): ?AddressTest
+    {
+        return $this->address;
+    }
+
+    public function setAddress(?AddressTest $address): UserModelTest
+    {
+        $this->address = $address;
+        return $this;
+    }
+
+    public function getAddresses(): array
+    {
+        return $this->addresses;
+    }
+
+    public function setAddresses(array $addresses): UserModelTest
+    {
+        $this->addresses = $addresses;
+        return $this;
+    }
+    public function isActive(): bool
+    {
+        return $this->active;
+    }
+
+    public function setActive(bool $active): UserModelTest
+    {
+        $this->active = $active;
+        return $this;
+    }
+}

+ 379 - 0
tests/SchemaTest.php

@@ -0,0 +1,379 @@
+<?php
+
+namespace Test\PhpDevCommunity\RequestKit;
+
+use DateTime;
+use PhpDevCommunity\RequestKit\Exceptions\InvalidDataException;
+use PhpDevCommunity\RequestKit\Schema\Schema;
+use PhpDevCommunity\RequestKit\Type;
+use PhpDevCommunity\UniTester\TestCase;
+
+class SchemaTest extends TestCase
+{
+
+    private ?Schema $schema = null;
+
+    protected function setUp(): void
+    {
+        $this->schema = Schema::create([
+            'name' => Type::string()->length(3, 100)->required(),
+            'age' => Type::int()->min(18)->max(99),
+            'email' => Type::email()->lowercase(),
+            'date_of_birth' => Type::date()->format('Y-m-d'),
+            'created_at' => Type::datetime()->format('Y-m-d H:i:s')->default(date('2025-01-01 12:00:00')),
+            'active' => Type::bool()->strict(),
+        ]);
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testValidData();
+        $this->testInvalidEmail();
+        $this->testEdgeCaseAge();
+        $this->testStrictBool();
+        $this->testMissingOptionalField();
+        $this->testMissingRequiredField();
+        $this->testMultipleValidationErrors();
+        $this->testNestedData();
+        $this->testCollection();
+        $this->testExtend();
+        $this->testExampleData();
+
+    }
+
+    public function testValidData(): void
+    {
+        $data = [
+            'name' => 'John Doe',
+            'age' => 25,
+            'email' => 'JOHN@EXAMPLE.COM',
+            'date_of_birth' => '1990-01-01',
+            'created_at' => '2023-01-01 12:00:00',
+            'active' => true,
+        ];
+
+        $result = $this->schema->process($data);
+        $result = $result->toArray();
+
+        $this->assertStrictEquals($result['name'], 'John Doe');
+        $this->assertStrictEquals($result['age'], 25);
+        $this->assertStrictEquals($result['email'], 'john@example.com');
+        $this->assertStrictEquals($result['date_of_birth']->format('Y-m-d'), '1990-01-01');
+        $this->assertStrictEquals($result['created_at']->format('Y-m-d H:i:s'), '2023-01-01 12:00:00');
+        $this->assertStrictEquals($result['active'], true);
+    }
+
+    public function testInvalidEmail(): void
+    {
+        $data = [
+            'name' => 'John Doe',
+            'age' => 25,
+            'email' => 'invalid-email', // Email invalide
+            'date_of_birth' => '1990-01-01',
+            'created_at' => '2023-01-01 12:00:00',
+            'active' => true,
+        ];
+        $this->expectException(InvalidDataException::class, function () use ($data) {
+            try {
+                $result = $this->schema->process($data);
+            } catch (InvalidDataException $e) {
+                $this->assertNotEmpty($e->getErrors());
+                $this->assertEquals(1, count($e->getErrors()));
+                $this->assertNotEmpty($e->getError('email'));
+                throw $e;
+            }
+        });
+
+    }
+
+    public function testEdgeCaseAge(): void
+    {
+        $data = [
+            'name' => 'John Doe',
+            'age' => '18', // Âge limite
+            'email' => 'john@example.com',
+            'date_of_birth' => '1990-01-01',
+            'created_at' => '2023-01-01 12:00:00',
+            'active' => true,
+        ];
+
+        $result = $this->schema->process($data);
+        $result = $result->toArray();
+
+        $this->assertEquals(18, $result['age']);
+    }
+
+    public function testStrictBool(): void
+    {
+        $data = [
+            'name' => 'John Doe',
+            'age' => 25,
+            'email' => 'john@example.com',
+            'date_of_birth' => '1990-01-01',
+            'created_at' => '2023-01-01 12:00:00',
+            'active' => 1, //
+        ];
+
+        $this->expectException(InvalidDataException::class, function () use ($data) {
+            try {
+                $result = $this->schema->process($data);
+            } catch (InvalidDataException $e) {
+                $this->assertNotEmpty($e->getErrors());
+                $this->assertEquals(1, count($e->getErrors()));
+                $this->assertNotEmpty($e->getError('active'));
+                throw $e;
+            }
+        });
+    }
+
+    public function testMissingOptionalField(): void
+    {
+        $data = [
+            'name' => 'John Doe',
+            'age' => 25,
+            'email' => 'john@example.com',
+            'date_of_birth' => '1990-01-01',
+            // 'created_at'
+            'active' => true,
+        ];
+
+        $result = $this->schema->process($data);
+        $result = $result->toArray();
+        $this->assertInstanceOf(DateTime::class, $result['created_at']);
+    }
+
+    public function testMissingRequiredField(): void
+    {
+
+        $data = [
+            // 'name' manquant
+            'age' => 25,
+            'email' => 'john@example.com',
+            'date_of_birth' => '1990-01-01',
+            'created_at' => '2023-01-01 12:00:00',
+            'active' => true,
+        ];
+
+        $this->expectException(InvalidDataException::class, function () use ($data) {
+            try {
+                $result = $this->schema->process($data);
+            } catch (InvalidDataException $e) {
+                $this->assertNotEmpty($e->getErrors());
+                $this->assertEquals(1, count($e->getErrors()));
+                $this->assertNotEmpty($e->getError('name'));
+                throw $e;
+            }
+        });
+    }
+
+    public function testMultipleValidationErrors(): void
+    {
+
+        $data = [
+            'name' => 'John Doe',
+            'age' => 17, // Âge invalide
+            'email' => 'invalid-email', // Email invalide
+            'date_of_birth' => '1990-01-01',
+            'created_at' => '2023-01-01 12:00:00',
+            'active' => true,
+        ];
+
+        $this->expectException(InvalidDataException::class, function () use ($data) {
+            try {
+                $result = $this->schema->process($data);
+            } catch (InvalidDataException $e) {
+                $this->assertNotEmpty($e->getErrors());
+                $this->assertEquals(2, count($e->getErrors()));
+                $this->assertNotEmpty($e->getError('age'));
+                $this->assertNotEmpty($e->getError('email'));
+                throw $e;
+            }
+        });
+    }
+
+    public function testNestedData(): void
+    {
+        $schema = Schema::create([
+            'user' => Type::item([
+                'name' => Type::string()->length(20, 50)->required(),
+                'age' => Type::int()->strict()->alias('my_age'),
+                'roles' => Type::arrayOf(Type::string()->strict())->required(),
+                'address' => Type::item([
+                    'street' => Type::string()->length(15, 100),
+                    'city' => Type::string()->allowed('Paris', 'London'),
+                ]),
+            ]),
+        ]);
+
+        $data = [
+            'user' => [
+//                'name' => 'John Doe',
+                'my_age' => '25',
+//                'roles' => [
+//                    1,
+//                    2,
+//                ],
+                'address' => [
+                    'street' => 'Main Street',
+                    'city' => 'New York',
+                ]
+            ],
+        ];
+
+        $this->expectException(InvalidDataException::class, function () use ($schema, $data) {
+            try {
+                $schema->process($data);
+            } catch (InvalidDataException $e) {
+                $this->assertNotEmpty($e->getErrors());
+                $this->assertEquals(5, count($e->getErrors()));
+                $this->assertNotEmpty($e->getError('user.name'));
+                $this->assertNotEmpty($e->getError('user.age'));
+                $this->assertNotEmpty($e->getError('user.address.street'));
+                $this->assertNotEmpty($e->getError('user.address.city'));
+                $this->assertNotEmpty($e->getError('user.roles'));
+                throw $e;
+            }
+        });
+
+    }
+
+    public function testCollection(): void
+    {
+        $schema = Schema::create([
+            'users' => Type::arrayOf(Type::item([
+                'name' => Type::string()->length(3, 50)->required(),
+                'age' => Type::int(),
+                'roles' => Type::arrayOf(Type::string())->required(),
+                'address' => Type::item([
+                    'street' => Type::string()->length(5, 100),
+                    'city' => Type::string()->allowed('Paris', 'London'),
+                ]),
+            ])),
+        ]);
+
+        $data = [
+            'users' => [
+                [
+                    'name' => 'John Doe',
+                    'age' => '25',
+                    'roles' => [
+                        1,
+                        2,
+                    ],
+                    'address' => [
+                        'street' => 'Main Street',
+                        'city' => 'London',
+                    ]
+                ],
+                [
+                    'name' => 'Jane Doe',
+                    'age' => '30',
+                    'roles' => [
+                        3,
+                        4,
+                    ],
+                    'address' => [
+                        'street' => 'Main Street',
+                        'city' => 'Paris',
+                    ]
+                ],
+            ]
+        ];
+
+        $result = $schema->process($data);
+        $this->assertStrictEquals(2, count($result->get('users')));
+        $this->assertStrictEquals('John Doe', $result->get('users.0.name'));
+        $this->assertStrictEquals(25, $result->get('users.0.age'));
+        $this->assertStrictEquals(2, count($result->get('users.0.roles')));
+        $this->assertStrictEquals('Main Street', $result->get('users.0.address.street'));
+        $this->assertStrictEquals('London', $result->get('users.0.address.city'));
+
+        $this->assertStrictEquals('Jane Doe', $result->get('users.1.name'));
+        $this->assertStrictEquals(30, $result->get('users.1.age'));
+        $this->assertStrictEquals(2, count($result->get('users.1.roles')));
+        $this->assertStrictEquals('Main Street', $result->get('users.1.address.street'));
+        $this->assertStrictEquals('Paris', $result->get('users.1.address.city'));
+    }
+
+    private function testExtend()
+    {
+
+        $schema1 = Schema::create([
+            'name' => Type::string()->length(20, 50)->required(),
+            'age' => Type::int()->strict()->alias('my_age'),
+            'roles' => Type::arrayOf(Type::string()->strict())->required(),
+            'address' => Type::item([
+                'street' => Type::string()->length(15, 100),
+                'city' => Type::string()->allowed('Paris', 'London'),
+            ]),
+        ]);
+
+        $schema2 = $schema1->extend([
+            'password' => Type::string()->length(10, 100),
+            'address' => Type::item([
+                'zip' => Type::string()->length(5, 10),
+            ]),
+        ]);
+
+        $this->assertStrictEquals(5, count($schema2->copyDefinitions()));
+        /**
+         * @var Type\ItemType $address
+         */
+        $address = $schema2->copyDefinitions()['address'];
+        $this->assertStrictEquals(1, count($address->copyDefinitions()));
+
+    }
+
+    private function testExampleData()
+    {
+
+        $schema1 = Schema::create([
+            'name' => Type::string()->length(20, 50)->required()->example('John Doe'),
+            'age' => Type::int()->strict()->alias('my_age')->example(20),
+            'roles' => Type::arrayOf(Type::string()->strict())->required()->example('admin'),
+            'address' => Type::item([
+                'street' => Type::string()->length(15, 100),
+                'city' => Type::string()->allowed('Paris', 'London'),
+            ])->example([
+                    'street' => 'Main Street',
+                    'city' => 'London',
+                ]
+            ),
+        ]);
+
+        $this->assertEquals($schema1->generateExampleData(), [
+            'name' => 'John Doe',
+            'age' => 20,
+            'roles' => ['admin'],
+            'address' => [
+                'street' => 'Main Street',
+                'city' => 'London',
+            ]
+        ]);
+
+        $schema2 = Schema::create([
+            'name' => Type::string()->length(20, 50)->required()->example('John Doe'),
+            'age' => Type::int()->strict()->alias('my_age')->example(20),
+            'roles' => Type::arrayOf(Type::string()->strict())->required()->example('admin'),
+            'address' => Type::item([
+                'street' => Type::string()->length(15, 100)->example('Main Street'),
+                'city' => Type::string()->allowed('Paris', 'London')->example('London'),
+            ]),
+        ]);
+
+        $this->assertEquals($schema2->generateExampleData(), [
+            'name' => 'John Doe',
+            'age' => 20,
+            'roles' => ['admin'],
+            'address' => [
+                'street' => 'Main Street',
+                'city' => 'London',
+            ]
+        ]);
+    }
+}

+ 272 - 0
tests/TypeTest.php

@@ -0,0 +1,272 @@
+<?php
+namespace Test\PhpDevCommunity\RequestKit;
+use PhpDevCommunity\RequestKit\Type\BoolType;
+use PhpDevCommunity\RequestKit\Type\DateTimeType;
+use PhpDevCommunity\RequestKit\Type\DateType;
+use PhpDevCommunity\RequestKit\Type\IntType;
+use PhpDevCommunity\RequestKit\Type\NumericType;
+use PhpDevCommunity\RequestKit\Type\StringType;
+
+class TypeTest extends \PhpDevCommunity\UniTester\TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testStringType();
+        $this->testIntType();
+        $this->testBoolType();
+        $this->testDateTimeType();
+        $this->testDateType();
+        $this->testNumericType();
+    }
+
+    private function testStringType()
+    {
+        $type = (new StringType())
+            ->required()
+            ->length(4, 20);
+        $result = $type->validate("  test  ");
+        $this->assertTrue($result->isValid());
+        $this->assertEquals('  test  ', $result->getValue());
+
+
+        $type->trim();
+        $result = $type->validate("  test  ");
+        $this->assertTrue($result->isValid());
+        $this->assertEquals('test', $result->getValue());
+
+
+        $type->length(10, 20);
+        $result = $type->validate("  test  ");
+        $this->assertFalse($result->isValid());
+        $this->assertEquals('test must be at least 10 characters long', $result->getError());
+
+
+        $type->length(1, 3);
+        $result = $type->validate("  test  ");
+        $this->assertFalse($result->isValid());
+        $this->assertEquals('test cannot be longer than 3 characters', $result->getError());
+
+        $type->length(10, 20)->optional();
+        $result = $type->validate(null);
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(null, $result->getValue());
+
+        $type->required();
+        $result = $type->validate(null);
+        $this->assertFalse($result->isValid());
+        $this->assertEquals('Value is required, but got null or empty string', $result->getError());
+
+
+        $type->length(1);
+        $result = $type->validate(123);
+        $this->assertTrue($result->isValid());
+        $this->assertEquals('123', $result->getValue());
+        $this->assertEquals(123, $result->getRawValue());
+
+        $type->strict();
+        $result = $type->validate(123);
+        $this->assertFalse($result->isValid());
+        $this->assertEquals('Value must be a string, got: integer', $result->getError());
+
+
+        $type->uppercase();
+        $result = $type->validate("is test");
+        $this->assertTrue($result->isValid());
+        $this->assertStrictEquals('IS TEST', $result->getValue());
+
+        $type->lowercase();
+        $type->removeSpaces();
+        $result = $type->validate("is test for me");
+        $this->assertTrue($result->isValid());
+        $this->assertStrictEquals('istestforme', $result->getValue());
+
+        $type->length(6);
+        $type->padLeft(6, "0");
+        $result = $type->validate("1");
+        $this->assertTrue($result->isValid());
+        $this->assertStrictEquals('000001', $result->getValue());
+
+        $type->length(6);
+        $type->removeChars('+', '-', '.');
+        $result = $type->validate("123-45+6.");
+        $this->assertTrue($result->isValid());
+        $this->assertStrictEquals('123456', $result->getValue());
+
+        $type->allowed('123456', '654321');
+        $result = $type->validate("654321");
+        $this->assertTrue($result->isValid());
+        $this->assertStrictEquals('654321', $result->getValue());
+
+        $type->allowed('123456', '654321');
+        $result = $type->validate("254321");
+        $this->assertFalse($result->isValid());
+        $this->assertNotNull($result->getError());
+
+    }
+
+    private function testIntType()
+    {
+        $type = (new IntType())
+            ->required()
+            ->min(5)
+            ->max(12);
+        $result = $type->validate(12);
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(12, $result->getValue());
+
+        $result = $type->validate('12');
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(12, $result->getValue());
+
+        $result = $type->validate(1);
+        $this->assertFalse($result->isValid());
+        $this->assertNotNull($result->getError());
+
+        $type->strict();
+        $result = $type->validate("10");
+        $this->assertFalse($result->isValid());
+        $this->assertNotNull($result->getError());
+
+        $result = $type->validate(null);
+        $this->assertFalse($result->isValid());
+        $this->assertNotNull($result->getError());
+
+        $type->optional();
+        $result = $type->validate(null);
+        $this->assertTrue($result->isValid());
+        $this->assertNull($result->getError());
+
+    }
+
+    private function testBoolType()
+    {
+        $type = (new BoolType())
+            ->required();
+        $result = $type->validate(true);
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(true, $result->getValue());
+
+        $result = $type->validate('true');
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(true, $result->getValue());
+
+        $result = $type->validate('1');
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(true, $result->getValue());
+
+        $result = $type->validate(1);
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(true, $result->getValue());
+
+
+        $result = $type->validate(false);
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(false, $result->getValue());
+
+        $result = $type->validate('false');
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(false, $result->getValue());
+
+        $result = $type->validate('0');
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(false, $result->getValue());
+
+        $result = $type->validate(0);
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(false, $result->getValue());
+
+
+    }
+
+    private function testDateTimeType()
+    {
+        $type = (new DateTimeType())
+            ->format('Y-m-d H:i:s')
+            ->required();
+        $result = $type->validate('2020-01-01 10:00:00');
+        $this->assertTrue($result->isValid());
+        $this->assertInstanceOf(\DateTime::class, $result->getValue());
+
+        $type->optional();
+        $result = $type->validate(null);
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(null, $result->getValue());
+
+        $type->required();
+        $result = $type->validate('2020-01-01 10:00');
+        $this->assertFalse($result->isValid());
+        $this->assertNotNull($result->getError());
+        ;
+        $result = $type->validate(strtotime('2020-01-01 10:00'));
+        $this->assertTrue($result->isValid());
+        $this->assertInstanceOf(\DateTime::class, $result->getValue());
+        $datetime = $result->getValue();
+        $this->assertEquals('2020-01-01 10:00:00', $datetime->format('Y-m-d H:i:s'));
+
+    }
+
+    private function testDateType()
+    {
+        $type = (new DateType())
+            ->format('Y-m-d')
+            ->required();
+        $result = $type->validate('2020-01-01');
+        $this->assertTrue($result->isValid());
+        $this->assertInstanceOf(\DateTime::class, $result->getValue());
+
+        $type->optional();
+        $result = $type->validate(null);
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(null, $result->getValue());
+
+        $type->required();
+        $result = $type->validate('2020-01-01 10:00');
+        $this->assertFalse($result->isValid());
+        $this->assertNotNull($result->getError());
+
+        $result = $type->validate(strtotime('2020-01-01'));
+        $this->assertTrue($result->isValid());
+        $this->assertInstanceOf(\DateTime::class, $result->getValue());
+        $datetime = $result->getValue();
+        $this->assertEquals('2020-01-01', $datetime->format('Y-m-d'));
+
+    }
+
+    private function testNumericType()
+    {
+        $testCases = [
+            [1, '1'],
+            ['1', '1'],
+            ['1.0', '1.0'],
+            [1.0, '1'],
+            [0, '0'],
+            [0.0, '0'],
+            ['0.0', '0.0'],
+            ['136585.589', '136585.589'],
+            [136585.589, '136585.589'],
+            [-1, "-1"],
+            [-1.5,'-1.5'],
+            [PHP_INT_MAX, (string)PHP_INT_MAX],
+        ];
+
+        foreach ($testCases as [$input, $expectedOutput]) {
+            $type = (new NumericType())->required();
+            $result = $type->validate($input);
+            $this->assertTrue($result->isValid());
+            $this->assertStrictEquals($expectedOutput, $result->getValue());
+        }
+
+
+    }
+}