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
17final 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}