Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.66% covered (warning)
79.66%
47 / 59
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
LogisticsEventsEndpoint
79.66% covered (warning)
79.66%
47 / 59
42.86% covered (danger)
42.86%
3 / 7
37.57
0.00% covered (danger)
0.00%
0 / 1
 handle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 create
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
3.03
 list
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 eventQuery
73.68% covered (warning)
73.68%
14 / 19
0.00% covered (danger)
0.00%
0 / 1
16.08
 parseAtNamed
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 validate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateEvent
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
9.00
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Server\Endpoint;
6
7use DateTimeImmutable;
8use LambdaTwelve\OneRecord\Api\Collection;
9use LambdaTwelve\OneRecord\Api\InvalidDocument;
10use LambdaTwelve\OneRecord\JsonLd\Context;
11use LambdaTwelve\OneRecord\JsonLd\JsonLdException;
12use LambdaTwelve\OneRecord\Model\LogisticsEvent;
13use LambdaTwelve\OneRecord\Model\ModelException;
14use LambdaTwelve\OneRecord\Rdf\Iri;
15use LambdaTwelve\OneRecord\Server\Event\LogisticsEventReceived;
16use LambdaTwelve\OneRecord\Server\Http\ContentNegotiation;
17use LambdaTwelve\OneRecord\Server\Http\HttpException;
18use LambdaTwelve\OneRecord\Server\Http\Negotiated;
19use LambdaTwelve\OneRecord\Server\Http\Responder;
20use LambdaTwelve\OneRecord\Server\Notification\Fanout;
21use LambdaTwelve\OneRecord\Server\Spi\Action;
22use LambdaTwelve\OneRecord\Server\Spi\Agent;
23use LambdaTwelve\OneRecord\Server\Spi\EventQuery;
24use LambdaTwelve\OneRecord\Vocabulary\Generated\Api;
25use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo;
26use Psr\Http\Message\ResponseInterface;
27use Psr\Http\Message\ServerRequestInterface;
28
29/**
30 * /logistics-objects/{id}/logistics-events: POST appends an immutable event
31 * (and notifies subscribers), GET lists them as an api:Collection with the
32 * spec's filters, HEAD gives the list's Last-Modified so clients can poll
33 * cheaply.
34 */
35final class LogisticsEventsEndpoint extends AbstractEndpoint
36{
37    private const array SORTS = [EventQuery::SORT_CREATED_ASC, EventQuery::SORT_CREATED_DESC, EventQuery::SORT_EVENT_ASC, EventQuery::SORT_EVENT_DESC];
38
39    public function handle(ServerRequestInterface $request, Agent $agent, Negotiated $negotiated, array $parameters): ResponseInterface
40    {
41        return strtoupper($request->getMethod()) === 'POST'
42            ? $this->create($request, $agent, $negotiated, $parameters['id'])
43            : $this->list($request, $agent, $negotiated, $parameters['id']);
44    }
45
46    private function create(ServerRequestInterface $request, Agent $agent, Negotiated $negotiated, string $id): ResponseInterface
47    {
48        $stored = $this->requireObject($id, $agent, Action::PostLogisticsEvent);
49        (new ContentNegotiation($this->services->config))->bodyVersion($request, $negotiated);
50        $json = $this->services->body->json($request);
51        $eventIri = $this->services->config->logisticsEventIri($id, $this->services->ids->next());
52        $now = $this->services->clock->now();
53        try {
54            $event = LogisticsEvent::fromJsonLd($json, $eventIri, $stored->object->iri, $now);
55        } catch (ModelException $e) {
56            throw HttpException::badRequest($e->getMessage(), null, $e->getPrevious() instanceof JsonLdException ? 'Invalid body request' : 'Invalid resource');
57        }
58        $this->validate($event, $stored->object->iri);
59
60        $this->services->events->append($event);
61        $this->services->dispatcher->dispatch(new LogisticsEventReceived($event, $agent));
62        (new Fanout($this->services))->logisticsEventReceived($stored, $event);
63
64        return $this->services->responder->empty(201, $negotiated, ['Location' => $eventIri->value, 'Type' => Cargo::LogisticsEvent]);
65    }
66
67    private function list(ServerRequestInterface $request, Agent $agent, Negotiated $negotiated, string $id): ResponseInterface
68    {
69        $stored = $this->requireObject($id, $agent, Action::ReadLogisticsEvent);
70        $query = $this->eventQuery(self::query($request));
71        $events = $this->services->events->query($stored->object->iri, $query);
72        $context = Context::oneRecord();
73        $items = array_map(static fn(LogisticsEvent $e): array => $e->toJsonLd($context, includeContext: false), $events);
74        $collectionIri = new Iri($stored->object->iri->value . '/logistics-events');
75        $lastModified = $this->services->events->lastModified($stored->object->iri) ?? $stored->createdAt;
76
77        return $this->services->responder->jsonLd(200, Collection::write($collectionIri, $items), $negotiated, Api::Collection, ['Last-Modified' => Responder::httpDate($lastModified)], self::isHead($request));
78    }
79
80    /**
81     * @param array<string, string> $query
82     */
83    private function eventQuery(array $query): EventQuery
84    {
85        $codes = [];
86        foreach (explode(',', $query['event-code'] ?? '') as $code) {
87            if (trim($code) !== '') {
88                $codes[] = trim($code);
89            }
90        }
91        $sort = $query['sort'] ?? EventQuery::SORT_CREATED_ASC;
92        if (!\in_array($sort, self::SORTS, true)) {
93            throw HttpException::invalidQuery(\sprintf('sort must be one of %s.', implode(', ', self::SORTS)), 'sort');
94        }
95        $limit = null;
96        if (isset($query['limit']) && $query['limit'] !== '') {
97            if (preg_match('/^\d+$/', $query['limit']) !== 1 || (int) $query['limit'] < 1) {
98                throw HttpException::invalidQuery('limit must be a positive integer.', 'limit');
99            }
100            $limit = (int) $query['limit'];
101        }
102        $skip = 0;
103        if (isset($query['skip']) && $query['skip'] !== '') {
104            if (preg_match('/^\d+$/', $query['skip']) !== 1) {
105                throw HttpException::invalidQuery('skip must be a non-negative integer.', 'skip');
106            }
107            $skip = (int) $query['skip'];
108        }
109        $time = static fn(string $name): ?DateTimeImmutable => isset($query[$name]) && trim($query[$name]) !== '' ? self::parseAtNamed($query[$name], $name) : null;
110
111        return new EventQuery($codes, $time('created-after'), $time('created-before'), $time('occurred-after'), $time('occurred-before'), $sort, $limit, $skip);
112    }
113
114    private static function parseAtNamed(string $value, string $parameter): DateTimeImmutable
115    {
116        try {
117            return self::parseAt($value);
118        } catch (HttpException) {
119            throw HttpException::invalidQuery(\sprintf('"%s" is not a timestamp in the form YYYYMMDDThhmmssZ.', $value), $parameter);
120        }
121    }
122
123    private function validate(LogisticsEvent $event, Iri $object): void
124    {
125        self::validateEvent($this->services->vocabulary, $event, $object);
126    }
127
128    /**
129     * The event must be a LogisticsEvent, say which object it is for (if it says
130     * anything) and use properties LogisticsEvent accepts. Shared with the bulk
131     * endpoint so both routes accept exactly the same events (AR-014).
132     */
133    public static function validateEvent(\LambdaTwelve\OneRecord\Vocabulary\Vocabulary $vocabulary, LogisticsEvent $event, Iri $object): void
134    {
135        $types = $event->types();
136        $isEvent = static fn(string $type): bool => $type === Cargo::LogisticsEvent || $vocabulary->isSubclassOf($type, Cargo::LogisticsEvent);
137        if (array_filter($types, $isEvent) === []) {
138            throw new InvalidDocument('The body is not a cargo:LogisticsEvent.', [\LambdaTwelve\OneRecord\Api\Error::of('Invalid resource', '400', \sprintf('Expected a cargo:LogisticsEvent, got %s.', $types === [] ? 'no @type' : implode(', ', $types)), '@type')]);
139        }
140        // The event and every node embedded in it (a location, an external reference) go through the
141        // same validator as a created object (D10-001).
142        $violations = (new \LambdaTwelve\OneRecord\Model\GraphValidator($vocabulary))->validate($event->graph, $event->iri);
143        if ($violations !== []) {
144            throw new InvalidDocument($violations[0]->message, [\LambdaTwelve\OneRecord\Api\Error::of('Invalid resource', '400', $violations[0]->message, $violations[0]->property, $violations[0]->subject)]);
145        }
146        // Every value, not the first one: RDF values are unordered, and an event is for one object (R7-006).
147        foreach ($event->graph->objects($event->iri, Cargo::eventFor) as $for) {
148            if (!$for instanceof Iri) {
149                throw new InvalidDocument('cargo:eventFor must reference a logistics object.', [\LambdaTwelve\OneRecord\Api\Error::of('Invalid resource', '400', 'cargo:eventFor must reference the logistics object the event was posted on.', Cargo::eventFor)]);
150            }
151            if (!$for->equals($object)) {
152                throw new InvalidDocument('cargo:eventFor names another object.', [\LambdaTwelve\OneRecord\Api\Error::of('Invalid resource', '400', \sprintf('cargo:eventFor names %s but the event was posted on %s.', $for->value, $object->value), Cargo::eventFor)]);
153            }
154        }
155    }
156}