Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
93.75% |
60 / 64 |
|
66.67% |
2 / 3 |
CRAP | |
0.00% |
0 / 1 |
| BulkLogisticsEventsEndpoint | |
93.75% |
60 / 64 |
|
66.67% |
2 / 3 |
24.14 | |
0.00% |
0 / 1 |
| handle | |
91.84% |
45 / 49 |
|
0.00% |
0 / 1 |
15.12 | |||
| eventFor | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
6 | |||
| result | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Server\Endpoint; |
| 6 | |
| 7 | use DateTimeImmutable; |
| 8 | use LambdaTwelve\OneRecord\Api\Error; |
| 9 | use LambdaTwelve\OneRecord\Api\ErrorDocument; |
| 10 | use LambdaTwelve\OneRecord\Api\InvalidDocument; |
| 11 | use LambdaTwelve\OneRecord\JsonLd\ExpandedDocument; |
| 12 | use LambdaTwelve\OneRecord\JsonLd\JsonLd; |
| 13 | use LambdaTwelve\OneRecord\JsonLd\JsonLdException; |
| 14 | use LambdaTwelve\OneRecord\JsonLd\Nodes; |
| 15 | use LambdaTwelve\OneRecord\Model\LogisticsEvent; |
| 16 | use LambdaTwelve\OneRecord\Rdf\Graph; |
| 17 | use LambdaTwelve\OneRecord\Rdf\Iri; |
| 18 | use LambdaTwelve\OneRecord\Rdf\Triple; |
| 19 | use LambdaTwelve\OneRecord\Server\Event\LogisticsEventReceived; |
| 20 | use LambdaTwelve\OneRecord\Server\Http\ContentNegotiation; |
| 21 | use LambdaTwelve\OneRecord\Server\Http\HttpException; |
| 22 | use LambdaTwelve\OneRecord\Server\Http\Negotiated; |
| 23 | use LambdaTwelve\OneRecord\Server\Notification\Fanout; |
| 24 | use LambdaTwelve\OneRecord\Server\Spi\Action; |
| 25 | use LambdaTwelve\OneRecord\Server\Spi\Agent; |
| 26 | use LambdaTwelve\OneRecord\Server\Spi\Decision; |
| 27 | use LambdaTwelve\OneRecord\Spec\Namespaces; |
| 28 | use LambdaTwelve\OneRecord\Vocabulary\Generated\Api; |
| 29 | use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo; |
| 30 | use Psr\Http\Message\ResponseInterface; |
| 31 | use Psr\Http\Message\ServerRequestInterface; |
| 32 | |
| 33 | /** |
| 34 | * POST /logistics-events (API 2.3, optional): one event for several objects, |
| 35 | * each evaluated on its own, answered with 207 Multi-Status and an |
| 36 | * api:MultiStatusResponse listing the outcome per object. |
| 37 | */ |
| 38 | final class BulkLogisticsEventsEndpoint extends AbstractEndpoint |
| 39 | { |
| 40 | public function handle(ServerRequestInterface $request, Agent $agent, Negotiated $negotiated, array $parameters): ResponseInterface |
| 41 | { |
| 42 | (new ContentNegotiation($this->services->config))->bodyVersion($request, $negotiated); |
| 43 | $json = $this->services->body->json($request); |
| 44 | // Expand once, so an alias for cargo:eventFor is understood like the single-object route does (AR-014). |
| 45 | try { |
| 46 | $expanded = JsonLd::expand($json); |
| 47 | } catch (JsonLdException $e) { |
| 48 | throw HttpException::badRequest($e->getMessage(), null, 'Invalid body request'); |
| 49 | } |
| 50 | // Every target is judged before any is chosen: a malformed one is a client error, as on the |
| 51 | // single-object route, not something to drop quietly from the multi-status answer (R14-003). |
| 52 | $targetIris = []; |
| 53 | foreach ($expanded->graph->objects($expanded->root, Cargo::eventFor) as $candidate) { |
| 54 | if (!$candidate instanceof Iri) { |
| 55 | throw HttpException::badRequest('cargo:eventFor must reference logistics objects by their URI.', Cargo::eventFor, 'Invalid resource'); |
| 56 | } |
| 57 | $targetIris[$candidate->value] = $candidate; |
| 58 | } |
| 59 | if ($targetIris === []) { |
| 60 | throw HttpException::badRequest('cargo:eventFor must list the logistics objects the event is for.', Cargo::eventFor, 'Invalid resource'); |
| 61 | } |
| 62 | |
| 63 | $now = $this->services->clock->now(); |
| 64 | $results = []; |
| 65 | $created = 0; |
| 66 | foreach ($targetIris as $target) { |
| 67 | $relative = $this->services->config->relativePath($target); |
| 68 | $objectId = $relative !== null && preg_match('#^logistics-objects/([^/]+)$#', $relative, $m) === 1 ? $m[1] : null; |
| 69 | $decision = $objectId === null ? Decision::Hide : $this->services->policy->decide($agent, Action::PostLogisticsEvent, $target); |
| 70 | $stored = $objectId !== null && $decision !== Decision::Hide ? $this->services->objects->latest($target) : null; |
| 71 | if ($stored === null) { |
| 72 | $results[] = $this->result(404, $target, null, Error::of('Resource not found', '404', 'Logistics Object could not be found.', null, $target->value), $negotiated->version); |
| 73 | continue; |
| 74 | } |
| 75 | if ($decision !== Decision::Allow) { |
| 76 | $results[] = $this->result(403, $target, null, Error::of('Not authorized to perform action', '403', 'The authenticated party is not authorized to post events on this object.', null, $target->value), $negotiated->version); |
| 77 | continue; |
| 78 | } |
| 79 | $eventIri = $this->services->config->logisticsEventIri($objectId ?? '', $this->services->ids->next()); |
| 80 | $event = self::eventFor($expanded, $eventIri, $target, $now); |
| 81 | try { |
| 82 | if ($event->eventDate() === null) { |
| 83 | throw new InvalidDocument('Every logistics event must have a cargo:eventDate.', [Error::of('Invalid resource', '400', 'Every logistics event must have a cargo:eventDate.', Cargo::eventDate)]); |
| 84 | } |
| 85 | LogisticsEventsEndpoint::validateEvent($this->services->vocabulary, $event, $target); |
| 86 | } catch (InvalidDocument $e) { |
| 87 | $results[] = $this->result(400, $target, null, $e->errors[0] ?? Error::of('Invalid resource', '400', $e->getMessage()), $negotiated->version); |
| 88 | continue; |
| 89 | } |
| 90 | $this->services->events->append($event); |
| 91 | $this->services->dispatcher->dispatch(new LogisticsEventReceived($event, $agent)); |
| 92 | (new Fanout($this->services))->logisticsEventReceived($stored, $event); |
| 93 | $results[] = $this->result(201, $target, $eventIri, null, $negotiated->version); |
| 94 | $created++; |
| 95 | } |
| 96 | |
| 97 | $document = [ |
| 98 | '@context' => Nodes::context(), |
| 99 | '@id' => Namespaces::EMBEDDED . $this->services->ids->next(), |
| 100 | '@type' => 'api:MultiStatusResponse', |
| 101 | 'api:hasTotalItems' => \count($results), |
| 102 | 'api:hasTotalCreated' => $created, |
| 103 | 'api:hasTotalFailed' => \count($results) - $created, |
| 104 | 'api:hasCreationResult' => $results, |
| 105 | ]; |
| 106 | |
| 107 | return $this->services->responder->jsonLd(207, $document, $negotiated, Api::MultiStatusResponse); |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * The posted event as one object's event: the root renamed to the event's |
| 112 | * URI, every eventFor replaced by this one target. |
| 113 | */ |
| 114 | private static function eventFor(ExpandedDocument $expanded, Iri $eventIri, Iri $target, DateTimeImmutable $now): LogisticsEvent |
| 115 | { |
| 116 | $graph = new Graph(); |
| 117 | foreach ($expanded->graph as $triple) { |
| 118 | if ($triple->predicate->value === Cargo::eventFor && $triple->subject->equals($expanded->root)) { |
| 119 | continue; |
| 120 | } |
| 121 | $subject = $triple->subject->equals($expanded->root) ? $eventIri : $triple->subject; |
| 122 | $object = $triple->object->equals($expanded->root) ? $eventIri : $triple->object; |
| 123 | $graph->add(new Triple($subject, $triple->predicate, $object)); |
| 124 | } |
| 125 | $graph->add(new Triple($eventIri, new Iri(Cargo::eventFor), $target)); |
| 126 | |
| 127 | return new LogisticsEvent($eventIri, $target, $graph, $now); |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * @return array<string, mixed> |
| 132 | */ |
| 133 | private function result(int $status, Iri $object, ?Iri $event, ?Error $error, \LambdaTwelve\OneRecord\Spec\ApiVersion $version): array |
| 134 | { |
| 135 | $node = ['@id' => Namespaces::EMBEDDED . $this->services->ids->next(), '@type' => 'api:EventCreationResult', 'api:hasHTTPStatus' => $status, 'api:hasLogisticsObject' => Nodes::ref($object)]; |
| 136 | if ($event !== null) { |
| 137 | $node['api:hasLogisticsEvent'] = Nodes::ref($event); |
| 138 | } |
| 139 | if ($error !== null) { |
| 140 | $node['api:hasError'] = ErrorDocument::node($error, $version); |
| 141 | } |
| 142 | |
| 143 | return $node; |
| 144 | } |
| 145 | } |