Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.08% covered (success)
97.08%
133 / 137
82.35% covered (warning)
82.35%
14 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Writer
97.08% covered (success)
97.08%
133 / 137
82.35% covered (warning)
82.35%
14 / 17
55
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 exclusive
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 replace
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 patch
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 delete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 patched
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 data
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 content
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 withDefaults
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 assertFields
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 assertMembers
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 contentOf
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 bool
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 nullableString
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 paths
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
7.39
 invalid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Api;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Celema\Core\Exception\HttpConflict;
9use Celema\Sire\Issue;
10use Closure;
11use Cosray\Actor;
12use Cosray\Cms;
13use Cosray\Context;
14use Cosray\Node\Factory;
15use Cosray\Node\PathManager;
16use Cosray\Node\ReservedPaths;
17use Cosray\Node\Store;
18use Cosray\Node\Types;
19use Cosray\Node\Wrapper;
20use Cosray\Util\Transaction;
21
22/**
23 * Turns API request bodies into node writes. A body uses the source view:
24 * `content` holds stored values without the field class, which the writer
25 * adds; the derived members of the representation are ignored, so a node
26 * read in the source view can be written back as it is.
27 */
28final class Writer
29{
30    private const array DERIVED = ['locale', 'title', 'path', 'created', 'changed', 'deleted', 'fields'];
31    private const array MEMBERS = ['type', 'uid', 'handle', 'parent', 'published', 'hidden', 'paths', 'content'];
32
33    private readonly Store $store;
34
35    public function __construct(
36        private readonly Context $context,
37        private readonly Cms $cms,
38        private readonly Types $types,
39    ) {
40        $this->store = new Store(
41            $context->db,
42            new PathManager(ReservedPaths::fromConfig($context->config)),
43            $types,
44            $cms->nodeFactory()->uid(),
45            factory: $cms->nodeFactory(),
46            cms: $cms,
47            context: $context,
48        );
49    }
50
51    /**
52     * Runs the work in a transaction that holds the node, so what the work
53     * reads of it, such as the version an `If-Match` names, stays current
54     * until its writes are done.
55     *
56     * @template T
57     *
58     * @param Closure(): T $work
59     * @return T
60     */
61    public function exclusive(string $uid, Closure $work): mixed
62    {
63        return Transaction::run($this->context->db, function () use ($uid, $work): mixed {
64            $this->store->lock($uid);
65
66            return $work();
67        });
68    }
69
70    /**
71     * Members the body leaves out start empty, content fields at their defaults.
72     *
73     * @param class-string $class
74     * @param array<array-key, mixed> $body
75     * @return string the new node's uid
76     */
77    public function create(string $class, array $body, Actor $actor, ?string $uid = null): string
78    {
79        self::assertMembers($body);
80        $node = $this->cms->nodeFactory()->blueprint($class, $this->context, $this->cms);
81        $data = [
82            'published' => self::bool($body, 'published', false),
83            'hidden' => self::bool($body, 'hidden', false),
84            'locked' => false,
85            'handle' => self::nullableString($body, 'handle', null),
86            'parent' => self::nullableString($body, 'parent', null),
87            'paths' => self::paths($body, []),
88            'content' => $this->content($node, $this->withDefaults($node, self::contentOf($body))),
89        ];
90
91        if ($uid !== null) {
92            $data['uid'] = $uid;
93        }
94
95        return $this->store->create($node, $data, $this->context->locales(), $actor)['uid'];
96    }
97
98    /**
99     * Replaces the content; fields the body leaves out return to their
100     * defaults, while node members it leaves out keep their values.
101     *
102     * @param array<array-key, mixed> $body
103     */
104    public function replace(object $node, array $body, Actor $actor): void
105    {
106        self::assertMembers($body);
107        $inner = Wrapper::unwrap($node);
108
109        if ($this->store->holdsDrafts($inner) && Factory::meta($inner, 'draft') !== null) {
110            throw new HttpConflict(payload: [
111                'message' => 'The node has unpublished changes from an editor; PATCH it, or have them published or discarded first.',
112            ]);
113        }
114
115        $data = $this->data($inner, $body);
116        $data['content'] = $this->content($inner, $this->withDefaults($inner, self::contentOf($body)));
117        $this->store->save($inner, $data, $this->context->locales(), $actor);
118    }
119
120    /**
121     * Applies a JSON Merge Patch to the live node and to an editor's working
122     * copy alike, so publishing that copy later does not undo the patch.
123     * Unpublishing folds the patched working copy into the node, as the
124     * panel does.
125     *
126     * @param array<array-key, mixed> $patch
127     */
128    public function patch(object $node, array $patch, Actor $actor): void
129    {
130        self::assertMembers($patch);
131        $inner = Wrapper::unwrap($node);
132        $uid = (string) Factory::meta($inner, 'uid');
133        $working = Factory::meta($inner, 'draft') !== null ? $this->cms->node->working($uid) : null;
134        $locales = $this->context->locales();
135
136        Transaction::run($this->context->db, function () use ($inner, $patch, $actor, $working, $uid, $locales): void {
137            $live = $this->patched($inner, $patch);
138
139            if ($working === null) {
140                $this->store->save($inner, $live, $locales, $actor);
141
142                return;
143            }
144
145            $draft = Wrapper::unwrap($working);
146            $this->store->draft($draft, $this->patched($draft, $patch), $locales, $actor);
147
148            if ($live['published']) {
149                $this->store->save($inner, $live, $locales, $actor);
150
151                return;
152            }
153
154            $fresh = $this->cms->node->get(['uid' => $uid, 'published' => null]);
155
156            if ($fresh !== null) {
157                $this->store->unpublish(Wrapper::unwrap($fresh), $locales, $actor);
158            }
159        });
160    }
161
162    public function delete(object $node, Actor $actor, bool $children): void
163    {
164        $this->store->delete(Wrapper::unwrap($node), $actor, $children);
165    }
166
167    /**
168     * @param array<array-key, mixed> $patch
169     * @return array<string, mixed>
170     */
171    private function patched(object $node, array $patch): array
172    {
173        $current = Factory::dataFor($node);
174        $stored = is_array($current['content'] ?? null) ? $current['content'] : [];
175        $data = $this->data($node, $patch);
176
177        if (array_key_exists('content', $patch)) {
178            if (!is_array($patch['content']) || $patch['content'] !== [] && array_is_list($patch['content'])) {
179                throw self::invalid(['content'], 'object', '`content` is an object of fields.');
180            }
181
182            $this->assertFields($node, $patch['content']);
183            $merged = MergePatch::apply($stored, $patch['content']);
184            $stored = is_array($merged) ? $merged : [];
185        }
186
187        $data['content'] = $this->content($node, $stored);
188
189        return $data;
190    }
191
192    /**
193     * The node members for a write over an existing node: what the body
194     * names, the node's current values otherwise.
195     *
196     * @param array<array-key, mixed> $body
197     * @return array<string, mixed>
198     */
199    private function data(object $node, array $body): array
200    {
201        $current = Factory::meta(...);
202        $type = $body['type'] ?? null;
203        $uid = $body['uid'] ?? null;
204
205        if ($uid !== null && $uid !== $current($node, 'uid')) {
206            throw self::invalid(['uid'], 'immutable', 'The uid of a node does not change.');
207        }
208
209        if ($type !== null && $type !== $this->types->get($node::class, 'handle')) {
210            throw new HttpConflict(payload: ['message' => 'The node has another type.']);
211        }
212
213        return [
214            'uid' => (string) $current($node, 'uid'),
215            'handle' => self::nullableString($body, 'handle', $current($node, 'handle')),
216            'parent' => self::nullableString($body, 'parent', $current($node, 'parent')),
217            'published' => self::bool($body, 'published', (bool) $current($node, 'published')),
218            'hidden' => self::bool($body, 'hidden', (bool) $current($node, 'hidden')),
219            'locked' => (bool) $current($node, 'locked'),
220            'paths' => self::paths($body, is_array($current($node, 'paths')) ? $current($node, 'paths') : []),
221        ];
222    }
223
224    /**
225     * Adds every field's class, as stored content carries it.
226     *
227     * @param array<array-key, mixed> $values
228     * @return array<string, mixed>
229     */
230    private function content(object $node, array $values): array
231    {
232        $this->assertFields($node, $values);
233        $content = [];
234
235        foreach ($values as $name => $value) {
236            if (!is_array($value) || $value !== [] && array_is_list($value)) {
237                throw self::invalid(['content', $name], 'object', "`{$name}` is an object like {\"value\": â€¦}.");
238            }
239
240            $content[$name] = ['type' => Factory::fieldFor($node, (string) $name)::class, ...$value];
241        }
242
243        return $content;
244    }
245
246    /**
247     * @param array<array-key, mixed> $values
248     * @return array<array-key, mixed>
249     */
250    private function withDefaults(object $node, array $values): array
251    {
252        $this->assertFields($node, $values);
253        $blueprint = $this->cms->nodeFactory()->blueprint($node::class, $this->context, $this->cms);
254        $content = [];
255
256        foreach (Factory::fieldNamesFor($blueprint) as $name) {
257            $default = Factory::fieldFor($blueprint, $name)->structure();
258            unset($default['type']);
259            $content[$name] = $values[$name] ?? $default;
260        }
261
262        return $content;
263    }
264
265    /** @param array<array-key, mixed> $values */
266    private function assertFields(object $node, array $values): void
267    {
268        $unknown = array_diff(array_map(strval(...), array_keys($values)), Factory::fieldNamesFor($node));
269
270        if ($unknown !== []) {
271            throw new HttpBadRequest(payload: [
272                'message' => 'The node type has no field ' . implode(', ', $unknown) . '.',
273                'errors' => array_values(array_map(
274                    static fn(string $name): Issue => new Issue(
275                        ['content', $name],
276                        'unknown',
277                        'The type has no such field.',
278                    ),
279                    $unknown,
280                )),
281            ]);
282        }
283    }
284
285    /** @param array<array-key, mixed> $body */
286    private static function assertMembers(array $body): void
287    {
288        $unknown = array_diff(array_map(strval(...), array_keys($body)), [...self::MEMBERS, ...self::DERIVED]);
289
290        if ($unknown !== []) {
291            throw new HttpBadRequest(payload: ['message' => 'Unknown member: ' . implode(', ', $unknown) . '.']);
292        }
293    }
294
295    /**
296     * @param array<array-key, mixed> $body
297     * @return array<array-key, mixed>
298     */
299    private static function contentOf(array $body): array
300    {
301        $content = $body['content'] ?? [];
302
303        if (!is_array($content) || $content !== [] && array_is_list($content)) {
304            throw self::invalid(['content'], 'object', '`content` is an object of fields.');
305        }
306
307        return $content;
308    }
309
310    /**
311     * Null in a body or patch keeps the current value.
312     *
313     * @param array<array-key, mixed> $body
314     */
315    private static function bool(array $body, string $member, bool $default): bool
316    {
317        $value = $body[$member] ?? $default;
318
319        return is_bool($value) ? $value : throw self::invalid([$member], 'bool', "`{$member}` is true or false.");
320    }
321
322    /** @param array<array-key, mixed> $body */
323    private static function nullableString(array $body, string $member, mixed $default): ?string
324    {
325        $value = array_key_exists($member, $body) ? $body[$member] : $default;
326
327        return (
328            $value === null || is_string($value)
329                ? $value
330                : throw self::invalid([$member], 'string', "`{$member}` is a string or null.")
331        );
332    }
333
334    /**
335     * Paths the body names replace those locales' paths; null removes one.
336     *
337     * @param array<array-key, mixed> $body
338     * @param array<array-key, mixed> $current
339     * @return array<string, string>
340     */
341    private static function paths(array $body, array $current): array
342    {
343        $given = $body['paths'] ?? [];
344
345        if (!is_array($given) || $given !== [] && array_is_list($given)) {
346            throw self::invalid(['paths'], 'object', '`paths` maps locales to paths.');
347        }
348
349        $merged = MergePatch::apply($current, $given);
350        $paths = [];
351
352        foreach (is_array($merged) ? $merged : [] as $locale => $path) {
353            if (!is_string($path)) {
354                throw self::invalid(['paths', (string) $locale], 'string', 'A path is a string.');
355            }
356
357            $paths[(string) $locale] = $path;
358        }
359
360        return $paths;
361    }
362
363    /** @param list<string|int> $path */
364    private static function invalid(array $path, string $code, string $message): HttpBadRequest
365    {
366        return new HttpBadRequest(payload: ['message' => $message, 'errors' => [new Issue($path, $code, $message)]]);
367    }
368}