Skip to content

Commit f320e94

Browse files
Implements the Json Schema standard for validation rules
1 parent bd14af3 commit f320e94

6 files changed

Lines changed: 216 additions & 1 deletion

File tree

routes/dynamic.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
'update',
1313
]);
1414
Route::get('dynamic/{action}', [DynamicController::class, 'show']);
15+
Route::get('dynamic/{action}/schema', [DynamicController::class, 'schema']);
1516
});

src/Http/Controllers/DynamicController.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
use EdineiValdameri\Laravel\DynamicValidation\Http\Requests\DynamicFormRequest;
88
use EdineiValdameri\Laravel\DynamicValidation\Http\Resources\RuleResource;
99
use EdineiValdameri\Laravel\DynamicValidation\Repositories\RuleRepository;
10+
use EdineiValdameri\Laravel\DynamicValidation\Services\JsonSchemaService;
1011
use Illuminate\Http\JsonResponse;
1112

1213
class DynamicController
1314
{
1415
public function __construct(
15-
protected RuleRepository $repository
16+
protected RuleRepository $repository,
17+
protected JsonSchemaService $jsonSchemaGenerator
1618
) {
1719
}
1820

@@ -45,4 +47,11 @@ public function show(string $action): JsonResponse
4547
RuleResource::collection($rules)
4648
);
4749
}
50+
51+
public function schema(string $action): JsonResponse
52+
{
53+
$schema = $this->jsonSchemaGenerator->generate($action);
54+
55+
return response()->json($schema);
56+
}
4857
}

src/Observers/RuleObserver.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ private function forgetCache(Rule $rule): void
1818
{
1919
Cache::forget('dynamic-validations.actions.' . $rule->action);
2020
Cache::forget('dynamic-validations.messages.' . $rule->action);
21+
Cache::forget('dynamic-validations.schema.' . $rule->action);
2122
}
2223
}

src/Services/JsonSchemaService.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace EdineiValdameri\Laravel\DynamicValidation\Services;
6+
7+
use EdineiValdameri\Laravel\DynamicValidation\Models\Rule;
8+
use EdineiValdameri\Laravel\DynamicValidation\Repositories\RuleRepository;
9+
use Illuminate\Support\Facades\Cache;
10+
11+
class JsonSchemaService
12+
{
13+
public function __construct(
14+
protected RuleRepository $ruleRepository
15+
) {
16+
}
17+
18+
/**
19+
* @param string $action
20+
* @return array<string, mixed>
21+
*/
22+
public function generate(string $action): array
23+
{
24+
/** @var array<string, mixed> $schema */
25+
$schema = Cache::rememberForever('dynamic-validations.schema.' . $action, function () use ($action) {
26+
$rules = $this->ruleRepository->getRulesByAction($action);
27+
28+
$schema = [
29+
'type' => 'object',
30+
'properties' => [],
31+
'required' => [],
32+
];
33+
34+
$rules->each(function (Rule $rule) use (&$schema) {
35+
$field = $rule->field;
36+
37+
if (!isset($schema['properties'][$field])) {
38+
$schema['properties'][$field] = [];
39+
}
40+
41+
$this->mappingRule($schema, $field, $rule);
42+
});
43+
44+
return $schema;
45+
});
46+
47+
return $schema;
48+
}
49+
50+
private function mappingRule(array &$schema, string $field, Rule $rule): void // @phpstan-ignore-line
51+
{
52+
$mapping = [
53+
'string' => ['type' => 'string'],
54+
'integer' => ['type' => 'integer'],
55+
'boolean' => ['type' => 'boolean'],
56+
'email' => ['format' => 'email'],
57+
'date' => ['type' => 'string', 'format' => 'date'],
58+
];
59+
60+
if (array_key_exists('type', $schema['properties'][$field]) && $schema['properties'][$field]['type'] === 'string') {
61+
$mapping['min'] = ['minLength' => (int) $rule->value];
62+
$mapping['max'] = ['maxLength' => (int) $rule->value];
63+
} else {
64+
$mapping['min'] = ['minimum' => (int) $rule->value];
65+
$mapping['max'] = ['maximum' => (int) $rule->value];
66+
}
67+
68+
match ($rule->rule) {
69+
'required' => $schema['required'][] = $field,
70+
default => isset($mapping[$rule->rule])
71+
? $schema['properties'][$field] = array_merge($schema['properties'][$field], $mapping[$rule->rule])
72+
: null,
73+
};
74+
}
75+
}

tests/Features/ControllerTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,25 @@
5757
'rule' => 'required',
5858
]);
5959
});
60+
61+
it('validates the return of the dynamic controller\'s schema method', function () {
62+
Rule::query()->create([
63+
'action' => 'test',
64+
'field' => 'test',
65+
'rule' => 'required',
66+
]);
67+
Rule::query()->create([
68+
'action' => 'test',
69+
'field' => 'test',
70+
'rule' => 'string',
71+
]);
72+
$response = $this->get('dynamic/test/schema');
73+
$response->assertOk();
74+
$response->assertJsonFragment([
75+
'type' => 'object',
76+
'required' => ['test'],
77+
'properties' => [
78+
'test' => ['type' => 'string'],
79+
],
80+
]);
81+
});

tests/UseCase/JsonSchemaTest.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use EdineiValdameri\Laravel\DynamicValidation\Models\Rule;
6+
use EdineiValdameri\Laravel\DynamicValidation\Repositories\RuleRepository;
7+
use EdineiValdameri\Laravel\DynamicValidation\Services\JsonSchemaService;
8+
use Illuminate\Support\Collection;
9+
use Mockery\MockInterface;
10+
11+
it('generates a JSON schema based on rules', function () {
12+
$this->mock(RuleRepository::class, function (MockInterface $mock) {
13+
$mock->shouldReceive('getRulesByAction')
14+
->with('users.store')
15+
->andReturn(new Collection([
16+
new Rule(['field' => 'name', 'rule' => 'string']),
17+
new Rule(['field' => 'name', 'rule' => 'required']),
18+
new Rule(['field' => 'name', 'rule' => 'max', 'value' => 255]),
19+
new Rule(['field' => 'password', 'rule' => 'string']),
20+
new Rule(['field' => 'password', 'rule' => 'required']),
21+
new Rule(['field' => 'password', 'rule' => 'min', 'value' => 6]),
22+
new Rule(['field' => 'password', 'rule' => 'max', 'value' => 20]),
23+
new Rule(['field' => 'email', 'rule' => 'string']),
24+
new Rule(['field' => 'email', 'rule' => 'required']),
25+
new Rule(['field' => 'email', 'rule' => 'email']),
26+
new Rule(['field' => 'age', 'rule' => 'integer']),
27+
new Rule(['field' => 'age', 'rule' => 'min', 'value' => 18]),
28+
new Rule(['field' => 'age', 'rule' => 'max', 'value' => 65]),
29+
new Rule(['field' => 'birth', 'rule' => 'date']),
30+
new Rule(['field' => 'birth', 'rule' => 'required']),
31+
new Rule(['field' => 'active', 'rule' => 'boolean']),
32+
]));
33+
});
34+
35+
$generator = app(JsonSchemaService::class);
36+
$schema = $generator->generate('users.store');
37+
38+
expect($schema)->toMatchArray([
39+
'type' => 'object',
40+
'required' => ['name', 'password', 'email', 'birth'],
41+
'properties' => [
42+
'name' => [
43+
'type' => 'string',
44+
'maxLength' => 255,
45+
],
46+
'password' => [
47+
'type' => 'string',
48+
'minLength' => 6,
49+
'maxLength' => 20,
50+
],
51+
'email' => [
52+
'type' => 'string',
53+
'format' => 'email',
54+
],
55+
'age' => [
56+
'type' => 'integer',
57+
'minimum' => 18,
58+
'maximum' => 65,
59+
],
60+
'birth' => [
61+
'type' => 'string',
62+
'format' => 'date',
63+
],
64+
'active' => ['type' => 'boolean'],
65+
],
66+
]);
67+
});
68+
69+
it('adds required fields correctly', function () {
70+
$this->mock(RuleRepository::class, function (MockInterface $mock) {
71+
$mock->shouldReceive('getRulesByAction')
72+
->with('users.store')
73+
->andReturn(new Collection([
74+
new Rule(['field' => 'email', 'rule' => 'required']),
75+
new Rule(['field' => 'password', 'rule' => 'required']),
76+
]));
77+
});
78+
79+
$generator = app(JsonSchemaService::class);
80+
$schema = $generator->generate('users.store');
81+
82+
expect($schema['required'])->toBe(['email', 'password']);
83+
});
84+
85+
it('handles optional fields correctly', function () {
86+
$this->mock(RuleRepository::class, function (MockInterface $mock) {
87+
$mock->shouldReceive('getRulesByAction')
88+
->with('users.store')
89+
->andReturn(new Collection([
90+
new Rule(['field' => 'name', 'rule' => 'string']),
91+
new Rule(['field' => 'age', 'rule' => 'integer']),
92+
new Rule(['field' => 'age', 'rule' => 'min', 'value' => 18]),
93+
]));
94+
});
95+
96+
$generator = app(JsonSchemaService::class);
97+
$schema = $generator->generate('users.store');
98+
99+
expect($schema['required'])->toBeEmpty()
100+
->and($schema['properties'])->toMatchArray([
101+
'name' => ['type' => 'string'],
102+
'age' => [
103+
'type' => 'integer',
104+
'minimum' => 18,
105+
],
106+
]);
107+
});

0 commit comments

Comments
 (0)