From 8b9923b903b101f7547eb7301ecc046996e40836 Mon Sep 17 00:00:00 2001
From: Mateusz Charytoniuk <mateusz.charytoniuk@protonmail.com>
Date: Tue, 6 Feb 2024 10:58:44 +0100
Subject: [PATCH] feat: switch to constraints

---
 composer.json                                 |   1 -
 composer.lock                                 | 397 ++++--------------
 config.ini.example                            |   3 +
 docs/pages/docs/changelog/index.md            |   4 +
 .../docs/features/configuration/index.md      |  36 +-
 docs/pages/docs/features/validation/index.md  |  31 +-
 .../docs/features/websockets/protocols.md     |   9 +-
 .../index.md                                  |  20 +-
 .../session-based-authentication/index.md     |  33 +-
 src/Attribute/RespondsWith.php                |   4 +-
 src/Command/TestHttpResponders.php            |  20 +-
 src/Constraint.php                            |   5 +
 src/Constraint/AnyOfConstraint.php            | 101 +++++
 src/Constraint/AnyOfConstraintTest.php        |  73 ++++
 src/Constraint/ListConstraint.php             | 125 ++++++
 src/Constraint/ListConstraintTest.php         |  91 ++++
 src/Constraint/MapConstraint.php              |   2 +-
 src/Constraint/ObjectConstraint.php           |  63 ++-
 src/Constraint/ObjectConstraintTest.php       | 126 +++---
 src/ConstraintReason.php                      |   1 +
 src/ConstraintResultErrorMessage.php          |  37 ++
 src/ConstraintStringFormat.php                |   1 +
 src/ConstraintValidationException.php         |  14 +-
 .../ValidatedRequestResolver.php              |   2 +-
 src/InputValidationResult.php                 |  21 +-
 src/InputValidator.php                        |   2 +-
 src/InputValidator/FrontMatterValidator.php   | 124 ++----
 src/InputValidator/RPCMessageValidator.php    |  30 +-
 src/InputValidatorController.php              |  32 +-
 src/JsonSchema.php                            |  20 -
 src/JsonSchemaSourceInterface.php             |  10 -
 src/JsonSchemaValidationErrorMessage.php      |  37 --
 src/JsonSchemaValidationException.php         |  19 -
 src/JsonSchemaValidationResult.php            |  22 -
 src/JsonSchemaValidator.php                   | 177 --------
 src/JsonSchemableInterface.php                |   4 +-
 .../RespondsWithExtractor.php                 |   2 +-
 src/OpenAPIReusableSchemaCollection.php       |  18 +-
 .../DoctrineEntityRouteParameterExtractor.php |  25 +-
 .../ValidatedRequestExtractor.php             |   4 +-
 src/OpenAPISchemaComponents.php               |   6 +-
 src/OpenAPISchemaParameter.php                |   4 +-
 src/OpenAPISchemaRequestBodyContent.php       |   6 +-
 src/OpenAPISchemaResponse.php                 |   6 +-
 .../ApplicationConfigurationProvider.php      |  14 +-
 .../DatabaseConfigurationProvider.php         |  26 +-
 .../LlamaCppConfigurationProvider.php         |  16 +-
 .../MailerConfigurationProvider.php           |  18 +-
 .../OAuth2ConfigurationProvider.php           |  20 +-
 .../OpenAPIConfigurationProvider.php          |  12 +-
 .../RedisConfigurationProvider.php            |  22 +-
 .../SQLiteVSSConfigurationProvider.php        |  10 +-
 .../SessionConfigurationProvider.php          |  14 +-
 .../StaticPageConfigurationProvider.php       |  16 +-
 .../SwooleConfigurationProvider.php           |  20 +-
 .../TranslatorConfigurationProvider.php       |  10 +-
 .../WebSocketConfigurationProvider.php        |  13 +-
 .../RPCProtocolController.php                 |  11 +-
 src/WebSocketRPCConnectionHandle.php          |  19 +-
 src/WebSocketRPCResponderInterface.php        |   2 +-
 60 files changed, 928 insertions(+), 1083 deletions(-)
 create mode 100644 src/Constraint/AnyOfConstraint.php
 create mode 100644 src/Constraint/AnyOfConstraintTest.php
 create mode 100644 src/Constraint/ListConstraint.php
 create mode 100644 src/Constraint/ListConstraintTest.php
 create mode 100644 src/ConstraintResultErrorMessage.php
 delete mode 100644 src/JsonSchema.php
 delete mode 100644 src/JsonSchemaSourceInterface.php
 delete mode 100644 src/JsonSchemaValidationErrorMessage.php
 delete mode 100644 src/JsonSchemaValidationException.php
 delete mode 100644 src/JsonSchemaValidationResult.php
 delete mode 100644 src/JsonSchemaValidator.php

diff --git a/composer.json b/composer.json
index 913aca54..78bab0aa 100644
--- a/composer.json
+++ b/composer.json
@@ -47,7 +47,6 @@
         "webonyx/graphql-php": "^15.6",
         "dragonmantank/cron-expression": "^3.3",
         "league/oauth2-client": "^2.7",
-        "opis/json-schema": "^2.3",
         "symfony/mailer": "^7.0",
         "symfony/messenger": "^7.0",
         "symfony/http-client": "^7.0"
diff --git a/composer.lock b/composer.lock
index d2f62758..646bb09b 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "6086fb3c49229272d9af22c93b741be1",
+    "content-hash": "4d012e211f64a4200dd92f58b584976a",
     "packages": [
         {
             "name": "defuse/php-encryption",
@@ -492,16 +492,16 @@
         },
         {
             "name": "doctrine/dbal",
-            "version": "3.8.0",
+            "version": "3.8.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/dbal.git",
-                "reference": "d244f2e6e6bf32bff5174e6729b57214923ecec9"
+                "reference": "c9ea252cdce4da324ede3d6c5913dd89f769afd2"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/dbal/zipball/d244f2e6e6bf32bff5174e6729b57214923ecec9",
-                "reference": "d244f2e6e6bf32bff5174e6729b57214923ecec9",
+                "url": "https://api.github.com/repos/doctrine/dbal/zipball/c9ea252cdce4da324ede3d6c5913dd89f769afd2",
+                "reference": "c9ea252cdce4da324ede3d6c5913dd89f769afd2",
                 "shasum": ""
             },
             "require": {
@@ -517,9 +517,9 @@
                 "doctrine/coding-standard": "12.0.0",
                 "fig/log-test": "^1",
                 "jetbrains/phpstorm-stubs": "2023.1",
-                "phpstan/phpstan": "1.10.56",
+                "phpstan/phpstan": "1.10.57",
                 "phpstan/phpstan-strict-rules": "^1.5",
-                "phpunit/phpunit": "9.6.15",
+                "phpunit/phpunit": "9.6.16",
                 "psalm/plugin-phpunit": "0.18.4",
                 "slevomat/coding-standard": "8.13.1",
                 "squizlabs/php_codesniffer": "3.8.1",
@@ -585,7 +585,7 @@
             ],
             "support": {
                 "issues": "https://github.com/doctrine/dbal/issues",
-                "source": "https://github.com/doctrine/dbal/tree/3.8.0"
+                "source": "https://github.com/doctrine/dbal/tree/3.8.1"
             },
             "funding": [
                 {
@@ -601,7 +601,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-01-25T21:44:02+00:00"
+            "time": "2024-02-03T17:33:49+00:00"
         },
         {
             "name": "doctrine/deprecations",
@@ -904,28 +904,27 @@
         },
         {
             "name": "doctrine/lexer",
-            "version": "2.1.0",
+            "version": "3.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/lexer.git",
-                "reference": "39ab8fcf5a51ce4b85ca97c7a7d033eb12831124"
+                "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/lexer/zipball/39ab8fcf5a51ce4b85ca97c7a7d033eb12831124",
-                "reference": "39ab8fcf5a51ce4b85ca97c7a7d033eb12831124",
+                "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
+                "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
                 "shasum": ""
             },
             "require": {
-                "doctrine/deprecations": "^1.0",
-                "php": "^7.1 || ^8.0"
+                "php": "^8.1"
             },
             "require-dev": {
-                "doctrine/coding-standard": "^9 || ^10",
-                "phpstan/phpstan": "^1.3",
-                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
+                "doctrine/coding-standard": "^12",
+                "phpstan/phpstan": "^1.10",
+                "phpunit/phpunit": "^10.5",
                 "psalm/plugin-phpunit": "^0.18.3",
-                "vimeo/psalm": "^4.11 || ^5.0"
+                "vimeo/psalm": "^5.21"
             },
             "type": "library",
             "autoload": {
@@ -962,7 +961,7 @@
             ],
             "support": {
                 "issues": "https://github.com/doctrine/lexer/issues",
-                "source": "https://github.com/doctrine/lexer/tree/2.1.0"
+                "source": "https://github.com/doctrine/lexer/tree/3.0.1"
             },
             "funding": [
                 {
@@ -978,7 +977,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-12-14T08:49:07+00:00"
+            "time": "2024-02-05T11:56:58+00:00"
         },
         {
             "name": "doctrine/migrations",
@@ -1084,16 +1083,16 @@
         },
         {
             "name": "doctrine/orm",
-            "version": "2.17.4",
+            "version": "2.18.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/orm.git",
-                "reference": "ccfc97c32f63aaa0988ac6aa42e71c5590bb794d"
+                "reference": "f2176a9ce56cafdfd1624d54bfdb076819083d5b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/orm/zipball/ccfc97c32f63aaa0988ac6aa42e71c5590bb794d",
-                "reference": "ccfc97c32f63aaa0988ac6aa42e71c5590bb794d",
+                "url": "https://api.github.com/repos/doctrine/orm/zipball/f2176a9ce56cafdfd1624d54bfdb076819083d5b",
+                "reference": "f2176a9ce56cafdfd1624d54bfdb076819083d5b",
                 "shasum": ""
             },
             "require": {
@@ -1106,7 +1105,7 @@
                 "doctrine/event-manager": "^1.2 || ^2",
                 "doctrine/inflector": "^1.4 || ^2.0",
                 "doctrine/instantiator": "^1.3 || ^2",
-                "doctrine/lexer": "^2",
+                "doctrine/lexer": "^2 || ^3",
                 "doctrine/persistence": "^2.4 || ^3",
                 "ext-ctype": "*",
                 "php": "^7.1 || ^8.0",
@@ -1142,7 +1141,7 @@
             "type": "library",
             "autoload": {
                 "psr-4": {
-                    "Doctrine\\ORM\\": "lib/Doctrine/ORM"
+                    "Doctrine\\ORM\\": "src"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
@@ -1179,9 +1178,9 @@
             ],
             "support": {
                 "issues": "https://github.com/doctrine/orm/issues",
-                "source": "https://github.com/doctrine/orm/tree/2.17.4"
+                "source": "https://github.com/doctrine/orm/tree/2.18.0"
             },
-            "time": "2024-01-26T19:41:16+00:00"
+            "time": "2024-01-31T15:53:12+00:00"
         },
         {
             "name": "doctrine/persistence",
@@ -1986,16 +1985,16 @@
         },
         {
             "name": "league/commonmark",
-            "version": "2.4.1",
+            "version": "2.4.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/commonmark.git",
-                "reference": "3669d6d5f7a47a93c08ddff335e6d945481a1dd5"
+                "reference": "91c24291965bd6d7c46c46a12ba7492f83b1cadf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/3669d6d5f7a47a93c08ddff335e6d945481a1dd5",
-                "reference": "3669d6d5f7a47a93c08ddff335e6d945481a1dd5",
+                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/91c24291965bd6d7c46c46a12ba7492f83b1cadf",
+                "reference": "91c24291965bd6d7c46c46a12ba7492f83b1cadf",
                 "shasum": ""
             },
             "require": {
@@ -2008,7 +2007,7 @@
             },
             "require-dev": {
                 "cebe/markdown": "^1.0",
-                "commonmark/cmark": "0.30.0",
+                "commonmark/cmark": "0.30.3",
                 "commonmark/commonmark.js": "0.30.0",
                 "composer/package-versions-deprecated": "^1.8",
                 "embed/embed": "^4.4",
@@ -2018,10 +2017,10 @@
                 "michelf/php-markdown": "^1.4 || ^2.0",
                 "nyholm/psr7": "^1.5",
                 "phpstan/phpstan": "^1.8.2",
-                "phpunit/phpunit": "^9.5.21",
+                "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0",
                 "scrutinizer/ocular": "^1.8.1",
-                "symfony/finder": "^5.3 | ^6.0",
-                "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0",
+                "symfony/finder": "^5.3 | ^6.0 || ^7.0",
+                "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 || ^7.0",
                 "unleashedtech/php-coding-standard": "^3.1.1",
                 "vimeo/psalm": "^4.24.0 || ^5.0.0"
             },
@@ -2088,7 +2087,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-08-30T16:55:00+00:00"
+            "time": "2024-02-02T11:59:32+00:00"
         },
         {
             "name": "league/config",
@@ -2973,196 +2972,6 @@
             ],
             "time": "2023-11-08T09:30:43+00:00"
         },
-        {
-            "name": "opis/json-schema",
-            "version": "2.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/opis/json-schema.git",
-                "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/opis/json-schema/zipball/c48df6d7089a45f01e1c82432348f2d5976f9bfb",
-                "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb",
-                "shasum": ""
-            },
-            "require": {
-                "ext-json": "*",
-                "opis/string": "^2.0",
-                "opis/uri": "^1.0",
-                "php": "^7.4 || ^8.0"
-            },
-            "require-dev": {
-                "ext-bcmath": "*",
-                "ext-intl": "*",
-                "phpunit/phpunit": "^9.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Opis\\JsonSchema\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "Apache-2.0"
-            ],
-            "authors": [
-                {
-                    "name": "Sorin Sarca",
-                    "email": "sarca_sorin@hotmail.com"
-                },
-                {
-                    "name": "Marius Sarca",
-                    "email": "marius.sarca@gmail.com"
-                }
-            ],
-            "description": "Json Schema Validator for PHP",
-            "homepage": "https://opis.io/json-schema",
-            "keywords": [
-                "json",
-                "json-schema",
-                "schema",
-                "validation",
-                "validator"
-            ],
-            "support": {
-                "issues": "https://github.com/opis/json-schema/issues",
-                "source": "https://github.com/opis/json-schema/tree/2.3.0"
-            },
-            "time": "2022-01-08T20:38:03+00:00"
-        },
-        {
-            "name": "opis/string",
-            "version": "2.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/opis/string.git",
-                "reference": "9ebf1a1f873f502f6859d11210b25a4bf5d141e7"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/opis/string/zipball/9ebf1a1f873f502f6859d11210b25a4bf5d141e7",
-                "reference": "9ebf1a1f873f502f6859d11210b25a4bf5d141e7",
-                "shasum": ""
-            },
-            "require": {
-                "ext-iconv": "*",
-                "ext-json": "*",
-                "php": "^7.4 || ^8.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^9.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Opis\\String\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "Apache-2.0"
-            ],
-            "authors": [
-                {
-                    "name": "Marius Sarca",
-                    "email": "marius.sarca@gmail.com"
-                },
-                {
-                    "name": "Sorin Sarca",
-                    "email": "sarca_sorin@hotmail.com"
-                }
-            ],
-            "description": "Multibyte strings as objects",
-            "homepage": "https://opis.io/string",
-            "keywords": [
-                "multi-byte",
-                "opis",
-                "string",
-                "string manipulation",
-                "utf-8"
-            ],
-            "support": {
-                "issues": "https://github.com/opis/string/issues",
-                "source": "https://github.com/opis/string/tree/2.0.1"
-            },
-            "time": "2022-01-14T15:42:23+00:00"
-        },
-        {
-            "name": "opis/uri",
-            "version": "1.1.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/opis/uri.git",
-                "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
-                "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
-                "shasum": ""
-            },
-            "require": {
-                "opis/string": "^2.0",
-                "php": "^7.4 || ^8.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^9"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Opis\\Uri\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "Apache-2.0"
-            ],
-            "authors": [
-                {
-                    "name": "Marius Sarca",
-                    "email": "marius.sarca@gmail.com"
-                },
-                {
-                    "name": "Sorin Sarca",
-                    "email": "sarca_sorin@hotmail.com"
-                }
-            ],
-            "description": "Build, parse and validate URIs and URI-templates",
-            "homepage": "https://opis.io",
-            "keywords": [
-                "URI Template",
-                "parse url",
-                "punycode",
-                "uri",
-                "uri components",
-                "url",
-                "validate uri"
-            ],
-            "support": {
-                "issues": "https://github.com/opis/uri/issues",
-                "source": "https://github.com/opis/uri/tree/1.1.0"
-            },
-            "time": "2021-05-22T15:57:08+00:00"
-        },
         {
             "name": "paragonie/random_compat",
             "version": "v9.99.100",
@@ -4833,16 +4642,16 @@
         },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.28.0",
+            "version": "v1.29.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb"
+                "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb",
-                "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ef4d7e442ca910c4764bce785146269b30cb5fc4",
+                "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4",
                 "shasum": ""
             },
             "require": {
@@ -4856,9 +4665,6 @@
             },
             "type": "library",
             "extra": {
-                "branch-alias": {
-                    "dev-main": "1.28-dev"
-                },
                 "thanks": {
                     "name": "symfony/polyfill",
                     "url": "https://github.com/symfony/polyfill"
@@ -4895,7 +4701,7 @@
                 "portable"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0"
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.29.0"
             },
             "funding": [
                 {
@@ -4911,20 +4717,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-01-26T09:26:14+00:00"
+            "time": "2024-01-29T20:11:03+00:00"
         },
         {
             "name": "symfony/polyfill-intl-grapheme",
-            "version": "v1.28.0",
+            "version": "v1.29.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
-                "reference": "875e90aeea2777b6f135677f618529449334a612"
+                "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/875e90aeea2777b6f135677f618529449334a612",
-                "reference": "875e90aeea2777b6f135677f618529449334a612",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/32a9da87d7b3245e09ac426c83d334ae9f06f80f",
+                "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f",
                 "shasum": ""
             },
             "require": {
@@ -4935,9 +4741,6 @@
             },
             "type": "library",
             "extra": {
-                "branch-alias": {
-                    "dev-main": "1.28-dev"
-                },
                 "thanks": {
                     "name": "symfony/polyfill",
                     "url": "https://github.com/symfony/polyfill"
@@ -4976,7 +4779,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.28.0"
+                "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.29.0"
             },
             "funding": [
                 {
@@ -4992,20 +4795,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-01-26T09:26:14+00:00"
+            "time": "2024-01-29T20:11:03+00:00"
         },
         {
             "name": "symfony/polyfill-intl-idn",
-            "version": "v1.28.0",
+            "version": "v1.29.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-intl-idn.git",
-                "reference": "ecaafce9f77234a6a449d29e49267ba10499116d"
+                "reference": "a287ed7475f85bf6f61890146edbc932c0fff919"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/ecaafce9f77234a6a449d29e49267ba10499116d",
-                "reference": "ecaafce9f77234a6a449d29e49267ba10499116d",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/a287ed7475f85bf6f61890146edbc932c0fff919",
+                "reference": "a287ed7475f85bf6f61890146edbc932c0fff919",
                 "shasum": ""
             },
             "require": {
@@ -5018,9 +4821,6 @@
             },
             "type": "library",
             "extra": {
-                "branch-alias": {
-                    "dev-main": "1.28-dev"
-                },
                 "thanks": {
                     "name": "symfony/polyfill",
                     "url": "https://github.com/symfony/polyfill"
@@ -5063,7 +4863,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.28.0"
+                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.29.0"
             },
             "funding": [
                 {
@@ -5079,20 +4879,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-01-26T09:30:37+00:00"
+            "time": "2024-01-29T20:11:03+00:00"
         },
         {
             "name": "symfony/polyfill-intl-normalizer",
-            "version": "v1.28.0",
+            "version": "v1.29.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
-                "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92"
+                "reference": "bc45c394692b948b4d383a08d7753968bed9a83d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92",
-                "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/bc45c394692b948b4d383a08d7753968bed9a83d",
+                "reference": "bc45c394692b948b4d383a08d7753968bed9a83d",
                 "shasum": ""
             },
             "require": {
@@ -5103,9 +4903,6 @@
             },
             "type": "library",
             "extra": {
-                "branch-alias": {
-                    "dev-main": "1.28-dev"
-                },
                 "thanks": {
                     "name": "symfony/polyfill",
                     "url": "https://github.com/symfony/polyfill"
@@ -5147,7 +4944,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.28.0"
+                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.29.0"
             },
             "funding": [
                 {
@@ -5163,20 +4960,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-01-26T09:26:14+00:00"
+            "time": "2024-01-29T20:11:03+00:00"
         },
         {
             "name": "symfony/polyfill-mbstring",
-            "version": "v1.28.0",
+            "version": "v1.29.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "42292d99c55abe617799667f454222c54c60e229"
+                "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229",
-                "reference": "42292d99c55abe617799667f454222c54c60e229",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9773676c8a1bb1f8d4340a62efe641cf76eda7ec",
+                "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec",
                 "shasum": ""
             },
             "require": {
@@ -5190,9 +4987,6 @@
             },
             "type": "library",
             "extra": {
-                "branch-alias": {
-                    "dev-main": "1.28-dev"
-                },
                 "thanks": {
                     "name": "symfony/polyfill",
                     "url": "https://github.com/symfony/polyfill"
@@ -5230,7 +5024,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0"
+                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.29.0"
             },
             "funding": [
                 {
@@ -5246,20 +5040,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-07-28T09:04:16+00:00"
+            "time": "2024-01-29T20:11:03+00:00"
         },
         {
             "name": "symfony/polyfill-php72",
-            "version": "v1.28.0",
+            "version": "v1.29.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php72.git",
-                "reference": "70f4aebd92afca2f865444d30a4d2151c13c3179"
+                "reference": "861391a8da9a04cbad2d232ddd9e4893220d6e25"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/70f4aebd92afca2f865444d30a4d2151c13c3179",
-                "reference": "70f4aebd92afca2f865444d30a4d2151c13c3179",
+                "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/861391a8da9a04cbad2d232ddd9e4893220d6e25",
+                "reference": "861391a8da9a04cbad2d232ddd9e4893220d6e25",
                 "shasum": ""
             },
             "require": {
@@ -5267,9 +5061,6 @@
             },
             "type": "library",
             "extra": {
-                "branch-alias": {
-                    "dev-main": "1.28-dev"
-                },
                 "thanks": {
                     "name": "symfony/polyfill",
                     "url": "https://github.com/symfony/polyfill"
@@ -5306,7 +5097,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-php72/tree/v1.28.0"
+                "source": "https://github.com/symfony/polyfill-php72/tree/v1.29.0"
             },
             "funding": [
                 {
@@ -5322,20 +5113,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-01-26T09:26:14+00:00"
+            "time": "2024-01-29T20:11:03+00:00"
         },
         {
             "name": "symfony/polyfill-php80",
-            "version": "v1.28.0",
+            "version": "v1.29.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php80.git",
-                "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5"
+                "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5",
-                "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5",
+                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/87b68208d5c1188808dd7839ee1e6c8ec3b02f1b",
+                "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b",
                 "shasum": ""
             },
             "require": {
@@ -5343,9 +5134,6 @@
             },
             "type": "library",
             "extra": {
-                "branch-alias": {
-                    "dev-main": "1.28-dev"
-                },
                 "thanks": {
                     "name": "symfony/polyfill",
                     "url": "https://github.com/symfony/polyfill"
@@ -5389,7 +5177,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-php80/tree/v1.28.0"
+                "source": "https://github.com/symfony/polyfill-php80/tree/v1.29.0"
             },
             "funding": [
                 {
@@ -5405,20 +5193,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-01-26T09:26:14+00:00"
+            "time": "2024-01-29T20:11:03+00:00"
         },
         {
             "name": "symfony/polyfill-php83",
-            "version": "v1.28.0",
+            "version": "v1.29.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php83.git",
-                "reference": "b0f46ebbeeeda3e9d2faebdfbf4b4eae9b59fa11"
+                "reference": "86fcae159633351e5fd145d1c47de6c528f8caff"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/b0f46ebbeeeda3e9d2faebdfbf4b4eae9b59fa11",
-                "reference": "b0f46ebbeeeda3e9d2faebdfbf4b4eae9b59fa11",
+                "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/86fcae159633351e5fd145d1c47de6c528f8caff",
+                "reference": "86fcae159633351e5fd145d1c47de6c528f8caff",
                 "shasum": ""
             },
             "require": {
@@ -5427,9 +5215,6 @@
             },
             "type": "library",
             "extra": {
-                "branch-alias": {
-                    "dev-main": "1.28-dev"
-                },
                 "thanks": {
                     "name": "symfony/polyfill",
                     "url": "https://github.com/symfony/polyfill"
@@ -5469,7 +5254,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-php83/tree/v1.28.0"
+                "source": "https://github.com/symfony/polyfill-php83/tree/v1.29.0"
             },
             "funding": [
                 {
@@ -5485,7 +5270,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-08-16T06:22:46+00:00"
+            "time": "2024-01-29T20:11:03+00:00"
         },
         {
             "name": "symfony/routing",
@@ -6899,16 +6684,16 @@
         },
         {
             "name": "phpunit/phpunit",
-            "version": "10.5.9",
+            "version": "10.5.10",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "0bd663704f0165c9e76fe4f06ffa6a1ca727fdbe"
+                "reference": "50b8e314b6d0dd06521dc31d1abffa73f25f850c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0bd663704f0165c9e76fe4f06ffa6a1ca727fdbe",
-                "reference": "0bd663704f0165c9e76fe4f06ffa6a1ca727fdbe",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/50b8e314b6d0dd06521dc31d1abffa73f25f850c",
+                "reference": "50b8e314b6d0dd06521dc31d1abffa73f25f850c",
                 "shasum": ""
             },
             "require": {
@@ -6980,7 +6765,7 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
                 "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.9"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.10"
             },
             "funding": [
                 {
@@ -6996,7 +6781,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-01-22T14:35:40+00:00"
+            "time": "2024-02-04T09:07:51+00:00"
         },
         {
             "name": "sebastian/cli-parser",
diff --git a/config.ini.example b/config.ini.example
index abd506bb..c9be3544 100644
--- a/config.ini.example
+++ b/config.ini.example
@@ -81,3 +81,6 @@ ssl_key_file = ssl/origin.key
 [translator]
 base_directory = app/lang
 default_primary_language = en
+
+[websocket]
+max_connections = 10000
diff --git a/docs/pages/docs/changelog/index.md b/docs/pages/docs/changelog/index.md
index a8f562ef..949a7642 100644
--- a/docs/pages/docs/changelog/index.md
+++ b/docs/pages/docs/changelog/index.md
@@ -10,6 +10,10 @@ title: Changelog
 
 # Changelog
 
+## v0.19.0
+
+- Use Json-Schema serializable constraints
+
 ## v0.18.0
 
 - Feature: add {{docs/features/mail/index}}
diff --git a/docs/pages/docs/features/configuration/index.md b/docs/pages/docs/features/configuration/index.md
index e79f7dc8..97e54d83 100644
--- a/docs/pages/docs/features/configuration/index.md
+++ b/docs/pages/docs/features/configuration/index.md
@@ -196,7 +196,7 @@ readonly class ManifestConfiguration
 ```
 
 Then you need to define the configuration provider. 
-[JSON Schema](https://json-schema.org/) is used for config validation:
+Constraints schema is used for config validation:
 
 ```php
 <?php
@@ -205,11 +205,13 @@ namespace App\SingletonProvider\ConfigurationProvider;
 
 use App\ManifestConfiguration;
 use Distantmagic\Resonance\Attribute\Singleton;
-use Distantmagic\Resonance\JsonSchema;
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\Constraint\ObjectConstraint;
+use Distantmagic\Resonance\Constraint\StringConstraint;
 use Distantmagic\Resonance\SingletonProvider\ConfigurationProvider;
 
 /**
- * @template-extends ConfigurationProvider<ManifestConfiguration, object{
+ * @template-extends ConfigurationProvider<ManifestConfiguration, array{
  *     background_color: string,
  *     theme_color: string,
  * }>
@@ -217,34 +219,24 @@ use Distantmagic\Resonance\SingletonProvider\ConfigurationProvider;
 #[Singleton(provides: ManifestConfiguration::class)]
 final readonly class ManifestConfigurationProvider extends ConfigurationProvider
 {
-    protected function getConfigurationKey(): string
+    public function getConstraint(): Constraint
     {
-        return 'manifest';
+        return new ObjectConstraint([
+            'background_color' => new StringConstraint(),
+            'theme_color' => new StringConstraint(),
+        ]);
     }
 
-    protected function getSchema(): JsonSchema
+    protected function getConfigurationKey(): string
     {
-        return new JsonSchema([
-            'type' => 'object',
-            'properties' => [
-                'background_color' => [
-                    'type' => 'string',
-                    'minLength' => 1,
-                ],
-                'theme_color' => [
-                    'type' => 'string',
-                    'minLength' => 1,
-                ]
-            ],
-            'required' => ['background_color', 'theme_color']
-        ]);
+        return 'manifest';
     }
 
     protected function provideConfiguration($validatedData): ManifestConfiguration
     {
         return new ManifestConfiguration(
-            backgroundColor: $validatedData->background_color,
-            themeColor: $validatedData->theme_color,
+            backgroundColor: $validatedData['background_color'],
+            themeColor: $validatedData['theme_color'],
         );
     }
 }
diff --git a/docs/pages/docs/features/validation/index.md b/docs/pages/docs/features/validation/index.md
index 5f8df33b..f8b57462 100644
--- a/docs/pages/docs/features/validation/index.md
+++ b/docs/pages/docs/features/validation/index.md
@@ -47,8 +47,7 @@ readonly class BlogPostForm extends InputValidatedData
 ## Validators
 
 Validators take in any data and check if it adheres to the configuration 
-schema. The `getSchema()` method must return a 
-[JSON Schema](https://json-schema.org/) object.
+schema. The `getConstraint()` method must return a constraints object.
 
 ```php
 <?php
@@ -57,8 +56,10 @@ namespace App\InputValidator;
 
 use App\InputValidatedData\BlogPostForm;
 use Distantmagic\Resonance\Attribute\Singleton;
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\Constraint\ObjectConstraint;
+use Distantmagic\Resonance\Constraint\StringConstraint;
 use Distantmagic\Resonance\InputValidator;
-use Distantmagic\Resonance\JsonSchema;
 use Distantmagic\Resonance\SingletonCollection;
 
 /**
@@ -78,21 +79,11 @@ readonly class BlogPostFormValidator extends InputValidator
         );
     }
 
-    public function getSchema(): JsonSchema
+    public function getConstraint(): Constraint
     {
-        return new JsonSchema([
-            'type' => 'object',
-            'properties' => [
-                'content' => [
-                    'type' => 'string',
-                    'minLength' => 1
-                ],
-                'title' => [
-                    'type' => 'string',
-                    'minLength' => 1
-                ]
-            ],
-            'required' => ['content', 'title']
+        return new ObjectConstraint([
+            'content' => new StringConstraint(),
+            'title' => new StringConstraint()
         ]);
     }
 }
@@ -106,11 +97,9 @@ parameters manually. Then you can call their `validateData()` method.
 <?php
 
 use Distantmagic\Resonance\InputValidatorController;
-use Distantmagic\Resonance\JsonSchemaValidator;
 use Distantmagic\Resonance\InputValidationResult;
 
-$jsonSchemaValidator = new JsonSchemaValidator();
-$inputValidatorController = new InputValidatorController($jsonSchemaValidator);
+$inputValidatorController = new InputValidatorController();
 
 /**
  * @var InputValidationResult $validationResult
@@ -122,7 +111,7 @@ $validationResult = $inputValidatorController->validateData($blogPostFormValidat
 
 // If validation is successful, the errors list is empty and validation data
 // is set.
-assert($validationResult->errors->isEmpty());
+assert($validationResult->constraintResult->getErrors()->isEmpty());
 
 /**
  * It's null if validation failed.
diff --git a/docs/pages/docs/features/websockets/protocols.md b/docs/pages/docs/features/websockets/protocols.md
index 438f3c88..887353e0 100644
--- a/docs/pages/docs/features/websockets/protocols.md
+++ b/docs/pages/docs/features/websockets/protocols.md
@@ -65,7 +65,8 @@ use Distantmagic\Resonance\Attribute\RespondsToWebSocketRPC;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Distantmagic\Resonance\Attribute\WantsFeature;
 use Distantmagic\Resonance\Feature;
-use Distantmagic\Resonance\JsonSchema;
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\Constraint\StringConstraint;
 use Distantmagic\Resonance\RPCRequest;
 use Distantmagic\Resonance\RPCResponse;
 use Distantmagic\Resonance\SingletonCollection;
@@ -78,11 +79,9 @@ use Distantmagic\Resonance\WebSocketRPCResponder;
 #[WantsFeature(Feature::WebSocket)]
 final readonly class EchoResponder extends WebSocketRPCResponder
 {
-    public function getSchema(): JsonSchema
+    public function getConstraint(): Constraint
     {
-        return new JsonSchema([
-            'type' => 'string',
-        ]);
+        return new StringConstraint();
     }
 
     public function onRequest(
diff --git a/docs/pages/tutorials/how-to-create-llm-websocket-chat-with-llama-cpp/index.md b/docs/pages/tutorials/how-to-create-llm-websocket-chat-with-llama-cpp/index.md
index 61583877..0c2c4b42 100644
--- a/docs/pages/tutorials/how-to-create-llm-websocket-chat-with-llama-cpp/index.md
+++ b/docs/pages/tutorials/how-to-create-llm-websocket-chat-with-llama-cpp/index.md
@@ -202,7 +202,9 @@ use Distantmagic\Resonance\Attribute\RespondsToWebSocketRPC;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Distantmagic\Resonance\Attribute\WantsFeature;
 use Distantmagic\Resonance\Feature;
-use Distantmagic\Resonance\JsonSchema;
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\Constraint\ObjectConstraint;
+use Distantmagic\Resonance\Constraint\StringConstraint;
 use Distantmagic\Resonance\LlamaCppClient;
 use Distantmagic\Resonance\LlamaCppCompletionRequest;
 use Distantmagic\Resonance\RPCNotification;
@@ -222,20 +224,10 @@ final readonly class LlmChatPromptResponder extends WebSocketRPCResponder
         private LlamaCppClient $llamaCppClient,
     ) {}
 
-    public function getSchema(): JsonSchema
+    public function getConstraint(): Constraint
     {
-        return new JsonSchema([
-            'type' => 'object',
-            'additionalProperties' => false,
-            'properties' => [
-                'prompt' => [
-                    'minLength' => 1,
-                    'type' => 'string',
-                ],
-            ],
-            'required' => [
-                'prompt',
-            ],
+        return new ObjectConstraint([
+            'prompt' => new StringConstraint(),
         ]);
     }
 
diff --git a/docs/pages/tutorials/session-based-authentication/index.md b/docs/pages/tutorials/session-based-authentication/index.md
index 0725b59f..ed1aaa76 100644
--- a/docs/pages/tutorials/session-based-authentication/index.md
+++ b/docs/pages/tutorials/session-based-authentication/index.md
@@ -346,8 +346,7 @@ readonly class UsernamePassword extends InputValidatedData
 ```
 
 We will use this input validator in the HTTP Responder. It uses
-[JSON Schema](https://json-schema.org/)
-to validate the incoming data:
+constraints to validate the incoming data:
 
 ```php file:app/InputValidator/UsernamePasswordValidator.php
 <?php
@@ -357,11 +356,13 @@ namespace App\InputValidator;
 use App\InputValidatedData\UsernamePassword;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Distantmagic\Resonance\InputValidator;
-use Distantmagic\Resonance\JsonSchema;
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\Constraint\ObjectConstraint;
+use Distantmagic\Resonance\Constraint\StringConstraint;
 use Distantmagic\Resonance\SingletonCollection;
 
 /**
- * @extends InputValidator<UsernamePassword, object{
+ * @extends InputValidator<UsernamePassword, array{
  *     csrf: string,
  *     username: string,
  *     password: string,
@@ -372,27 +373,15 @@ readonly class UsernamePasswordValidator extends InputValidator
 {
     public function castValidatedData(mixed $data): UsernamePassword
     {
-        return new UsernamePassword($data->username, $data->password);
+        return new UsernamePassword($data['username'], $data['password']);
     }
 
-    public function getSchema(): Schema
+    public function getSchema(): Constraint
     {
-        return new JsonSchema([
-            'type' => 'object',
-            'properties' => [
-                'csrf' => [
-                    'type' => 'string',
-                ],
-                'username' => [
-                    'type' => 'string',
-                    'minLength' => 1,
-                ],
-                'password' => [
-                    'type' => 'string',
-                    'minLength' => 1,
-                ]
-            ],
-            'required' => ['csrf', 'username', 'password']
+        return new ObjectConstraint([
+            'csrf' => (new StringConstraint())->optional(),
+            'username' => new StringConstraint(),
+            'password' => new StringConstraint(),
         ]);
     }
 }
diff --git a/src/Attribute/RespondsWith.php b/src/Attribute/RespondsWith.php
index fe0a8f80..78d8505b 100644
--- a/src/Attribute/RespondsWith.php
+++ b/src/Attribute/RespondsWith.php
@@ -6,8 +6,8 @@ namespace Distantmagic\Resonance\Attribute;
 
 use Attribute;
 use Distantmagic\Resonance\Attribute as BaseAttribute;
+use Distantmagic\Resonance\Constraint;
 use Distantmagic\Resonance\ContentType;
-use Distantmagic\Resonance\JsonSchema;
 
 #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_CLASS)]
 final readonly class RespondsWith extends BaseAttribute
@@ -17,7 +17,7 @@ final readonly class RespondsWith extends BaseAttribute
      */
     public function __construct(
         public ContentType $contentType,
-        public JsonSchema $jsonSchema,
+        public Constraint $constraint,
         public int $status,
         public ?string $description = null,
     ) {}
diff --git a/src/Command/TestHttpResponders.php b/src/Command/TestHttpResponders.php
index 0bd34c72..ef0e27a0 100644
--- a/src/Command/TestHttpResponders.php
+++ b/src/Command/TestHttpResponders.php
@@ -11,7 +11,6 @@ use Distantmagic\Resonance\HttpRecursiveResponder;
 use Distantmagic\Resonance\HttpResponderAggregate;
 use Distantmagic\Resonance\HttpResponderInterface;
 use Distantmagic\Resonance\InspectableSwooleResponse;
-use Distantmagic\Resonance\JsonSchemaValidator;
 use Distantmagic\Resonance\SwooleCoroutineHelper;
 use Distantmagic\Resonance\TestableHttpResponseCollection;
 use Ds\Map;
@@ -29,7 +28,6 @@ final class TestHttpResponders extends Command
     public function __construct(
         private readonly HttpRecursiveResponder $recursiveResponder,
         private readonly HttpResponderAggregate $httpResponderAggregate,
-        private readonly JsonSchemaValidator $jsonSchemaValidator,
         private readonly TestableHttpResponseCollection $testableHttpResponseCollection,
     ) {
         parent::__construct();
@@ -109,15 +107,12 @@ final class TestHttpResponders extends Command
             ));
         }
 
-        $jsonSchemaValidationResult = $this
-            ->jsonSchemaValidator
-            ->validateSchema(
-                $respondsWith->jsonSchema,
-                $response->mockGetCastedContent(),
-            )
+        $constraintResult = $respondsWith
+            ->constraint
+            ->validate($response->mockGetCastedContent())
         ;
 
-        if (empty($jsonSchemaValidationResult->errors)) {
+        if ($constraintResult->status->isValid()) {
             $output->writeln('ok');
 
             return true;
@@ -125,10 +120,9 @@ final class TestHttpResponders extends Command
 
         $output->writeln('<error>error</error>');
 
-        foreach ($jsonSchemaValidationResult->errors as $path => $errors) {
-            foreach ($errors as $error) {
-                $output->writeln(sprintf('%s -> %s', $path, $error));
-            }
+        foreach ($constraintResult->getErrors() as $path => $error) {
+            $output->writeln(sprintf('%s -> %s', $path, $error));
+            $output->writeln(print_r($constraintResult->castedData, true));
         }
 
         return false;
diff --git a/src/Constraint.php b/src/Constraint.php
index 75be005a..7cc6f036 100644
--- a/src/Constraint.php
+++ b/src/Constraint.php
@@ -33,6 +33,11 @@ abstract readonly class Constraint implements JsonSchemableInterface
         public bool $isRequired = true,
     ) {}
 
+    public function jsonSerialize(): array|object
+    {
+        return $this->toJsonSchema();
+    }
+
     /**
      * @return PJsonSchema
      */
diff --git a/src/Constraint/AnyOfConstraint.php b/src/Constraint/AnyOfConstraint.php
new file mode 100644
index 00000000..5fb62b8f
--- /dev/null
+++ b/src/Constraint/AnyOfConstraint.php
@@ -0,0 +1,101 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\ConstraintDefaultValue;
+use Distantmagic\Resonance\ConstraintPath;
+use Distantmagic\Resonance\ConstraintReason;
+use Distantmagic\Resonance\ConstraintResult;
+use Distantmagic\Resonance\ConstraintResultStatus;
+use RuntimeException;
+
+final readonly class AnyOfConstraint extends Constraint
+{
+    /**
+     * @param array<Constraint> $anyOf
+     */
+    public function __construct(
+        public array $anyOf,
+        ?ConstraintDefaultValue $defaultValue = null,
+        bool $isNullable = false,
+        bool $isRequired = true,
+    ) {
+        parent::__construct(
+            defaultValue: $defaultValue,
+            isNullable: $isNullable,
+            isRequired: $isRequired,
+        );
+    }
+
+    public function default(mixed $defaultValue): self
+    {
+        return new self(
+            anyOf: $this->anyOf,
+            defaultValue: new ConstraintDefaultValue($defaultValue),
+            isNullable: $this->isNullable,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function nullable(): never
+    {
+        throw new RuntimeException('AnyOf constraint cannot be nullable');
+    }
+
+    public function optional(): self
+    {
+        return new self(
+            anyOf: $this->anyOf,
+            defaultValue: $this->defaultValue,
+            isNullable: $this->isNullable,
+            isRequired: false,
+        );
+    }
+
+    protected function doConvertToJsonSchema(): array
+    {
+        /**
+         * @var list<array> $anyOf
+         */
+        $anyOf = [];
+
+        foreach ($this->anyOf as $constraint) {
+            $anyOf[] = $constraint->toJsonSchema();
+        }
+
+        /**
+         * @var array{
+         *     anyOf: list<array>
+         * }
+         */
+        return [
+            'anyOf' => $anyOf,
+        ];
+    }
+
+    protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult
+    {
+        foreach ($this->anyOf as $constraint) {
+            $constraintResult = $constraint->validate($notValidatedData, $path);
+
+            if ($constraintResult->status->isValid()) {
+                return new ConstraintResult(
+                    castedData: $notValidatedData,
+                    path: $path,
+                    reason: ConstraintReason::Ok,
+                    status: ConstraintResultStatus::Valid,
+                );
+            }
+        }
+
+        return new ConstraintResult(
+            castedData: $notValidatedData,
+            path: $path,
+            reason: ConstraintReason::InvalidNestedConstraint,
+            status: ConstraintResultStatus::Invalid,
+        );
+    }
+}
diff --git a/src/Constraint/AnyOfConstraintTest.php b/src/Constraint/AnyOfConstraintTest.php
new file mode 100644
index 00000000..3e08d56e
--- /dev/null
+++ b/src/Constraint/AnyOfConstraintTest.php
@@ -0,0 +1,73 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversNothing
+ *
+ * @internal
+ */
+final class AnyOfConstraintTest extends TestCase
+{
+    public function test_is_converted_optionally_to_json_schema(): void
+    {
+        $constraint = new AnyOfConstraint(
+            anyOf: [
+                new StringConstraint(),
+                new IntegerConstraint(),
+            ]
+        );
+
+        self::assertEquals([
+            'anyOf' => [
+                [
+                    'type' => 'string',
+                    'minLength' => 1,
+                ],
+                [
+                    'type' => 'integer',
+                ],
+            ],
+        ], $constraint->optional()->toJsonSchema());
+    }
+
+    public function test_is_converted_to_json_schema(): void
+    {
+        $constraint = new AnyOfConstraint(
+            anyOf: [
+                new StringConstraint(),
+                new IntegerConstraint(),
+            ]
+        );
+
+        self::assertEquals([
+            'anyOf' => [
+                [
+                    'type' => 'string',
+                    'minLength' => 1,
+                ],
+                [
+                    'type' => 'integer',
+                ],
+            ],
+        ], $constraint->optional()->toJsonSchema());
+    }
+
+    public function test_validates(): void
+    {
+        $constraint = new AnyOfConstraint(
+            anyOf: [
+                new StringConstraint(),
+                new IntegerConstraint(),
+            ]
+        );
+
+        self::assertTrue($constraint->validate('hi')->status->isValid());
+        self::assertTrue($constraint->validate(5)->status->isValid());
+        self::assertFalse($constraint->validate(false)->status->isValid());
+    }
+}
diff --git a/src/Constraint/ListConstraint.php b/src/Constraint/ListConstraint.php
new file mode 100644
index 00000000..8ea6c9dd
--- /dev/null
+++ b/src/Constraint/ListConstraint.php
@@ -0,0 +1,125 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\ConstraintDefaultValue;
+use Distantmagic\Resonance\ConstraintPath;
+use Distantmagic\Resonance\ConstraintReason;
+use Distantmagic\Resonance\ConstraintResult;
+use Distantmagic\Resonance\ConstraintResultStatus;
+
+final readonly class ListConstraint extends Constraint
+{
+    public function __construct(
+        public Constraint $valueConstraint,
+        ConstraintDefaultValue $defaultValue = new ConstraintDefaultValue([]),
+        bool $isNullable = false,
+        bool $isRequired = true,
+    ) {
+        parent::__construct(
+            defaultValue: $defaultValue,
+            isNullable: $isNullable,
+            isRequired: $isRequired,
+        );
+    }
+
+    public function default(mixed $defaultValue): self
+    {
+        return new self(
+            valueConstraint: $this->valueConstraint,
+            defaultValue: new ConstraintDefaultValue($defaultValue),
+            isNullable: $this->isNullable,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function nullable(): self
+    {
+        return new self(
+            valueConstraint: $this->valueConstraint,
+            defaultValue: new ConstraintDefaultValue(null),
+            isNullable: true,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function optional(): self
+    {
+        return new self(
+            valueConstraint: $this->valueConstraint,
+            defaultValue: $this->defaultValue ?? new ConstraintDefaultValue([]),
+            isNullable: $this->isNullable,
+            isRequired: false,
+        );
+    }
+
+    protected function doConvertToJsonSchema(): array
+    {
+        return [
+            'type' => 'array',
+            'items' => $this->valueConstraint->toJsonSchema(),
+        ];
+    }
+
+    protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult
+    {
+        if (!is_array($notValidatedData)) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                path: $path,
+                reason: ConstraintReason::InvalidDataType,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        $ret = [];
+
+        $i = 0;
+
+        /**
+         * @var list<ConstraintResult>
+         */
+        $invalidChildStatuses = [];
+
+        /**
+         * @var mixed $notValidatedValue explicitly mixed for typechecks
+         */
+        foreach ($notValidatedData as $notValidatedValue) {
+            $childResult = $this->valueConstraint->validate(
+                notValidatedData: $notValidatedValue,
+                path: $path->fork((string) $i)
+            );
+
+            if ($childResult->status->isValid()) {
+                /**
+                 * @var mixed explicitly mixed for typechecks
+                 */
+                $ret[] = $childResult->castedData;
+            } else {
+                $invalidChildStatuses[] = $childResult;
+            }
+
+            ++$i;
+        }
+
+        if (!empty($invalidChildStatuses)) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                nested: $invalidChildStatuses,
+                path: $path,
+                reason: ConstraintReason::InvalidNestedConstraint,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        return new ConstraintResult(
+            castedData: $ret,
+            path: $path,
+            reason: ConstraintReason::Ok,
+            status: ConstraintResultStatus::Valid,
+        );
+    }
+}
diff --git a/src/Constraint/ListConstraintTest.php b/src/Constraint/ListConstraintTest.php
new file mode 100644
index 00000000..fa81178b
--- /dev/null
+++ b/src/Constraint/ListConstraintTest.php
@@ -0,0 +1,91 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversNothing
+ *
+ * @internal
+ */
+final class ListConstraintTest extends TestCase
+{
+    public function test_is_converted_optionally_to_json_schema(): void
+    {
+        $constraint = new ListConstraint(
+            valueConstraint: new StringConstraint()
+        );
+        self::assertEquals([
+            'type' => 'array',
+            'items' => [
+                'type' => 'string',
+                'minLength' => 1,
+            ],
+            'default' => [],
+        ], $constraint->optional()->toJsonSchema());
+    }
+
+    public function test_is_converted_to_json_schema(): void
+    {
+        $constraint = new ListConstraint(
+            valueConstraint: new StringConstraint()
+        );
+        self::assertEquals([
+            'type' => 'array',
+            'items' => [
+                'type' => 'string',
+                'minLength' => 1,
+            ],
+            'default' => [],
+        ], $constraint->optional()->toJsonSchema());
+    }
+
+    public function test_nullable_is_converted_to_json_schema(): void
+    {
+        $constraint = new ListConstraint(
+            valueConstraint: new StringConstraint()
+        );
+        self::assertEquals([
+            'type' => ['null', 'array'],
+            'items' => [
+                'type' => 'string',
+                'minLength' => 1,
+            ],
+            'default' => null,
+        ], $constraint->nullable()->toJsonSchema());
+    }
+
+    public function test_validates_fail(): void
+    {
+        $constraint = new ListConstraint(
+            valueConstraint: new StringConstraint()
+        );
+
+        $validatedResult = $constraint->validate([
+            'hi',
+            5,
+        ]);
+        self::assertFalse($validatedResult->status->isValid());
+        self::assertEquals([
+            '' => 'invalid_nested_constraint',
+            '1' => 'invalid_data_type',
+        ], $validatedResult->getErrors()->toArray());
+    }
+
+    public function test_validates_ok(): void
+    {
+        $constraint = new ListConstraint(
+            valueConstraint: new StringConstraint()
+        );
+
+        $validatedResult = $constraint->validate([
+            'hi',
+            'foo',
+        ]);
+
+        self::assertTrue($validatedResult->status->isValid());
+    }
+}
diff --git a/src/Constraint/MapConstraint.php b/src/Constraint/MapConstraint.php
index 837067b6..cad126f2 100644
--- a/src/Constraint/MapConstraint.php
+++ b/src/Constraint/MapConstraint.php
@@ -66,7 +66,7 @@ final readonly class MapConstraint extends Constraint
 
     protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult
     {
-        if (!is_array($notValidatedData)) {
+        if (!is_array($notValidatedData) && !is_object($notValidatedData)) {
             return new ConstraintResult(
                 castedData: $notValidatedData,
                 path: $path,
diff --git a/src/Constraint/ObjectConstraint.php b/src/Constraint/ObjectConstraint.php
index 3a24b775..ed1c636e 100644
--- a/src/Constraint/ObjectConstraint.php
+++ b/src/Constraint/ObjectConstraint.php
@@ -17,7 +17,8 @@ final readonly class ObjectConstraint extends Constraint
      * @param array<non-empty-string,Constraint> $properties
      */
     public function __construct(
-        public array $properties,
+        public array $properties = [],
+        public bool $additionalProperties = false,
         ?ConstraintDefaultValue $defaultValue = null,
         bool $isNullable = false,
         bool $isRequired = true,
@@ -29,9 +30,21 @@ final readonly class ObjectConstraint extends Constraint
         );
     }
 
+    public function additionalProperties(bool $allow): self
+    {
+        return new self(
+            additionalProperties: $allow,
+            properties: $this->properties,
+            defaultValue: $this->defaultValue,
+            isNullable: $this->isNullable,
+            isRequired: $this->isRequired,
+        );
+    }
+
     public function default(mixed $defaultValue): self
     {
         return new self(
+            additionalProperties: $this->additionalProperties,
             properties: $this->properties,
             defaultValue: new ConstraintDefaultValue($defaultValue),
             isNullable: $this->isNullable,
@@ -42,6 +55,7 @@ final readonly class ObjectConstraint extends Constraint
     public function nullable(): self
     {
         return new self(
+            additionalProperties: $this->additionalProperties,
             properties: $this->properties,
             defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null),
             isNullable: true,
@@ -52,6 +66,7 @@ final readonly class ObjectConstraint extends Constraint
     public function optional(): self
     {
         return new self(
+            additionalProperties: $this->additionalProperties,
             properties: $this->properties,
             defaultValue: $this->defaultValue,
             isNullable: $this->isNullable,
@@ -76,13 +91,34 @@ final readonly class ObjectConstraint extends Constraint
             'type' => 'object',
             'properties' => $convertedProperties,
             'required' => $requiredProperties,
-            'additionalProperties' => false,
+            'additionalProperties' => $this->additionalProperties,
         ];
     }
 
+    protected function getProperty(array|object $notValidatedData, string $propertyName): mixed
+    {
+        if (is_array($notValidatedData)) {
+            return $notValidatedData[$propertyName];
+        }
+
+        return $notValidatedData->{$propertyName};
+    }
+
+    protected function hasProperty(array|object $notValidatedData, string $propertyName): bool
+    {
+        if (is_array($notValidatedData)) {
+            return array_key_exists($propertyName, $notValidatedData);
+        }
+
+        return property_exists($notValidatedData, $propertyName);
+    }
+
+    /**
+     * @psalm-suppress UnusedForeachValue we need foreach here
+     */
     protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult
     {
-        if (!is_array($notValidatedData)) {
+        if (!is_array($notValidatedData) && !is_object($notValidatedData)) {
             return new ConstraintResult(
                 castedData: $notValidatedData,
                 path: $path,
@@ -99,7 +135,7 @@ final readonly class ObjectConstraint extends Constraint
         $invalidChildStatuses = [];
 
         foreach ($this->properties as $name => $validator) {
-            if (!array_key_exists($name, $notValidatedData)) {
+            if (!$this->hasProperty($notValidatedData, $name)) {
                 if ($validator->defaultValue) {
                     /**
                      * @var mixed explicitly mixed for typechecks
@@ -115,7 +151,7 @@ final readonly class ObjectConstraint extends Constraint
                 }
             } else {
                 $childResult = $validator->validate(
-                    notValidatedData: $notValidatedData[$name],
+                    notValidatedData: $this->getProperty($notValidatedData, $name),
                     path: $path->fork($name)
                 );
 
@@ -140,6 +176,23 @@ final readonly class ObjectConstraint extends Constraint
             );
         }
 
+        if (!$this->additionalProperties) {
+            /**
+             * @var array-key|string $notValidatedKey
+             * @var mixed $notValidatedValue explicitly mixed for typechecks
+             */
+            foreach ($notValidatedData as $notValidatedKey => $notValidatedValue) {
+                if (!array_key_exists($notValidatedKey, $this->properties)) {
+                    return new ConstraintResult(
+                        castedData: $notValidatedData,
+                        path: $path->fork((string) $notValidatedKey),
+                        reason: ConstraintReason::UnexpectedProperty,
+                        status: ConstraintResultStatus::Invalid,
+                    );
+                }
+            }
+        }
+
         return new ConstraintResult(
             castedData: $ret,
             path: $path,
diff --git a/src/Constraint/ObjectConstraintTest.php b/src/Constraint/ObjectConstraintTest.php
index a28960ab..bcf082ed 100644
--- a/src/Constraint/ObjectConstraintTest.php
+++ b/src/Constraint/ObjectConstraintTest.php
@@ -15,12 +15,10 @@ final class ObjectConstraintTest extends TestCase
 {
     public function test_is_converted_optionally_to_json_schema(): void
     {
-        $constraint = new ObjectConstraint(
-            properties: [
-                'aaa' => new StringConstraint(),
-                'bbb' => new EnumConstraint(['foo']),
-            ]
-        );
+        $constraint = new ObjectConstraint([
+            'aaa' => new StringConstraint(),
+            'bbb' => new EnumConstraint(['foo']),
+        ]);
         self::assertEquals([
             'type' => 'object',
             'properties' => [
@@ -40,12 +38,10 @@ final class ObjectConstraintTest extends TestCase
 
     public function test_is_converted_to_json_schema(): void
     {
-        $constraint = new ObjectConstraint(
-            properties: [
-                'aaa' => new StringConstraint(),
-                'bbb' => new EnumConstraint(['foo']),
-            ]
-        );
+        $constraint = new ObjectConstraint([
+            'aaa' => new StringConstraint(),
+            'bbb' => new EnumConstraint(['foo']),
+        ]);
         self::assertEquals([
             'type' => 'object',
             'properties' => [
@@ -65,12 +61,10 @@ final class ObjectConstraintTest extends TestCase
 
     public function test_is_converted_to_json_schema_with_optionals(): void
     {
-        $constraint = new ObjectConstraint(
-            properties: [
-                'aaa' => (new StringConstraint())->optional(),
-                'bbb' => (new EnumConstraint(['foo']))->default(null),
-            ]
-        );
+        $constraint = new ObjectConstraint([
+            'aaa' => (new StringConstraint())->optional(),
+            'bbb' => (new EnumConstraint(['foo']))->default(null),
+        ]);
         self::assertEquals([
             'type' => 'object',
             'properties' => [
@@ -91,12 +85,10 @@ final class ObjectConstraintTest extends TestCase
 
     public function test_nullable_is_converted_to_json_schema(): void
     {
-        $constraint = new ObjectConstraint(
-            properties: [
-                'aaa' => new StringConstraint(),
-                'bbb' => new EnumConstraint(['foo']),
-            ]
-        );
+        $constraint = new ObjectConstraint([
+            'aaa' => new StringConstraint(),
+            'bbb' => new EnumConstraint(['foo']),
+        ]);
         self::assertEquals([
             'type' => ['null', 'object'],
             'properties' => [
@@ -115,14 +107,38 @@ final class ObjectConstraintTest extends TestCase
         ], $constraint->nullable()->toJsonSchema());
     }
 
+    public function test_validates_additional_properties_failure(): void
+    {
+        $constraint = new ObjectConstraint(additionalProperties: false);
+
+        $validatedResult = $constraint->validate([
+            'aaa' => 'hi',
+            'bbb' => 'foo',
+            'cc' => 'bar',
+        ]);
+
+        self::assertFalse($validatedResult->status->isValid());
+    }
+
+    public function test_validates_additional_properties_ok(): void
+    {
+        $constraint = new ObjectConstraint(additionalProperties: true);
+
+        $validatedResult = $constraint->validate([
+            'aaa' => 'hi',
+            'bbb' => 'foo',
+            'cc' => 'bar',
+        ]);
+
+        self::assertTrue($validatedResult->status->isValid());
+    }
+
     public function test_validates_defaults(): void
     {
-        $constraint = new ObjectConstraint(
-            properties: [
-                'aaa' => (new StringConstraint())->default('hi'),
-                'bbb' => new EnumConstraint(['foo']),
-            ]
-        );
+        $constraint = new ObjectConstraint([
+            'aaa' => (new StringConstraint())->default('hi'),
+            'bbb' => new EnumConstraint(['foo']),
+        ]);
 
         $validatedResult = $constraint->validate([
             'bbb' => 'foo',
@@ -137,12 +153,10 @@ final class ObjectConstraintTest extends TestCase
 
     public function test_validates_fail(): void
     {
-        $constraint = new ObjectConstraint(
-            properties: [
-                'aaa' => new StringConstraint(),
-                'bbb' => new EnumConstraint(['foo']),
-            ]
-        );
+        $constraint = new ObjectConstraint([
+            'aaa' => new StringConstraint(),
+            'bbb' => new EnumConstraint(['foo']),
+        ]);
 
         $validatedResult = $constraint->validate([
             'aaa' => 'hi',
@@ -157,12 +171,10 @@ final class ObjectConstraintTest extends TestCase
 
     public function test_validates_nullable(): void
     {
-        $constraint = new ObjectConstraint(
-            properties: [
-                'aaa' => (new StringConstraint())->nullable(),
-                'bbb' => new EnumConstraint(['foo']),
-            ]
-        );
+        $constraint = new ObjectConstraint([
+            'aaa' => (new StringConstraint())->nullable(),
+            'bbb' => new EnumConstraint(['foo']),
+        ]);
 
         $validatedResult = $constraint->validate([
             'bbb' => 'foo',
@@ -177,12 +189,10 @@ final class ObjectConstraintTest extends TestCase
 
     public function test_validates_nullable_null(): void
     {
-        $constraint = new ObjectConstraint(
-            properties: [
-                'aaa' => (new StringConstraint())->nullable(),
-                'bbb' => new EnumConstraint(['foo']),
-            ]
-        );
+        $constraint = new ObjectConstraint([
+            'aaa' => (new StringConstraint())->nullable(),
+            'bbb' => new EnumConstraint(['foo']),
+        ]);
 
         $validatedResult = $constraint->validate([
             'aaa' => null,
@@ -198,12 +208,10 @@ final class ObjectConstraintTest extends TestCase
 
     public function test_validates_ok(): void
     {
-        $constraint = new ObjectConstraint(
-            properties: [
-                'aaa' => new StringConstraint(),
-                'bbb' => new EnumConstraint(['foo']),
-            ]
-        );
+        $constraint = new ObjectConstraint([
+            'aaa' => new StringConstraint(),
+            'bbb' => new EnumConstraint(['foo']),
+        ]);
 
         $validatedResult = $constraint->validate([
             'aaa' => 'hi',
@@ -215,12 +223,10 @@ final class ObjectConstraintTest extends TestCase
 
     public function test_validates_optional(): void
     {
-        $constraint = new ObjectConstraint(
-            properties: [
-                'aaa' => (new StringConstraint())->optional(),
-                'bbb' => new EnumConstraint(['foo']),
-            ]
-        );
+        $constraint = new ObjectConstraint([
+            'aaa' => (new StringConstraint())->optional(),
+            'bbb' => new EnumConstraint(['foo']),
+        ]);
 
         $validatedResult = $constraint->validate([
             'bbb' => 'foo',
diff --git a/src/ConstraintReason.php b/src/ConstraintReason.php
index bc858543..0c990ff2 100644
--- a/src/ConstraintReason.php
+++ b/src/ConstraintReason.php
@@ -12,4 +12,5 @@ enum ConstraintReason: string
     case InvalidNestedConstraint = 'invalid_nested_constraint';
     case MissingProperty = 'missing_property';
     case Ok = 'ok';
+    case UnexpectedProperty = 'unexpected_property';
 }
diff --git a/src/ConstraintResultErrorMessage.php b/src/ConstraintResultErrorMessage.php
new file mode 100644
index 00000000..0b6c2d75
--- /dev/null
+++ b/src/ConstraintResultErrorMessage.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use Stringable;
+
+readonly class ConstraintResultErrorMessage implements Stringable
+{
+    public string $message;
+
+    public function __construct(
+        string $constraintId,
+        ConstraintResult $constraintResult,
+    ) {
+        $errors = $constraintResult->getErrors();
+        $message = [];
+
+        foreach ($errors as $name => $errorCode) {
+            $message[] = sprintf('"%s" -> %s', $name, $errorCode);
+        }
+
+        // var_dump($constraintResult->castedData);
+
+        $this->message = sprintf(
+            "%s:\n%s",
+            $constraintId,
+            implode("\n", $message),
+        );
+    }
+
+    public function __toString(): string
+    {
+        return $this->message;
+    }
+}
diff --git a/src/ConstraintStringFormat.php b/src/ConstraintStringFormat.php
index 4df07ee7..ef7cb307 100644
--- a/src/ConstraintStringFormat.php
+++ b/src/ConstraintStringFormat.php
@@ -6,5 +6,6 @@ namespace Distantmagic\Resonance;
 
 enum ConstraintStringFormat
 {
+    case Mail;
     case Uuid;
 }
diff --git a/src/ConstraintValidationException.php b/src/ConstraintValidationException.php
index e09148d4..878b3982 100644
--- a/src/ConstraintValidationException.php
+++ b/src/ConstraintValidationException.php
@@ -12,19 +12,9 @@ class ConstraintValidationException extends RuntimeException
         string $constraintId,
         ConstraintResult $constraintResult,
     ) {
-        $errors = $constraintResult->getErrors();
-        $message = [];
-
-        foreach ($errors as $name => $errorCode) {
-            $message[] = sprintf('"%s" -> %s', $name, $errorCode);
-        }
-
-        var_dump($constraintResult->castedData);
-
-        parent::__construct(sprintf(
-            "%s:\n%s",
+        parent::__construct((string) new ConstraintResultErrorMessage(
             $constraintId,
-            implode("\n", $message),
+            $constraintResult,
         ));
     }
 }
diff --git a/src/HttpControllerParameterResolver/ValidatedRequestResolver.php b/src/HttpControllerParameterResolver/ValidatedRequestResolver.php
index ea2e03fc..23b2bade 100644
--- a/src/HttpControllerParameterResolver/ValidatedRequestResolver.php
+++ b/src/HttpControllerParameterResolver/ValidatedRequestResolver.php
@@ -62,7 +62,7 @@ readonly class ValidatedRequestResolver extends HttpControllerParameterResolver
 
         return new HttpControllerParameterResolution(
             HttpControllerParameterResolutionStatus::ValidationErrors,
-            $validationResult->errors,
+            $validationResult->constraintResult->getErrors(),
         );
     }
 }
diff --git a/src/InputValidationResult.php b/src/InputValidationResult.php
index 36c9ac51..2e9814af 100644
--- a/src/InputValidationResult.php
+++ b/src/InputValidationResult.php
@@ -4,29 +4,24 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
-use Ds\Map;
-use Ds\Set;
-
 /**
  * @template TValidatedModel of InputValidatedData
  */
 readonly class InputValidationResult
 {
-    /**
-     * @var Map<non-empty-string,Set<non-empty-string>> $errors
-     */
-    public Map $errors;
-
     /**
      * @param null|TValidatedModel $inputValidatedData
      */
-    public function __construct(public ?InputValidatedData $inputValidatedData = null)
-    {
-        $this->errors = new Map();
-    }
+    public function __construct(
+        public ?InputValidatedData $inputValidatedData,
+        public ConstraintResult $constraintResult,
+    ) {}
 
     public function getErrorMessage(): string
     {
-        return $this->errors->values()->join("\n");
+        return (string) (new ConstraintResultErrorMessage(
+            'input_validation_result',
+            $this->constraintResult,
+        ));
     }
 }
diff --git a/src/InputValidator.php b/src/InputValidator.php
index 9ea2e3e6..65c2eaeb 100644
--- a/src/InputValidator.php
+++ b/src/InputValidator.php
@@ -10,4 +10,4 @@ namespace Distantmagic\Resonance;
  *
  * @template-implements CastsValidatedDataInterface<TCastedData,TValidatedData>
  */
-abstract readonly class InputValidator implements CastsValidatedDataInterface, JsonSchemaSourceInterface {}
+abstract readonly class InputValidator implements CastsValidatedDataInterface, ConstraintSourceInterface {}
diff --git a/src/InputValidator/FrontMatterValidator.php b/src/InputValidator/FrontMatterValidator.php
index 0737d644..eb1aebeb 100644
--- a/src/InputValidator/FrontMatterValidator.php
+++ b/src/InputValidator/FrontMatterValidator.php
@@ -5,17 +5,23 @@ declare(strict_types=1);
 namespace Distantmagic\Resonance\InputValidator;
 
 use Distantmagic\Resonance\Attribute\Singleton;
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\Constraint\AnyOfConstraint;
+use Distantmagic\Resonance\Constraint\BooleanConstraint;
+use Distantmagic\Resonance\Constraint\EnumConstraint;
+use Distantmagic\Resonance\Constraint\ListConstraint;
+use Distantmagic\Resonance\Constraint\ObjectConstraint;
+use Distantmagic\Resonance\Constraint\StringConstraint;
 use Distantmagic\Resonance\FrontMatterCollectionReference;
 use Distantmagic\Resonance\InputValidatedData\FrontMatter;
 use Distantmagic\Resonance\InputValidator;
-use Distantmagic\Resonance\JsonSchema;
 use Distantmagic\Resonance\StaticPageContentType;
 use Generator;
 use RuntimeException;
 
 /**
- * @extends InputValidator<FrontMatter, object{
- *     collections: array<non-empty-string|object{ name: non-empty-string, next: non-empty-string }>,
+ * @extends InputValidator<FrontMatter, array{
+ *     collections: array<non-empty-string|array{ name: non-empty-string, next: non-empty-string }>,
  *     content_type: non-empty-string,
  *     description: non-empty-string,
  *     draft: bool,
@@ -31,15 +37,15 @@ readonly class FrontMatterValidator extends InputValidator
 {
     public function castValidatedData(mixed $data): FrontMatter
     {
-        $collections = iterator_to_array($this->normalizeDataCollections($data->collections));
+        $collections = iterator_to_array($this->normalizeDataCollections($data['collections']));
 
-        $description = trim($data->description);
+        $description = trim($data['description']);
 
         if (empty($description)) {
             throw new RuntimeException('Description cannot be empty');
         }
 
-        $title = trim($data->title);
+        $title = trim($data['title']);
 
         if (empty($title)) {
             throw new RuntimeException('Title cannot be empty');
@@ -47,94 +53,46 @@ readonly class FrontMatterValidator extends InputValidator
 
         return new FrontMatter(
             collections: $collections,
-            contentType: StaticPageContentType::from($data->content_type),
+            contentType: StaticPageContentType::from($data['content_type']),
             description: $description,
-            isDraft: $data->draft,
-            layout: $data->layout,
-            next: $data->next ?? null,
-            parent: $data->parent ?? null,
-            registerStylesheets: $data->register_stylesheets,
+            isDraft: $data['draft'],
+            layout: $data['layout'],
+            next: $data['next'] ?? null,
+            parent: $data['parent'] ?? null,
+            registerStylesheets: $data['register_stylesheets'],
             title: $title,
         );
     }
 
-    public function getSchema(): JsonSchema
+    public function getConstraint(): Constraint
     {
         $contentTypes = StaticPageContentType::values();
 
-        return new JsonSchema([
-            'type' => 'object',
-            'properties' => [
-                'collections' => [
-                    'type' => 'array',
-                    'items' => [
-                        'anyOf' => [
-                            [
-                                'type' => 'string',
-                                'minLength' => 1,
-                            ],
-                            [
-                                'type' => 'object',
-                                'properties' => [
-                                    'name' => [
-                                        'type' => 'string',
-                                        'minLength' => 1,
-                                    ],
-                                    'next' => [
-                                        'type' => 'string',
-                                        'minLength' => 1,
-                                    ],
-                                ],
-                                'required' => ['name', 'next'],
-                            ],
+        return new ObjectConstraint([
+            'collections' => new ListConstraint(
+                valueConstraint: new AnyOfConstraint([
+                    new StringConstraint(),
+                    new ObjectConstraint(
+                        properties: [
+                            'name' => new StringConstraint(),
+                            'next' => new StringConstraint(),
                         ],
-                    ],
-                    'default' => [],
-                ],
-                'content_type' => [
-                    'type' => 'string',
-                    'enum' => $contentTypes,
-                    'default' => StaticPageContentType::Markdown->value,
-                ],
-                'description' => [
-                    'type' => 'string',
-                    'minLength' => 1,
-                ],
-                'draft' => [
-                    'type' => 'boolean',
-                    'default' => false,
-                ],
-                'layout' => [
-                    'type' => 'string',
-                    'minLength' => 1,
-                ],
-                'next' => [
-                    'type' => 'string',
-                    'minLength' => 1,
-                ],
-                'parent' => [
-                    'type' => 'string',
-                    'minLength' => 1,
-                ],
-                'register_stylesheets' => [
-                    'type' => 'array',
-                    'items' => [
-                        'type' => 'string',
-                        'minLength' => 1,
-                    ],
-                    'default' => [],
-                ],
-                'title' => [
-                    'type' => 'string',
-                    'minLength' => 1,
-                ],
-            ],
-            'required' => ['description', 'layout', 'title'],
+                    ),
+                ]),
+            ),
+            'content_type' => (new EnumConstraint($contentTypes))->default(StaticPageContentType::Markdown->value),
+            'description' => new StringConstraint(),
+            'draft' => (new BooleanConstraint())->default(false),
+            'layout' => new StringConstraint(),
+            'next' => (new StringConstraint())->nullable(),
+            'parent' => (new StringConstraint())->nullable(),
+            'register_stylesheets' => new ListConstraint(valueConstraint: new StringConstraint()),
+            'title' => new StringConstraint(),
         ]);
     }
 
     /**
-     * @param array<non-empty-string|object{ name: non-empty-string, next: non-empty-string }> $collections
+     * @param array<array{ name: non-empty-string, next: non-empty-string }|non-empty-string> $collections
      *
      * @return Generator<FrontMatterCollectionReference>
      */
@@ -145,8 +103,8 @@ readonly class FrontMatterValidator extends InputValidator
                 yield new FrontMatterCollectionReference($collection, null);
             } else {
                 yield new FrontMatterCollectionReference(
-                    $collection->name,
-                    $collection->next,
+                    $collection['name'],
+                    $collection['next'],
                 );
             }
         }
diff --git a/src/InputValidator/RPCMessageValidator.php b/src/InputValidator/RPCMessageValidator.php
index cc421450..954d1344 100644
--- a/src/InputValidator/RPCMessageValidator.php
+++ b/src/InputValidator/RPCMessageValidator.php
@@ -6,12 +6,16 @@ namespace Distantmagic\Resonance\InputValidator;
 
 use Distantmagic\Resonance\Attribute\GrantsFeature;
 use Distantmagic\Resonance\Attribute\Singleton;
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\Constraint\AnyConstraint;
+use Distantmagic\Resonance\Constraint\EnumConstraint;
+use Distantmagic\Resonance\Constraint\StringConstraint;
+use Distantmagic\Resonance\Constraint\TupleConstraint;
+use Distantmagic\Resonance\ConstraintStringFormat;
 use Distantmagic\Resonance\Feature;
 use Distantmagic\Resonance\InputValidatedData\RPCMessage;
 use Distantmagic\Resonance\InputValidator;
-use Distantmagic\Resonance\JsonSchema;
 use Distantmagic\Resonance\RPCMethodValidatorInterface;
-use stdClass;
 
 /**
  * @extends InputValidator<RPCMessage, array{
@@ -35,22 +39,14 @@ readonly class RPCMessageValidator extends InputValidator
         );
     }
 
-    public function getSchema(): JsonSchema
+    public function getConstraint(): Constraint
     {
-        return new JsonSchema([
-            'type' => 'array',
-            'items' => false,
-            'prefixItems' => [
-                [
-                    'type' => 'string',
-                    'enum' => $this->rpcMethodValidator->values(),
-                ],
-                new stdClass(),
-                [
-                    'type' => ['null', 'string'],
-                    'format' => 'uuid',
-                ],
+        return new TupleConstraint(
+            items: [
+                new EnumConstraint($this->rpcMethodValidator->values()),
+                new AnyConstraint(),
+                (new StringConstraint(format: ConstraintStringFormat::Uuid))->nullable(),
             ],
-        ]);
+        );
     }
 }
diff --git a/src/InputValidatorController.php b/src/InputValidatorController.php
index 91ed354d..d02f6d09 100644
--- a/src/InputValidatorController.php
+++ b/src/InputValidatorController.php
@@ -9,8 +9,6 @@ use Distantmagic\Resonance\Attribute\Singleton;
 #[Singleton]
 readonly class InputValidatorController
 {
-    public function __construct(private JsonSchemaValidator $jsonSchemaValidator) {}
-
     /**
      * @template TCastedData of InputValidatedData
      * @template TValidatedData
@@ -21,9 +19,9 @@ readonly class InputValidatorController
      */
     public function validateData(InputValidator $inputValidator, mixed $data): InputValidationResult
     {
-        $jsonSchemaValidationResult = $this->jsonSchemaValidator->validate($inputValidator, $data);
+        $constraintResult = $inputValidator->getConstraint()->validate($data);
 
-        return $this->castJsonSchemaValidationResult($inputValidator, $jsonSchemaValidationResult);
+        return $this->castJsonSchemaValidationResult($constraintResult, $inputValidator);
     }
 
     /**
@@ -31,32 +29,26 @@ readonly class InputValidatorController
      * @template TValidatedData
      *
      * @param InputValidator<TCastedData,TValidatedData> $inputValidator
-     * @param JsonSchemaValidationResult<TValidatedData> $jsonSchemaValidationResult
      *
      * @return InputValidationResult<TCastedData>
      */
     protected function castJsonSchemaValidationResult(
+        ConstraintResult $constraintResult,
         InputValidator $inputValidator,
-        JsonSchemaValidationResult $jsonSchemaValidationResult,
     ): InputValidationResult {
-        $errors = $jsonSchemaValidationResult->errors;
-
-        if (empty($errors)) {
-            return new InputValidationResult($inputValidator->castValidatedData($jsonSchemaValidationResult->data));
+        if ($constraintResult->status->isValid()) {
+            /**
+             * @var TValidatedData $constraintResult->castedData
+             */
+            return new InputValidationResult(
+                $inputValidator->castValidatedData($constraintResult->castedData),
+                $constraintResult,
+            );
         }
 
         /**
          * @var InputValidationResult<TCastedData>
          */
-        $validationResult = new InputValidationResult();
-
-        foreach ($errors as $propertyName => $propertyErrors) {
-            $validationResult->errors->put(
-                $propertyName,
-                $propertyErrors,
-            );
-        }
-
-        return $validationResult;
+        return new InputValidationResult(null, $constraintResult);
     }
 }
diff --git a/src/JsonSchema.php b/src/JsonSchema.php
deleted file mode 100644
index 594bd587..00000000
--- a/src/JsonSchema.php
+++ /dev/null
@@ -1,20 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Distantmagic\Resonance;
-
-use JsonSerializable;
-
-readonly class JsonSchema implements JsonSerializable
-{
-    /**
-     * @param non-empty-array $schema
-     */
-    public function __construct(public array $schema) {}
-
-    public function jsonSerialize(): array
-    {
-        return $this->schema;
-    }
-}
diff --git a/src/JsonSchemaSourceInterface.php b/src/JsonSchemaSourceInterface.php
deleted file mode 100644
index 2ec9a2a3..00000000
--- a/src/JsonSchemaSourceInterface.php
+++ /dev/null
@@ -1,10 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Distantmagic\Resonance;
-
-interface JsonSchemaSourceInterface
-{
-    public function getSchema(): JsonSchema;
-}
diff --git a/src/JsonSchemaValidationErrorMessage.php b/src/JsonSchemaValidationErrorMessage.php
deleted file mode 100644
index 9f503624..00000000
--- a/src/JsonSchemaValidationErrorMessage.php
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Distantmagic\Resonance;
-
-use Ds\Set;
-use Stringable;
-
-readonly class JsonSchemaValidationErrorMessage implements Stringable
-{
-    public string $message;
-
-    /**
-     * @param array<non-empty-string,Set<non-empty-string>> $errors
-     */
-    public function __construct(array $errors)
-    {
-        $messages = [];
-
-        foreach ($errors as $propertyName => $propertyErrors) {
-            foreach ($propertyErrors as $propertyError) {
-                $messages[] = sprintf('"%s": "%s"', $propertyName, $propertyError);
-            }
-        }
-
-        $this->message = sprintf(
-            "Encountered validation errors:\n-> %s",
-            implode("\n-> ", $messages)
-        );
-    }
-
-    public function __toString(): string
-    {
-        return $this->message;
-    }
-}
diff --git a/src/JsonSchemaValidationException.php b/src/JsonSchemaValidationException.php
deleted file mode 100644
index 9f81f837..00000000
--- a/src/JsonSchemaValidationException.php
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Distantmagic\Resonance;
-
-use Ds\Set;
-use RuntimeException;
-
-class JsonSchemaValidationException extends RuntimeException
-{
-    /**
-     * @param array<non-empty-string,Set<non-empty-string>> $errors
-     */
-    public function __construct(array $errors)
-    {
-        parent::__construct((string) new JsonSchemaValidationErrorMessage($errors));
-    }
-}
diff --git a/src/JsonSchemaValidationResult.php b/src/JsonSchemaValidationResult.php
deleted file mode 100644
index db8b14cb..00000000
--- a/src/JsonSchemaValidationResult.php
+++ /dev/null
@@ -1,22 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Distantmagic\Resonance;
-
-use Ds\Set;
-
-/**
- * @template TValidatedData
- */
-readonly class JsonSchemaValidationResult
-{
-    /**
-     * @param TValidatedData                                $data
-     * @param array<non-empty-string,Set<non-empty-string>> $errors
-     */
-    public function __construct(
-        public mixed $data,
-        public array $errors,
-    ) {}
-}
diff --git a/src/JsonSchemaValidator.php b/src/JsonSchemaValidator.php
deleted file mode 100644
index 713e64a5..00000000
--- a/src/JsonSchemaValidator.php
+++ /dev/null
@@ -1,177 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Distantmagic\Resonance;
-
-use Distantmagic\Resonance\Attribute\Singleton;
-use Ds\Map;
-use Ds\Set;
-use Opis\JsonSchema\Errors\ErrorFormatter;
-use Opis\JsonSchema\Errors\ValidationError;
-use Opis\JsonSchema\Helper;
-use Opis\JsonSchema\Parsers\SchemaParser;
-use Opis\JsonSchema\Schema;
-use Opis\JsonSchema\SchemaLoader;
-use Opis\JsonSchema\ValidationResult;
-use Opis\JsonSchema\Validator;
-use RuntimeException;
-
-#[Singleton]
-readonly class JsonSchemaValidator
-{
-    /**
-     * @var Map<JsonSchemaSourceInterface,Schema>
-     */
-    private Map $convertedSchemas;
-
-    private ErrorFormatter $errorFormatter;
-    private SchemaLoader $schemaLoader;
-    private Validator $validator;
-
-    public function __construct()
-    {
-        $this->convertedSchemas = new Map();
-        $this->errorFormatter = new ErrorFormatter();
-        $this->schemaLoader = new SchemaLoader(new SchemaParser(), null, false);
-        $this->validator = new Validator($this->schemaLoader);
-    }
-
-    public function validate(JsonSchemaSourceInterface $jsonSchemaSource, mixed $data): JsonSchemaValidationResult
-    {
-        return $this->validateConvertedSchema(
-            $this->convertSchemaSource($jsonSchemaSource),
-            $data,
-        );
-    }
-
-    public function validateConvertedSchema(Schema $schema, mixed $data): JsonSchemaValidationResult
-    {
-        /**
-         * @var bool|object|string $convertedData
-         */
-        $convertedData = Helper::toJSON($data);
-
-        $validationResult = $this->validator->validate($convertedData, $schema);
-
-        return new JsonSchemaValidationResult(
-            data: $convertedData,
-            errors: $this->formatErrors($validationResult),
-        );
-    }
-
-    public function validateSchema(JsonSchema $jsonSchema, mixed $data): JsonSchemaValidationResult
-    {
-        return $this->validateConvertedSchema(
-            $this->convertSchema($jsonSchema),
-            $data,
-        );
-    }
-
-    private function convertSchema(JsonSchema $jsonSchema): Schema
-    {
-        $convertedSchema = Helper::toJSON($jsonSchema->schema);
-
-        if (!is_object($convertedSchema)) {
-            throw new RuntimeException('Json Schema must be an object');
-        }
-
-        return $this->schemaLoader->loadObjectSchema($convertedSchema);
-    }
-
-    private function convertSchemaSource(JsonSchemaSourceInterface $jsonSchemaSource): Schema
-    {
-        if ($this->convertedSchemas->hasKey($jsonSchemaSource)) {
-            return $this->convertedSchemas->get($jsonSchemaSource);
-        }
-
-        $validatedSchema = $this->convertSchema($jsonSchemaSource->getSchema());
-
-        $this->convertedSchemas->put($jsonSchemaSource, $validatedSchema);
-
-        return $validatedSchema;
-    }
-
-    /**
-     * @return array<non-empty-string,Set<non-empty-string>>
-     */
-    private function formatErrors(ValidationResult $validationResult): array
-    {
-        $error = $validationResult->error();
-
-        if (empty($error)) {
-            return [];
-        }
-
-        /**
-         * @var list<array{
-         *     message: non-empty-string,
-         *     keywords: list<non-empty-string>,
-         *     path: non-empty-string,
-         * }>
-         */
-        $nestedFormat = [];
-
-        $this->errorFormatter->formatNested(
-            error: $error,
-            formatter: function (ValidationError $validationError) use (&$nestedFormat) {
-                $fullPath = implode('.', $validationError->data()->fullPath());
-
-                /**
-                 * @var array<non-empty-string> $keywords
-                 */
-                $keywords = [
-                    $validationError->keyword(),
-                ];
-
-                $args = $validationError->args();
-
-                /**
-                 * @var non-empty-string $keyword
-                 * @var list<string>     $paths
-                 */
-                foreach ($args as $keyword => $paths) {
-                    $keywords[] = $keyword;
-
-                    foreach ($paths as $path) {
-                        if (empty($fullPath)) {
-                            $fullPath = $path;
-                        } else {
-                            throw new RuntimeException('Ambigous error path');
-                        }
-                    }
-                }
-
-                if (empty($fullPath)) {
-                    throw new RuntimeException('Unable to determine error path');
-                }
-
-                $nestedFormat[] = [
-                    'message' => $this->errorFormatter->formatErrorMessage($validationError),
-                    'keywords' => $keywords,
-                    'path' => $fullPath,
-                ];
-            },
-        );
-
-        /**
-         * @var array<non-empty-string,Set<non-empty-string>>
-         */
-        $ret = [];
-
-        foreach ($nestedFormat as $error) {
-            if (!isset($ret[$error['path']])) {
-                /**
-                 * @var Set<non-empty-string>
-                 */
-                $ret[$error['path']] = new Set();
-            }
-
-            foreach ($error['keywords'] as $keyword) {
-                $ret[$error['path']]->add($keyword);
-            }
-        }
-
-        return $ret;
-    }
-}
diff --git a/src/JsonSchemableInterface.php b/src/JsonSchemableInterface.php
index 7fe349fe..994f5bdf 100644
--- a/src/JsonSchemableInterface.php
+++ b/src/JsonSchemableInterface.php
@@ -4,17 +4,19 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use JsonSerializable;
 use stdClass;
 
 /**
  * @psalm-type PJsonSchema = stdClass|array{
+ *     anyOf?: list<array>,
  *     const?: int|float|non-empty-string,
  *     default?: mixed,
  *     type?: non-empty-string|list<non-empty-string>,
  *     ...
  * }
  */
-interface JsonSchemableInterface
+interface JsonSchemableInterface extends JsonSerializable
 {
     /**
      * @return PJsonSchema
diff --git a/src/OpenAPIMetadataResponseExtractor/RespondsWithExtractor.php b/src/OpenAPIMetadataResponseExtractor/RespondsWithExtractor.php
index 3e9d2870..c35c3d26 100644
--- a/src/OpenAPIMetadataResponseExtractor/RespondsWithExtractor.php
+++ b/src/OpenAPIMetadataResponseExtractor/RespondsWithExtractor.php
@@ -29,7 +29,7 @@ readonly class RespondsWithExtractor extends OpenAPIMetadataResponseExtractor
                 contentType: $attribute->contentType,
                 description: $attribute->description,
                 status: $attribute->status,
-                jsonSchema: $attribute->jsonSchema,
+                jsonSchemable: $attribute->constraint,
             ),
         ];
     }
diff --git a/src/OpenAPIReusableSchemaCollection.php b/src/OpenAPIReusableSchemaCollection.php
index 4a35df5e..1b67a454 100644
--- a/src/OpenAPIReusableSchemaCollection.php
+++ b/src/OpenAPIReusableSchemaCollection.php
@@ -15,7 +15,7 @@ readonly class OpenAPIReusableSchemaCollection
     public Map $hashes;
 
     /**
-     * @var Map<JsonSchema,non-empty-string>
+     * @var Map<JsonSchemableInterface,non-empty-string>
      */
     public Map $references;
 
@@ -25,9 +25,9 @@ readonly class OpenAPIReusableSchemaCollection
         $this->references = new Map();
     }
 
-    public function reuse(JsonSchema $jsonSchema): JsonSchema
+    public function reuse(JsonSchemableInterface $jsonSchemable): array
     {
-        $hashed = $this->makeHash($jsonSchema);
+        $hashed = $this->makeHash($jsonSchemable);
 
         if (!$this->hashes->hasKey($hashed)) {
             $this->hashes->put($hashed, uniqid());
@@ -35,25 +35,25 @@ readonly class OpenAPIReusableSchemaCollection
 
         $schemaId = $this->hashes->get($hashed);
 
-        $this->references->put($jsonSchema, $schemaId);
+        $this->references->put($jsonSchemable, $schemaId);
 
-        return new JsonSchema([
+        return [
             '$ref' => sprintf(
                 '#/components/schemas/%s',
                 $schemaId,
             ),
-        ]);
+        ];
     }
 
     /**
      * @return non-empty-string
      */
-    private function makeHash(JsonSchema $jsonSchema): string
+    private function makeHash(JsonSchemableInterface $jsonSchemable): string
     {
-        $serialized = serialize($jsonSchema->schema);
+        $serialized = serialize($jsonSchemable->toJsonSchema());
 
         if (empty($serialized)) {
-            throw new RuntimeException('Unable to serialize JsonSchema');
+            throw new RuntimeException('Unable to serialize JsonSchemableInterface');
         }
 
         return $serialized;
diff --git a/src/OpenAPIRouteParameterExtractor/DoctrineEntityRouteParameterExtractor.php b/src/OpenAPIRouteParameterExtractor/DoctrineEntityRouteParameterExtractor.php
index d1b5320d..0ba8bc53 100644
--- a/src/OpenAPIRouteParameterExtractor/DoctrineEntityRouteParameterExtractor.php
+++ b/src/OpenAPIRouteParameterExtractor/DoctrineEntityRouteParameterExtractor.php
@@ -8,9 +8,11 @@ use Distantmagic\Resonance\Attribute;
 use Distantmagic\Resonance\Attribute\DoctrineEntityRouteParameter;
 use Distantmagic\Resonance\Attribute\ExtractsOpenAPIRouteParameter;
 use Distantmagic\Resonance\Attribute\Singleton;
+use Distantmagic\Resonance\Constraint\IntegerConstraint;
+use Distantmagic\Resonance\Constraint\StringConstraint;
 use Distantmagic\Resonance\DoctrineAttributeDriver;
 use Distantmagic\Resonance\DoctrineEntityManagerRepository;
-use Distantmagic\Resonance\JsonSchema;
+use Distantmagic\Resonance\JsonSchemableInterface;
 use Distantmagic\Resonance\OpenAPIParameterIn;
 use Distantmagic\Resonance\OpenAPIRouteParameterExtractor;
 use Distantmagic\Resonance\OpenAPISchemaParameter;
@@ -26,21 +28,10 @@ use RuntimeException;
 #[Singleton(collection: SingletonCollection::OpenAPIRouteParameterExtractor)]
 readonly class DoctrineEntityRouteParameterExtractor extends OpenAPIRouteParameterExtractor
 {
-    private JsonSchema $jsonSchemaInteger;
-    private JsonSchema $jsonSchemaString;
-
     public function __construct(
         private DoctrineAttributeDriver $doctrineAttributeDriver,
         private DoctrineEntityManagerRepository $doctrineEntityManagerRepository,
-    ) {
-        $this->jsonSchemaInteger = new JsonSchema([
-            'type' => 'integer',
-        ]);
-        $this->jsonSchemaString = new JsonSchema([
-            'minLength' => 1,
-            'type' => 'string',
-        ]);
-    }
+    ) {}
 
     /**
      * Unfortunately to extract the metadata factory we need an instance of
@@ -84,7 +75,7 @@ readonly class DoctrineEntityRouteParameterExtractor extends OpenAPIRouteParamet
             in: OpenAPIParameterIn::Path,
             name: $attribute->from,
             required: true,
-            jsonSchema: $this->jsonSchemaFromFieldType($parameterFieldType),
+            jsonSchemable: $this->jsonSchemableFromFieldType($parameterFieldType),
         );
 
         return [
@@ -92,11 +83,11 @@ readonly class DoctrineEntityRouteParameterExtractor extends OpenAPIRouteParamet
         ];
     }
 
-    private function jsonSchemaFromFieldType(string $fieldType): JsonSchema
+    private function jsonSchemableFromFieldType(string $fieldType): JsonSchemableInterface
     {
         return match ($fieldType) {
-            'integer' => $this->jsonSchemaInteger,
-            'string' => $this->jsonSchemaString,
+            'integer' => new IntegerConstraint(),
+            'string' => new StringConstraint(),
             default => throw new LogicException(sprintf(
                 'Unsupported Doctrine field type: "%s"',
                 $fieldType,
diff --git a/src/OpenAPIRouteRequestBodyContentExtractor/ValidatedRequestExtractor.php b/src/OpenAPIRouteRequestBodyContentExtractor/ValidatedRequestExtractor.php
index 4bbdb52a..6fa4b0cc 100644
--- a/src/OpenAPIRouteRequestBodyContentExtractor/ValidatedRequestExtractor.php
+++ b/src/OpenAPIRouteRequestBodyContentExtractor/ValidatedRequestExtractor.php
@@ -32,11 +32,11 @@ readonly class ValidatedRequestExtractor extends OpenAPIRouteRequestBodyContentE
         return [
             new OpenAPISchemaRequestBodyContent(
                 mimeType: 'application/x-www-form-urlencoded',
-                jsonSchema: $this
+                jsonSchemable: $this
                     ->inputValidatorCollection
                     ->inputValidators
                     ->get($attribute->validator)
-                    ->getSchema(),
+                    ->getConstraint(),
             ),
         ];
     }
diff --git a/src/OpenAPISchemaComponents.php b/src/OpenAPISchemaComponents.php
index 2093d9c1..1985bd19 100644
--- a/src/OpenAPISchemaComponents.php
+++ b/src/OpenAPISchemaComponents.php
@@ -8,7 +8,7 @@ use stdClass;
 
 /**
  * @template-implements OpenAPISerializableFieldInterface<array{
- *     schemas: object|array<non-empty-string,JsonSchema>,
+ *     schemas: object|array<non-empty-string,JsonSchemableInterface>,
  *     securitySchemes: OpenAPISchemaComponentsSecuritySchemes,
  * }>
  */
@@ -27,12 +27,12 @@ readonly class OpenAPISchemaComponents implements OpenAPISerializableFieldInterf
     }
 
     /**
-     * @return array<non-empty-string,JsonSchema>|object
+     * @return array<non-empty-string,JsonSchemableInterface>|object
      */
     private function serializeSchemaCollection(OpenAPIReusableSchemaCollection $openAPIReusableSchemaCollection): array|object
     {
         /**
-         * @var array<non-empty-string,JsonSchema> $schemas
+         * @var array<non-empty-string,JsonSchemableInterface> $schemas
          */
         $schemas = [];
 
diff --git a/src/OpenAPISchemaParameter.php b/src/OpenAPISchemaParameter.php
index cc231ee3..5f7ff5ae 100644
--- a/src/OpenAPISchemaParameter.php
+++ b/src/OpenAPISchemaParameter.php
@@ -10,7 +10,7 @@ readonly class OpenAPISchemaParameter implements JsonSerializable
 {
     public function __construct(
         private OpenAPIParameterIn $in,
-        private JsonSchema $jsonSchema,
+        private JsonSchemableInterface $jsonSchemable,
         private string $name,
         private bool $required,
     ) {}
@@ -21,7 +21,7 @@ readonly class OpenAPISchemaParameter implements JsonSerializable
             'in' => $this->in->value,
             'name' => $this->name,
             'required' => $this->required,
-            'schema' => $this->jsonSchema,
+            'schema' => $this->jsonSchemable,
         ];
     }
 }
diff --git a/src/OpenAPISchemaRequestBodyContent.php b/src/OpenAPISchemaRequestBodyContent.php
index 853ff872..33afab25 100644
--- a/src/OpenAPISchemaRequestBodyContent.php
+++ b/src/OpenAPISchemaRequestBodyContent.php
@@ -6,7 +6,7 @@ namespace Distantmagic\Resonance;
 
 /**
  * @psalm-type PArraySerializedOpenAPISchemaRequestBodyContent = array{
- *     schema: JsonSchema
+ *     schema: array
  * }
  *
  * @template-implements OpenAPISerializableFieldInterface<PArraySerializedOpenAPISchemaRequestBodyContent>
@@ -18,13 +18,13 @@ readonly class OpenAPISchemaRequestBodyContent implements OpenAPISerializableFie
      */
     public function __construct(
         public string $mimeType,
-        public JsonSchema $jsonSchema,
+        public JsonSchemableInterface $jsonSchemable,
     ) {}
 
     public function toArray(OpenAPIReusableSchemaCollection $openAPIReusableSchemaCollection): array
     {
         return [
-            'schema' => $openAPIReusableSchemaCollection->reuse($this->jsonSchema),
+            'schema' => $openAPIReusableSchemaCollection->reuse($this->jsonSchemable),
         ];
     }
 }
diff --git a/src/OpenAPISchemaResponse.php b/src/OpenAPISchemaResponse.php
index 63a11eca..cdaf2aad 100644
--- a/src/OpenAPISchemaResponse.php
+++ b/src/OpenAPISchemaResponse.php
@@ -7,7 +7,7 @@ namespace Distantmagic\Resonance;
 /**
  * @psalm-type PArraySerializedOpenAPISchemaResponse = array{
  *     description?: non-empty-string,
- *     content: array<string, array{ schema: JsonSchema }>
+ *     content: array<string, array{ schema: array }>
  * }
  *
  * @template-implements OpenAPISerializableFieldInterface<PArraySerializedOpenAPISchemaResponse>
@@ -19,7 +19,7 @@ readonly class OpenAPISchemaResponse implements OpenAPISerializableFieldInterfac
      */
     public function __construct(
         public ContentType $contentType,
-        public JsonSchema $jsonSchema,
+        public JsonSchemableInterface $jsonSchemable,
         public int $status,
         public ?string $description = null,
     ) {}
@@ -34,7 +34,7 @@ readonly class OpenAPISchemaResponse implements OpenAPISerializableFieldInterfac
 
         $response['content'] = [
             $this->contentType->value => [
-                'schema' => $openAPIReusableSchemaCollection->reuse($this->jsonSchema),
+                'schema' => $openAPIReusableSchemaCollection->reuse($this->jsonSchemable),
             ],
         ];
 
diff --git a/src/SingletonProvider/ConfigurationProvider/ApplicationConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/ApplicationConfigurationProvider.php
index 8fb7c2ae..a85ab7a6 100644
--- a/src/SingletonProvider/ConfigurationProvider/ApplicationConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/ApplicationConfigurationProvider.php
@@ -27,14 +27,12 @@ final readonly class ApplicationConfigurationProvider extends ConfigurationProvi
 {
     public function getConstraint(): Constraint
     {
-        return new ObjectConstraint(
-            properties: [
-                'env' => new EnumConstraint(Environment::values()),
-                'esbuild_metafile' => (new StringConstraint())->default('esbuild-meta.json'),
-                'scheme' => (new EnumConstraint(['http', 'https']))->default('https'),
-                'url' => new StringConstraint(),
-            ],
-        );
+        return new ObjectConstraint([
+            'env' => new EnumConstraint(Environment::values()),
+            'esbuild_metafile' => (new StringConstraint())->default('esbuild-meta.json'),
+            'scheme' => (new EnumConstraint(['http', 'https']))->default('https'),
+            'url' => new StringConstraint(),
+        ]);
     }
 
     protected function getConfigurationKey(): string
diff --git a/src/SingletonProvider/ConfigurationProvider/DatabaseConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/DatabaseConfigurationProvider.php
index a3353715..ca419170 100644
--- a/src/SingletonProvider/ConfigurationProvider/DatabaseConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/DatabaseConfigurationProvider.php
@@ -39,20 +39,18 @@ final readonly class DatabaseConfigurationProvider extends ConfigurationProvider
 {
     public function getConstraint(): Constraint
     {
-        $valueConstraint = new ObjectConstraint(
-            properties: [
-                'database' => new StringConstraint(),
-                'driver' => new EnumConstraint(DatabaseConnectionPoolDriverName::values()),
-                'host' => (new StringConstraint())->default(null),
-                'log_queries' => new BooleanConstraint(),
-                'password' => (new StringConstraint())->nullable(),
-                'pool_prefill' => (new BooleanConstraint())->default(true),
-                'pool_size' => new IntegerConstraint(),
-                'port' => (new IntegerConstraint())->nullable()->default(3306),
-                'unix_socket' => (new StringConstraint())->nullable(),
-                'username' => new StringConstraint(),
-            ],
-        );
+        $valueConstraint = new ObjectConstraint([
+            'database' => new StringConstraint(),
+            'driver' => new EnumConstraint(DatabaseConnectionPoolDriverName::values()),
+            'host' => (new StringConstraint())->default(null),
+            'log_queries' => new BooleanConstraint(),
+            'password' => (new StringConstraint())->nullable(),
+            'pool_prefill' => (new BooleanConstraint())->default(true),
+            'pool_size' => new IntegerConstraint(),
+            'port' => (new IntegerConstraint())->nullable()->default(3306),
+            'unix_socket' => (new StringConstraint())->nullable(),
+            'username' => new StringConstraint(),
+        ]);
 
         return new MapConstraint(valueConstraint: $valueConstraint);
     }
diff --git a/src/SingletonProvider/ConfigurationProvider/LlamaCppConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/LlamaCppConfigurationProvider.php
index 0f7f0b4c..4ca3224e 100644
--- a/src/SingletonProvider/ConfigurationProvider/LlamaCppConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/LlamaCppConfigurationProvider.php
@@ -28,15 +28,13 @@ final readonly class LlamaCppConfigurationProvider extends ConfigurationProvider
 {
     public function getConstraint(): Constraint
     {
-        return new ObjectConstraint(
-            properties: [
-                'api_key' => (new StringConstraint())->default(null),
-                'completion_token_timeout' => (new NumberConstraint())->default(1.0),
-                'host' => new StringConstraint(),
-                'port' => new IntegerConstraint(),
-                'scheme' => (new EnumConstraint(['http', 'https']))->default('http'),
-            ],
-        );
+        return new ObjectConstraint([
+            'api_key' => (new StringConstraint())->default(null),
+            'completion_token_timeout' => (new NumberConstraint())->default(1.0),
+            'host' => new StringConstraint(),
+            'port' => new IntegerConstraint(),
+            'scheme' => (new EnumConstraint(['http', 'https']))->default('http'),
+        ]);
     }
 
     protected function getConfigurationKey(): string
diff --git a/src/SingletonProvider/ConfigurationProvider/MailerConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/MailerConfigurationProvider.php
index 4f09ae76..0a59a230 100644
--- a/src/SingletonProvider/ConfigurationProvider/MailerConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/MailerConfigurationProvider.php
@@ -31,16 +31,14 @@ final readonly class MailerConfigurationProvider extends ConfigurationProvider
 {
     public function getConstraint(): Constraint
     {
-        $valueConstraint = new ObjectConstraint(
-            properties: [
-                'dkim_domain_name' => (new StringConstraint())->nullable(),
-                'dkim_selector' => (new StringConstraint())->nullable(),
-                'dkim_signing_key_passphrase' => (new StringConstraint())->nullable(),
-                'dkim_signing_key_private' => (new StringConstraint())->nullable(),
-                'dkim_signing_key_public' => (new StringConstraint())->nullable(),
-                'transport_dsn' => new StringConstraint(),
-            ],
-        );
+        $valueConstraint = new ObjectConstraint([
+            'dkim_domain_name' => (new StringConstraint())->nullable(),
+            'dkim_selector' => (new StringConstraint())->nullable(),
+            'dkim_signing_key_passphrase' => (new StringConstraint())->nullable(),
+            'dkim_signing_key_private' => (new StringConstraint())->nullable(),
+            'dkim_signing_key_public' => (new StringConstraint())->nullable(),
+            'transport_dsn' => new StringConstraint(),
+        ]);
 
         return new MapConstraint(valueConstraint: $valueConstraint);
     }
diff --git a/src/SingletonProvider/ConfigurationProvider/OAuth2ConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/OAuth2ConfigurationProvider.php
index 8df863e2..6590cc91 100644
--- a/src/SingletonProvider/ConfigurationProvider/OAuth2ConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/OAuth2ConfigurationProvider.php
@@ -34,17 +34,15 @@ final readonly class OAuth2ConfigurationProvider extends ConfigurationProvider
 {
     public function getConstraint(): Constraint
     {
-        return new ObjectConstraint(
-            properties: [
-                'encryption_key' => new StringConstraint(),
-                'jwt_signing_key_passphrase' => (new StringConstraint())->nullable(),
-                'jwt_signing_key_private' => new StringConstraint(),
-                'jwt_signing_key_public' => new StringConstraint(),
-                'session_key_authorization_request' => (new StringConstraint())->default('oauth2.authorization_request'),
-                'session_key_pkce' => (new StringConstraint())->default('oauth2.pkce'),
-                'session_key_state' => (new StringConstraint())->default('oauth2.state'),
-            ],
-        );
+        return new ObjectConstraint([
+            'encryption_key' => new StringConstraint(),
+            'jwt_signing_key_passphrase' => (new StringConstraint())->nullable(),
+            'jwt_signing_key_private' => new StringConstraint(),
+            'jwt_signing_key_public' => new StringConstraint(),
+            'session_key_authorization_request' => (new StringConstraint())->default('oauth2.authorization_request'),
+            'session_key_pkce' => (new StringConstraint())->default('oauth2.pkce'),
+            'session_key_state' => (new StringConstraint())->default('oauth2.state'),
+        ]);
     }
 
     protected function getConfigurationKey(): string
diff --git a/src/SingletonProvider/ConfigurationProvider/OpenAPIConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/OpenAPIConfigurationProvider.php
index d779e780..e97a0690 100644
--- a/src/SingletonProvider/ConfigurationProvider/OpenAPIConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/OpenAPIConfigurationProvider.php
@@ -23,13 +23,11 @@ final readonly class OpenAPIConfigurationProvider extends ConfigurationProvider
 {
     public function getConstraint(): Constraint
     {
-        return new ObjectConstraint(
-            properties: [
-                'description' => new StringConstraint(),
-                'title' => new StringConstraint(),
-                'version' => new StringConstraint(),
-            ],
-        );
+        return new ObjectConstraint([
+            'description' => new StringConstraint(),
+            'title' => new StringConstraint(),
+            'version' => new StringConstraint(),
+        ]);
     }
 
     protected function getConfigurationKey(): string
diff --git a/src/SingletonProvider/ConfigurationProvider/RedisConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/RedisConfigurationProvider.php
index e05c94de..98fb15d7 100644
--- a/src/SingletonProvider/ConfigurationProvider/RedisConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/RedisConfigurationProvider.php
@@ -35,18 +35,16 @@ final readonly class RedisConfigurationProvider extends ConfigurationProvider
 {
     public function getConstraint(): Constraint
     {
-        $valueConstraint = new ObjectConstraint(
-            properties: [
-                'db_index' => new IntegerConstraint(),
-                'host' => new StringConstraint(),
-                'password' => (new StringConstraint())->nullable(),
-                'pool_prefill' => (new BooleanConstraint())->default(true),
-                'pool_size' => new IntegerConstraint(),
-                'port' => new IntegerConstraint(),
-                'prefix' => new StringConstraint(),
-                'timeout' => new IntegerConstraint(),
-            ],
-        );
+        $valueConstraint = new ObjectConstraint([
+            'db_index' => new IntegerConstraint(),
+            'host' => new StringConstraint(),
+            'password' => (new StringConstraint())->nullable(),
+            'pool_prefill' => (new BooleanConstraint())->default(true),
+            'pool_size' => new IntegerConstraint(),
+            'port' => new IntegerConstraint(),
+            'prefix' => new StringConstraint(),
+            'timeout' => new IntegerConstraint(),
+        ]);
 
         return new MapConstraint(valueConstraint: $valueConstraint);
     }
diff --git a/src/SingletonProvider/ConfigurationProvider/SQLiteVSSConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/SQLiteVSSConfigurationProvider.php
index e7ab93b4..88da3f84 100644
--- a/src/SingletonProvider/ConfigurationProvider/SQLiteVSSConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/SQLiteVSSConfigurationProvider.php
@@ -22,12 +22,10 @@ final readonly class SQLiteVSSConfigurationProvider extends ConfigurationProvide
 {
     public function getConstraint(): Constraint
     {
-        return new ObjectConstraint(
-            properties: [
-                'extension_vector0' => new StringConstraint(),
-                'extension_vss0' => new StringConstraint(),
-            ]
-        );
+        return new ObjectConstraint([
+            'extension_vector0' => new StringConstraint(),
+            'extension_vss0' => new StringConstraint(),
+        ]);
     }
 
     protected function getConfigurationKey(): string
diff --git a/src/SingletonProvider/ConfigurationProvider/SessionConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/SessionConfigurationProvider.php
index c96ea49b..bd8f5767 100644
--- a/src/SingletonProvider/ConfigurationProvider/SessionConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/SessionConfigurationProvider.php
@@ -42,14 +42,12 @@ final readonly class SessionConfigurationProvider extends ConfigurationProvider
             ->toArray()
         ;
 
-        return new ObjectConstraint(
-            properties: [
-                'cookie_lifespan' => new IntegerConstraint(),
-                'cookie_name' => new StringConstraint(),
-                'cookie_samesite' => (new EnumConstraint(['lax', 'none', 'strict']))->default('lax'),
-                'redis_connection_pool' => new EnumConstraint($redisConnectionPools),
-            ],
-        );
+        return new ObjectConstraint([
+            'cookie_lifespan' => new IntegerConstraint(),
+            'cookie_name' => new StringConstraint(),
+            'cookie_samesite' => (new EnumConstraint(['lax', 'none', 'strict']))->default('lax'),
+            'redis_connection_pool' => new EnumConstraint($redisConnectionPools),
+        ]);
     }
 
     protected function getConfigurationKey(): string
diff --git a/src/SingletonProvider/ConfigurationProvider/StaticPageConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/StaticPageConfigurationProvider.php
index 5f3fca8f..90940c99 100644
--- a/src/SingletonProvider/ConfigurationProvider/StaticPageConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/StaticPageConfigurationProvider.php
@@ -25,15 +25,13 @@ final readonly class StaticPageConfigurationProvider extends ConfigurationProvid
 {
     public function getConstraint(): Constraint
     {
-        return new ObjectConstraint(
-            properties: [
-                'base_url' => new StringConstraint(),
-                'esbuild_metafile' => new StringConstraint(),
-                'input_directory' => new StringConstraint(),
-                'output_directory' => new StringConstraint(),
-                'sitemap' => new StringConstraint(),
-            ],
-        );
+        return new ObjectConstraint([
+            'base_url' => new StringConstraint(),
+            'esbuild_metafile' => new StringConstraint(),
+            'input_directory' => new StringConstraint(),
+            'output_directory' => new StringConstraint(),
+            'sitemap' => new StringConstraint(),
+        ]);
     }
 
     protected function getConfigurationKey(): string
diff --git a/src/SingletonProvider/ConfigurationProvider/SwooleConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/SwooleConfigurationProvider.php
index deb95764..a819cf6e 100644
--- a/src/SingletonProvider/ConfigurationProvider/SwooleConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/SwooleConfigurationProvider.php
@@ -29,17 +29,15 @@ final readonly class SwooleConfigurationProvider extends ConfigurationProvider
 {
     public function getConstraint(): Constraint
     {
-        return new ObjectConstraint(
-            properties: [
-                'host' => new StringConstraint(),
-                'log_level' => new IntegerConstraint(),
-                'log_requests' => (new BooleanConstraint())->default(false),
-                'port' => new IntegerConstraint(),
-                'ssl_cert_file' => new StringConstraint(),
-                'ssl_key_file' => new StringConstraint(),
-                'task_worker_num' => (new IntegerConstraint())->default(4),
-            ],
-        );
+        return new ObjectConstraint([
+            'host' => new StringConstraint(),
+            'log_level' => new IntegerConstraint(),
+            'log_requests' => (new BooleanConstraint())->default(false),
+            'port' => new IntegerConstraint(),
+            'ssl_cert_file' => new StringConstraint(),
+            'ssl_key_file' => new StringConstraint(),
+            'task_worker_num' => (new IntegerConstraint())->default(4),
+        ]);
     }
 
     protected function getConfigurationKey(): string
diff --git a/src/SingletonProvider/ConfigurationProvider/TranslatorConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/TranslatorConfigurationProvider.php
index f090d176..fc5119bb 100644
--- a/src/SingletonProvider/ConfigurationProvider/TranslatorConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/TranslatorConfigurationProvider.php
@@ -22,12 +22,10 @@ final readonly class TranslatorConfigurationProvider extends ConfigurationProvid
 {
     public function getConstraint(): Constraint
     {
-        return new ObjectConstraint(
-            properties: [
-                'base_directory' => new StringConstraint(),
-                'default_primary_language' => new StringConstraint(),
-            ],
-        );
+        return new ObjectConstraint([
+            'base_directory' => new StringConstraint(),
+            'default_primary_language' => new StringConstraint(),
+        ]);
     }
 
     protected function getConfigurationKey(): string
diff --git a/src/SingletonProvider/ConfigurationProvider/WebSocketConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/WebSocketConfigurationProvider.php
index 40540e10..fd3bbb13 100644
--- a/src/SingletonProvider/ConfigurationProvider/WebSocketConfigurationProvider.php
+++ b/src/SingletonProvider/ConfigurationProvider/WebSocketConfigurationProvider.php
@@ -24,19 +24,14 @@ final readonly class WebSocketConfigurationProvider extends ConfigurationProvide
 {
     public function getConstraint(): Constraint
     {
-        return new ObjectConstraint(
-            // 'minimum' => 1,
-            // 'maximum' => 65535,
-            // 'default' => 10000,
-            properties: [
-                'max_connections' => (new IntegerConstraint())->default(10000),
-            ],
-        );
+        return new ObjectConstraint([
+            'max_connections' => (new IntegerConstraint())->default(10000),
+        ]);
     }
 
     protected function getConfigurationKey(): string
     {
-        return 'swoole';
+        return 'websocket';
     }
 
     protected function provideConfiguration($validatedData): WebSocketConfiguration
diff --git a/src/WebSocketProtocolController/RPCProtocolController.php b/src/WebSocketProtocolController/RPCProtocolController.php
index b47cfb0d..0510b25a 100644
--- a/src/WebSocketProtocolController/RPCProtocolController.php
+++ b/src/WebSocketProtocolController/RPCProtocolController.php
@@ -8,13 +8,12 @@ use Distantmagic\Resonance\Attribute\ControlsWebSocketProtocol;
 use Distantmagic\Resonance\Attribute\GrantsFeature;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Distantmagic\Resonance\AuthenticatedUserStoreAggregate;
+use Distantmagic\Resonance\ConstraintResultErrorMessage;
 use Distantmagic\Resonance\CSRFManager;
 use Distantmagic\Resonance\Feature;
 use Distantmagic\Resonance\Gatekeeper;
 use Distantmagic\Resonance\InputValidator\RPCMessageValidator;
 use Distantmagic\Resonance\InputValidatorController;
-use Distantmagic\Resonance\JsonSchemaValidationErrorMessage;
-use Distantmagic\Resonance\JsonSchemaValidator;
 use Distantmagic\Resonance\JsonSerializer;
 use Distantmagic\Resonance\SingletonCollection;
 use Distantmagic\Resonance\SiteAction;
@@ -51,7 +50,6 @@ final readonly class RPCProtocolController extends WebSocketProtocolController
         private AuthenticatedUserStoreAggregate $authenticatedUserSourceAggregate,
         private Gatekeeper $gatekeeper,
         private InputValidatorController $inputValidatorController,
-        private JsonSchemaValidator $jsonSchemaValidator,
         private JsonSerializer $jsonSerializer,
         private LoggerInterface $logger,
         private RPCMessageValidator $rpcMessageValidator,
@@ -124,7 +122,6 @@ final readonly class RPCProtocolController extends WebSocketProtocolController
     {
         $webSocketConnection = new WebSocketConnection($server, $fd);
         $connectionHandle = new WebSocketRPCConnectionHandle(
-            $this->jsonSchemaValidator,
             $this->webSocketRPCResponderAggregate,
             $webSocketAuthResolution,
             $webSocketConnection,
@@ -168,16 +165,16 @@ final readonly class RPCProtocolController extends WebSocketProtocolController
             return;
         }
 
-        $payloadValidationResult = $this
+        $payloadConstraintResult = $this
             ->getFrameController($frame)
             ->onRPCMessage($rpcMessageValidationResult->inputValidatedData)
         ;
 
-        if (!empty($payloadValidationResult->errors)) {
+        if (!$payloadConstraintResult->status->isValid()) {
             $this->onProtocolError(
                 $server,
                 $frame,
-                (string) new JsonSchemaValidationErrorMessage($payloadValidationResult->errors),
+                (string) new ConstraintResultErrorMessage('rpc_protocol_controller', $payloadConstraintResult),
             );
         }
     }
diff --git a/src/WebSocketRPCConnectionHandle.php b/src/WebSocketRPCConnectionHandle.php
index 27a2b084..022c632e 100644
--- a/src/WebSocketRPCConnectionHandle.php
+++ b/src/WebSocketRPCConnectionHandle.php
@@ -15,7 +15,6 @@ readonly class WebSocketRPCConnectionHandle
     private Set $activeResponders;
 
     public function __construct(
-        public JsonSchemaValidator $jsonSchemaValidator,
         public WebSocketRPCResponderAggregate $webSocketRPCResponderAggregate,
         public WebSocketAuthResolution $webSocketAuthResolution,
         public WebSocketConnection $webSocketConnection,
@@ -37,20 +36,20 @@ readonly class WebSocketRPCConnectionHandle
         }
     }
 
-    public function onRPCMessage(RPCMessage $rpcMessage): JsonSchemaValidationResult
+    public function onRPCMessage(RPCMessage $rpcMessage): ConstraintResult
     {
         $responder = $this
             ->webSocketRPCResponderAggregate
             ->selectResponder($rpcMessage)
         ;
 
-        $jsonSchemaValidationResult = $this
-            ->jsonSchemaValidator
-            ->validate($responder, $rpcMessage->payload)
+        $constraintResult = $responder
+            ->getConstraint()
+            ->validate($rpcMessage->payload)
         ;
 
-        if (!empty($jsonSchemaValidationResult->errors)) {
-            return $jsonSchemaValidationResult;
+        if ($constraintResult->status->isValid()) {
+            return $constraintResult;
         }
 
         $this->activeResponders->add($responder);
@@ -66,7 +65,7 @@ readonly class WebSocketRPCConnectionHandle
                 $this->webSocketConnection,
                 new RPCRequest(
                     $rpcMessage->method,
-                    $jsonSchemaValidationResult->data,
+                    $constraintResult->castedData,
                     $rpcMessage->requestId,
                 ),
             );
@@ -76,11 +75,11 @@ readonly class WebSocketRPCConnectionHandle
                 $this->webSocketConnection,
                 new RPCNotification(
                     $rpcMessage->method,
-                    $jsonSchemaValidationResult->data,
+                    $constraintResult->castedData,
                 )
             );
         }
 
-        return $jsonSchemaValidationResult;
+        return $constraintResult;
     }
 }
diff --git a/src/WebSocketRPCResponderInterface.php b/src/WebSocketRPCResponderInterface.php
index e88f71b2..904a7454 100644
--- a/src/WebSocketRPCResponderInterface.php
+++ b/src/WebSocketRPCResponderInterface.php
@@ -7,7 +7,7 @@ namespace Distantmagic\Resonance;
 /**
  * @template TPayload
  */
-interface WebSocketRPCResponderInterface extends JsonSchemaSourceInterface
+interface WebSocketRPCResponderInterface extends ConstraintSourceInterface
 {
     public function onBeforeMessage(
         WebSocketAuthResolution $webSocketAuthResolution,
-- 
GitLab