Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.19% covered (warning)
77.19%
88 / 114
41.67% covered (danger)
41.67%
5 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractEndpoint
77.19% covered (warning)
77.19%
88 / 114
41.67% covered (danger)
41.67%
5 / 12
115.12
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
 requireObject
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
9.16
 decide
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 objectHeaders
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 writeObject
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 addRevisions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 embedLinked
38.10% covered (danger)
38.10%
8 / 21
0.00% covered (danger)
0.00%
0 / 1
46.16
 rewriteLocalLinks
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
14
 parseAt
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
10.36
 query
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
16.00
 isHead
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 negotiatedOrDefault
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Server\Endpoint;
6
7use DateTimeImmutable;
8use Exception;
9use LambdaTwelve\OneRecord\JsonLd\Context;
10use LambdaTwelve\OneRecord\JsonLd\Writer;
11use LambdaTwelve\OneRecord\Model\LogisticsObject;
12use LambdaTwelve\OneRecord\Rdf\Graph;
13use LambdaTwelve\OneRecord\Rdf\Iri;
14use LambdaTwelve\OneRecord\Rdf\Literal;
15use LambdaTwelve\OneRecord\Rdf\Triple;
16use LambdaTwelve\OneRecord\Server\Http\HttpException;
17use LambdaTwelve\OneRecord\Server\Http\Negotiated;
18use LambdaTwelve\OneRecord\Server\Http\Responder;
19use LambdaTwelve\OneRecord\Server\Services;
20use LambdaTwelve\OneRecord\Server\Spi\Action;
21use LambdaTwelve\OneRecord\Server\Spi\Agent;
22use LambdaTwelve\OneRecord\Server\Spi\Decision;
23use LambdaTwelve\OneRecord\Server\Spi\StoredObject;
24use LambdaTwelve\OneRecord\Vocabulary\Generated\Api;
25use Psr\Http\Message\ServerRequestInterface;
26
27/**
28 * What endpoints share: resolving an object id to a stored object under the
29 * access policy, writing an object with its revision properties and headers,
30 * reading the spec's timestamps and query parameters.
31 */
32abstract class AbstractEndpoint implements Endpoint
33{
34    public function __construct(protected readonly Services $services) {}
35
36    /**
37     * The stored object (latest, or at a revision or time), after the policy
38     * allowed $action on it. A hidden or missing object is a 404; a forbidden
39     * one a 403. Both use the same body shape so nothing leaks.
40     */
41    protected function requireObject(string $id, Agent $agent, Action $action, ?int $revision = null, ?DateTimeImmutable $at = null): StoredObject
42    {
43        $iri = $this->services->config->logisticsObjectIri($id);
44        $decision = $this->services->policy->decide($agent, $action, $iri);
45        if ($decision === Decision::Hide) {
46            throw HttpException::notFound('Logistics Object', $iri->value);
47        }
48        $stored = match (true) {
49            $revision !== null => $this->services->objects->revision($iri, $revision),
50            $at !== null => $this->services->objects->at($iri, $at),
51            default => $this->services->objects->latest($iri),
52        };
53        if ($stored === null) {
54            if ($decision === Decision::Forbid && $this->services->objects->exists($iri)) {
55                throw HttpException::forbidden($iri->value);
56            }
57            throw HttpException::notFound('Logistics Object', $iri->value);
58        }
59        if ($decision === Decision::Forbid) {
60            throw HttpException::forbidden($iri->value);
61        }
62
63        return $stored;
64    }
65
66    protected function decide(Agent $agent, Action $action, ?Iri $resource): void
67    {
68        $decision = $this->services->policy->decide($agent, $action, $resource);
69        if ($decision === Decision::Hide) {
70            throw HttpException::notFound('The requested resource', $resource?->value);
71        }
72        if ($decision === Decision::Forbid) {
73            throw HttpException::forbidden($resource?->value);
74        }
75    }
76
77    /**
78     * @return array<string, string>
79     */
80    protected function objectHeaders(StoredObject $stored, ?string $location = null): array
81    {
82        $type = $stored->object->mostSpecificType($this->services->vocabulary);
83        $headers = [
84            'Revision' => (string) $stored->revision,
85            'Latest-Revision' => (string) $stored->latestRevision,
86            'Last-Modified' => Responder::httpDate($stored->lastModified),
87            'Location' => $location ?? $stored->object->iri->value,
88        ];
89        if ($type !== null) {
90            $headers['Type'] = $type;
91        }
92
93        return $headers;
94    }
95
96    /**
97     * The object as JSON-LD with api:hasRevision and api:hasLatestRevision,
98     * optionally with linked objects on this server embedded (?embedded=true)
99     * and, for a historical read, every local link carrying the same ?at=.
100     *
101     * @return array<string, mixed>
102     */
103    protected function writeObject(StoredObject $stored, Agent $agent, bool $embedded, ?string $atParameter): array
104    {
105        $graph = new Graph($stored->object->graph);
106        $root = $stored->object->iri;
107        $this->addRevisions($graph, $root, $stored);
108        if ($embedded) {
109            $this->embedLinked($graph, $root, $agent, $atParameter, $this->services->config->embeddedDepth, [$root->value => true]);
110        }
111        if ($atParameter !== null) {
112            // The document is about the object at that time: its @id and every local link carry the same ?at=.
113            $historicalRoot = new Iri($root->value . '?at=' . $atParameter);
114            $graph = $this->rewriteLocalLinks($graph, $atParameter, $root, $historicalRoot);
115            $root = $historicalRoot;
116        }
117        $context = Context::oneRecord();
118
119        return (new Writer())->write($graph, $root, $context);
120    }
121
122    private function addRevisions(Graph $graph, Iri $subject, StoredObject $stored): void
123    {
124        $graph->add(new Triple($subject, new Iri(Api::hasRevision), Literal::integer($stored->revision)));
125        $graph->add(new Triple($subject, new Iri(Api::hasLatestRevision), Literal::integer($stored->latestRevision)));
126    }
127
128    /**
129     * @param array<string, true> $seen
130     */
131    private function embedLinked(Graph $graph, Iri $node, Agent $agent, ?string $atParameter, int $depth, array $seen): void
132    {
133        if ($depth <= 0) {
134            return;
135        }
136        foreach ($graph->about($node) as $triple) {
137            $object = $triple->object;
138            if (!$object instanceof Iri || isset($seen[$object->value]) || LogisticsObject::isEmbeddedIn($graph, $object, $node)) {
139                continue;
140            }
141            $relative = $this->services->config->relativePath($object);
142            if ($relative === null || preg_match('#^logistics-objects/[^/]+$#', $relative) !== 1) {
143                continue;
144            }
145            // Only objects the caller may read are embedded; others stay links, exactly as a direct GET would answer.
146            if (!$this->services->policy->decide($agent, Action::ReadLogisticsObject, $object)->allowed()) {
147                continue;
148            }
149            $linked = $atParameter !== null
150                ? $this->services->objects->at($object, self::parseAt($atParameter))
151                : $this->services->objects->latest($object);
152            if ($linked === null) {
153                continue;
154            }
155            $seen[$object->value] = true;
156            foreach ($linked->object->graph as $t) {
157                $graph->add($t);
158            }
159            $this->addRevisions($graph, $object, $linked);
160            $this->embedLinked($graph, $object, $agent, $atParameter, $depth - 1, $seen);
161        }
162    }
163
164    /**
165     * Links to objects on this server carry the same ?at= so a client following
166     * them stays at one point in time (spec: "Retrieve a historical Logistics Object").
167     */
168    private function rewriteLocalLinks(Graph $graph, string $atParameter, Iri $root, Iri $historicalRoot): Graph
169    {
170        // A link to another object of this server gets the same ?at=, whether it is bare or typed
171        // ({"@id", "@type"}); only an object embedded with its data (?embedded=true) keeps its own
172        // identity, because its triples are already the historical ones (AR-018).
173        $links = [];
174        foreach ($graph as $triple) {
175            $object = $triple->object;
176            if (!$object instanceof Iri || $object->equals($root) || LogisticsObject::isEmbeddedIn($graph, $object, $root)) {
177                continue;
178            }
179            $relative = $this->services->config->relativePath($object);
180            if ($relative === null || preg_match('#^logistics-objects/[^/]+$#', $relative) !== 1) {
181                continue;
182            }
183            $onlyTypes = array_filter($graph->about($object), static fn(Triple $t): bool => $t->predicate->value !== Graph::RDF_TYPE) === [];
184            if ($onlyTypes) {
185                $links[$object->value] = new Iri($object->value . '?at=' . $atParameter);
186            }
187        }
188        $rewritten = new Graph();
189        foreach ($graph as $triple) {
190            $subject = $triple->subject->equals($root) ? $historicalRoot : ($triple->subject instanceof Iri ? ($links[$triple->subject->value] ?? $triple->subject) : $triple->subject);
191            $object = $triple->object;
192            if ($object->equals($root)) {
193                $object = $historicalRoot;
194            } elseif ($object instanceof Iri && isset($links[$object->value])) {
195                $object = $links[$object->value];
196            }
197            $rewritten->add(new Triple($subject, $triple->predicate, $object));
198        }
199
200        return $rewritten;
201    }
202
203    /**
204     * The spec's query timestamps: YYYYMMDDThhmmssZ, with RFC 3339 accepted too.
205     */
206    public static function parseAt(string $value): DateTimeImmutable
207    {
208        $trimmed = trim($value);
209        $parsed = null;
210        try {
211            if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/', $trimmed, $m) === 1) {
212                $parsed = checkdate((int) $m[2], (int) $m[3], (int) $m[1]) && (int) $m[4] < 24 && (int) $m[5] < 60 && (int) $m[6] < 61
213                    ? DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:sP', \sprintf('%s-%s-%sT%s:%s:%s+00:00', $m[1], $m[2], $m[3], $m[4], $m[5], $m[6]))
214                    : false;
215            } elseif (preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/', $trimmed) === 1) {
216                $parsed = \LambdaTwelve\OneRecord\JsonLd\Nodes::parseDateTime($trimmed);
217            }
218        } catch (Exception) {
219            // PHP's date constructor throws on values the regex let through (a 99th month); that is a 400, not a 500 (AR-023).
220            $parsed = null;
221        }
222        if ($parsed === false || $parsed === null) {
223            throw HttpException::invalidQuery(\sprintf('"%s" is not a timestamp in the form YYYYMMDDThhmmssZ.', $trimmed), 'at');
224        }
225
226        return $parsed;
227    }
228
229    /**
230     * @return array<string, string>
231     */
232    protected static function query(ServerRequestInterface $request): array
233    {
234        $out = [];
235        foreach ($request->getQueryParams() as $key => $value) {
236            if (\is_string($value)) {
237                $out[(string) $key] = $value;
238            } elseif (\is_array($value)) {
239                $out[(string) $key] = implode(',', array_filter($value, is_string(...)));
240            }
241        }
242        if ($out === [] && $request->getUri()->getQuery() !== '') {
243            parse_str($request->getUri()->getQuery(), $parsed);
244            foreach ($parsed as $key => $value) {
245                if (\is_string($value)) {
246                    $out[(string) $key] = $value;
247                }
248            }
249        }
250
251        return $out;
252    }
253
254    protected static function isHead(ServerRequestInterface $request): bool
255    {
256        return strtoupper($request->getMethod()) === 'HEAD';
257    }
258
259    protected function negotiatedOrDefault(Negotiated $negotiated): Negotiated
260    {
261        return $negotiated;
262    }
263}