Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/Illuminate/Validation/Concerns/ValidatesAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<int, int|string> $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.
*
Expand Down
13 changes: 13 additions & 0 deletions src/Illuminate/Validation/Rule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down
104 changes: 104 additions & 0 deletions src/Illuminate/Validation/Rules/JsonSchema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

namespace Illuminate\Validation\Rules;

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 validation error message.
*/
protected ?string $errorMessage = null;

/**
* Create a new JSON schema validation rule.
*/
public function __construct(protected 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 !== 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";
}
}
2 changes: 2 additions & 0 deletions src/Illuminate/Validation/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
171 changes: 171 additions & 0 deletions tests/Validation/JsonSchemaRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php

// File: tests/Validation/JsonSchemaRuleTest.php

namespace Illuminate\Tests\Validation;

use Illuminate\JsonSchema\JsonSchema;
use Illuminate\Validation\Rules\JsonSchema as JsonSchemaRule;
use PHPUnit\Framework\TestCase;

class JsonSchemaRuleTest extends TestCase
{
public function test_passes_with_valid_json_object(): void
{
$schema = JsonSchema::object([
'name' => 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));
}
}