diff --git a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php index a33a3c17c84d..eabf15572c68 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,23 @@ 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 (! ($parameters[0] ?? null) instanceof Type) { + throw new InvalidArgumentException('The json_schema rule requires a JsonSchema Type instance.'); + } + + return (new JsonSchemaRule($parameters[0]))->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..0137e46b1aba --- /dev/null +++ b/src/Illuminate/Validation/Rules/JsonSchema.php @@ -0,0 +1,104 @@ +errorMessage = null; + + // Normalize the data to what Opis expects + $data = $this->normalizeData($value); + + if ($data === null && $this->errorMessage !== null) { + return false; + } + + try { + $result = (new Validator)->validate($data, $this->schema->toString()); + + if ($result->isValid()) { + return true; + } + + $this->errorMessage = $this->formatValidationError($result->error()); + } catch (Exception $e) { + $this->errorMessage = "Schema validation error: {$e->getMessage()}"; + } + + return false; + } + + /** + * Get the validation error message. + */ + public function message(): string + { + return $this->errorMessage ?? 'The :attribute does not match the required schema.'; + } + + /** + * Normalize input data for Opis validation. + * + * @param mixed $value + * @return mixed|null + */ + protected function normalizeData($value) + { + 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); + } + + 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 + { + $keyword = $error->keyword(); + $dataPath = implode('.', $error->data()->path() ?? []); + + return $dataPath !== '' ? + "Validation failed at '$dataPath': $keyword" : + "Validation failed: $keyword"; + } +} diff --git a/src/Illuminate/Validation/composer.json b/src/Illuminate/Validation/composer.json index 9c9567444549..e9e560e958ca 100755 --- a/src/Illuminate/Validation/composer.json +++ b/src/Illuminate/Validation/composer.json @@ -22,9 +22,11 @@ "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", + "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)); + } +}