Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.08% |
133 / 137 |
|
82.35% |
14 / 17 |
CRAP | |
0.00% |
0 / 1 |
| Writer | |
97.08% |
133 / 137 |
|
82.35% |
14 / 17 |
55 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| exclusive | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| create | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
| replace | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| patch | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
5 | |||
| delete | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| patched | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
7.04 | |||
| data | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
6 | |||
| content | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| withDefaults | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| assertFields | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
| assertMembers | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| contentOf | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
4.25 | |||
| bool | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| nullableString | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| paths | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
7.39 | |||
| invalid | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Api; |
| 6 | |
| 7 | use Celema\Core\Exception\HttpBadRequest; |
| 8 | use Celema\Core\Exception\HttpConflict; |
| 9 | use Celema\Sire\Issue; |
| 10 | use Closure; |
| 11 | use Cosray\Actor; |
| 12 | use Cosray\Cms; |
| 13 | use Cosray\Context; |
| 14 | use Cosray\Node\Factory; |
| 15 | use Cosray\Node\PathManager; |
| 16 | use Cosray\Node\ReservedPaths; |
| 17 | use Cosray\Node\Store; |
| 18 | use Cosray\Node\Types; |
| 19 | use Cosray\Node\Wrapper; |
| 20 | use 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 | */ |
| 28 | final 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 | } |