From f8c4b5dc756cda12a2847c79c94a4c9868c983cd Mon Sep 17 00:00:00 2001 From: Lucas Michot Date: Wed, 3 Sep 2025 23:41:52 +0200 Subject: [PATCH 1/6] Introduce as JsonSchema validation rule --- .../Concerns/ValidatesAttributes.php | 21 +++ src/Illuminate/Validation/Rule.php | 13 ++ .../Validation/Rules/JsonSchema.php | 118 ++++++++++++ src/Illuminate/Validation/composer.json | 1 + tests/Validation/JsonSchemaRuleTest.php | 171 ++++++++++++++++++ 5 files changed, 324 insertions(+) create mode 100644 src/Illuminate/Validation/Rules/JsonSchema.php create mode 100644 tests/Validation/JsonSchemaRuleTest.php diff --git a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php index a33a3c17c84d..6c62fefdc11c 100644 --- a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php +++ b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php @@ -17,12 +17,14 @@ use Exception; use Illuminate\Container\Container; use Illuminate\Database\Eloquent\Model; +use Illuminate\JsonSchema\Types\Type; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Exceptions\MathException; use Illuminate\Support\Facades\Date; use Illuminate\Support\Str; use Illuminate\Validation\Rules\Exists; +use Illuminate\Validation\Rules\JsonSchema as JsonSchemaRule; use Illuminate\Validation\Rules\Unique; use Illuminate\Validation\ValidationData; use InvalidArgumentException; @@ -1633,6 +1635,25 @@ public function validateJson($attribute, $value) return json_validate($value); } + /** + * Validate that an attribute matches a JSON schema. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateJsonSchema($attribute, $value, $parameters): bool + { + if (! isset($parameters[0]) || ! $parameters[0] instanceof Type) { + throw new InvalidArgumentException('The json_schema rule requires a JsonSchema Type instance.'); + } + + $rule = new JsonSchemaRule($parameters[0]); + + return $rule->passes($attribute, $value); + } + /** * Validate the size of an attribute is less than or equal to a maximum value. * diff --git a/src/Illuminate/Validation/Rule.php b/src/Illuminate/Validation/Rule.php index c98fc64e8d95..d48995ecd481 100644 --- a/src/Illuminate/Validation/Rule.php +++ b/src/Illuminate/Validation/Rule.php @@ -3,6 +3,7 @@ namespace Illuminate\Validation; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\JsonSchema\Types\Type; use Illuminate\Support\Arr; use Illuminate\Support\Traits\Macroable; use Illuminate\Validation\Rules\AnyOf; @@ -17,6 +18,7 @@ use Illuminate\Validation\Rules\File; use Illuminate\Validation\Rules\ImageFile; use Illuminate\Validation\Rules\In; +use Illuminate\Validation\Rules\JsonSchema as JsonSchemaRule; use Illuminate\Validation\Rules\NotIn; use Illuminate\Validation\Rules\Numeric; use Illuminate\Validation\Rules\ProhibitedIf; @@ -247,6 +249,17 @@ public static function numeric() return new Numeric; } + /** + * Create a JSON Schema validation rule. + * + * @param \Illuminate\JsonSchema\Types\Type $schema + * @return \Illuminate\Validation\Rules\JsonSchema + */ + public static function jsonSchema(Type $schema) + { + return new JsonSchemaRule($schema); + } + /** * Get an "any of" rule builder instance. * diff --git a/src/Illuminate/Validation/Rules/JsonSchema.php b/src/Illuminate/Validation/Rules/JsonSchema.php new file mode 100644 index 000000000000..2be5592c529d --- /dev/null +++ b/src/Illuminate/Validation/Rules/JsonSchema.php @@ -0,0 +1,118 @@ +schema = $schema; + } + + /** + * Determine if the validation rule passes. + * + * @param string $attribute + * @param mixed $value + */ + public function passes($attribute, $value): bool + { + $this->errorMessage = null; + + // Normalize the data to what Opis expects + $data = $this->normalizeData($value); + + if ($data === null && $this->errorMessage) { + return false; + } + + try { + $validator = new Validator; + $schemaString = $this->schema->toString(); + $result = $validator->validate($data, $schemaString); + + if (! $result->isValid()) { + $this->errorMessage = $this->formatValidationError($result->error()); + + return false; + } + + return true; + } catch (Exception $e) { + $this->errorMessage = "Schema validation error: {$e->getMessage()}"; + + return false; + } + } + + /** + * Normalize input data for Opis validation. + * + * @param mixed $value + * @return mixed|null + */ + protected function normalizeData($value) + { + if (is_string($value)) { + $decoded = json_decode($value); + + if (json_last_error() !== JSON_ERROR_NONE) { + $this->errorMessage = 'Invalid JSON format: '.json_last_error_msg(); + + return null; + } + + return $decoded; + } + + if (is_array($value) || is_object($value)) { + // Convert to JSON and back to ensure proper object/array structure for Opis + return json_decode(json_encode($value, JSON_FORCE_OBJECT), false); + } + + return $value; + } + + /** + * Format the validation error message. + */ + protected function formatValidationError(?ValidationError $error): string + { + $keyword = $error->keyword(); + $dataPath = implode('.', $error->data()->path() ?? []); + + if ($dataPath) { + return "Validation failed at '{$dataPath}': {$keyword}"; + } + + return "Validation failed: {$keyword}"; + } + + /** + * Get the validation error message. + */ + public function message(): string + { + return $this->errorMessage ?? 'The :attribute does not match the required schema.'; + } +} diff --git a/src/Illuminate/Validation/composer.json b/src/Illuminate/Validation/composer.json index 9c9567444549..8eebb18d2712 100755 --- a/src/Illuminate/Validation/composer.json +++ b/src/Illuminate/Validation/composer.json @@ -25,6 +25,7 @@ "illuminate/macroable": "^12.0", "illuminate/support": "^12.0", "illuminate/translation": "^12.0", + "opis/json-schema": "^2.4.1", "symfony/http-foundation": "^7.2", "symfony/mime": "^7.2", "symfony/polyfill-php83": "^1.33" diff --git a/tests/Validation/JsonSchemaRuleTest.php b/tests/Validation/JsonSchemaRuleTest.php new file mode 100644 index 000000000000..4e98d87a5cef --- /dev/null +++ b/tests/Validation/JsonSchemaRuleTest.php @@ -0,0 +1,171 @@ + JsonSchema::string()->required(), + 'age' => JsonSchema::integer()->min(0), + ]); + + $rule = new JsonSchemaRule($schema); + $validData = json_encode(['name' => 'John', 'age' => 25]); + + $this->assertTrue($rule->passes('data', $validData)); + } + + public function test_fails_with_missing_required_field(): void + { + $schema = JsonSchema::object([ + 'name' => JsonSchema::string()->required(), + 'age' => JsonSchema::integer()->min(0), + ]); + + $rule = new JsonSchemaRule($schema); + $invalidData = json_encode(['age' => 25]); // missing required 'name' + + $this->assertFalse($rule->passes('data', $invalidData)); + } + + public function test_fails_with_wrong_data_type(): void + { + $schema = JsonSchema::object([ + 'age' => JsonSchema::integer()->min(0), + ]); + + $rule = new JsonSchemaRule($schema); + $invalidData = json_encode(['age' => 'not-a-number']); + + $this->assertFalse($rule->passes('data', $invalidData)); + } + + public function test_works_with_already_decoded_data(): void + { + $schema = JsonSchema::object([ + 'name' => JsonSchema::string()->required(), + ]); + + $rule = new JsonSchemaRule($schema); + $validData = ['name' => 'John']; // already decoded array + + $this->assertTrue($rule->passes('data', $validData)); + } + + public function test_fails_with_invalid_json_string(): void + { + $schema = JsonSchema::object([ + 'name' => JsonSchema::string()->required(), + ]); + + $rule = new JsonSchemaRule($schema); + $invalidJson = '{"name": "John"'; // malformed JSON + + $this->assertFalse($rule->passes('data', $invalidJson)); + $this->assertStringContainsString('Invalid JSON format', $rule->message()); + } + + public function test_handles_json_decode_errors_gracefully(): void + { + $schema = JsonSchema::object([ + 'name' => JsonSchema::string()->required(), + ]); + + $rule = new JsonSchemaRule($schema); + $invalidJson = 'not-json-at-all'; + + $this->assertFalse($rule->passes('data', $invalidJson)); + $this->assertStringContainsString('Invalid JSON format', $rule->message()); + } + + public function test_handles_complex_nested_schema(): void + { + $schema = JsonSchema::object([ + 'user' => JsonSchema::object([ + 'profile' => JsonSchema::object([ + 'name' => JsonSchema::string()->required(), + 'age' => JsonSchema::integer()->min(0)->max(150), + ])->required(), + 'preferences' => JsonSchema::object([ + 'theme' => JsonSchema::string()->enum(['light', 'dark']), + 'notifications' => JsonSchema::boolean()->default(true), + ]), + ])->required(), + ]); + + $rule = new JsonSchemaRule($schema); + + // Valid complex data + $validData = json_encode([ + 'user' => [ + 'profile' => [ + 'name' => 'John Doe', + 'age' => 30, + ], + 'preferences' => [ + 'theme' => 'dark', + 'notifications' => true, + ], + ], + ]); + + $this->assertTrue($rule->passes('data', $validData)); + + // Invalid complex data (missing required field) + $invalidData = json_encode([ + 'user' => [ + 'profile' => [ + 'age' => 30, // missing required 'name' + ], + ], + ]); + + $this->assertFalse($rule->passes('data', $invalidData)); + } + + public function test_validates_array_schemas(): void + { + $schema = JsonSchema::object([ + 'tags' => JsonSchema::array()->items(JsonSchema::string())->min(1)->max(5), + ]); + + $rule = new JsonSchemaRule($schema); + + // Valid array data + $validData = json_encode(['tags' => ['php', 'laravel', 'json']]); + $this->assertTrue($rule->passes('data', $validData)); + + // Invalid array data (too many items) + $invalidData = json_encode(['tags' => ['a', 'b', 'c', 'd', 'e', 'f']]); + $this->assertFalse($rule->passes('data', $invalidData)); + + // Invalid array data (empty array) + $emptyData = json_encode(['tags' => []]); + $this->assertFalse($rule->passes('data', $emptyData)); + } + + public function test_validates_enum_values(): void + { + $schema = JsonSchema::object([ + 'status' => JsonSchema::string()->enum(['draft', 'published', 'archived']), + ]); + + $rule = new JsonSchemaRule($schema); + + // Valid enum value + $validData = json_encode(['status' => 'published']); + $this->assertTrue($rule->passes('data', $validData)); + + // Invalid enum value + $invalidData = json_encode(['status' => 'invalid-status']); + $this->assertFalse($rule->passes('data', $invalidData)); + } +} From 45a82b464263db92e0a75b13e886abed4f624982 Mon Sep 17 00:00:00 2001 From: Lucas Michot Date: Wed, 3 Sep 2025 23:52:26 +0200 Subject: [PATCH 2/6] Apply StyleCI fix --- src/Illuminate/Validation/Rules/JsonSchema.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Illuminate/Validation/Rules/JsonSchema.php b/src/Illuminate/Validation/Rules/JsonSchema.php index 2be5592c529d..5fbf259d228f 100644 --- a/src/Illuminate/Validation/Rules/JsonSchema.php +++ b/src/Illuminate/Validation/Rules/JsonSchema.php @@ -22,7 +22,6 @@ class JsonSchema implements Rule /** * Create a new JSON schema validation rule. - * */ public function __construct(Schema $schema) { From 06071e9ce66df14a5cc1e304929b6a647b8eef66 Mon Sep 17 00:00:00 2001 From: Lucas Michot Date: Wed, 3 Sep 2025 23:57:46 +0200 Subject: [PATCH 3/6] Require illuminate/json-schema --- src/Illuminate/Validation/composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Illuminate/Validation/composer.json b/src/Illuminate/Validation/composer.json index 8eebb18d2712..e9e560e958ca 100755 --- a/src/Illuminate/Validation/composer.json +++ b/src/Illuminate/Validation/composer.json @@ -22,6 +22,7 @@ "illuminate/collections": "^12.0", "illuminate/container": "^12.0", "illuminate/contracts": "^12.0", + "illuminate/json-schema": "^12.0", "illuminate/macroable": "^12.0", "illuminate/support": "^12.0", "illuminate/translation": "^12.0", From f25b7de5de1d776f89e0704f027e447ae4863d11 Mon Sep 17 00:00:00 2001 From: Lucas Michot Date: Thu, 4 Sep 2025 10:38:42 +0200 Subject: [PATCH 4/6] Simplify code --- .../Validation/Rules/JsonSchema.php | 77 ++++++++----------- 1 file changed, 32 insertions(+), 45 deletions(-) diff --git a/src/Illuminate/Validation/Rules/JsonSchema.php b/src/Illuminate/Validation/Rules/JsonSchema.php index 5fbf259d228f..5dcb14cbc5e4 100644 --- a/src/Illuminate/Validation/Rules/JsonSchema.php +++ b/src/Illuminate/Validation/Rules/JsonSchema.php @@ -5,16 +5,12 @@ use Exception; use Illuminate\Contracts\Validation\Rule; use Illuminate\JsonSchema\JsonSchema as Schema; +use JsonException; use Opis\JsonSchema\Errors\ValidationError; use Opis\JsonSchema\Validator; class JsonSchema implements Rule { - /** - * The JSON schema instance. - */ - protected Schema $schema; - /** * The validation error message. */ @@ -23,9 +19,8 @@ class JsonSchema implements Rule /** * Create a new JSON schema validation rule. */ - public function __construct(Schema $schema) + public function __construct(protected Schema $schema) { - $this->schema = $schema; } /** @@ -41,27 +36,31 @@ public function passes($attribute, $value): bool // Normalize the data to what Opis expects $data = $this->normalizeData($value); - if ($data === null && $this->errorMessage) { + if ($data === null && $this->errorMessage !== null) { return false; } try { - $validator = new Validator; - $schemaString = $this->schema->toString(); - $result = $validator->validate($data, $schemaString); + $result = (new Validator)->validate($data, $this->schema->toString()); - if (! $result->isValid()) { - $this->errorMessage = $this->formatValidationError($result->error()); - - return false; + if ($result->isValid()) { + return true; } - return true; + $this->errorMessage = $this->formatValidationError($result->error()); } catch (Exception $e) { $this->errorMessage = "Schema validation error: {$e->getMessage()}"; - - return false; } + + return false; + } + + /** + * Get the validation error message. + */ + public function message(): string + { + return $this->errorMessage ?? 'The :attribute does not match the required schema.'; } /** @@ -72,46 +71,34 @@ public function passes($attribute, $value): bool */ protected function normalizeData($value) { - if (is_string($value)) { - $decoded = json_decode($value); - - if (json_last_error() !== JSON_ERROR_NONE) { - $this->errorMessage = 'Invalid JSON format: '.json_last_error_msg(); - - return null; - } - - return $decoded; - } - if (is_array($value) || is_object($value)) { // Convert to JSON and back to ensure proper object/array structure for Opis return json_decode(json_encode($value, JSON_FORCE_OBJECT), false); } - return $value; + if (! is_string($value)) { + return $value; + } + + try { + return json_decode($value, false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->errorMessage = "Invalid JSON format: {$e->getMessage()}"; + + return null; + } } /** * Format the validation error message. */ - protected function formatValidationError(?ValidationError $error): string + protected function formatValidationError(ValidationError $error): string { $keyword = $error->keyword(); $dataPath = implode('.', $error->data()->path() ?? []); - if ($dataPath) { - return "Validation failed at '{$dataPath}': {$keyword}"; - } - - return "Validation failed: {$keyword}"; - } - - /** - * Get the validation error message. - */ - public function message(): string - { - return $this->errorMessage ?? 'The :attribute does not match the required schema.'; + return $dataPath !== '' ? + "Validation failed at '$dataPath': $keyword" : + "Validation failed: $keyword"; } } From 24efdfb766f1ab8e989cfcb02c42132f82e4c157 Mon Sep 17 00:00:00 2001 From: Lucas Michot Date: Thu, 4 Sep 2025 10:39:51 +0200 Subject: [PATCH 5/6] Fix CS --- src/Illuminate/Validation/Rules/JsonSchema.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Validation/Rules/JsonSchema.php b/src/Illuminate/Validation/Rules/JsonSchema.php index 5dcb14cbc5e4..0137e46b1aba 100644 --- a/src/Illuminate/Validation/Rules/JsonSchema.php +++ b/src/Illuminate/Validation/Rules/JsonSchema.php @@ -97,7 +97,7 @@ protected function formatValidationError(ValidationError $error): string $keyword = $error->keyword(); $dataPath = implode('.', $error->data()->path() ?? []); - return $dataPath !== '' ? + return $dataPath !== '' ? "Validation failed at '$dataPath': $keyword" : "Validation failed: $keyword"; } From a1c18c11791051988402e77dc0b4108ad2ce3480 Mon Sep 17 00:00:00 2001 From: Lucas Michot Date: Thu, 4 Sep 2025 10:42:46 +0200 Subject: [PATCH 6/6] Update validateJsonSchema --- src/Illuminate/Validation/Concerns/ValidatesAttributes.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php index 6c62fefdc11c..eabf15572c68 100644 --- a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php +++ b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php @@ -1645,13 +1645,11 @@ public function validateJson($attribute, $value) */ public function validateJsonSchema($attribute, $value, $parameters): bool { - if (! isset($parameters[0]) || ! $parameters[0] instanceof Type) { + if (! ($parameters[0] ?? null) instanceof Type) { throw new InvalidArgumentException('The json_schema rule requires a JsonSchema Type instance.'); } - $rule = new JsonSchemaRule($parameters[0]); - - return $rule->passes($attribute, $value); + return (new JsonSchemaRule($parameters[0]))->passes($attribute, $value); } /**