Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.94% covered (success)
93.94%
31 / 33
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Route
93.94% covered (success)
93.94%
31 / 33
66.67% covered (warning)
66.67%
2 / 3
19.08
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 route
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
13
 activePath
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Api\Controller;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Celema\Core\Exception\HttpNotFound;
9use Celema\Core\Request;
10use Cosray\Api\NodeQuery;
11use Cosray\Api\Responses;
12use Cosray\Api\Serializer;
13use Cosray\Cms;
14use Cosray\Context;
15use Cosray\Field\Services;
16use Cosray\Security\NodeAccess;
17use Cosray\User;
18use Psr\Http\Message\ResponseInterface as Response;
19
20/**
21 * Resolves a URL path the way the website does, so a headless frontend can
22 * route its own URLs: any published node with that active path, read in the
23 * path's locale, or the path to redirect to when the node moved.
24 */
25final class Route
26{
27    public function __construct(
28        private readonly Responses $responses,
29    ) {}
30
31    public function route(Request $request, Context $context, Services $services, NodeAccess $access): Response
32    {
33        // Finder, values and serializer share this context, so switching
34        // its locale switches the locale the node is read in.
35        $cms = new Cms($context, $services);
36        $serializer = new Serializer($context, $services->types);
37        $params = $request->params();
38        $path = $params['path'] ?? null;
39
40        if (!is_string($path) || !str_starts_with($path, '/')) {
41            throw new HttpBadRequest($request, payload: ['message' => '`path` is a URL path starting with a slash.']);
42        }
43
44        $row = $context->db->paths->byPath(['path' => $path])->first();
45        $node = $row !== null && $row['inactive'] === null ? $cms->node->byPath($path) : null;
46        $user = $request->get('user', null);
47
48        if ($node !== null && !$access->serves($user instanceof User ? $user : null, $node)) {
49            throw new HttpNotFound($request, payload: ['message' => "Nothing answers at '{$path}'."]);
50        }
51
52        if ($node === null) {
53            $redirect = $row !== null ? $this->activePath($context, (int) $row['node']) : null;
54
55            if ($redirect === null || $redirect === $path) {
56                throw new HttpNotFound($request, payload: ['message' => "Nothing answers at '{$path}'."]);
57            }
58
59            return $this->responses->json(['redirect' => $redirect]);
60        }
61
62        $locale = isset($params['locale']) ? $context->locale() : $context->locales()->get((string) $row['locale']);
63        $data = $context->withLocale($locale, static fn(): array => $serializer->node(
64            $node,
65            NodeQuery::source($params['view'] ?? null),
66            NodeQuery::fields($params['fields'] ?? null),
67        ));
68
69        return $this->responses->json(['node' => $data], headers: ['ETag' => Serializer::etag($node)]);
70    }
71
72    /** The node's active path in the request locale or its nearest fallback. */
73    private function activePath(Context $context, int $node): ?string
74    {
75        $paths = [];
76
77        foreach ($context->db->paths->activeByNode(['node' => $node])->all() as $active) {
78            $paths[(string) $active['locale']] = (string) $active['path'];
79        }
80
81        $locale = $context->locale();
82
83        while ($locale) {
84            if (isset($paths[$locale->id])) {
85                return $paths[$locale->id];
86            }
87
88            $locale = $locale->fallback();
89        }
90
91        return $paths === [] ? null : reset($paths);
92    }
93}