-
Notifications
You must be signed in to change notification settings - Fork 52
Expand file tree
/
Copy pathBaseDescriptor.php
More file actions
227 lines (213 loc) · 10.3 KB
/
BaseDescriptor.php
File metadata and controls
227 lines (213 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
<?php
namespace Fhp\Segment;
use Fhp\Syntax\Bin;
/**
* Common functionality for segment/Deg descriptors.
*/
abstract class BaseDescriptor
{
/** Example: "Fhp\Segment\TAN\HITANSv6" (Segment) or "Fhp\Common\Kik" (Deg) */
public string $class;
/** Example: 1 */
public int $version = 1;
/**
* Descriptors for the elements inside the segment/Deg in the order of the wire format. The indices in this array
* match the speficiation. In particular, the first index is 1 (not 0) and some indices may be missing if the
* documentation does not specify it (anymore).
* @var ElementDescriptor[]
*/
public array $elements = [];
/**
* The last index that can be present in an exploded serialized segment/DEG. If one were to append a new field to
* segment/DEG described by this descriptor, it would get index $maxIndex+1.
* Usually $maxIndex==array_key_last($elements), but when the last element is repeated, then $maxIndex is larger.
*/
public int $maxIndex;
protected function __construct(\ReflectionClass $clazz)
{
// Use reflection to map PHP class fields to elements in the segment/Deg.
$nextIndex = 0;
foreach (static::enumerateProperties($clazz) as $property) {
if ($nextIndex === null) {
throw new \InvalidArgumentException("Disallowed property $property after an @Unlimited field");
}
$docComment = $property->getDocComment() ?: '';
if (static::getBoolAnnotation('Ignore', $docComment)) {
continue; // Skip @Ignore-d propeties.
}
$index = $nextIndex;
$descriptor = new ElementDescriptor();
$descriptor->field = $property->getName();
$maxCount = static::getIntAnnotation('Max', $docComment);
$unlimitedCount = static::getBoolAnnotation('Unlimited', $docComment);
if ($type = static::getVarAnnotation($docComment)) {
if (str_ends_with($type, '|null')) { // Nullable field
$descriptor->optional = true;
$type = substr($type, 0, -5);
}
if (str_ends_with($type, '[]')) { // Array/repeated field
$type = substr($type, 0, -2);
if ($unlimitedCount) {
$descriptor->repeated = PHP_INT_MAX;
// A repeated field of unlimited size cannot be followed by anything, because it would not be
// clear which of the following values still belong to the repeated field vs to the next field.
$nextIndex = null;
} elseif ($maxCount !== null) {
$descriptor->repeated = $maxCount;
// If there's another field value after this repeated field, then a serialized message will
// contain placeholders (i.e. empty field values separated by possibly hundreds of `+`) to fill
// up to the repeated field's maximum length, after which the next message continues at the next
// index.
$nextIndex += $maxCount;
} else {
throw new \InvalidArgumentException(
"Repeated property $property needs @Max(.) or (rarely) @Unlimited annotation"
);
}
} elseif ($maxCount !== null) {
throw new \InvalidArgumentException("@Max() annotation not recognized on single $property");
} elseif ($unlimitedCount) {
throw new \InvalidArgumentException("@Unlimited annotation not recognized on single $property");
} else {
++$nextIndex; // Singular field, so the index advances by 1.
}
$descriptor->type = static::resolveType($type, $property->getDeclaringClass());
} elseif ($type = $property->getType()) {
$descriptor->optional = $type->allowsNull();
if ($type instanceof \ReflectionUnionType) {
throw new \InvalidArgumentException("Union type not supported for $property");
} elseif ($type->getName() === 'array') {
throw new \InvalidArgumentException("Array type must use @type annotation on $property");
} elseif ($type->isBuiltin()) {
$descriptor->type = $type->getName();
} else {
try {
$descriptor->type = new \ReflectionClass($type->getName());
} catch (\ReflectionException $e) {
throw new \InvalidArgumentException(
"Cannot resolve type {$type->getName()} for $property", 0, $e);
}
}
++$nextIndex; // Singular field, so the index advances by 1.
} else {
throw new \InvalidArgumentException("Need type on property $property");
}
$this->elements[$index] = $descriptor;
}
if (count($this->elements) === 0) {
throw new \InvalidArgumentException("No fields found in $clazz->name");
}
ksort($this->elements); // Make sure elements are parsed in wire-format order.
$this->maxIndex = $nextIndex === null ? PHP_INT_MAX : $nextIndex - 1;
}
/**
* @param object $obj The object to be validated.
* @throws \InvalidArgumentException If any of the fields in the given object is not valid according to the schema
* defined by this descriptor.
*/
public function validateObject($obj): void
{
if (!is_a($obj, $this->class)) {
throw new \InvalidArgumentException("Expected $this->class, got " . gettype($obj));
}
foreach ($this->elements as $elementDescriptor) {
$elementDescriptor->validateField($obj);
}
}
/**
* @param \ReflectionClass $clazz The class name.
* @return \Generator|\ReflectionProperty[] All non-static public properties of the given class and its parents, but
* with the parents' properties *first*.
*/
private static function enumerateProperties(\ReflectionClass $clazz): array|\Generator
{
if ($clazz->getParentClass() !== false) {
yield from static::enumerateProperties($clazz->getParentClass());
}
foreach ($clazz->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
if (!$property->isStatic() && $property->getDeclaringClass()->name === $clazz->name) {
yield $property;
}
}
}
/**
* Looks for the annotation with the given name and extracts the content of the parentheses behind it. For instance,
* when called with the name "Max" and a docComment that contains {@}Max(15), this would return "15".
* @param string $name The name of the annotation.
* @param string $docComment The documentation string of a PHP field.
* @return string|null The content of the annotation, or null if absent.
*/
private static function getAnnotation(string $name, string $docComment): ?string
{
$ret = preg_match("/@$name\\((.*?)\\)/", $docComment, $match);
if ($ret === false) {
throw new \RuntimeException("preg_match failed on $name");
}
return $ret === 1 ? $match[1] : null;
}
/**
* Same as above, with integer parsing.
* @param string $name The name of the annotation.
* @param string $docComment The documentation string of a PHP field.
* @return int|null The value of the annotation as an integer, or null if absent.
*/
private static function getIntAnnotation(string $name, string $docComment): ?int
{
$val = static::getAnnotation($name, $docComment);
if ($val === null) {
return null;
}
if (!is_numeric($val)) {
throw new \InvalidArgumentException("Annotation $name has non-integer value $val");
}
return (int) $val;
}
/**
* @param string $name The name of the annotation.
* @param string $docComment The documentation string of a PHP field.
* @return bool Whether the annotation with the given name is present.
*/
private static function getBoolAnnotation(string $name, string $docComment): bool
{
return str_contains($docComment, "@$name ")
|| str_contains($docComment, "@$name())");
}
/**
* Separate parser for the {@}var` annotation because it does not use parentheses.
* @param string $docComment The documentation string of a PHP field.
* @return string|null The value of the {@}var annotation, or null if absent.
*/
private static function getVarAnnotation(string $docComment): ?string
{
$ret = preg_match('/@var ([^\\s]+)/', $docComment, $match);
if ($ret === false) {
throw new \RuntimeException('preg_match failed for @var');
}
return $ret === 1 ? $match[1] : null;
}
/**
* NOTE: This does *not* resolve `use` statements in the source file.
* @param string $typeName A type name (PHP class name, fully qualified or not) or a scalar type name.
* @param \ReflectionClass $contextClass The class where this type name was encountered, used for resolution of
* classes in the same package.
* @return string|\ReflectionClass The class that the type name refers to, or the scalar type name as a string.
*/
private static function resolveType(string $typeName, \ReflectionClass $contextClass): \ReflectionClass|string
{
if (ElementDescriptor::isScalarType($typeName)) {
return $typeName;
}
if ($typeName === 'Bin') {
$typeName = Bin::class;
} elseif (!str_contains($typeName, '\\')) {
// Let's assume it's a relative type name, e.g. `X` mentioned in a file that starts with `namespace Fhp\Y`
// would become `\Fhp\X\Y`.
$typeName = $contextClass->getNamespaceName() . '\\' . $typeName;
}
try {
return new \ReflectionClass($typeName);
} catch (\ReflectionException $e) {
throw new \RuntimeException("$typeName not found in context of " . $contextClass->getName(), 0, $e);
}
}
}