Skip to content

Commit fa7413d

Browse files
committed
Introduce as JsonSchema validation rule
1 parent 5d773d2 commit fa7413d

File tree

5 files changed

+324
-0
lines changed

5 files changed

+324
-0
lines changed

src/Illuminate/Validation/Concerns/ValidatesAttributes.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
use Exception;
1818
use Illuminate\Container\Container;
1919
use Illuminate\Database\Eloquent\Model;
20+
use Illuminate\JsonSchema\Types\Type;
2021
use Illuminate\Support\Arr;
2122
use Illuminate\Support\Collection;
2223
use Illuminate\Support\Exceptions\MathException;
2324
use Illuminate\Support\Facades\Date;
2425
use Illuminate\Support\Str;
2526
use Illuminate\Validation\Rules\Exists;
27+
use Illuminate\Validation\Rules\JsonSchema as JsonSchemaRule;
2628
use Illuminate\Validation\Rules\Unique;
2729
use Illuminate\Validation\ValidationData;
2830
use InvalidArgumentException;
@@ -1634,6 +1636,25 @@ public function validateJson($attribute, $value)
16341636
return json_validate($value);
16351637
}
16361638

1639+
/**
1640+
* Validate that an attribute matches a JSON schema.
1641+
*
1642+
* @param string $attribute
1643+
* @param mixed $value
1644+
* @param array<int, int|string> $parameters
1645+
* @return bool
1646+
*/
1647+
public function validateJsonSchema($attribute, $value, $parameters): bool
1648+
{
1649+
if (! isset($parameters[0]) || ! $parameters[0] instanceof Type) {
1650+
throw new InvalidArgumentException('The json_schema rule requires a JsonSchema Type instance.');
1651+
}
1652+
1653+
$rule = new JsonSchemaRule($parameters[0]);
1654+
1655+
return $rule->passes($attribute, $value);
1656+
}
1657+
16371658
/**
16381659
* Validate the size of an attribute is less than or equal to a maximum value.
16391660
*

src/Illuminate/Validation/Rule.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Illuminate\Validation;
44

55
use Illuminate\Contracts\Support\Arrayable;
6+
use Illuminate\JsonSchema\Types\Type;
67
use Illuminate\Support\Arr;
78
use Illuminate\Support\Traits\Macroable;
89
use Illuminate\Validation\Rules\AnyOf;
@@ -17,6 +18,7 @@
1718
use Illuminate\Validation\Rules\File;
1819
use Illuminate\Validation\Rules\ImageFile;
1920
use Illuminate\Validation\Rules\In;
21+
use Illuminate\Validation\Rules\JsonSchema as JsonSchemaRule;
2022
use Illuminate\Validation\Rules\NotIn;
2123
use Illuminate\Validation\Rules\Numeric;
2224
use Illuminate\Validation\Rules\ProhibitedIf;
@@ -247,6 +249,17 @@ public static function numeric()
247249
return new Numeric;
248250
}
249251

252+
/**
253+
* Create a JSON Schema validation rule.
254+
*
255+
* @param \Illuminate\JsonSchema\Types\Type $schema
256+
* @return \Illuminate\Validation\Rules\JsonSchema
257+
*/
258+
public static function jsonSchema(Type $schema)
259+
{
260+
return new JsonSchemaRule($schema);
261+
}
262+
250263
/**
251264
* Get an "any of" rule builder instance.
252265
*
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
namespace Illuminate\Validation\Rules;
4+
5+
use Exception;
6+
use Illuminate\Contracts\Validation\Rule;
7+
use Illuminate\JsonSchema\JsonSchema as Schema;
8+
use Opis\JsonSchema\Errors\ValidationError;
9+
use Opis\JsonSchema\Validator;
10+
11+
class JsonSchema implements Rule
12+
{
13+
/**
14+
* The JSON schema instance.
15+
*/
16+
protected Schema $schema;
17+
18+
/**
19+
* The validation error message.
20+
*/
21+
protected ?string $errorMessage = null;
22+
23+
/**
24+
* Create a new JSON schema validation rule.
25+
*
26+
*/
27+
public function __construct(Schema $schema)
28+
{
29+
$this->schema = $schema;
30+
}
31+
32+
/**
33+
* Determine if the validation rule passes.
34+
*
35+
* @param string $attribute
36+
* @param mixed $value
37+
*/
38+
public function passes($attribute, $value): bool
39+
{
40+
$this->errorMessage = null;
41+
42+
// Normalize the data to what Opis expects
43+
$data = $this->normalizeData($value);
44+
45+
if ($data === null && $this->errorMessage) {
46+
return false;
47+
}
48+
49+
try {
50+
$validator = new Validator;
51+
$schemaString = $this->schema->toString();
52+
$result = $validator->validate($data, $schemaString);
53+
54+
if (! $result->isValid()) {
55+
$this->errorMessage = $this->formatValidationError($result->error());
56+
57+
return false;
58+
}
59+
60+
return true;
61+
} catch (Exception $e) {
62+
$this->errorMessage = "Schema validation error: {$e->getMessage()}";
63+
64+
return false;
65+
}
66+
}
67+
68+
/**
69+
* Normalize input data for Opis validation.
70+
*
71+
* @param mixed $value
72+
* @return mixed|null
73+
*/
74+
protected function normalizeData($value)
75+
{
76+
if (is_string($value)) {
77+
$decoded = json_decode($value);
78+
79+
if (json_last_error() !== JSON_ERROR_NONE) {
80+
$this->errorMessage = 'Invalid JSON format: '.json_last_error_msg();
81+
82+
return null;
83+
}
84+
85+
return $decoded;
86+
}
87+
88+
if (is_array($value) || is_object($value)) {
89+
// Convert to JSON and back to ensure proper object/array structure for Opis
90+
return json_decode(json_encode($value, JSON_FORCE_OBJECT), false);
91+
}
92+
93+
return $value;
94+
}
95+
96+
/**
97+
* Format the validation error message.
98+
*/
99+
protected function formatValidationError(?ValidationError $error): string
100+
{
101+
$keyword = $error->keyword();
102+
$dataPath = implode('.', $error->data()->path() ?? []);
103+
104+
if ($dataPath) {
105+
return "Validation failed at '{$dataPath}': {$keyword}";
106+
}
107+
108+
return "Validation failed: {$keyword}";
109+
}
110+
111+
/**
112+
* Get the validation error message.
113+
*/
114+
public function message(): string
115+
{
116+
return $this->errorMessage ?? 'The :attribute does not match the required schema.';
117+
}
118+
}

src/Illuminate/Validation/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"illuminate/macroable": "^12.0",
2626
"illuminate/support": "^12.0",
2727
"illuminate/translation": "^12.0",
28+
"opis/json-schema": "^2.4.1",
2829
"symfony/http-foundation": "^7.2",
2930
"symfony/mime": "^7.2",
3031
"symfony/polyfill-php83": "^1.33"
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
// File: tests/Validation/JsonSchemaRuleTest.php
4+
5+
namespace Illuminate\Tests\Validation;
6+
7+
use Illuminate\JsonSchema\JsonSchema;
8+
use Illuminate\Validation\Rules\JsonSchema as JsonSchemaRule;
9+
use PHPUnit\Framework\TestCase;
10+
11+
class JsonSchemaRuleTest extends TestCase
12+
{
13+
public function test_passes_with_valid_json_object(): void
14+
{
15+
$schema = JsonSchema::object([
16+
'name' => JsonSchema::string()->required(),
17+
'age' => JsonSchema::integer()->min(0),
18+
]);
19+
20+
$rule = new JsonSchemaRule($schema);
21+
$validData = json_encode(['name' => 'John', 'age' => 25]);
22+
23+
$this->assertTrue($rule->passes('data', $validData));
24+
}
25+
26+
public function test_fails_with_missing_required_field(): void
27+
{
28+
$schema = JsonSchema::object([
29+
'name' => JsonSchema::string()->required(),
30+
'age' => JsonSchema::integer()->min(0),
31+
]);
32+
33+
$rule = new JsonSchemaRule($schema);
34+
$invalidData = json_encode(['age' => 25]); // missing required 'name'
35+
36+
$this->assertFalse($rule->passes('data', $invalidData));
37+
}
38+
39+
public function test_fails_with_wrong_data_type(): void
40+
{
41+
$schema = JsonSchema::object([
42+
'age' => JsonSchema::integer()->min(0),
43+
]);
44+
45+
$rule = new JsonSchemaRule($schema);
46+
$invalidData = json_encode(['age' => 'not-a-number']);
47+
48+
$this->assertFalse($rule->passes('data', $invalidData));
49+
}
50+
51+
public function test_works_with_already_decoded_data(): void
52+
{
53+
$schema = JsonSchema::object([
54+
'name' => JsonSchema::string()->required(),
55+
]);
56+
57+
$rule = new JsonSchemaRule($schema);
58+
$validData = ['name' => 'John']; // already decoded array
59+
60+
$this->assertTrue($rule->passes('data', $validData));
61+
}
62+
63+
public function test_fails_with_invalid_json_string(): void
64+
{
65+
$schema = JsonSchema::object([
66+
'name' => JsonSchema::string()->required(),
67+
]);
68+
69+
$rule = new JsonSchemaRule($schema);
70+
$invalidJson = '{"name": "John"'; // malformed JSON
71+
72+
$this->assertFalse($rule->passes('data', $invalidJson));
73+
$this->assertStringContainsString('Invalid JSON format', $rule->message());
74+
}
75+
76+
public function test_handles_json_decode_errors_gracefully(): void
77+
{
78+
$schema = JsonSchema::object([
79+
'name' => JsonSchema::string()->required(),
80+
]);
81+
82+
$rule = new JsonSchemaRule($schema);
83+
$invalidJson = 'not-json-at-all';
84+
85+
$this->assertFalse($rule->passes('data', $invalidJson));
86+
$this->assertStringContainsString('Invalid JSON format', $rule->message());
87+
}
88+
89+
public function test_handles_complex_nested_schema(): void
90+
{
91+
$schema = JsonSchema::object([
92+
'user' => JsonSchema::object([
93+
'profile' => JsonSchema::object([
94+
'name' => JsonSchema::string()->required(),
95+
'age' => JsonSchema::integer()->min(0)->max(150),
96+
])->required(),
97+
'preferences' => JsonSchema::object([
98+
'theme' => JsonSchema::string()->enum(['light', 'dark']),
99+
'notifications' => JsonSchema::boolean()->default(true),
100+
]),
101+
])->required(),
102+
]);
103+
104+
$rule = new JsonSchemaRule($schema);
105+
106+
// Valid complex data
107+
$validData = json_encode([
108+
'user' => [
109+
'profile' => [
110+
'name' => 'John Doe',
111+
'age' => 30,
112+
],
113+
'preferences' => [
114+
'theme' => 'dark',
115+
'notifications' => true,
116+
],
117+
],
118+
]);
119+
120+
$this->assertTrue($rule->passes('data', $validData));
121+
122+
// Invalid complex data (missing required field)
123+
$invalidData = json_encode([
124+
'user' => [
125+
'profile' => [
126+
'age' => 30, // missing required 'name'
127+
],
128+
],
129+
]);
130+
131+
$this->assertFalse($rule->passes('data', $invalidData));
132+
}
133+
134+
public function test_validates_array_schemas(): void
135+
{
136+
$schema = JsonSchema::object([
137+
'tags' => JsonSchema::array()->items(JsonSchema::string())->min(1)->max(5),
138+
]);
139+
140+
$rule = new JsonSchemaRule($schema);
141+
142+
// Valid array data
143+
$validData = json_encode(['tags' => ['php', 'laravel', 'json']]);
144+
$this->assertTrue($rule->passes('data', $validData));
145+
146+
// Invalid array data (too many items)
147+
$invalidData = json_encode(['tags' => ['a', 'b', 'c', 'd', 'e', 'f']]);
148+
$this->assertFalse($rule->passes('data', $invalidData));
149+
150+
// Invalid array data (empty array)
151+
$emptyData = json_encode(['tags' => []]);
152+
$this->assertFalse($rule->passes('data', $emptyData));
153+
}
154+
155+
public function test_validates_enum_values(): void
156+
{
157+
$schema = JsonSchema::object([
158+
'status' => JsonSchema::string()->enum(['draft', 'published', 'archived']),
159+
]);
160+
161+
$rule = new JsonSchemaRule($schema);
162+
163+
// Valid enum value
164+
$validData = json_encode(['status' => 'published']);
165+
$this->assertTrue($rule->passes('data', $validData));
166+
167+
// Invalid enum value
168+
$invalidData = json_encode(['status' => 'invalid-status']);
169+
$this->assertFalse($rule->passes('data', $invalidData));
170+
}
171+
}

0 commit comments

Comments
 (0)