Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
80 / 80 |
|
100.00% |
10 / 10 |
CRAP | |
100.00% |
1 / 1 |
| Serializer | |
100.00% |
80 / 80 |
|
100.00% |
10 / 10 |
33 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| node | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
6 | |||
| asset | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
5 | |||
| etag | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| fields | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| content | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| path | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| localized | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
| instant | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
3 | |||
| object | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Api; |
| 6 | |
| 7 | use Cosray\Assets\Asset; |
| 8 | use Cosray\Context; |
| 9 | use Cosray\DateTime\Codec; |
| 10 | use Cosray\Node\Factory; |
| 11 | use Cosray\Node\Types; |
| 12 | use Cosray\Node\Wrapper; |
| 13 | use DateTimeImmutable; |
| 14 | use 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 | */ |
| 22 | final 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 | } |