Lines
92.00%
92 / 100
Methods
77.27%
17 / 22
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| encodeObject | 100.00% | 10 / 10 | 100.00% | 1 / 1 | 4 | ||
| encodeObjectList | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | ||
| normalizeObject | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | ||
| decodeObject | 100.00% | 10 / 10 | 100.00% | 1 / 1 | 7 | ||
| decodeObjectList | 0.00% | 0 / 4 | 0.00% | 0 / 1 | 6 | ||
| normalizeFields | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 5 | ||
| normalizeValue | 90.00% | 9 / 10 | 0.00% | 0 / 1 | 9.08 | ||
| normalizeEnum | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | ||
| normalizeNestedObject | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | ||
| normalizeVariant | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 3 | ||
| normalizeConvertedValue | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 2 | ||
| normalizeListItems | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 3 | ||
| normalizeMap | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 2 | ||
| decodeValue | 100.00% | 14 / 14 | 100.00% | 1 / 1 | 22 | ||
| decodeNestedObject | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 2 | ||
| decodeVariant | 88.88% | 8 / 9 | 0.00% | 0 / 1 | 4.02 | ||
| decodeConvertedValue | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | ||
| decodeFragment | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | ||
| decodeListItems | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 4 | ||
| decodeEnum | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 4 | ||
| createWrongTypeException | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| 17 | final class WireMapper | |
| 18 | { | |
| 19 | public function __construct(public readonly ClassShapeReader $shapes) {} | |
| 20 | ||
| 21 | /** @throws JsonException|StewartException */ | |
| 22 | public function encodeObject(object $dto): string | |
| 23 | { | |
| 24 | $shape = $this->shapes->resolveClassShape($dto::class); | |
| 25 | ||
| 26 | if (!$shape->holdsFragment) { | |
| 27 | return JsonEncoder::encodeToJson($this->normalizeFields($shape, $dto)); | |
| 28 | } | |
| 29 | ||
| 30 | $members = []; | |
| 31 | ||
| 32 | foreach ($shape->fields as $field) { | |
| 33 | $value = $dto->{$field->property}; | |
| 34 | $members[] = JsonEncoder::encodeToJson($field->key) . ':' . ($value instanceof JsonFragment | |
| 35 | ? $value->encodeToJson($this) | |
| 36 | : JsonEncoder::encodeToJson($this->normalizeValue($field->value, $value))); | |
| 37 | } | |
| 38 | ||
| 39 | return '{' . implode(',', $members) . '}'; | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * @param list<object> $dtos | |
| 44 | * @throws JsonException|StewartException | |
| 45 | */ | |
| 46 | public function encodeObjectList(array $dtos): string | |
| 47 | { | |
| 48 | return JsonEncoder::encodeToJson(array_map($this->normalizeObject(...), $dtos)); | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * @return array<string, mixed>|stdClass | |
| 53 | * @throws StewartException | |
| 54 | */ | |
| 55 | public function normalizeObject(object $dto): array|stdClass | |
| 56 | { | |
| 57 | return $this->normalizeFields($this->shapes->resolveClassShape($dto::class), $dto); | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * @template T of object | |
| 62 | * @param class-string<T> $class | |
| 63 | * @param array<array-key, mixed> $data | |
| 64 | * @return T | |
| 65 | * @throws StewartException | |
| 66 | */ | |
| 67 | public function decodeObject(string $class, array $data, string $path = ''): object | |
| 68 | { | |
| 69 | $arguments = []; | |
| 70 | ||
| 71 | foreach ($this->shapes->resolveClassShape($class)->fields as $field) { | |
| 72 | $fieldPath = $path === '' ? $field->key : $path . '.' . $field->key; | |
| 73 | ||
| 74 | if (!\array_key_exists($field->key, $data)) { | |
| 75 | throw JsonShapeException::missing($fieldPath, $field->value->describeExpectedValue()); | |
| 76 | } | |
| 77 | ||
| 78 | $value = $data[$field->key]; | |
| 79 | $arguments[] = $value === null && $field->nullable ? null : $this->decodeValue($field->value, $value, $fieldPath); | |
| 80 | } | |
| 81 | ||
| 82 | try { | |
| 83 | return new $class(...$arguments); | |
| 84 | } catch (StewartException $e) { | |
| 85 | throw JsonShapeException::createForReason(JsonShapeError::ObjectRefused, $e, key: $path); | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * @template T of object | |
| 91 | * @param class-string<T> $class | |
| 92 | * @return list<T> | |
| 93 | * @throws StewartException | |
| 94 | */ | |
| 95 | public function decodeObjectList(string $class, mixed $data, string $path): array | |
| 96 | { | |
| 97 | $element = $this->shapes->describeValueShape($class, $class); | |
| 98 | $items = $this->decodeValue(new ValueShape(ValueKind::List, element: $element), $data, $path); | |
| 99 | \assert(\is_array($items) && array_is_list($items)); | |
| 100 | ||
| 101 | /** @var list<T> $items */ | |
| 102 | return $items; | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * @return array<string, mixed>|stdClass | |
| 107 | * @throws StewartException | |
| 108 | */ | |
| 109 | private function normalizeFields(ClassShape $shape, object $dto): array|stdClass | |
| 110 | { | |
| 111 | $normalized = []; | |
| 112 | ||
| 113 | foreach ($shape->fields as $field) { | |
| 114 | $value = $dto->{$field->property}; | |
| 115 | $normalized[$field->key] = $value === null || $field->value->passesThrough ? $value : $this->normalizeValue($field->value, $value); | |
| 116 | } | |
| 117 | ||
| 118 | return $normalized === [] ? new stdClass() : $normalized; | |
| 119 | } | |
| 120 | ||
| 121 | /** @throws StewartException */ | |
| 122 | private function normalizeValue(ValueShape $shape, mixed $value): mixed | |
| 123 | { | |
| 124 | if ($value === null) { | |
| 125 | return null; | |
| 126 | } | |
| 127 | ||
| 128 | return match ($shape->kind) { | |
| 129 | ValueKind::Enum => $this->normalizeEnum($value), | |
| 130 | ValueKind::Dto => $this->normalizeNestedObject($value), | |
| 131 | ValueKind::Variant => $this->normalizeVariant($shape, $value), | |
| 132 | ValueKind::Converted => $this->normalizeConvertedValue($shape, $value), | |
| 133 | ValueKind::List => $this->normalizeListItems($shape, $value), | |
| 134 | ValueKind::Map => $this->normalizeMap($value), | |
| 135 | default => $value, | |
| 136 | }; | |
| 137 | } | |
| 138 | ||
| 139 | private function normalizeEnum(mixed $value): int|string | |
| 140 | { | |
| 141 | \assert($value instanceof BackedEnum); | |
| 142 | ||
| 143 | return $value->value; | |
| 144 | } | |
| 145 | ||
| 146 | /** | |
| 147 | * @return array<string, mixed>|stdClass | |
| 148 | * @throws StewartException | |
| 149 | */ | |
| 150 | private function normalizeNestedObject(mixed $value): array|stdClass | |
| 151 | { | |
| 152 | \assert(\is_object($value)); | |
| 153 | ||
| 154 | return $this->normalizeFields($this->shapes->resolveClassShape($value::class), $value); | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * @return array<string, mixed> | |
| 159 | * @throws StewartException | |
| 160 | */ | |
| 161 | private function normalizeVariant(ValueShape $shape, mixed $value): array | |
| 162 | { | |
| 163 | \assert(\is_object($value) && $shape->variants !== null); | |
| 164 | $name = $shape->variants->findVariantName($value) ?? throw new LogicException(\sprintf('%s is not a listed variant of %s.', $value::class, $shape->type)); | |
| 165 | ||
| 166 | $fields = $this->normalizeNestedObject($value); | |
| 167 | ||
| 168 | return [$shape->variants->key => $name, ...($fields instanceof stdClass ? [] : $fields)]; | |
| 169 | } | |
| 170 | ||
| 171 | private function normalizeConvertedValue(ValueShape $shape, mixed $value): mixed | |
| 172 | { | |
| 173 | \assert(\is_object($value) && $shape->converter !== null); | |
| 174 | ||
| 175 | return $shape->converter->encodeValue($value); | |
| 176 | } | |
| 177 | ||
| 178 | /** | |
| 179 | * @return array<array-key, mixed> | |
| 180 | * @throws StewartException | |
| 181 | */ | |
| 182 | private function normalizeListItems(ValueShape $shape, mixed $value): array | |
| 183 | { | |
| 184 | \assert(\is_array($value) && $shape->element !== null); | |
| 185 | $element = $shape->element; | |
| 186 | ||
| 187 | return $element->passesThrough ? $value : array_map(fn(mixed $item): mixed => $this->normalizeValue($element, $item), $value); | |
| 188 | } | |
| 189 | ||
| 190 | /** @return array<array-key, mixed>|object */ | |
| 191 | private function normalizeMap(mixed $value): array|object | |
| 192 | { | |
| 193 | \assert(\is_array($value)); | |
| 194 | ||
| 195 | // Cast so a list-shaped map encodes as a JSON object, not an array. | |
| 196 | return array_is_list($value) ? (object) $value : $value; | |
| 197 | } | |
| 198 | ||
| 199 | /** @throws StewartException */ | |
| 200 | private function decodeValue(ValueShape $shape, mixed $value, string $path): mixed | |
| 201 | { | |
| 202 | return match ($shape->kind) { | |
| 203 | ValueKind::String => \is_string($value) ? $value : throw $this->createWrongTypeException($shape, $path, $value), | |
| 204 | ValueKind::Int => \is_int($value) ? $value : throw $this->createWrongTypeException($shape, $path, $value), | |
| 205 | ValueKind::Float => \is_float($value) || \is_int($value) ? (float) $value : throw $this->createWrongTypeException($shape, $path, $value), | |
| 206 | ValueKind::Number => \is_float($value) || \is_int($value) ? $value : throw $this->createWrongTypeException($shape, $path, $value), | |
| 207 | ValueKind::Bool => \is_bool($value) ? $value : throw $this->createWrongTypeException($shape, $path, $value), | |
| 208 | ValueKind::Json => $value, | |
| 209 | ValueKind::Enum => $this->decodeEnum($shape, $value, $path), | |
| 210 | ValueKind::Dto => $this->decodeNestedObject($shape, $value, $path), | |
| 211 | ValueKind::Variant => $this->decodeVariant($shape, $value, $path), | |
| 212 | ValueKind::Converted => $this->decodeConvertedValue($shape, $value, $path), | |
| 213 | ValueKind::Map => \is_array($value) ? JsonShape::treatKeysAsStrings($value) : throw $this->createWrongTypeException($shape, $path, $value), | |
| 214 | ValueKind::Fragment => $this->decodeFragment($shape, $value, $path), | |
| 215 | ValueKind::List => $this->decodeListItems($shape, $value, $path), | |
| 216 | }; | |
| 217 | } | |
| 218 | ||
| 219 | /** @throws StewartException */ | |
| 220 | private function decodeNestedObject(ValueShape $shape, mixed $value, string $path): object | |
| 221 | { | |
| 222 | /** @var class-string $class */ | |
| 223 | $class = $shape->type; | |
| 224 | ||
| 225 | return \is_array($value) ? $this->decodeObject($class, $value, $path) : throw $this->createWrongTypeException($shape, $path, $value); | |
| 226 | } | |
| 227 | ||
| 228 | /** @throws StewartException */ | |
| 229 | private function decodeVariant(ValueShape $shape, mixed $value, string $path): object | |
| 230 | { | |
| 231 | \assert($shape->variants !== null); | |
| 232 | ||
| 233 | if (!\is_array($value)) { | |
| 234 | throw $this->createWrongTypeException($shape, $path, $value); | |
| 235 | } | |
| 236 | ||
| 237 | $key = $shape->variants->key; | |
| 238 | $name = $value[$key] ?? null; | |
| 239 | $class = \is_string($name) ? $shape->variants->variants[$name] ?? null : null; | |
| 240 | ||
| 241 | if ($class === null) { | |
| 242 | throw JsonShapeException::unexpectedValue($path . '.' . $key, 'one of ' . implode(', ', array_keys($shape->variants->variants)), $name); | |
| 243 | } | |
| 244 | ||
| 245 | return $this->decodeObject($class, $value, $path); | |
| 246 | } | |
| 247 | ||
| 248 | /** @throws StewartException */ | |
| 249 | private function decodeConvertedValue(ValueShape $shape, mixed $value, string $path): mixed | |
| 250 | { | |
| 251 | \assert($shape->converter !== null); | |
| 252 | ||
| 253 | return $shape->converter->decodeValue($value, $path); | |
| 254 | } | |
| 255 | ||
| 256 | /** @throws StewartException */ | |
| 257 | private function decodeFragment(ValueShape $shape, mixed $value, string $path): JsonFragment | |
| 258 | { | |
| 259 | /** @var class-string<JsonFragment> $fragment */ | |
| 260 | $fragment = $shape->type; | |
| 261 | ||
| 262 | return $fragment::fromDecodedValue($value, $path, $this); | |
| 263 | } | |
| 264 | ||
| 265 | /** | |
| 266 | * @return list<mixed> | |
| 267 | * @throws StewartException | |
| 268 | */ | |
| 269 | private function decodeListItems(ValueShape $shape, mixed $value, string $path): array | |
| 270 | { | |
| 271 | if (!\is_array($value) || !array_is_list($value)) { | |
| 272 | throw $this->createWrongTypeException($shape, $path, $value); | |
| 273 | } | |
| 274 | ||
| 275 | \assert($shape->element !== null); | |
| 276 | $items = []; | |
| 277 | ||
| 278 | foreach ($value as $index => $item) { | |
| 279 | $items[] = $this->decodeValue($shape->element, $item, $path . '.' . $index); | |
| 280 | } | |
| 281 | ||
| 282 | return $items; | |
| 283 | } | |
| 284 | ||
| 285 | /** @throws JsonShapeException */ | |
| 286 | private function decodeEnum(ValueShape $shape, mixed $value, string $path): BackedEnum | |
| 287 | { | |
| 288 | $enum = $shape->type; | |
| 289 | \assert(is_subclass_of($enum, BackedEnum::class)); | |
| 290 | $expected = 'one of ' . implode(', ', array_map(static fn(BackedEnum $case): string => (string) $case->value, $enum::cases())); | |
| 291 | ||
| 292 | if (!(\is_int($value) || \is_string($value)) || get_debug_type($value) !== $shape->enumBackingType) { | |
| 293 | throw JsonShapeException::wrongType($path, $expected, get_debug_type($value)); | |
| 294 | } | |
| 295 | ||
| 296 | return $enum::tryFrom($value) ?? throw JsonShapeException::unexpectedValue($path, $expected, $value); | |
| 297 | } | |
| 298 | ||
| 299 | private function createWrongTypeException(ValueShape $shape, string $path, mixed $value): JsonShapeException | |
| 300 | { | |
| 301 | return JsonShapeException::wrongType($path, $shape->describeExpectedValue(), get_debug_type($value)); | |
| 302 | } | |
| 303 | } |