Skip to content

Commit 54a7085

Browse files
committed
[DTO-8] Implement schema validation
1 parent e89ca18 commit 54a7085

16 files changed

+778
-146
lines changed

README.md

Lines changed: 256 additions & 46 deletions
Large diffs are not rendered by default.

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@
2626
"require": {
2727
"php": "^8.2",
2828
"ext-json": "*",
29-
"vlucas/phpdotenv": "^5.3",
3029
"twig/twig": "^3.5",
31-
"webmozart/assert": "^1.11"
30+
"webmozart/assert": "^1.11",
31+
"opis/json-schema": "^2.3"
3232
},
3333
"require-dev": {
3434
"symfony/var-dumper": "^6.2",

src/AbstractTransfer.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ public function toArray(): array
3030

3131
/**
3232
* @param array<string,mixed> $data
33-
* @return TransferInterface
33+
*
34+
* @return static
3435
*/
35-
public static function fromArray(array $data): TransferInterface
36+
public static function fromArray(array $data): static
3637
{
3738
$transfer = new static();
3839
foreach ($data as $key => $value) {

src/DefinitionProvider.php

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
namespace Tlab\TransferObjects;
44

5+
use Tlab\TransferObjects\Exceptions\DefinitionException;
6+
57
class DefinitionProvider
68
{
9+
private const NATIVE_TYPES = ['string', 'int', 'float', 'bool'];
10+
711
public function __construct(
812
private readonly string $definitionPath,
913
private readonly string $namespace,
@@ -15,32 +19,46 @@ public function __construct(
1519
*/
1620
public function provide(): array
1721
{
22+
$schemaValidator = new SchemaValidator();
1823
$definitions = [];
1924
foreach (glob($this->definitionPath . DIRECTORY_SEPARATOR . '*.json') as $filename) {
25+
$errors = [];
26+
if (!$schemaValidator->validate((string)file_get_contents($filename), $errors)) {
27+
throw new DefinitionException('Invalid definition file: ' . $filename);
28+
}
2029
$decodeFile = json_decode((string)file_get_contents($filename), true);
2130
$definitions = array_merge($decodeFile['transfers'], $definitions);
2231
}
2332

24-
$tranfers = [];
33+
$transfers = [];
2534
foreach ($definitions as $definition) {
35+
$useNamespaces = [
36+
'Tlab\TransferObjects\AbstractTransfer'
37+
];
2638
$classTransfer = [
2739
'namespace' => $this->namespace,
2840
'className' => $definition['name'] . 'Transfer',
2941
'abstractClass' => 'AbstractTransfer',
30-
'description' => $definition['description'] ?? null,
3142
'deprecationDescription' => $definition['deprecationDescription'] ?? null,
3243
];
3344

3445
$classProperties = [];
3546
foreach ($definition['properties'] as $property) {
47+
if (isset($property['namespace'])) {
48+
$useNamespaces[] = trim($property['namespace'], '\\');
49+
}
50+
3651
$classProperties[] = $this->processProperty($property);
3752
}
3853

54+
$useNamespaces = array_unique($useNamespaces);
55+
sort($useNamespaces, SORT_STRING);
56+
$classTransfer['useNamespaces'] = $useNamespaces;
3957
$classTransfer['properties'] = $classProperties;
40-
$tranfers[] = $classTransfer;
58+
$transfers[] = $classTransfer;
4159
}
4260

43-
return $tranfers;
61+
return $transfers;
4462
}
4563

4664
/**
@@ -53,11 +71,14 @@ private function processProperty(array $property): array
5371
return $this->processArrayType($property);
5472
}
5573

74+
if (!in_array($property['type'], self::NATIVE_TYPES)) {
75+
return $this->processNonNativeType($property);
76+
}
77+
5678
return [
5779
'type' => $property['type'],
5880
'camelCaseName' => $property['name'],
5981
'nullable' => $property['nullable'] ?? false,
60-
'description' => $property['description'] ?? null,
6182
'deprecationDescription' => $property['deprecationDescription'] ?? null,
6283
];
6384
}
@@ -70,8 +91,16 @@ private function processArrayType(array $property): array
7091
{
7192
$elementsType = substr($property['type'], 0, -2);
7293

73-
if (!in_array($elementsType, ['string', 'int', 'float'])) {
74-
$elementsType = $elementsType . 'Transfer';
94+
if (!in_array($elementsType, self::NATIVE_TYPES)) {
95+
return [
96+
'type' => 'array',
97+
'elementsType' => $elementsType,
98+
'camelCaseName' => $property['name'],
99+
'camelCaseSingularName' => $property['singular'],
100+
'namespace' => isset($property['namespace']) ? trim($property['namespace'], '\\') : null,
101+
'nullable' => $property['nullable'] ?? false,
102+
'deprecationDescription' => $property['deprecationDescription'] ?? null,
103+
];
75104
}
76105

77106
return [
@@ -80,7 +109,22 @@ private function processArrayType(array $property): array
80109
'camelCaseName' => $property['name'],
81110
'camelCaseSingularName' => $property['singular'],
82111
'nullable' => $property['nullable'] ?? false,
83-
'description' => $property['description'] ?? null,
112+
'deprecationDescription' => $property['deprecationDescription'] ?? null,
113+
];
114+
}
115+
116+
/**
117+
* @param array<string,string|null> $property
118+
*
119+
* @return array<string,string|null>
120+
*/
121+
private function processNonNativeType(array $property): array
122+
{
123+
return [
124+
'type' => $property['type'],
125+
'camelCaseName' => $property['name'],
126+
'namespace' => isset($property['namespace']) ? trim($property['namespace'], '\\') : null,
127+
'nullable' => $property['nullable'] ?? false,
84128
'deprecationDescription' => $property['deprecationDescription'] ?? null,
85129
];
86130
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace Tlab\TransferObjects\Exceptions;
4+
5+
use Exception;
6+
7+
class DefinitionException extends Exception
8+
{
9+
}

src/Schema/schema.json

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"title": "Data transfer objects definition",
4+
"type": "object",
5+
"properties": {
6+
"transfers": {
7+
"type": "array",
8+
"items": {
9+
"type": "object",
10+
"properties": {
11+
"name": {
12+
"type": "string",
13+
"description": "Transfer Object Name"
14+
},
15+
"deprecationDescription": {
16+
"type": "string",
17+
"description": "Class deprecation description"
18+
},
19+
"properties": {
20+
"type": "array",
21+
"items": {
22+
"type": "object",
23+
"properties": {
24+
"name": {
25+
"type": "string",
26+
"pattern": "^([a-z])+([A-Z][a-z]+)*$",
27+
"description": "Class property name in camelcase"
28+
},
29+
"type": {
30+
"type": "string",
31+
"pattern": "^[A-Za-z]+(\\[\\])?$",
32+
"description": "Class property type"
33+
},
34+
"namespace": {
35+
"type": "string",
36+
"pattern": "^(\\\\)?[A-Za-z]+(\\\\[A-Za-z]+)*(\\\\)?$",
37+
"description": "Namespace for the type, if applicable"
38+
},
39+
"nullable": {
40+
"type": "boolean",
41+
"description": "If class property can be nullable"
42+
},
43+
"deprecationDescription": {
44+
"type": "string",
45+
"description": "Class property deprecation description"
46+
},
47+
"singular": {
48+
"type": "string",
49+
"pattern": "(^[a-z]|[A-Z0-9])[a-z]*$",
50+
"description": "Singular name of the property for array types"
51+
}
52+
},
53+
"required": [
54+
"name",
55+
"type"
56+
]
57+
}
58+
}
59+
},
60+
"required": [
61+
"name",
62+
"properties"
63+
]
64+
}
65+
}
66+
},
67+
"required": [
68+
"transfers"
69+
]
70+
}

src/SchemaValidator.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Tlab\TransferObjects;
4+
5+
use Opis\JsonSchema\Validator;
6+
use Opis\JsonSchema\Errors\ErrorFormatter;
7+
8+
class SchemaValidator
9+
{
10+
/**
11+
* @param string $data
12+
* @param array<string,string> $errors
13+
*
14+
* @return bool
15+
*/
16+
public function validate(string $data, array &$errors): bool
17+
{
18+
$schema = file_get_contents(__DIR__ . '/Schema/schema.json');
19+
20+
$data = json_decode($data);
21+
22+
$validator = new Validator();
23+
$validator->setMaxErrors(5);
24+
25+
$result = $validator->validate($data, $schema);
26+
27+
if ($result->isValid()) {
28+
return true;
29+
}
30+
31+
$error = $result->error();
32+
$formatter = new ErrorFormatter();
33+
34+
$errors = $formatter->format($error, false);
35+
36+
return false;
37+
}
38+
}

src/Templates/class.twig

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
namespace {{ namespace }};
55

6-
use Tlab\TransferObjects\AbstractTransfer;
6+
{% for useNamespace in useNamespaces %}
7+
use {{ useNamespace }};
8+
{% endfor %}
79

810
/**
911
* !!! THIS TRANSFER CLASS FILE IS AUTO-GENERATED, CHANGES WILL BREAK YOUR PROJECT
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"transfers": [
3+
{
4+
"name": "Product",
5+
"properties": [
6+
{
7+
"name": "sku",
8+
"type": "string"
9+
},
10+
{
11+
"name": "some-name",
12+
"type": "string",
13+
"nullable": true
14+
},
15+
{
16+
"name": "price"
17+
},
18+
{
19+
"name": "categories",
20+
"type": "CategoryTransfer[]",
21+
"singular": "category"
22+
}
23+
]
24+
}
25+
]
26+
}

tests/Data/customer.json

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,62 +2,66 @@
22
"transfers": [
33
{
44
"name": "Customer",
5-
"description": "DTO description",
65
"deprecationDescription": "This class is deprecated",
76
"properties": [
87
{
98
"name": "email",
109
"type": "string",
11-
"nullable": false,
12-
"description": "The customer email"
10+
"nullable": false
11+
},
12+
{
13+
"name": "category",
14+
"type": "CategoryTransfer"
1315
},
1416
{
1517
"name": "firstName",
16-
"type": "string",
17-
"nullable": false,
18-
"description": "The customer first name"
18+
"type": "string"
1919
},
2020
{
2121
"name": "lastName",
2222
"type": "string",
23-
"nullable": true,
24-
"description": "The customer last name"
23+
"nullable": true
24+
},
25+
{
26+
"name": "birthDate",
27+
"type": "DateTime",
28+
"namespace": "DateTime"
29+
},
30+
{
31+
"name": "timeTables",
32+
"type": "DateTime[]",
33+
"singular": "timeTable",
34+
"namespace": "DateTime"
35+
},
36+
{
37+
"name": "someOtherField",
38+
"type": "Environment",
39+
"namespace": "\\Acme\\Environment\\",
40+
"nullable": true
2541
},
2642
{
2743
"name": "isGuest",
2844
"type": "bool",
29-
"description": "Is a guest customer",
3045
"deprecationDescription": "isGuest property is deprecated"
3146
}
3247
]
3348
},
3449
{
3550
"name": "SomeOtherDataTransferObject",
36-
"description": "DTO description",
3751
"deprecationDescription": "This class is deprecated",
3852
"properties": [
3953
{
4054
"name": "id",
41-
"type": "int",
42-
"nullable": false,
43-
"description": "An integer field"
55+
"type": "int"
4456
},
4557
{
4658
"name": "name",
4759
"type": "string",
48-
"nullable": true,
49-
"description": "A string field"
60+
"nullable": true
5061
},
5162
{
5263
"name": "price",
53-
"type": "float",
54-
"description": "A float field"
55-
},
56-
{
57-
"name": "isActive",
58-
"type": "bool",
59-
"description": "A bool field",
60-
"deprecationDescription": "isActive property is deprecated"
64+
"type": "float"
6165
},
6266
{
6367
"name": "tags",

0 commit comments

Comments
 (0)