Skip to content

Commit 596b895

Browse files
committed
feat: implement request validation rules {Includes, Fields}
1 parent 027d3f5 commit 596b895

File tree

12 files changed

+478
-2
lines changed

12 files changed

+478
-2
lines changed

readme.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,64 @@ This package is an specialisation of Laravel's `JsonResource` class.
1616
All the underlying API's are still there, thus in your controller you can still interact
1717
with `JsonApiResource` classes as you would with the base `JsonResource` class
1818

19+
## Request
20+
This package allows the reading and dynamic inclusion of resources that will be requested in the requests via the "include" parameter.
21+
**@see** _[{json:api} fetching-includes](https://jsonapi.org/format/#fetching-includes)_
22+
23+
Resource attributes will also be filtered according to the "fields" parameter.
24+
**@see** _[{json:api} fetching-fields](https://jsonapi.org/format/#fetching-sparse-fieldsets)_
25+
26+
You can also very simply validate your requests for a given resource via the rules `Rules\Includes` and `Rules\Fields`.
27+
28+
### Include validation
29+
30+
```php
31+
use \Ark4ne\JsonApi\Requests\Rules\Includes;
32+
use \Illuminate\Foundation\Http\FormRequest;
33+
34+
class UserFetchRequest extends FormRequest
35+
{
36+
public function rules()
37+
{
38+
return [
39+
'include' => [new Includes(UserResource::class)],
40+
]
41+
}
42+
}
43+
```
44+
45+
`Rules\Includes` will validate the include to exactly match the UserResource schema (determined by the relationships).
46+
47+
48+
### Fields validation
49+
50+
```php
51+
use \Ark4ne\JsonApi\Requests\Rules\Fields;
52+
use \Illuminate\Foundation\Http\FormRequest;
53+
54+
class UserFetchRequest extends FormRequest
55+
{
56+
public function rules()
57+
{
58+
return [
59+
'include' => [new Fields(UserResource::class)],
60+
]
61+
}
62+
}
63+
```
64+
65+
`Rules\Fields` will validate the fields to exactly match the UserResource schema (determined by the attributes and relationships).
66+
67+
68+
### Customize validation message
69+
| Trans key | default |
70+
|-------------------------------------------------------|------------------------------------------------------|
71+
| `validation.custom.jsonapi.fields.invalid` | The selected :attribute is invalid. |
72+
| `validation.custom.jsonapi.fields.invalid_fields` | ":resource" doesn \' t have fields ":fields". |
73+
| `validation.custom.jsonapi.fields.invalid_resource` | ":resource" doesn \' t exists. |
74+
| `validation.custom.jsonapi.includes.invalid` | The selected :attribute is invalid. |
75+
| `validation.custom.jsonapi.includes.invalid_includes` | ":include" doesn \' t have relationship ":relation". |
76+
1977
## Resource
2078
**@see** _[{json:api} resource-type](https://jsonapi.org/format/#document-resource-objects)_
2179

@@ -145,6 +203,13 @@ _**@see** [{json:api} resources-relationships](https://jsonapi.org/format/#docum
145203

146204
Returns resource relationships.
147205

206+
All relationships **must** be created with `ModelResource::relationship`.
207+
This allows the generation of the schema representing the resource and thus the validation of request includes.
208+
209+
If your relation should have been a collection created via the `::collection(...)` method, you can simply use `->asCollection()`.
210+
211+
If you want the relation data to be loaded only when it is present in the request include, you can use the `->whenIncluded()` method.
212+
148213
```php
149214
protected function toRelationships(Request $request): array
150215
{

src/Requests/Rules/Fields.php

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
namespace Ark4ne\JsonApi\Requests\Rules;
4+
5+
use Ark4ne\JsonApi\Requests\Rules\Traits\UseTrans;
6+
use Ark4ne\JsonApi\Support\Fields as SupportFields;
7+
use Illuminate\Contracts\Validation\Rule;
8+
9+
/**
10+
* @template T as \Ark4ne\JsonApi\Resource\JsonApiResource
11+
*/
12+
class Fields implements Rule
13+
{
14+
use UseTrans;
15+
16+
protected array $failures;
17+
18+
/**
19+
* @param class-string<T> $resource
20+
*/
21+
public function __construct(
22+
protected string $resource
23+
) {
24+
}
25+
26+
public function passes($attribute, $value): bool
27+
{
28+
$desired = SupportFields::parse($value);
29+
$schema = $this->resource::schema();
30+
31+
return $this->assert($schema, $desired);
32+
}
33+
34+
public function message()
35+
{
36+
$base = 'validation.custom.jsonapi.fields';
37+
$message = $this->trans(
38+
"$base.invalid",
39+
'The selected :attribute is invalid.'
40+
);
41+
42+
return array_merge($message, ...array_map(
43+
fn($failure) => isset($failure[':fields'])
44+
? $this->trans(
45+
"$base.invalid_fields",
46+
'":resource" doesn\'t have fields ":fields".',
47+
$failure
48+
)
49+
: $this->trans(
50+
"$base.invalid_resource",
51+
'":resource" doesn\'t exists.',
52+
$failure
53+
),
54+
$this->failures
55+
));
56+
}
57+
58+
private function assert(object $schema, array $desired): bool
59+
{
60+
$resources = $this->extractSchemaFields($schema);
61+
62+
foreach ($desired as $resource => $fields) {
63+
if (!isset($resources[$resource])) {
64+
$this->failures[] = [
65+
':resource' => $resource
66+
];
67+
} elseif (!empty($diff = array_diff($fields, $resources[$resource]))) {
68+
$this->failures[] = [
69+
':resource' => $resource,
70+
':fields' => implode(',', $diff)
71+
];
72+
}
73+
}
74+
75+
return empty($this->failures);
76+
}
77+
78+
private function extractSchemaFields(object $schema, array $resources = []): array
79+
{
80+
if (isset($resources[$schema->type])) {
81+
return $resources;
82+
}
83+
84+
$resources[$schema->type] = $schema->fields;
85+
86+
foreach ($schema->relationships as $relationship) {
87+
$resources = $this->extractSchemaFields($relationship, $resources);
88+
}
89+
90+
return $resources;
91+
}
92+
}

src/Requests/Rules/Includes.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
namespace Ark4ne\JsonApi\Requests\Rules;
4+
5+
use Ark4ne\JsonApi\Requests\Rules\Traits\UseTrans;
6+
use Ark4ne\JsonApi\Support\Includes as SupportIncludes;
7+
use Illuminate\Contracts\Validation\Rule;
8+
9+
/**
10+
* @template T as \Ark4ne\JsonApi\Resource\JsonApiResource
11+
*/
12+
class Includes implements Rule
13+
{
14+
use UseTrans;
15+
16+
protected array $failures;
17+
18+
/**
19+
* @param class-string<T> $resource
20+
*/
21+
public function __construct(
22+
protected string $resource
23+
) {
24+
}
25+
26+
public function passes($attribute, $value): bool
27+
{
28+
$desired = SupportIncludes::parse($value);
29+
$schema = $this->resource::schema();
30+
31+
return $this->assert($schema, $desired);
32+
}
33+
34+
public function message()
35+
{
36+
$base = 'validation.custom.jsonapi.includes';
37+
$message = $this->trans(
38+
"$base.invalid",
39+
'The selected :attribute is invalid.'
40+
);
41+
42+
return array_merge($message, ...array_map(
43+
fn($failure) => $this->trans(
44+
"$base.invalid_includes",
45+
'":include" doesn\'t have relationship ":relation".',
46+
$failure
47+
),
48+
$this->failures
49+
));
50+
}
51+
52+
53+
private function assert(object $schema, array $desired, string $pretend = ''): bool
54+
{
55+
foreach ($desired as $relation => $sub) {
56+
if (!isset($schema->relationships[$relation])) {
57+
$this->failures[] = [
58+
':include' => $pretend ?: $schema->type,
59+
':relation' => $relation
60+
];
61+
} else {
62+
$this->assert(
63+
$schema->relationships[$relation],
64+
$sub,
65+
$pretend ? "$pretend.$relation" : $relation
66+
);
67+
}
68+
}
69+
70+
return empty($this->failures);
71+
}
72+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace Ark4ne\JsonApi\Requests\Rules\Traits;
4+
5+
use Illuminate\Support\Str;
6+
7+
use function trans;
8+
9+
trait UseTrans
10+
{
11+
protected function trans($key, $default, array $replace = []): array
12+
{
13+
$message = trans($key);
14+
15+
return array_map(
16+
static fn($msg) => Str::replace(array_keys($replace), array_values($replace), $msg),
17+
$message === $key ? [$default] : (array)$message
18+
);
19+
}
20+
}

tests/Feature/User/ResourceTest.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,67 @@ public function testShowWithRelationshipsDeepInclude()
7474
$response->assertExactJson($expected);
7575
}
7676

77+
public function testShowFailWithAttributes()
78+
{
79+
$user = $this->dataSeed();
80+
81+
$response = $this->getJson("user/{$user->id}?fields[user]=name,unknown&fields[foo]=bar");
82+
83+
$response->assertUnprocessable();
84+
$response->assertJsonValidationErrors([
85+
'fields' => [
86+
'The selected fields is invalid.',
87+
'"user" doesn\'t have fields "unknown".',
88+
'"foo" doesn\'t exists.'
89+
]
90+
]);
91+
}
92+
93+
public function testShowFailWithIncludes()
94+
{
95+
$user = $this->dataSeed();
96+
97+
$response = $this->getJson("user/{$user->id}?include=unknown");
98+
99+
$response->assertUnprocessable();
100+
$response->assertJsonValidationErrors(['include' => [
101+
'The selected include is invalid.',
102+
'"user" doesn\'t have relationship "unknown".'
103+
]]);
104+
}
105+
106+
public function testShowFailWithIncludesSub()
107+
{
108+
$user = $this->dataSeed();
109+
110+
$response = $this->getJson("user/{$user->id}?include=posts.unknown");
111+
112+
$response->assertUnprocessable();
113+
$response->assertJsonValidationErrors(['include' => [
114+
'The selected include is invalid.',
115+
'"posts" doesn\'t have relationship "unknown"'
116+
]]);
117+
}
118+
119+
public function testShowMultipleFailures()
120+
{
121+
$user = $this->dataSeed();
122+
123+
$response = $this->getJson("user/{$user->id}?include=posts.one,two&fields[user]=name,one_field&fields[unknown]=some");
124+
125+
$response->assertUnprocessable();
126+
$response->assertJsonValidationErrors(['include' => [
127+
'The selected include is invalid.',
128+
'"posts" doesn\'t have relationship "one"',
129+
'"user" doesn\'t have relationship "two"',
130+
], 'fields' => [
131+
'The selected fields is invalid.',
132+
'"user" doesn\'t have relationship "one_field"',
133+
'"unknown" doesn\'t exists.',
134+
]]);
135+
}
136+
137+
77138
private function dataSeed()
78139
{
79140
/** @var User $user */
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace Test\Unit\Requests\Rules;
4+
5+
use Ark4ne\JsonApi\Requests\Rules\Fields;
6+
use Test\app\Http\Resources\UserResource;
7+
use Test\TestCase;
8+
9+
class FieldsTest extends TestCase
10+
{
11+
public function testPasses()
12+
{
13+
$rule = new Fields(UserResource::class);
14+
15+
$this->assertTrue($rule->passes(null, [
16+
'user' => 'name,email',
17+
'post' => 'content',
18+
]));
19+
20+
$this->assertFalse($rule->passes(null, [
21+
'user' => 'name,email',
22+
'unknown' => 'content',
23+
]));
24+
25+
$this->assertFalse($rule->passes(null, [
26+
'user' => 'name,unknown',
27+
'post' => 'content',
28+
]));
29+
30+
$this->assertFalse($rule->passes(null, [
31+
'user' => 'name,email',
32+
'post' => 'unknown',
33+
]));
34+
}
35+
}

0 commit comments

Comments
 (0)