فهرست منبع

Initial release of php-requestkit

michelphp 1 روز پیش
کامیت
372ae8b2b1

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+/.idea
+/vendor
+composer.lock

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Michel.F
+
+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.

+ 515 - 0
README.md

@@ -0,0 +1,515 @@
+# Michel PHP requestkit
+
+**Lightweight and efficient PHP library for robust request data validation and transformation.**
+
+Simplify your request processing with `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.
+* **HTTP Header Validation:** Define rules to validate incoming request headers.
+* **Form & CSRF Processing:** Securely process form submissions with built-in CSRF token validation.
+* **Internationalization (i18n):** Error messages can be easily translated. Comes with English and French built-in.
+* **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 michel/requestkit
+```
+
+## Basic Usage
+
+1.  **Create a Schema:** Define your data structure and validation rules using `Schema::create()` and `Type` classes.
+
+```php
+<?php
+
+use Michel\RequestKit\Schema\Schema;
+use Michel\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 Michel\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 Michel\RequestKit\Schema\Schema;
+use Michel\RequestKit\Type;
+use Michel\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(),
+                'metadata' => Type::map(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');
+            $metadata = $validatedData->get('user.metadata'); // <-- map : Instance Of KeyValueObject
+            $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'],
+        'metadata' => [
+            'department' => 'IT',
+            'level' => 'senior',
+        ],
+        '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 Michel\RequestKit\Schema\Schema;
+use Michel\RequestKit\Type;
+use Michel\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 Michel\RequestKit\Schema\Schema;
+use Michel\RequestKit\Type;
+use Michel\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
+```
+
+## Advanced Usage: HTTP Requests, Headers, and Forms
+
+While `process()` is great for arrays, the library shines when working with PSR-7 `ServerRequestInterface` objects, allowing you to validate headers, form data, and CSRF tokens seamlessly.
+
+### Validating Request Headers with `withHeaders()`
+
+You can enforce rules on incoming HTTP headers by chaining the `withHeaders()` method to your schema. This is perfect for validating API keys, content types, or custom headers.
+
+```php
+<?php
+use Michel\RequestKit\Schema\Schema;
+use Michel\RequestKit\Type;
+use Psr\Http\Message\ServerRequestInterface;
+
+// Assume $request is a PSR-7 ServerRequestInterface object from your framework
+
+$schema = Schema::create([
+    'name' => Type::string()->required(),
+])->withHeaders([
+    'Content-Type' => Type::string()->equals('application/json'),
+    'X-Api-Key' => Type::string()->required()->length(16),
+]);
+
+try {
+    // processHttpRequest validates both headers and the request body
+    $validatedData = $schema->processHttpRequest($request);
+    $name = $validatedData->get('name');
+    
+} catch (InvalidDataException $e) {
+    // Throws an exception if headers or body are invalid
+    // e.g., if 'X-Api-Key' is missing or 'Content-Type' is not 'application/json'
+    http_response_code(400);
+    return ['errors' => $e->getErrors()];
+}
+```
+
+### Processing Forms with CSRF Protection using `processForm()`
+
+Securely handle form submissions (`application/x-www-form-urlencoded`) with optional CSRF token validation.
+
+**1. Form with CSRF Validation (Recommended)**
+
+Pass the expected CSRF token (e.g., from the user's session) as the second argument. The library will ensure the token is present and matches.
+
+```php
+<?php
+use Michel\RequestKit\Schema\Schema;
+use Michel\RequestKit\Type;
+use Psr\Http\Message\ServerRequestInterface;
+
+// Assume $request is a PSR-7 ServerRequestInterface object
+// and $_SESSION['csrf_token'] holds the expected token.
+
+$schema = Schema::create([
+    'username' => Type::string()->required(),
+    'comment' => Type::string()->required(),
+]);
+
+$expectedToken = $_SESSION['csrf_token'] ?? null;
+
+try {
+    // processForm validates the form body and the CSRF token
+    // The third argument '_csrf' is the name of the form field containing the token.
+    $validatedData = $schema->processFormHttpRequest($request, $expectedToken, '_csrf');
+    // The '_csrf' field is automatically removed from the validated data.
+    
+} catch (InvalidDataException $e) {
+    // Throws an exception if form data is invalid or CSRF token is missing/incorrect
+    http_response_code(400);
+    if ($e->getMessage() === 'Invalid CSRF token.') {
+        http_response_code(403); // Forbidden
+    }
+    return ['errors' => $e->getErrors()];
+}
+```
+
+**2. Form without CSRF Validation**
+
+If you don't need CSRF protection for a specific form (e.g., a public search form), simply omit the second argument (or pass `null`).
+
+```php
+<?php
+// ...
+try {
+    // No CSRF token is expected or validated
+    $validatedData = $schema->processFormHttpRequest($request);
+    
+} catch (InvalidDataException $e) {
+    // ...
+}
+```
+
+## Error Handling with `InvalidDataException`
+
+When validation fails, an `InvalidDataException` is thrown. 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
+        }
+    }
+```
+
+## Available Validation Types and Rules
+
+`php-requestkit` provides a variety of built-in data types with a rich set of validation rules.
+
+*   **General Rules (Available for most types):**
+    *   `required()`: Field is mandatory.
+    *   `optional()`: Field is optional.
+    *   `strict()`: Strict type validation (e.g., `Type::int()->strict()` will not accept `"123"`).
+
+*   **`Type::string()`:**
+    *   `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.
+    *   `equals(value)`: The final value must be strictly equal to the given `value`.
+
+*   **`Type::int()` & `Type::float()`:**
+    *   `min(value)`: Minimum value.
+    *   `max(value)`: Maximum value.
+    *   `equals(value)`: The final value must be strictly equal to the given `value`.
+
+
+*   **`Type::bool()`:**
+    *   Accepts `true`, `false`, `'true'`, `'false'`, `1`, `0`, `'1'`, `'0'`.
+    *   `equals(value)`: The final value must be strictly equal to the given `value`.
+
+*   **`Type::date()` and `Type::datetime()`:**
+    *   `format(format)`: Specify the date/datetime format (using PHP date formats).
+
+*   **`Type::numeric()`:**
+    *   Validates any numeric value (integer or float).
+    *   `equals(value)`: The final value must be strictly equal to the given `value`.
+
+*   **`Type::item(array $schema)`:** For nested objects/items. Defines a schema for a nested object within the main schema.
+
+*   **`Type::arrayOf(Type $type)`:** For collections/arrays.  Defines that a field should be an array of items, each validated against the provided `Type`.
+*   **`Type::map(Type $type)`:** For key-value objects (associative arrays). Defines that a field should be an object where each value is validated against the provided Type, and keys must be strings.
+
+## Internationalization (i18n)
+
+All error messages are translatable. The library includes English (`en`) and French (`fr`) by default.
+
+### Changing the Language
+
+To switch the language for all subsequent error messages, use the static method `Locale::setLocale()` at the beginning of your application.
+
+```php
+use Michel\RequestKit\Locale;
+
+// Set the active language to French
+Locale::setLocale('fr');
+```
+
+### Adding a New Language
+
+You can easily add support for a new language using `Locale::addMessages()`. Provide the two-letter language code and an associative array of translations. If a key is missing for the active language, the library will automatically fall back to the English version.
+
+Here is a full template for creating a new translation. You only need to translate the values.
+
+```php
+use Michel\RequestKit\Locale;
+
+Locale::addMessages('en', [ // Example for English
+    'error' => [
+        'required' => 'Value is required, but got null or empty string.',
+        'equals' => 'The value does not match the expected value.',
+        'csrf' => 'Invalid CSRF token.',
+        'json' => 'Invalid JSON input: {error}',
+        'type' => [
+            'string' => 'Value must be a string, got: {type}.',
+            'int' => 'Value must be an integer, got: {type}.',
+            'float' => 'Value must be a float, got: {type}.',
+            'bool' => 'Value must be a boolean, got: {type}.',
+            'numeric' => 'Value must be numeric, got: {type}.',
+            'date' => 'Value must be a valid date.',
+            'datetime' => 'Value must be a valid datetime.',
+            'array' => 'Value must be an array.',
+        ],
+        'string' => [
+            'min_length' => 'Value must be at least {min} characters long.',
+            'max_length' => 'Value cannot be longer than {max} characters.',
+            'email' => 'Value must be a valid email address.',
+            'allowed' => 'Value is not allowed.',
+        ],
+        'int' => [
+            'min' => 'Value must be at least {min}.',
+            'max' => 'Value must be no more than {max}.',
+        ],
+        'array' => [
+            'min_items' => 'Value must have at least {min} item(s).',
+            'max_items' => 'Value must have at most {max} item(s).',
+            'integer_keys' => 'All keys must be integers.',
+        ],
+        'map' => [
+            'string_key' => 'Key "{key}" must be a string, got {type}.',
+        ]
+    ],
+]);
+```
+
+## Extending Schemas
+
+You can extend existing schemas to reuse and build upon validation logic.
+
+```php
+<?php
+use Michel\RequestKit\Schema\Schema;
+use Michel\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
+```

+ 28 - 0
composer.json

@@ -0,0 +1,28 @@
+{
+  "name": "michel/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": "Michel.F"
+    }
+  ],
+  "autoload": {
+    "psr-4": {
+      "Michel\\RequestKit\\": "src",
+      "Test\\Michel\\RequestKit\\": "tests"
+    },
+    "files": [
+      "functions/helpers.php"
+    ]
+  },
+  "require": {
+    "php": ">=7.4",
+    "ext-json": "*",
+    "psr/http-message": "^1.0 || ^2.0"
+  },
+  "require-dev": {
+    "michel/unitester": "^1.0.0"
+  }
+}

+ 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 Michel\RequestKit\Builder;
+
+use Michel\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 Michel\RequestKit\Builder;
+
+use Michel\RequestKit\Type;
+use Michel\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 Michel\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 Michel\RequestKit\Generator;
+
+use Michel\RequestKit\Builder\SchemaObjectFactory;
+use Michel\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);
+    }
+
+}

+ 87 - 0
src/Hydrator/ObjectHydrator.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace Michel\RequestKit\Hydrator;
+
+use LogicException;
+use Michel\RequestKit\Type\ArrayOfType;
+use Michel\RequestKit\Type\ItemType;
+use Michel\RequestKit\Type\MapType;
+use Michel\RequestKit\Utils\KeyValueObject;
+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 ($value instanceof KeyValueObject) {
+                $value = $value->getArrayCopy();
+            }
+            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);
+    }
+
+}

+ 142 - 0
src/Locale.php

@@ -0,0 +1,142 @@
+<?php
+
+namespace Michel\RequestKit;
+
+final class Locale
+{
+    private static string $currentLocale = 'en';
+    private static array $messages = [];
+
+    /**
+     * Sets the active locale for error messages.
+     * @param string $locale e.g., 'en', 'fr'
+     */
+    public static function setLocale(string $locale): void
+    {
+        self::$currentLocale = $locale;
+    }
+
+    /**
+     * Adds a set of translation messages for a specific locale.
+     * @param string $locale
+     * @param array $messages
+     */
+    public static function addMessages(string $locale, array $messages): void
+    {
+        // array_replace_recursive is used to merge nested message arrays
+        self::$messages[$locale] = array_replace_recursive(self::$messages[$locale] ?? [], $messages);
+    }
+
+    /**
+     * Gets a translated message by its key.
+     *
+     * @param string $key The key of the message (e.g., 'error.required').
+     * @param array $params Placeholders to replace in the message.
+     * @return string
+     */
+    public static function get(string $key, array $params = []): string
+    {
+        if (empty(self::$messages['en'])) {
+            self::initializeDefaultMessages('en');
+        }
+        if (self::$currentLocale === 'fr' && empty(self::$messages['fr'])) {
+            self::initializeDefaultMessages('fr');
+        }
+
+        $messages = array_dot(self::$messages[self::$currentLocale]);
+        $message = $messages[$key] ?? null;
+        if ($message == null) {
+            $messages = array_dot( self::$messages['en']);
+            $message = $messages[$key] ?? $key;
+        }
+        unset($messages);
+        foreach ($params as $param => $value) {
+            $message = str_replace('{' . $param . '}', (string) $value, $message);
+        }
+
+        return $message;
+    }
+
+    /**
+     * Initializes the default English messages.
+     */
+    private static function initializeDefaultMessages(string $locale): void
+    {
+        if ($locale === 'en') {
+            self::addMessages('en', [
+                'error' => [
+                    'required' => 'Value is required, but got null or empty string.',
+                    'equals' => 'The value does not match the expected value.',
+                    'csrf' => 'Invalid CSRF token.',
+                    'json' => 'Invalid JSON input: {error}',
+                    'type' => [
+                        'string' => 'Value must be a string, got: {type}.',
+                        'int' => 'Value must be an integer, got: {type}.',
+                        'float' => 'Value must be a float, got: {type}.',
+                        'bool' => 'Value must be a boolean, got: {type}.',
+                        'numeric' => 'Value must be numeric, got: {type}.',
+                        'date' => 'Value must be a valid date.',
+                        'datetime' => 'Value must be a valid datetime.',
+                        'array' => 'Value must be an array.',
+                    ],
+                    'string' => [
+                        'min_length' => 'Value must be at least {min} characters long.',
+                        'max_length' => 'Value cannot be longer than {max} characters.',
+                        'email' => 'Value must be a valid email address.',
+                        'allowed' => 'Value is not allowed, allowed values are: {allowed}.',
+                    ],
+                    'int' => [
+                        'min' => 'Value must be at least {min}.',
+                        'max' => 'Value must be no more than {max}.',
+                    ],
+                    'array' => [
+                        'min_items' => 'Value must have at least {min} item(s).',
+                        'max_items' => 'Value must have at most {max} item(s).',
+                        'integer_keys' => 'All keys must be integers.',
+                    ],
+                    'map' => [
+                        'string_key' => 'Key "{key}" must be a string, got {type}.',
+                    ]
+                ],
+            ]);
+        }elseif ($locale === 'fr') {
+            self::addMessages('fr', [
+                'error' => [
+                    'required' => 'La valeur est requise.',
+                    'equals' => 'La valeur ne correspond pas à la valeur attendue.',
+                    'csrf' => 'Jeton CSRF invalide.',
+                    'json' => 'Entrée JSON invalide : {error}',
+                    'type' => [
+                        'string' => 'La valeur doit être une chaîne de caractères, reçu : {type}.',
+                        'int' => 'La valeur doit être un entier, reçu : {type}.',
+                        'float' => 'La valeur doit être un flottant, reçu : {type}.',
+                        'bool' => 'La valeur doit être un booléen, reçu : {type}.',
+                        'numeric' => 'La valeur doit être numérique, reçu : {type}.',
+                        'date' => 'La valeur doit être une date valide.',
+                        'datetime' => 'La valeur doit être une date et heure valide.',
+                        'array' => 'La valeur doit être un tableau.',
+                    ],
+                    'string' => [
+                        'min_length' => 'La valeur doit contenir au moins {min} caractères.',
+                        'max_length' => 'La valeur ne peut pas dépasser {max} caractères.',
+                        'email' => 'La valeur doit être une adresse e-mail valide.',
+                        'allowed' => 'La valeur n\'est pas autorisée. Les valeurs autorisées sont : {allowed}.',
+                    ],
+                    'int' => [
+                        'min' => 'La valeur doit être d\'au moins {min}.',
+                        'max' => 'La valeur ne doit pas dépasser {max}.',
+                    ],
+                    'array' => [
+                        'min_items' => 'La valeur doit contenir au moins {min} élément(s).',
+                        'max_items' => 'La valeur ne doit pas dépasser {max} élément(s).',
+                        'integer_keys' => 'Toutes les clés doivent être des entiers.',
+                    ],
+                    'map' => [
+                        'string_key' => 'La clé "{key}" doit être une chaîne de caractères, reçu {type}.',
+                    ]
+                ],
+            ]);
+        }
+
+    }
+}

+ 158 - 0
src/Schema/AbstractSchema.php

@@ -0,0 +1,158 @@
+<?php
+
+namespace Michel\RequestKit\Schema;
+
+use Michel\RequestKit\Exceptions\InvalidDataException;
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\Type\AbstractType;
+use Michel\RequestKit\Type\ArrayOfType;
+use Michel\RequestKit\Type\ItemType;
+use Psr\Http\Message\ServerRequestInterface;
+
+abstract class AbstractSchema
+{
+    protected bool $patchMode = false;
+    protected string $title = '';
+    protected string $version = '2.0';
+    protected array $headerDefinitions = [];
+
+    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 withHeaders(array $definitions): self
+    {
+        $this->headerDefinitions = $definitions;
+        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(Locale::get('error.json', ['error' => $errorMessage]));
+        }
+        return $this->process($data);
+    }
+
+    final public function processHttpRequest(ServerRequestInterface $request): SchemaAccessor
+    {
+        $this->validateHeaders($request);
+
+        if (in_array('application/json', $request->getHeader('Content-Type'))) {
+            return $this->processJsonInput($request->getBody()->getContents());
+        }
+
+        return $this->processFormHttpRequest($request);
+    }
+
+    final public function processFormHttpRequest(ServerRequestInterface $request, ?string $expectedToken = null, string $csrfKey = '_csrf'): SchemaAccessor
+    {
+        $this->validateHeaders($request);
+        $data = $request->getParsedBody();
+
+        if ($expectedToken !== null) {
+            if (!isset($data[$csrfKey]) || !hash_equals($expectedToken, $data[$csrfKey])) {
+                throw new InvalidDataException(Locale::get('error.csrf'));
+            }
+            unset($data[$csrfKey]);
+        }
+
+        return $this->process($data);
+    }
+
+    final public function processHttpQuery(ServerRequestInterface $request): SchemaAccessor
+    {
+        return $this->process($request->getQueryParams(), true);
+    }
+
+    final public function process(array $data, bool $allowEmptyData = false): SchemaAccessor
+    {
+        $accessor = new SchemaAccessor($data, $this, $allowEmptyData);
+        $accessor->execute();
+        return $accessor;
+    }
+
+    abstract protected function definitions(): array;
+
+    private function validateHeaders(ServerRequestInterface $request): void
+    {
+        if (empty($this->headerDefinitions)) {
+            return;
+        }
+
+        $headerData = [];
+        foreach ($request->getHeaders() as $name => $values) {
+            $headerData[strtolower($name)] = $values[0] ?? null;
+        }
+        $headerDefinitions = [];
+        foreach ($this->headerDefinitions as $name => $definition) {
+            $headerDefinitions[strtolower($name)] = clone $definition;
+        }
+
+        $headerSchema = Schema::create($headerDefinitions);
+        $headerSchema->process($headerData);
+    }
+
+    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;
+    }
+
+    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;
+            }
+            $data[$key] = $definition->getExample();
+        }
+        return $data;
+    }
+
+    final public function metadata(): array
+    {
+        $metadata = [];
+        foreach ($this->getDefinitions() as $key => $definition) {
+            $metadata[$key] = $definition->getMetadata();
+        }
+        return $metadata;
+    }
+}

+ 73 - 0
src/Schema/Schema.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace Michel\RequestKit\Schema;
+
+use Michel\RequestKit\Builder\SchemaObjectFactory;
+use Michel\RequestKit\Generator\DefinitionGenerator;
+use Michel\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;
+    }
+}

+ 121 - 0
src/Schema/SchemaAccessor.php

@@ -0,0 +1,121 @@
+<?php
+
+namespace Michel\RequestKit\Schema;
+
+use InvalidArgumentException;
+use Michel\RequestKit\Exceptions\InvalidDataException;
+use Michel\RequestKit\Hydrator\ObjectHydrator;
+use Michel\RequestKit\Type\ItemType;
+
+final class SchemaAccessor
+{
+    private array $initialData;
+    private AbstractSchema $schema;
+    private ?\ArrayObject $data = null;
+    private bool $executed = false;
+
+    private bool $allowEmptyData;
+
+    public function __construct(array $initialData, Schema $schema, bool $allowEmptyData = false)
+    {
+        $this->initialData = $initialData;
+        $this->schema = $schema;
+        $this->allowEmptyData = $allowEmptyData;
+    }
+
+    public function execute(): void
+    {
+        $data = $this->initialData;
+        if (empty($data) && $this->allowEmptyData === false) {
+            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();
+        if (array_key_exists($key, $current)) {
+            return $current[$key];
+        }
+        $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();
+    }
+
+}

+ 131 - 0
src/Type.php

@@ -0,0 +1,131 @@
+<?php
+
+namespace Michel\RequestKit;
+use Michel\RequestKit\Schema\Schema;
+use Michel\RequestKit\Type\AbstractType;
+use Michel\RequestKit\Type\ArrayOfType;
+use Michel\RequestKit\Type\BoolType;
+use Michel\RequestKit\Type\DateTimeType;
+use Michel\RequestKit\Type\DateType;
+use Michel\RequestKit\Type\EmailType;
+use Michel\RequestKit\Type\FloatType;
+use Michel\RequestKit\Type\IntType;
+use Michel\RequestKit\Type\ItemType;
+use Michel\RequestKit\Type\MapType;
+use Michel\RequestKit\Type\NumericType;
+use Michel\RequestKit\Type\StringType;
+use Michel\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 map(AbstractType $type) : MapType
+    {
+        return new MapType($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);
+        }
+
+    }
+}

+ 88 - 0
src/Type/AbstractStringType.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Michel\RequestKit\ValidationResult;
+
+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 function_exists('mb_strtoupper') ? mb_strtoupper($value) : strtoupper($value);
+        });
+        return $this;
+    }
+
+    public function lowercase(): self
+    {
+        $this->transform(function ($value) {
+            if (empty($value) || !is_string($value)) {
+                return $value;
+            }
+            return function_exists('mb_strtoupper') ? mb_strtolower($value) : strtolower($value);
+        });
+        return $this;
+    }
+}

+ 135 - 0
src/Type/AbstractType.php

@@ -0,0 +1,135 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Closure;
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\ValidationResult;
+use InvalidArgumentException;
+
+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 requiredIf($condition): self
+    {
+        if (!is_bool($condition) && !is_callable($condition)) {
+            throw new InvalidArgumentException('condition must be boolean or callable');
+        }
+
+        if (is_callable($condition)) {
+            $condition = $condition();
+        }
+        if ($condition) {
+            $this->required();
+        }
+        return $this;
+    }
+
+    final public function optional(): self
+    {
+        $this->required = false;
+        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(Locale::get('error.required'));
+            }
+            $result->setValue(null);
+            return $result;
+        }
+
+        $this->transformValue($result);
+        $this->validateValue($result);
+        return $result;
+    }
+
+    abstract protected function validateValue(ValidationResult $result): void;
+
+    protected function forceDefaultValue(ValidationResult $result): void
+    {
+    }
+
+}

+ 112 - 0
src/Type/ArrayOfType.php

@@ -0,0 +1,112 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Michel\RequestKit\Exceptions\InvalidDataException;
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\Schema\Schema;
+use Michel\RequestKit\ValidationResult;
+
+final class ArrayOfType extends AbstractType
+{
+    private AbstractType $type;
+    private ?int $min = null;
+    private ?int $max = null;
+    private bool $acceptStringKeys = false;
+    private bool $acceptCommaSeparatedValues = false;
+
+    public function min(int $min): self
+    {
+        $this->min = $min;
+        return $this;
+    }
+
+    public function max(int $max): self
+    {
+        $this->max = $max;
+        return $this;
+    }
+
+    public function acceptStringKeys(): self
+    {
+        $this->acceptStringKeys = true;
+        return $this;
+    }
+
+    public function acceptCommaSeparatedValues(): self
+    {
+        $this->acceptCommaSeparatedValues = true;
+        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_string($values) && $this->acceptCommaSeparatedValues) {
+            $values = explode(',', $values);
+            $values = array_map('trim', $values);
+            $values = array_filter($values, fn($v) => $v !== '');
+        }
+        if (!is_array($values)) {
+            $result->setError(Locale::get('error.type.array'));
+            return;
+        }
+
+        $definitions = [];
+        $count = count($values);
+        if ($this->min && $count < $this->min) {
+            $result->setError(Locale::get('error.array.min_items', ['min' => $this->min]));
+            return;
+        }
+        if ($this->max && $count > $this->max) {
+            $result->setError(Locale::get('error.array.max_items', ['max' => $this->max]));
+            return;
+        }
+
+        foreach ($values as $key => $value) {
+            if ($this->acceptStringKeys === false && !is_int($key)) {
+                $result->setError(Locale::get('error.array.integer_keys'));
+                return;
+            }
+            if (is_string($key)) {
+                $key = trim($key);
+            }
+            $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);
+    }
+}

+ 39 - 0
src/Type/BoolType.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\Type\Traits\EqualTrait;
+use Michel\RequestKit\Type\Traits\StrictTrait;
+use Michel\RequestKit\ValidationResult;
+
+final class BoolType extends AbstractType
+{
+    use StrictTrait;
+    use EqualTrait;
+
+    protected function validateValue(ValidationResult $result): void
+    {
+        $value = $result->getValue();
+
+        if ($this->isStrict() && !is_bool($value)) {
+            $result->setError(Locale::get('error.type.bool', ['type' => gettype($value)]));
+            return;
+        }
+
+        if ($this->isStrict() === false && !is_bool($value)) {
+            if (in_array($value, [1, '1', 'true', 'on', 'TRUE', 'ON'], true)) {
+                $result->setValue(true);
+            } elseif (in_array($value, [0, '0', 'false', 'off', 'FALSE', 'OFF'], true)) {
+                $result->setValue(false);
+            } else {
+                $result->setError(Locale::get('error.type.bool', ['type' => gettype($value)]));
+                return;
+            }
+        }
+
+        if ($this->checkEquals && $result->getValue() !== $this->equalTo) {
+            $result->setError(Locale::get('error.equals'));
+        }
+    }
+}

+ 40 - 0
src/Type/DateTimeType.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\ValidationResult;
+
+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
+    {
+        $value = $result->getValue();
+        if ($value instanceof \DateTimeInterface) {
+            return;
+        }
+
+        if (is_string($value)) {
+            $datetime = \DateTime::createFromFormat($this->format, $value);
+            if ($datetime === false || $datetime->format($this->format) !== $value) {
+                $result->setError(Locale::get('error.type.datetime'));
+                return;
+            }
+            $result->setValue($datetime);
+        } elseif (is_int($value)) {
+            $datetime = new \DateTime();
+            $datetime->setTimestamp($value);
+            $result->setValue($datetime);
+        } else {
+            $result->setError(Locale::get('error.type.datetime'));
+        }
+    }
+}

+ 42 - 0
src/Type/DateType.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\Utils\DateOnly;
+use Michel\RequestKit\ValidationResult;
+
+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
+    {
+        $value = $result->getValue();
+
+        if ($value instanceof \DateTimeInterface) {
+            return;
+        }
+
+        if (is_string($value)) {
+            $datetime = DateOnly::createFromFormat($this->format, $value);
+            if ($datetime === false || $datetime->format($this->format) !== $value) {
+                $result->setError(Locale::get('error.type.date'));
+                return;
+            }
+            $result->setValue($datetime);
+        } elseif (is_int($value)) {
+            $datetime = new DateOnly();
+            $datetime->setTimestamp($value);
+            $result->setValue($datetime);
+        } else {
+            $result->setError(Locale::get('error.type.date'));
+        }
+    }
+}

+ 16 - 0
src/Type/EmailType.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\ValidationResult;
+
+final class EmailType extends AbstractStringType
+{
+    protected function validateValue(ValidationResult $result): void
+    {
+        if (filter_var($result->getValue(), FILTER_VALIDATE_EMAIL) === false) {
+            $result->setError(Locale::get('error.string.email'));
+        }
+    }
+}

+ 53 - 0
src/Type/FloatType.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\Type\Traits\StrictTrait;
+use Michel\RequestKit\ValidationResult;
+
+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(Locale::get('error.type.float', ['type' => gettype($result->getValue())]));
+            return;
+        }
+
+        if (!$this->isStrict() && !is_numeric($result->getValue())) {
+            $result->setError(Locale::get('error.type.float', ['type' => gettype($result->getValue())]));
+            return;
+        }
+
+        if (!$this->isStrict()) {
+            $result->setValue(floatval($result->getValue()));
+        }
+
+        if ($this->min && $result->getValue() < $this->min) {
+            $result->setError(Locale::get('error.int.min', ['min' => $this->min]));
+            return;
+        }
+
+        if ($this->max && $result->getValue() > $this->max) {
+            $result->setError(Locale::get('error.int.max', ['max' => $this->max]));
+        }
+    }
+}

+ 60 - 0
src/Type/IntType.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\Type\Traits\EqualTrait;
+use Michel\RequestKit\Type\Traits\StrictTrait;
+use Michel\RequestKit\ValidationResult;
+
+final class IntType extends AbstractType
+{
+    use StrictTrait;
+    use EqualTrait;
+
+    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(Locale::get('error.type.int', ['type' => gettype($result->getValue())]));
+            return;
+        }
+
+        if (!$this->isStrict() && !is_numeric($result->getValue())) {
+            $result->setError(Locale::get('error.type.int', ['type' => gettype($result->getValue())]));
+            return;
+        }
+        
+        if (!$this->isStrict()) {
+            $result->setValue(intval($result->getValue()));
+        }
+
+        if ($this->checkEquals && $result->getValue() !== $this->equalTo) {
+            $result->setError(Locale::get('error.equals'));
+            return;
+        }
+
+        if ($this->min !== null && $result->getValue() < $this->min) {
+            $result->setError(Locale::get('error.int.min', ['min' => $this->min]));
+            return;
+        }
+
+        if ($this->max !== null && $result->getValue() > $this->max) {
+            $result->setError(Locale::get('error.int.max', ['max' => $this->max]));
+        }
+    }
+}

+ 53 - 0
src/Type/ItemType.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Michel\RequestKit\Exceptions\InvalidDataException;
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\Schema\Schema;
+use Michel\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
+    {
+        $value = $result->getValue();
+        if (!is_array($value)) {
+            $result->setError(Locale::get('error.type.array'));
+            return;
+        }
+        try {
+            $result->setValue($this->schema->process($value));
+        } catch (InvalidDataException $e) {
+            $result->setErrors($e->getErrors(), false);
+        }
+    }
+}

+ 93 - 0
src/Type/MapType.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Michel\RequestKit\Exceptions\InvalidDataException;
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\Schema\Schema;
+use Michel\RequestKit\Utils\KeyValueObject;
+use Michel\RequestKit\ValidationResult;
+
+final class MapType extends AbstractType
+{
+    private AbstractType $type;
+
+    private ?int $min = null;
+    private ?int $max = null;
+
+    public function min(int $min): self
+    {
+        $this->min = $min;
+        return $this;
+    }
+
+    public function max(int $max): self
+    {
+        $this->max = $max;
+        return $this;
+    }
+
+    public function __construct(AbstractType $type)
+    {
+        $this->type = $type;
+        $this->default(new KeyValueObject());
+    }
+
+    public function getCopyType(): AbstractType
+    {
+        return clone $this->type;
+    }
+
+    protected function forceDefaultValue(ValidationResult $result): void
+    {
+        if ($result->getValue() === null) {
+            $result->setValue(new KeyValueObject());
+        }
+    }
+
+    protected function validateValue(ValidationResult $result): void
+    {
+        if ($this->isRequired() && empty($this->min)) {
+            $this->min = 1;
+        }
+        $values = $result->getValue();
+        if (!is_array($values) && !$values instanceof KeyValueObject) {
+            $result->setError(Locale::get('error.type.array'));
+            return;
+        }
+
+        $count = count($values);
+        if ($this->min !== null && $count < $this->min) {
+            $result->setError(Locale::get('error.array.min_items', ['min' => $this->min]));
+            return;
+        }
+        if ($this->max !== null && $count > $this->max) {
+            $result->setError(Locale::get('error.array.max_items', ['max' => $this->max]));
+            return;
+        }
+
+        $definitions = [];
+        foreach ($values as $key => $value) {
+            if (!is_string($key)) {
+                $result->setError(Locale::get('error.map.string_key', ['key' => $key, 'type' => gettype($key)]));
+                return;
+            }
+            $key = trim($key);
+            $definitions[$key] = $this->type;
+        }
+        if (empty($definitions)) {
+            $result->setValue(new KeyValueObject());
+            return;
+        }
+
+        $schema = Schema::create($definitions);
+        try {
+            $values = $schema->process($values);
+        } catch (InvalidDataException $e) {
+            $result->setErrors($e->getErrors(), false);
+            return;
+        }
+
+        $result->setValue(new KeyValueObject($values->toArray()));
+    }
+}

+ 26 - 0
src/Type/NumericType.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\Type\Traits\EqualTrait;
+use Michel\RequestKit\ValidationResult;
+
+final class NumericType extends AbstractType
+{
+    use EqualTrait;
+
+    protected function validateValue(ValidationResult $result): void
+    {
+        if (!is_numeric($result->getValue())) {
+            $result->setError(Locale::get('error.type.numeric', ['type' => gettype($result->getValue())]));
+            return;
+        }
+
+        $result->setValue(strval($result->getValue()));
+
+        if ($this->checkEquals && $result->getValue() !== $this->equalTo) {
+            $result->setError(Locale::get('error.equals'));
+        }
+    }
+}

+ 67 - 0
src/Type/StringType.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace Michel\RequestKit\Type;
+
+use Michel\RequestKit\Locale;
+use Michel\RequestKit\Type\Traits\EqualTrait;
+use Michel\RequestKit\Type\Traits\StrictTrait;
+use Michel\RequestKit\ValidationResult;
+
+final class StringType extends AbstractStringType
+{
+    use StrictTrait;
+    use EqualTrait;
+    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(Locale::get('error.type.string', ['type' => gettype($result->getValue())]));
+            return;
+        }
+
+        if (!$this->isStrict() && !is_string($result->getValue())) {
+            if (!is_scalar($result->getValue())) {
+                $result->setError(Locale::get('error.type.string', ['type' => gettype($result->getValue())]));
+                return;
+            }
+            $result->setValue(strval($result->getValue()));
+        }
+
+        if ($this->checkEquals && $result->getValue() !== $this->equalTo) {
+            $result->setError(Locale::get('error.equals'));
+            return;
+        }
+
+
+        if (!empty($this->allowed) && !in_array($result->getValue(), $this->allowed, true)) {
+            $result->setError(Locale::get('error.string.allowed', ['allowed' => implode(", ", $this->allowed)]));
+            return;
+        }
+
+        $valueLength = function_exists('mb_strlen') ? mb_strlen($result->getValue()) : strlen($result->getValue());
+        if ($this->min !== null && $valueLength < $this->min) {
+            $result->setError(Locale::get('error.string.min_length', ['min' => $this->min]));
+            return;
+        }
+
+        if ($this->max !== null && $valueLength > $this->max) {
+            $result->setError(Locale::get('error.string.max_length', ['max' => $this->max]));
+        }
+    }
+}

+ 22 - 0
src/Type/Traits/EqualTrait.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace Michel\RequestKit\Type\Traits;
+
+trait EqualTrait
+{
+
+    /**
+     * @var mixed
+     */
+    protected $equalTo = null;
+    protected bool $checkEquals = false;
+
+    final public function equals($expectedValue)
+    {
+        $this->equalTo = $expectedValue;
+        $this->checkEquals = true;
+        return $this;
+    }
+
+
+}

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

@@ -0,0 +1,26 @@
+<?php
+
+namespace Michel\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 Michel\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;
+    }
+}

+ 11 - 0
src/Utils/KeyValueObject.php

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

+ 82 - 0
src/ValidationResult.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace Michel\RequestKit;
+
+use Michel\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);
+    }
+}

+ 238 - 0
tests/HydratorTest.php

@@ -0,0 +1,238 @@
+<?php
+
+namespace Test\Michel\RequestKit;
+
+use DateTime;
+use Michel\RequestKit\Builder\SchemaObjectFactory;
+use Michel\RequestKit\Exceptions\InvalidDataException;
+use Michel\RequestKit\Schema\Schema;
+use Michel\RequestKit\Type;
+use Michel\UniTester\TestCase;
+use Test\Michel\RequestKit\Model\AddressTest;
+use Test\Michel\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;
+            }
+        });
+    }
+
+
+
+}

+ 87 - 0
tests/LocaleTest.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace Test\Michel\RequestKit;
+
+use Michel\RequestKit\Locale;
+use Michel\UniTester\TestCase;
+
+class LocaleTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        // Reset locale to default before each test group
+        Locale::setLocale('en');
+    }
+
+    protected function tearDown(): void
+    {
+        // Clean up after tests
+        Locale::setLocale('en');
+    }
+
+    protected function execute(): void
+    {
+        $this->testGetDefaultEnglishMessage();
+        $this->testSetLocaleToFrench();
+        $this->testMessageWithParameters();
+        $this->testFallbackToEnglish();
+        $this->testAddNewLanguage();
+    }
+
+    private function testGetDefaultEnglishMessage()
+    {
+        $message = Locale::get('error.required');
+        $this->assertStrictEquals('Value is required, but got null or empty string.', $message);
+    }
+
+    private function testSetLocaleToFrench()
+    {
+        Locale::setLocale('fr');
+        $message = Locale::get('error.required');
+        $this->assertStrictEquals('La valeur est requise.', $message);
+    }
+
+    private function testMessageWithParameters()
+    {
+        // English
+        Locale::setLocale('en');
+        $messageEn = Locale::get('error.string.min_length', ['min' => 5]);
+        $this->assertStrictEquals('Value must be at least 5 characters long.', $messageEn);
+
+        // French
+        Locale::setLocale('fr');
+        $messageFr = Locale::get('error.string.min_length', ['min' => 10]);
+        $this->assertStrictEquals('La valeur doit contenir au moins 10 caractères.', $messageFr);
+    }
+
+    private function testFallbackToEnglish()
+    {
+        // Add a message only in English
+        Locale::addMessages('en', ['error.test' => 'This is a test.']);
+        
+        // Set locale to French
+        Locale::setLocale('fr');
+
+        // The key 'error.test' does not exist in French, so it should fall back to English.
+        $message = Locale::get('error.test');
+        $this->assertStrictEquals('This is a test.', $message);
+    }
+
+    private function testAddNewLanguage()
+    {
+        $spanishMessages = [
+            'error' => [
+                'required' => 'El valor es requerido.'
+            ]
+        ];
+        Locale::addMessages('es', $spanishMessages);
+        Locale::setLocale('es');
+
+        $message = Locale::get('error.required');
+        $this->assertStrictEquals('El valor es requerido.', $message);
+
+        // Check that it falls back to English for non-translated messages
+        $messageEquals = Locale::get('error.equals');
+        $this->assertStrictEquals('The value does not match the expected value.', $messageEquals);
+    }
+}

+ 51 - 0
tests/Model/AddressTest.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Test\Michel\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\Michel\RequestKit\Model;
+
+use Michel\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;
+    }
+}

+ 653 - 0
tests/SchemaTest.php

@@ -0,0 +1,653 @@
+<?php
+
+namespace Test\Michel\RequestKit;
+
+use DateTime;
+use Michel\RequestKit\Exceptions\InvalidDataException;
+use Michel\RequestKit\Schema\Schema;
+use Michel\RequestKit\Type;
+use Michel\RequestKit\Utils\KeyValueObject;
+use Michel\UniTester\TestCase;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\StreamInterface;
+use Psr\Http\Message\UriInterface;
+
+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->testArray();
+        $this->testExtend();
+        $this->testExampleData();
+        $this->testWithHeaders();
+        $this->testProcessForm();
+    }
+
+    public function testWithHeaders()
+    {
+        $schema = Schema::create([
+            'name' => Type::string()->required(),
+        ])->withHeaders([
+            'Content-Type' => Type::string()->equals('application/json'),
+            'X-Api-Key' => Type::string()->required()->length(10),
+        ]);
+
+        // Test case 1: Valid headers - should not throw an exception
+        $request = $this->createServerRequest(
+            ['Content-Type' => 'application/json', 'X-Api-Key' => '1234567890'],
+            ['name' => 'test']
+        );
+        $result = $schema->processHttpRequest($request);
+        $this->assertEquals('test', $result->get('name')); // Assert data is processed
+
+        // Test case 2: Missing required header
+        $request = $this->createServerRequest(
+            ['Content-Type' => 'application/json'],
+            ['name' => 'test']
+        );
+        $this->expectException(InvalidDataException::class, function () use ($schema, $request) {
+            try {
+                $schema->processHttpRequest($request);
+            } catch (InvalidDataException $e) {
+                $this->assertNotEmpty($e->getError('x-api-key'));
+                throw $e;
+            }
+        });
+
+        // Test case 3: Header constraint violation
+        $request = $this->createServerRequest(
+            ['Content-Type' => 'application/xml', 'X-Api-Key' => '1234567890'],
+            ['name' => 'test']
+        );
+        $this->expectException(InvalidDataException::class, function () use ($schema, $request) {
+            try {
+                $schema->processHttpRequest($request);
+            } catch (InvalidDataException $e) {
+                $this->assertNotEmpty($e->getError('content-type'));
+                throw $e;
+            }
+        });
+    }
+
+    public function testProcessForm()
+    {
+        $schema = Schema::create([
+            'username' => Type::string()->required(),
+        ]);
+        $csrfToken = 'a_very_secret_token_123';
+
+        // Test case 1: Valid form with correct CSRF token - should not throw an exception
+        $request = $this->createServerRequest(
+            [
+                'Content-Type' => 'application/x-www-form-urlencoded',
+            ],
+            ['username' => 'john.doe', '_csrf' => $csrfToken]
+        );
+        $result = $schema->processFormHttpRequest($request, $csrfToken);
+        $this->assertEquals('john.doe', $result->get('username'));
+
+        // Test case 2: Valid form without CSRF check (optional) - should not throw an exception
+        $request = $this->createServerRequest(
+            [
+                'Content-Type' => 'application/x-www-form-urlencoded',
+            ],
+            ['username' => 'jane.doe']
+        );
+        $result = $schema->processFormHttpRequest($request, null); // Pass null to skip CSRF
+        $this->assertEquals('jane.doe', $result->get('username'));
+
+        // Test case 3: Form with incorrect CSRF token
+        $request = $this->createServerRequest(
+            [
+                'Content-Type' => 'application/x-www-form-urlencoded',
+            ],
+            ['username' => 'hacker', '_csrf' => 'wrong_token'],
+        );
+        $this->expectException(InvalidDataException::class, function () use ($schema, $request, $csrfToken) {
+            try {
+                $schema->processFormHttpRequest($request, $csrfToken);
+            } catch (InvalidDataException $e) {
+                $this->assertEquals('Invalid CSRF token.', $e->getMessage());
+                throw $e;
+            }
+        });
+
+        // Test case 4: Form with missing CSRF token
+        $request = $this->createServerRequest(
+            [
+                'Content-Type' => 'application/x-www-form-urlencoded',
+            ],
+            ['username' => 'hacker'],
+        );
+        $this->expectException(InvalidDataException::class, function () use ($schema, $request, $csrfToken) {
+            try {
+                $schema->processFormHttpRequest($request, $csrfToken);
+            } catch (InvalidDataException $e) {
+                $this->assertEquals('Invalid CSRF token.', $e->getMessage());
+                throw $e;
+            }
+        });
+    }
+
+
+    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']),
+            'keysValues' => Type::arrayOf(Type::string()->strict())
+                ->required()
+                ->acceptStringKeys()
+                ->example(['key' => 'value']),
+            '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'],
+            'keysValues' => ['key' => 'value'],
+            'address' => [
+                'street' => 'Main Street',
+                'city' => 'London',
+            ]
+        ]);
+    }
+
+    private function testArray()
+    {
+
+        $schema = Schema::create([
+            'roles' => Type::arrayOf(Type::string()->strict())->required()->example(['admin']),
+            'dependencies' => Type::arrayOf(Type::string()->strict())->acceptStringKeys()
+        ]);
+
+        $data = [
+            'roles' => ['admin'],
+            'dependencies' => [
+                'key1' => 'value1',
+                'key2' => 'value2',
+            ],
+        ];
+        $result = $schema->process($data);
+        $this->assertStrictEquals('admin', $result->get('roles.0'));
+        $this->assertStrictEquals('value1', $result->get('dependencies.key1'));
+        $this->assertStrictEquals('value2', $result->get('dependencies.key2'));
+
+
+        $schema = Schema::create([
+            'roles' => Type::arrayOf(Type::string()->strict())->required()->example('admin')->acceptCommaSeparatedValues(),
+        ]);
+
+        $data = [
+            'roles' => 'admin,user,manager',
+        ];
+        $result = $schema->process($data);
+        $this->assertStrictEquals('admin', $result->get('roles.0'));
+        $this->assertStrictEquals('user', $result->get('roles.1'));
+        $this->assertStrictEquals('manager', $result->get('roles.2'));
+
+
+        $schema = Schema::create([
+            'autoload.psr-4' => Type::map(Type::string()->strict()->trim())->required(),
+            'dependencies' => Type::map(Type::string()->strict()->trim())
+        ]);
+
+        $data = [
+            'autoload.psr-4' => [
+                'App\\' => 'app/',
+            ],
+            'dependencies' => [
+                'key1' => 'value1',
+                'key2' => 'value2',
+            ],
+        ];
+        $result = $schema->process($data);
+        $this->assertInstanceOf( KeyValueObject::class, $result->get('autoload.psr-4'));
+        $this->assertInstanceOf( KeyValueObject::class, $result->get('dependencies'));
+        $this->assertEquals(1, count($result->get('autoload.psr-4')));
+        $this->assertEquals(2, count($result->get('dependencies')));
+
+
+        $schema = Schema::create([
+            'autoload.psr-4' => Type::map(Type::string()->strict()->trim()),
+            'dependencies' => Type::map(Type::string()->strict()->trim())
+        ]);
+
+        $data = [
+            'autoload.psr-4' => [
+            ],
+        ];
+        $result = $schema->process($data);
+        $this->assertInstanceOf( KeyValueObject::class, $result->get('autoload.psr-4'));
+        $this->assertInstanceOf( KeyValueObject::class, $result->get('dependencies'));
+        $this->assertEquals(0, count($result->get('autoload.psr-4')));
+        $this->assertEquals(0, count($result->get('dependencies')));
+
+    }
+
+    /**
+     * Helper to create a simple ServerRequestInterface object for tests.
+     */
+    private function createServerRequest(array $headers, array $body): ServerRequestInterface
+    {
+        return new class($headers, $body) implements ServerRequestInterface {
+            private array $headers;
+            private array $body;
+
+            public function __construct(array $headers, array $body)
+            {
+                foreach ($headers as $name => $value) {
+                    $this->headers[$name][] = $value;
+                }
+                $this->body = $body;
+            }
+
+            public function getHeaders(): array { return $this->headers; }
+            public function hasHeader($name): bool { return isset($this->headers[strtolower($name)]); }
+            public function getHeader($name): array { return (array)($this->headers[strtolower($name)] ?? []); }
+            public function getHeaderLine($name): string { return implode(', ', $this->getHeader(strtolower($name))); }
+            public function getParsedBody() { return $this->body; }
+            public function getBody(): StreamInterface {
+                $stream = $this->createMock(StreamInterface::class);
+                $stream->method('getContents')->willReturn(json_encode($this->body));
+                return $stream;
+            }
+            // --- The rest of the methods are not needed for these tests ---
+            public function getProtocolVersion(): string
+            {}
+            public function withProtocolVersion($version): \Psr\Http\Message\MessageInterface
+            {}
+            public function withHeader($name, $value): \Psr\Http\Message\MessageInterface
+            {}
+            public function withAddedHeader($name, $value): \Psr\Http\Message\MessageInterface
+            {}
+            public function withoutHeader($name): \Psr\Http\Message\MessageInterface
+            {}
+            public function withBody(StreamInterface $body): \Psr\Http\Message\MessageInterface
+            {}
+            public function getRequestTarget(): string
+            {}
+            public function withRequestTarget($requestTarget): \Psr\Http\Message\RequestInterface
+            {}
+            public function getMethod(): string
+            {}
+            public function withMethod($method): \Psr\Http\Message\RequestInterface
+            {}
+            public function getUri(): UriInterface
+            {}
+            public function withUri(UriInterface $uri, $preserveHost = false): \Psr\Http\Message\RequestInterface
+            {}
+            public function getServerParams(): array
+            {}
+            public function getCookieParams(): array
+            {}
+            public function withCookieParams(array $cookies): ServerRequestInterface
+            {}
+            public function getQueryParams(): array
+            {}
+            public function withQueryParams(array $query): ServerRequestInterface
+            {}
+            public function getUploadedFiles(): array
+            {}
+            public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface
+            {}
+            public function getAttribute($name, $default = null){}
+            public function withAttribute($name, $value): ServerRequestInterface
+            {}
+            public function withoutAttribute($name): ServerRequestInterface
+            {}
+
+            public function withParsedBody($data): ServerRequestInterface
+            {
+                // TODO: Implement withParsedBody() method.
+            }
+
+            public function getAttributes(): array
+            {
+                // TODO: Implement getAttributes() method.
+            }
+        };
+    }
+}

+ 371 - 0
tests/TypeTest.php

@@ -0,0 +1,371 @@
+<?php
+namespace Test\Michel\RequestKit;
+use Michel\RequestKit\Type;
+use Michel\RequestKit\Type\BoolType;
+use Michel\RequestKit\Type\DateTimeType;
+use Michel\RequestKit\Type\DateType;
+use Michel\RequestKit\Type\FloatType;
+use Michel\RequestKit\Type\IntType;
+use Michel\RequestKit\Type\NumericType;
+use Michel\RequestKit\Type\StringType;
+
+class TypeTest extends \Michel\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();
+        $this->testEqualsConstraint(); // Add new test method
+    }
+
+    private function testEqualsConstraint()
+    {
+        // 1. String equals: success
+        $type = Type::string()->equals('admin');
+        $result = $type->validate('admin');
+        $this->assertTrue($result->isValid());
+        $this->assertStrictEquals('admin', $result->getValue());
+
+        // 2. String equals: failure
+        $type = Type::string()->equals('admin');
+        $result = $type->validate('user');
+        $this->assertFalse($result->isValid());
+        $this->assertEquals('The value does not match the expected value.', $result->getError());
+
+        // 3. Integer equals: success
+        $type = Type::int()->equals(123);
+        $result = $type->validate(123);
+        $this->assertTrue($result->isValid());
+        $this->assertStrictEquals(123, $result->getValue());
+
+        // 4. Integer equals: failure
+        $type = Type::int()->equals(123);
+        $result = $type->validate(456);
+        $this->assertFalse($result->isValid());
+        $this->assertEquals('The value does not match the expected value.', $result->getError());
+
+        // 5. Optional field with equals: success on null
+        $type = Type::string()->equals('secret_token')->optional();
+        $result = $type->validate(null);
+        $this->assertTrue($result->isValid());
+        $this->assertNull($result->getValue());
+
+        // 6. Required field with equals: failure on null
+        $type = Type::string()->equals('secret_token')->required();
+        $result = $type->validate(null);
+        $this->assertFalse($result->isValid());
+        $this->assertEquals('Value is required, but got null or empty string.', $result->getError());
+
+        // 7. Equals after transformation
+        $type = Type::string()->lowercase()->equals('admin');
+        $result = $type->validate('ADMIN');
+        $this->assertTrue($result->isValid());
+        $this->assertStrictEquals('admin', $result->getValue());
+
+        // 8. Security check: Ensure error message does not leak sensitive data
+        $secret = 'super_secret_api_key_12345';
+        $type = Type::string()->equals($secret);
+        $result = $type->validate('wrong_key');
+        $this->assertFalse($result->isValid());
+        $this->assertEquals('The value does not match the expected value.', $result->getError());
+    }
+
+
+    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('Value must be at least 10 characters long.', $result->getError());
+
+
+        $type->length(1, 3);
+        $result = $type->validate("  test  ");
+        $this->assertFalse($result->isValid());
+        $this->assertEquals('Value 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());
+
+        $intWithTransform = (new IntType())
+            ->required()
+            ->transform(function ($value) {
+                {
+                    if (!is_string($value)) {
+                        return $value;
+                    }
+
+                    if (preg_match('/-?\d+(\.\d+)?/', $value, $match)) {
+                        return $match[0];
+                    }
+                    return $value;
+                }
+            })
+            ->min(1)
+            ->max(12);
+
+        $result = $intWithTransform->validate("5 UNION ALL");
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(5,$result->getValue());
+
+
+        $floatWithTransform = (new FloatType())
+            ->required()
+            ->transform(function ($value) {
+                {
+                    if (!is_string($value)) {
+                        return $value;
+                    }
+
+                    if (preg_match('/-?\d+(\.\d+)?/', $value, $match)) {
+                        return $match[0];
+                    }
+                    return $value;
+                }
+            })
+            ->min(1)
+            ->max(12);
+        $result = $floatWithTransform->validate("3.04 OR 1=1");
+        $this->assertTrue($result->isValid());
+        $this->assertEquals(3.04,$result->getValue());
+
+    }
+
+    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());
+        }
+
+
+    }
+}