Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
Serializer
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
10 / 10
33
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 node
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
6
 asset
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 etag
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fields
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 content
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 path
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 localized
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 instant
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 object
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Api;
6
7use Cosray\Assets\Asset;
8use Cosray\Context;
9use Cosray\DateTime\Codec;
10use Cosray\Node\Factory;
11use Cosray\Node\Types;
12use Cosray\Node\Wrapper;
13use DateTimeImmutable;
14use stdClass;
15
16/**
17 * Nodes as the API delivers them. Both views share the node's identity and
18 * state; the resolved view adds `fields`, every value resolved for the
19 * request locale through `Value::json()`, the source view adds `content`,
20 * the stored values of every locale without the field class.
21 */
22final class Serializer
23{
24    public function __construct(
25        private readonly Context $context,
26        private readonly Types $types,
27    ) {}
28
29    /**
30     * @param ?list<string> $fields only these fields; null for all, unknown names are skipped
31     * @return array<string, mixed>
32     */
33    public function node(object $node, bool $source = false, ?array $fields = null): array
34    {
35        $inner = Wrapper::unwrap($node);
36        $data = Factory::dataFor($inner);
37        $names = Factory::fieldNamesFor($inner);
38
39        if ($fields !== null) {
40            $names = array_values(array_intersect($names, $fields));
41        }
42
43        $paths = is_array($data['paths'] ?? null) ? $data['paths'] : [];
44        $wrapper = $node instanceof Wrapper ? $node : new Wrapper($inner, Factory::fieldNamesFor($inner), $this->types);
45        $result = [
46            'uid' => (string) $data['uid'],
47            'type' => (string) ($data['type_handle'] ?? $this->types->get($inner::class, 'handle')),
48            'handle' => $data['handle'] ?? null,
49            'parent' => $data['parent'] ?? null,
50            'locale' => $this->context->localeId(),
51            'title' => $wrapper->title(),
52            'path' => $this->path($paths),
53            'paths' => $paths === [] ? new stdClass() : $paths,
54            'published' => (bool) $data['published'],
55            'hidden' => (bool) $data['hidden'],
56            'created' => self::instant($data['created'] ?? null),
57            'changed' => self::instant($data['changed'] ?? null),
58            'deleted' => self::instant($data['deleted'] ?? null),
59        ];
60
61        if ($source) {
62            $result['content'] = self::object($this->content($inner, $data, $names));
63        } else {
64            $result['fields'] = self::object($this->fields($inner, $names));
65        }
66
67        return $result;
68    }
69
70    /**
71     * A catalog asset: its file, its texts resolved for the locale, and a URL
72     * for every named size.
73     *
74     * @return array<string, mixed>
75     */
76    public function asset(Asset $asset): array
77    {
78        $sizes = [];
79
80        foreach ($this->context->config->media->sizes->names() as $name) {
81            $sizes[$name] = $asset->resizable() ? $asset->sizePath($name) : $asset->path();
82        }
83
84        $credit = $asset->meta['credit'] ?? null;
85        $focal = $asset->meta['focal'] ?? null;
86
87        return [
88            'uid' => $asset->uid,
89            'url' => $asset->path(),
90            'filename' => $asset->filename,
91            'kind' => $asset->kind,
92            'mime' => $asset->mime,
93            'bytes' => $asset->bytes,
94            'width' => $asset->width,
95            'height' => $asset->height,
96            'alt' => $this->localized($asset->metaMap('alt')),
97            'title' => $this->localized($asset->metaMap('title')),
98            'caption' => $this->localized($asset->metaMap('caption')),
99            'credit' => is_string($credit) ? $credit : null,
100            'focal' => is_array($focal) ? $focal : null,
101            'sizes' => $sizes,
102        ];
103    }
104
105    /**
106     * The node's version as a strong entity tag. It changes with every write
107     * to the node or to an editor's working copy and is the same in every
108     * view, so a client can send it back in `If-Match`.
109     */
110    public static function etag(object $node): string
111    {
112        $data = Factory::dataFor(Wrapper::unwrap($node));
113        $draft = is_array($data['draft'] ?? null) ? $data['draft']['changed'] ?? '' : '';
114        $version = implode('|', [$data['uid'] ?? '', $data['changed'] ?? '', $data['deleted'] ?? '', $draft]);
115
116        return '"' . substr(hash('sha256', $version), 0, 32) . '"';
117    }
118
119    /**
120     * @param list<string> $names
121     * @return array<string, mixed>
122     */
123    private function fields(object $node, array $names): array
124    {
125        $fields = [];
126
127        foreach ($names as $name) {
128            $fields[$name] = Factory::fieldFor($node, $name)->value()->json();
129        }
130
131        return $fields;
132    }
133
134    /**
135     * @param array<mixed> $data
136     * @param list<string> $names
137     * @return array<string, mixed>
138     */
139    private function content(object $node, array $data, array $names): array
140    {
141        $content = [];
142
143        foreach ($names as $name) {
144            $stored = $data['content'][$name] ?? [];
145            $value = array_merge(Factory::fieldFor($node, $name)->structure(), is_array($stored) ? $stored : []);
146            unset($value['type']);
147            $content[$name] = $value;
148        }
149
150        return $content;
151    }
152
153    /** @param array<mixed> $paths */
154    private function path(array $paths): ?string
155    {
156        $locale = $this->context->locale();
157
158        while ($locale) {
159            $path = $paths[$locale->id] ?? null;
160
161            if (is_string($path) && $path !== '') {
162                return $path;
163            }
164
165            $locale = $locale->fallback();
166        }
167
168        return null;
169    }
170
171    /** @param ?array<array-key, mixed> $map */
172    private function localized(?array $map): string
173    {
174        $locale = $this->context->locale();
175
176        while ($locale) {
177            $value = $map[$locale->id] ?? null;
178
179            if (is_string($value) && $value !== '') {
180                return $value;
181            }
182
183            $locale = $locale->fallback();
184        }
185
186        $neutral = $map['zxx'] ?? null;
187
188        return is_string($neutral) ? $neutral : '';
189    }
190
191    private static function instant(mixed $value): ?string
192    {
193        return is_string($value) && $value !== '' ? Codec::format(new DateTimeImmutable($value)) : null;
194    }
195
196    /**
197     * @param array<string, mixed> $map
198     * @return array<string, mixed>|stdClass
199     */
200    private static function object(array $map): array|stdClass
201    {
202        return $map === [] ? new stdClass() : $map;
203    }
204}