Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
94.49% |
240 / 254 |
|
82.61% |
19 / 23 |
CRAP | |
0.00% |
0 / 1 |
| Nodes | |
94.49% |
240 / 254 |
|
82.61% |
19 / 23 |
83.13 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| list | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| search | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| query | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
5.01 | |||
| create | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
| put | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
4 | |||
| patch | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| delete | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| listing | |
100.00% |
41 / 41 |
|
100.00% |
1 / 1 |
9 | |||
| sync | |
100.00% |
43 / 43 |
|
100.00% |
1 / 1 |
6 | |||
| narrow | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
6 | |||
| guarded | |
23.08% |
3 / 13 |
|
0.00% |
0 / 1 |
11.28 | |||
| passes | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| readable | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
| assertWritable | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
4.25 | |||
| allow | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| type | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| preconditions | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
7 | |||
| body | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
6 | |||
| written | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
3.00 | |||
| actor | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| user | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Api\Controller; |
| 6 | |
| 7 | use Celema\Core\Exception\HttpBadRequest; |
| 8 | use Celema\Core\Exception\HttpConflict; |
| 9 | use Celema\Core\Exception\HttpForbidden; |
| 10 | use Celema\Core\Exception\HttpNotFound; |
| 11 | use Celema\Core\Exception\HttpUnauthorized; |
| 12 | use Celema\Core\Request; |
| 13 | use Closure; |
| 14 | use Cosray\Actor; |
| 15 | use Cosray\Api\Exception\PreconditionFailed; |
| 16 | use Cosray\Api\Exception\UnsupportedMediaType; |
| 17 | use Cosray\Api\NodeQuery; |
| 18 | use Cosray\Api\PreviewToken; |
| 19 | use Cosray\Api\Responses; |
| 20 | use Cosray\Api\Serializer; |
| 21 | use Cosray\Api\SyncCursor; |
| 22 | use Cosray\Api\Writer; |
| 23 | use Cosray\Cms; |
| 24 | use Cosray\Config; |
| 25 | use Cosray\Exception\ParserException; |
| 26 | use Cosray\Finder\Nodes as NodeFinder; |
| 27 | use Cosray\Finder\Order; |
| 28 | use Cosray\Node\Factory; |
| 29 | use Cosray\Node\Wrapper; |
| 30 | use Cosray\Security\NodeAccess; |
| 31 | use Cosray\User; |
| 32 | use JsonException; |
| 33 | use PDOException; |
| 34 | use Psr\Http\Message\ResponseInterface as Response; |
| 35 | |
| 36 | final class Nodes |
| 37 | { |
| 38 | public function __construct( |
| 39 | private readonly Responses $responses, |
| 40 | private readonly NodeAccess $access, |
| 41 | private readonly Serializer $serializer, |
| 42 | ) {} |
| 43 | |
| 44 | public function list(Request $request, Cms $cms): Response |
| 45 | { |
| 46 | return $this->listing($request, $cms, NodeQuery::fromQuery($request->params())); |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * Full-text search over what the website lists: `q` takes the words, |
| 51 | * every listing parameter narrows the result, and relevance orders it |
| 52 | * unless `order` names another order. |
| 53 | */ |
| 54 | public function search(Request $request, Cms $cms): Response |
| 55 | { |
| 56 | $params = $request->params(); |
| 57 | $q = $params['q'] ?? null; |
| 58 | |
| 59 | if (!is_string($q) || trim($q) === '') { |
| 60 | throw new HttpBadRequest($request, payload: ['message' => '`q` names the words to search for.']); |
| 61 | } |
| 62 | |
| 63 | unset($params['q']); |
| 64 | |
| 65 | return $this->listing($request, $cms, NodeQuery::fromQuery($params), trim($q)); |
| 66 | } |
| 67 | |
| 68 | /** The listing with its parameters in a JSON body, for uid lists and filters too long for a URL. */ |
| 69 | public function query(Request $request, Cms $cms): Response |
| 70 | { |
| 71 | return $this->listing($request, $cms, NodeQuery::fromBody($this->body($request, ['application/json']))); |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * `preview` takes a token the panel issued for this node and answers |
| 76 | * with its working copy, published or not. |
| 77 | */ |
| 78 | public function get(Request $request, Cms $cms, Config $config, string $uid): Response |
| 79 | { |
| 80 | $params = $request->params(); |
| 81 | $preview = $params['preview'] ?? null; |
| 82 | |
| 83 | if ($preview !== null) { |
| 84 | $tokens = PreviewToken::fromConfig($config); |
| 85 | |
| 86 | if ($tokens === null || !is_string($preview) || $tokens->uid($preview) !== $uid) { |
| 87 | throw new HttpUnauthorized($request, payload: [ |
| 88 | 'message' => 'The preview token is invalid or expired.', |
| 89 | ]); |
| 90 | } |
| 91 | |
| 92 | $node = $cms->node->working($uid) |
| 93 | ?? throw new HttpNotFound($request, payload: ['message' => "There is no node '{$uid}'."]); |
| 94 | } else { |
| 95 | $deleted = NodeQuery::state($params['deleted'] ?? null, 'deleted', false); |
| 96 | $node = $this->readable($request, $cms, $uid, $deleted); |
| 97 | } |
| 98 | |
| 99 | return $this->responses->json($this->serializer->node( |
| 100 | $node, |
| 101 | NodeQuery::source($params['view'] ?? null), |
| 102 | NodeQuery::fields($params['fields'] ?? null), |
| 103 | ), headers: ['ETag' => Serializer::etag($node)]); |
| 104 | } |
| 105 | |
| 106 | /** Creates a node; the body names its type and may choose its uid. */ |
| 107 | public function create(Request $request, Cms $cms, Writer $writer): Response |
| 108 | { |
| 109 | $body = $this->body($request, ['application/json']); |
| 110 | $class = $this->type($request, $body['type'] ?? null); |
| 111 | $uid = $body['uid'] ?? null; |
| 112 | |
| 113 | if ($uid !== null && (!is_string($uid) || preg_match('/^[A-Za-z0-9-_.]{1,64}$/', $uid) !== 1)) { |
| 114 | throw new HttpBadRequest($request, payload: [ |
| 115 | 'message' => '`uid` is up to 64 letters, digits, dashes, underscores and dots.', |
| 116 | ]); |
| 117 | } |
| 118 | |
| 119 | $this->allow($request, NodeAccess::CREATE, $class); |
| 120 | $uid = $writer->create($class, $body, $this->actor($request), $uid); |
| 121 | |
| 122 | return $this->written($request, $cms, $uid, 201); |
| 123 | } |
| 124 | |
| 125 | /** |
| 126 | * Creates the node under this uid or replaces its content. `If-Match` |
| 127 | * guards a replacement, `If-None-Match: *` makes the request create only. |
| 128 | */ |
| 129 | public function put(Request $request, Cms $cms, Writer $writer, string $uid): Response |
| 130 | { |
| 131 | $body = $this->body($request, ['application/json']); |
| 132 | |
| 133 | if (array_key_exists('uid', $body) && $body['uid'] !== $uid) { |
| 134 | throw new HttpBadRequest($request, payload: [ |
| 135 | 'message' => 'The uid in the body differs from the uid in the URL.', |
| 136 | ]); |
| 137 | } |
| 138 | |
| 139 | $status = $writer->exclusive($uid, function () use ($request, $cms, $writer, $uid, $body): int { |
| 140 | $node = $cms->node->get(['uid' => $uid, 'published' => null]); |
| 141 | $this->preconditions($request, $node); |
| 142 | |
| 143 | if ($node === null) { |
| 144 | $class = $this->type($request, $body['type'] ?? null); |
| 145 | $this->allow($request, NodeAccess::CREATE, $class); |
| 146 | $writer->create($class, $body, $this->actor($request), $uid); |
| 147 | |
| 148 | return 201; |
| 149 | } |
| 150 | |
| 151 | $this->assertWritable($request, $node); |
| 152 | $writer->replace($node, $body, $this->actor($request)); |
| 153 | |
| 154 | return 200; |
| 155 | }); |
| 156 | |
| 157 | return $this->written($request, $cms, $uid, $status); |
| 158 | } |
| 159 | |
| 160 | /** Applies a JSON Merge Patch to the node and to an editor's working copy of it. */ |
| 161 | public function patch(Request $request, Cms $cms, Writer $writer, string $uid): Response |
| 162 | { |
| 163 | $patch = $this->body($request, ['application/merge-patch+json', 'application/json']); |
| 164 | $writer->exclusive($uid, function () use ($request, $cms, $writer, $uid, $patch): void { |
| 165 | $node = $this->readable($request, $cms, $uid, false); |
| 166 | $this->preconditions($request, $node); |
| 167 | $this->assertWritable($request, $node); |
| 168 | $writer->patch($node, $patch, $this->actor($request)); |
| 169 | }); |
| 170 | |
| 171 | return $this->written($request, $cms, $uid, 200); |
| 172 | } |
| 173 | |
| 174 | /** Soft-deletes the node; `?children=true` takes its descendants along. */ |
| 175 | public function delete(Request $request, Cms $cms, Writer $writer, string $uid): Response |
| 176 | { |
| 177 | $children = NodeQuery::state($request->params()['children'] ?? null, 'children', false) === true; |
| 178 | $writer->exclusive($uid, function () use ($request, $cms, $writer, $uid, $children): void { |
| 179 | $node = $this->readable($request, $cms, $uid, false); |
| 180 | $this->preconditions($request, $node); |
| 181 | $this->allow($request, NodeAccess::DELETE, Wrapper::unwrap($node)::class); |
| 182 | $writer->delete($node, $this->actor($request), $children); |
| 183 | }); |
| 184 | |
| 185 | return $this->responses->empty(); |
| 186 | } |
| 187 | |
| 188 | private function listing(Request $request, Cms $cms, NodeQuery $query, ?string $fulltext = null): Response |
| 189 | { |
| 190 | $unknown = array_diff($query->types, array_keys($this->access->registered())); |
| 191 | |
| 192 | if ($unknown !== []) { |
| 193 | throw new HttpBadRequest($request, payload: [ |
| 194 | 'message' => 'Unknown type: ' . implode(', ', $unknown) . '.', |
| 195 | ]); |
| 196 | } |
| 197 | |
| 198 | if ($query->sync !== null) { |
| 199 | if ($fulltext !== null) { |
| 200 | throw new HttpBadRequest($request, payload: [ |
| 201 | 'message' => 'A search does not sync; list the nodes with `changedSince` instead.', |
| 202 | ]); |
| 203 | } |
| 204 | |
| 205 | return $this->sync($request, $cms, $query, $query->sync); |
| 206 | } |
| 207 | |
| 208 | $readable = $this->access->readable(self::user($request)); |
| 209 | $nodes = $cms |
| 210 | ->nodes() |
| 211 | ->published($query->published) |
| 212 | ->hidden($query->hidden) |
| 213 | ->deleted($query->deleted) |
| 214 | ->readable($readable['live'], $readable['any']); |
| 215 | |
| 216 | if ($fulltext !== null) { |
| 217 | $nodes->fulltext($fulltext); |
| 218 | } |
| 219 | |
| 220 | [$items, $total] = $this->guarded($request, function () use ($nodes, $query, $fulltext): array { |
| 221 | $this->narrow($nodes, $query); |
| 222 | $total = $nodes->count(); |
| 223 | |
| 224 | if ($fulltext === null || $query->ordered) { |
| 225 | $nodes->order(...$query->order); |
| 226 | } |
| 227 | |
| 228 | $nodes->limit($query->limit)->offset($query->offset); |
| 229 | $items = []; |
| 230 | |
| 231 | foreach ($nodes as $node) { |
| 232 | $item = $this->serializer->node($node, $query->source, $query->fields); |
| 233 | $search = $node->meta->search; |
| 234 | |
| 235 | if ($search !== null) { |
| 236 | $item['search'] = ['score' => $search->score, 'snippet' => (string) $search->snippet->html()]; |
| 237 | } |
| 238 | |
| 239 | $items[] = $item; |
| 240 | } |
| 241 | |
| 242 | return [$items, $total]; |
| 243 | }); |
| 244 | |
| 245 | return $this->responses->json([ |
| 246 | 'items' => $items, |
| 247 | 'total' => $total, |
| 248 | 'limit' => $query->limit, |
| 249 | 'offset' => $query->offset, |
| 250 | ]); |
| 251 | } |
| 252 | |
| 253 | /** |
| 254 | * The next changes after the cursor, in change order: nodes that pass the |
| 255 | * state filters come as items, the others, which the client should drop, |
| 256 | * under `removed` with only their uid, type and change time. `cursor` |
| 257 | * goes on after the last change given, `more` tells whether others |
| 258 | * follow right away. |
| 259 | */ |
| 260 | private function sync(Request $request, Cms $cms, NodeQuery $query, SyncCursor $cursor): Response |
| 261 | { |
| 262 | $user = self::user($request); |
| 263 | $readable = $this->access->readable($user); |
| 264 | $nodes = $cms |
| 265 | ->nodes() |
| 266 | ->published(null) |
| 267 | ->hidden(null) |
| 268 | ->deleted(null) |
| 269 | ->readable([], [...$readable['live'], ...$readable['any']]); |
| 270 | |
| 271 | [$items, $removed, $last, $more] = $this->guarded($request, function () use ( |
| 272 | $nodes, |
| 273 | $query, |
| 274 | $cursor, |
| 275 | $user, |
| 276 | ): array { |
| 277 | $this->narrow($nodes, $query); |
| 278 | $nodes |
| 279 | ->match($cursor->filter()) |
| 280 | ->order(new Order('changed'), new Order('uid')) |
| 281 | ->limit($query->limit + 1); |
| 282 | $items = []; |
| 283 | $removed = []; |
| 284 | $last = null; |
| 285 | |
| 286 | foreach ($nodes as $node) { |
| 287 | if ((count($items) + count($removed)) === $query->limit) { |
| 288 | return [$items, $removed, $last, true]; |
| 289 | } |
| 290 | |
| 291 | $last = Wrapper::unwrap($node); |
| 292 | |
| 293 | if (self::passes($last, $query) && $this->access->reads($user, $node)) { |
| 294 | $items[] = $this->serializer->node($node, $query->source, $query->fields); |
| 295 | |
| 296 | continue; |
| 297 | } |
| 298 | |
| 299 | $item = $this->serializer->node($node, fields: []); |
| 300 | $removed[] = ['uid' => $item['uid'], 'type' => $item['type'], 'changed' => $item['changed']]; |
| 301 | } |
| 302 | |
| 303 | return [$items, $removed, $last, false]; |
| 304 | }); |
| 305 | $next = $last === null |
| 306 | ? $cursor |
| 307 | : SyncCursor::after((string) Factory::meta($last, 'changed'), (string) Factory::meta($last, 'uid')); |
| 308 | |
| 309 | return $this->responses->json([ |
| 310 | 'items' => $items, |
| 311 | 'removed' => $removed, |
| 312 | 'limit' => $query->limit, |
| 313 | 'cursor' => $next->token(), |
| 314 | 'more' => $more, |
| 315 | ]); |
| 316 | } |
| 317 | |
| 318 | /** The query's conditions besides the states, which listing and sync treat apart. */ |
| 319 | private function narrow(NodeFinder $nodes, NodeQuery $query): void |
| 320 | { |
| 321 | if ($query->types !== []) { |
| 322 | $nodes->types(...$query->types); |
| 323 | } |
| 324 | |
| 325 | if ($query->uids !== []) { |
| 326 | $nodes->only(...$query->uids); |
| 327 | } |
| 328 | |
| 329 | if ($query->handles !== []) { |
| 330 | $nodes->match(['handle' => ['in' => $query->handles]]); |
| 331 | } |
| 332 | |
| 333 | if ($query->parent !== null) { |
| 334 | $nodes->childrenOf($query->parent); |
| 335 | } |
| 336 | |
| 337 | if ($query->filter !== null) { |
| 338 | $nodes->match($query->filter, NodeQuery::BUILTINS); |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | /** |
| 343 | * Runs the query, answering a filter or order the stored values do not |
| 344 | * fit with 400. |
| 345 | * |
| 346 | * @template T |
| 347 | * |
| 348 | * @param Closure(): T $query |
| 349 | * @return T |
| 350 | */ |
| 351 | private function guarded(Request $request, Closure $query): mixed |
| 352 | { |
| 353 | try { |
| 354 | return $query(); |
| 355 | } catch (ParserException $e) { |
| 356 | throw new HttpBadRequest($request, payload: ['message' => $e->getMessage()], previous: $e); |
| 357 | } catch (PDOException $e) { |
| 358 | // Class 22, data exceptions: a typed order or filter met values of another type. |
| 359 | if (!str_starts_with((string) $e->getCode(), '22')) { |
| 360 | throw $e; |
| 361 | } |
| 362 | |
| 363 | throw new HttpBadRequest( |
| 364 | $request, |
| 365 | payload: [ |
| 366 | 'message' => 'The filter or order does not fit the stored values.', |
| 367 | ], |
| 368 | previous: $e, |
| 369 | ); |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | /** Whether the node is in the states the query asks for. */ |
| 374 | private static function passes(object $node, NodeQuery $query): bool |
| 375 | { |
| 376 | $states = [ |
| 377 | [$query->published, (bool) Factory::meta($node, 'published')], |
| 378 | [$query->hidden, (bool) Factory::meta($node, 'hidden')], |
| 379 | [$query->deleted, Factory::meta($node, 'deleted') !== null], |
| 380 | ]; |
| 381 | |
| 382 | foreach ($states as [$wanted, $actual]) { |
| 383 | if ($wanted !== null && $wanted !== $actual) { |
| 384 | return false; |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | return true; |
| 389 | } |
| 390 | |
| 391 | /** A node the client may read; any other answers 404, as if it did not exist. */ |
| 392 | private function readable(Request $request, Cms $cms, string $uid, ?bool $deleted): object |
| 393 | { |
| 394 | $node = $cms->node->get(array_filter( |
| 395 | [ |
| 396 | 'uid' => $uid, |
| 397 | 'published' => null, |
| 398 | 'deleted' => $deleted, |
| 399 | ], |
| 400 | static fn(mixed $value): bool => $value !== null, |
| 401 | )); |
| 402 | |
| 403 | if ($node === null || !$this->access->reads(self::user($request), $node)) { |
| 404 | throw new HttpNotFound($request, payload: ['message' => "There is no node '{$uid}'."]); |
| 405 | } |
| 406 | |
| 407 | return $node; |
| 408 | } |
| 409 | |
| 410 | private function assertWritable(Request $request, object $node): void |
| 411 | { |
| 412 | $inner = Wrapper::unwrap($node); |
| 413 | |
| 414 | if (!$this->access->reads(self::user($request), $node)) { |
| 415 | throw new HttpNotFound($request, payload: ['message' => 'There is no such node.']); |
| 416 | } |
| 417 | |
| 418 | if (Factory::meta($inner, 'deleted') !== null) { |
| 419 | throw new HttpConflict($request, payload: ['message' => 'The node is deleted.']); |
| 420 | } |
| 421 | |
| 422 | $this->allow($request, NodeAccess::CHANGE, $inner::class); |
| 423 | |
| 424 | if ((bool) Factory::meta($inner, 'locked')) { |
| 425 | throw new HttpConflict($request, payload: ['message' => 'The node is locked.']); |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | /** @param class-string $class */ |
| 430 | private function allow(Request $request, string $action, string $class): void |
| 431 | { |
| 432 | if (!$this->access->allows(self::user($request), $action, $class)) { |
| 433 | throw new HttpForbidden($request, payload: [ |
| 434 | 'message' => "The token may not {$action} nodes of this type.", |
| 435 | ]); |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | /** @return class-string */ |
| 440 | private function type(Request $request, mixed $type): string |
| 441 | { |
| 442 | $registered = $this->access->registered(); |
| 443 | |
| 444 | if (!is_string($type) || !isset($registered[$type])) { |
| 445 | throw new HttpBadRequest($request, payload: ['message' => 'Name a known node `type`.']); |
| 446 | } |
| 447 | |
| 448 | return $registered[$type]; |
| 449 | } |
| 450 | |
| 451 | private function preconditions(Request $request, ?object $node): void |
| 452 | { |
| 453 | $psr = $request->unwrap(); |
| 454 | $ifMatch = trim($psr->getHeaderLine('If-Match')); |
| 455 | $ifNoneMatch = trim($psr->getHeaderLine('If-None-Match')); |
| 456 | |
| 457 | if ($ifNoneMatch === '*' && $node !== null) { |
| 458 | throw new PreconditionFailed($request, payload: ['message' => 'The node exists.']); |
| 459 | } |
| 460 | |
| 461 | if ($ifMatch === '') { |
| 462 | return; |
| 463 | } |
| 464 | |
| 465 | if ($node === null) { |
| 466 | throw new PreconditionFailed($request, payload: ['message' => 'The node does not exist.']); |
| 467 | } |
| 468 | |
| 469 | $tags = array_map(trim(...), explode(',', $ifMatch)); |
| 470 | |
| 471 | if ($ifMatch !== '*' && !in_array(Serializer::etag($node), $tags, true)) { |
| 472 | throw new PreconditionFailed($request, payload: ['message' => 'The node changed since it was read.']); |
| 473 | } |
| 474 | } |
| 475 | |
| 476 | /** |
| 477 | * @param list<string> $types |
| 478 | * @return array<array-key, mixed> |
| 479 | */ |
| 480 | private function body(Request $request, array $types): array |
| 481 | { |
| 482 | $type = strtolower(trim(explode(';', $request->unwrap()->getHeaderLine('Content-Type'))[0])); |
| 483 | |
| 484 | if (!in_array($type, $types, true)) { |
| 485 | throw new UnsupportedMediaType($request, payload: ['message' => 'Send ' . implode(' or ', $types) . '.']); |
| 486 | } |
| 487 | |
| 488 | try { |
| 489 | $body = $request->json(JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR); |
| 490 | } catch (JsonException) { |
| 491 | throw new HttpBadRequest($request, payload: ['message' => 'The body is not valid JSON.']); |
| 492 | } |
| 493 | |
| 494 | if (!is_array($body) || $body !== [] && array_is_list($body)) { |
| 495 | throw new HttpBadRequest($request, payload: ['message' => 'The body is a JSON object.']); |
| 496 | } |
| 497 | |
| 498 | return $body; |
| 499 | } |
| 500 | |
| 501 | /** The node after a write, in the view the request asks for. */ |
| 502 | private function written(Request $request, Cms $cms, string $uid, int $status): Response |
| 503 | { |
| 504 | $node = $cms->node->get(['uid' => $uid, 'published' => null]) |
| 505 | ?? throw new HttpNotFound($request, payload: ['message' => "There is no node '{$uid}'."]); |
| 506 | $params = $request->params(); |
| 507 | $headers = ['ETag' => Serializer::etag($node)]; |
| 508 | |
| 509 | if ($status === 201) { |
| 510 | $headers['Location'] = |
| 511 | rtrim($request->uri()->getPath(), '/') . ($request->method() === 'POST' ? "/{$uid}" : ''); |
| 512 | } |
| 513 | |
| 514 | return $this->responses->json( |
| 515 | $this->serializer->node( |
| 516 | $node, |
| 517 | NodeQuery::source($params['view'] ?? null), |
| 518 | NodeQuery::fields($params['fields'] ?? null), |
| 519 | ), |
| 520 | $status, |
| 521 | $headers, |
| 522 | ); |
| 523 | } |
| 524 | |
| 525 | private function actor(Request $request): Actor |
| 526 | { |
| 527 | $user = self::user($request); |
| 528 | |
| 529 | return $user === null ? Actor::system() : new Actor($user->id); |
| 530 | } |
| 531 | |
| 532 | private static function user(Request $request): ?User |
| 533 | { |
| 534 | $user = $request->get('user', null); |
| 535 | |
| 536 | return $user instanceof User ? $user : null; |
| 537 | } |
| 538 | } |