Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.75% covered (success)
93.75%
60 / 64
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
BulkLogisticsEventsEndpoint
93.75% covered (success)
93.75%
60 / 64
66.67% covered (warning)
66.67%
2 / 3
24.14
0.00% covered (danger)
0.00%
0 / 1
 handle
91.84% covered (success)
91.84%
45 / 49
0.00% covered (danger)
0.00%
0 / 1
15.12
 eventFor
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 result
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Server\Endpoint;
6
7use DateTimeImmutable;
8use LambdaTwelve\OneRecord\Api\Error;
9use LambdaTwelve\OneRecord\Api\ErrorDocument;
10use LambdaTwelve\OneRecord\Api\InvalidDocument;
11use LambdaTwelve\OneRecord\JsonLd\ExpandedDocument;
12use LambdaTwelve\OneRecord\JsonLd\JsonLd;
13use LambdaTwelve\OneRecord\JsonLd\JsonLdException;
14use LambdaTwelve\OneRecord\JsonLd\Nodes;
15use LambdaTwelve\OneRecord\Model\LogisticsEvent;
16use LambdaTwelve\OneRecord\Rdf\Graph;
17use LambdaTwelve\OneRecord\Rdf\Iri;
18use LambdaTwelve\OneRecord\Rdf\Triple;
19use LambdaTwelve\OneRecord\Server\Event\LogisticsEventReceived;
20use LambdaTwelve\OneRecord\Server\Http\ContentNegotiation;
21use LambdaTwelve\OneRecord\Server\Http\HttpException;
22use LambdaTwelve\OneRecord\Server\Http\Negotiated;
23use LambdaTwelve\OneRecord\Server\Notification\Fanout;
24use LambdaTwelve\OneRecord\Server\Spi\Action;
25use LambdaTwelve\OneRecord\Server\Spi\Agent;
26use LambdaTwelve\OneRecord\Server\Spi\Decision;
27use LambdaTwelve\OneRecord\Spec\Namespaces;
28use LambdaTwelve\OneRecord\Vocabulary\Generated\Api;
29use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo;
30use Psr\Http\Message\ResponseInterface;
31use 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 */
38final 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}