Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
79.66% |
47 / 59 |
|
42.86% |
3 / 7 |
CRAP | |
0.00% |
0 / 1 |
| LogisticsEventsEndpoint | |
79.66% |
47 / 59 |
|
42.86% |
3 / 7 |
37.57 | |
0.00% |
0 / 1 |
| handle | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| create | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
3.03 | |||
| list | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| eventQuery | |
73.68% |
14 / 19 |
|
0.00% |
0 / 1 |
16.08 | |||
| parseAtNamed | |
33.33% |
1 / 3 |
|
0.00% |
0 / 1 |
3.19 | |||
| validate | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| validateEvent | |
75.00% |
9 / 12 |
|
0.00% |
0 / 1 |
9.00 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Server\Endpoint; |
| 6 | |
| 7 | use DateTimeImmutable; |
| 8 | use LambdaTwelve\OneRecord\Api\Collection; |
| 9 | use LambdaTwelve\OneRecord\Api\InvalidDocument; |
| 10 | use LambdaTwelve\OneRecord\JsonLd\Context; |
| 11 | use LambdaTwelve\OneRecord\JsonLd\JsonLdException; |
| 12 | use LambdaTwelve\OneRecord\Model\LogisticsEvent; |
| 13 | use LambdaTwelve\OneRecord\Model\ModelException; |
| 14 | use LambdaTwelve\OneRecord\Rdf\Iri; |
| 15 | use LambdaTwelve\OneRecord\Server\Event\LogisticsEventReceived; |
| 16 | use LambdaTwelve\OneRecord\Server\Http\ContentNegotiation; |
| 17 | use LambdaTwelve\OneRecord\Server\Http\HttpException; |
| 18 | use LambdaTwelve\OneRecord\Server\Http\Negotiated; |
| 19 | use LambdaTwelve\OneRecord\Server\Http\Responder; |
| 20 | use LambdaTwelve\OneRecord\Server\Notification\Fanout; |
| 21 | use LambdaTwelve\OneRecord\Server\Spi\Action; |
| 22 | use LambdaTwelve\OneRecord\Server\Spi\Agent; |
| 23 | use LambdaTwelve\OneRecord\Server\Spi\EventQuery; |
| 24 | use LambdaTwelve\OneRecord\Vocabulary\Generated\Api; |
| 25 | use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo; |
| 26 | use Psr\Http\Message\ResponseInterface; |
| 27 | use 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 | */ |
| 35 | final 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 | } |