Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.36% covered (warning)
74.36%
29 / 39
0.00% covered (danger)
0.00%
0 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
CreateLogisticsObjectEndpoint
74.36% covered (warning)
74.36%
29 / 39
0.00% covered (danger)
0.00%
0 / 2
17.30
0.00% covered (danger)
0.00%
0 / 1
 handle
76.67% covered (warning)
76.67%
23 / 30
0.00% covered (danger)
0.00%
0 / 1
11.27
 validate
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
4.59
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Server\Endpoint;
6
7use InvalidArgumentException;
8use LambdaTwelve\OneRecord\JsonLd\JsonLdException;
9use LambdaTwelve\OneRecord\Model\LogisticsObject;
10use LambdaTwelve\OneRecord\Model\ModelException;
11use LambdaTwelve\OneRecord\Server\Event\LogisticsObjectCreated;
12use LambdaTwelve\OneRecord\Server\Http\HttpException;
13use LambdaTwelve\OneRecord\Server\Http\Negotiated;
14use LambdaTwelve\OneRecord\Server\Notification\Fanout;
15use LambdaTwelve\OneRecord\Server\Spi\Action;
16use LambdaTwelve\OneRecord\Server\Spi\Agent;
17use LambdaTwelve\OneRecord\Server\Spi\StoreException;
18use Psr\Http\Message\ResponseInterface;
19use Psr\Http\Message\ServerRequestInterface;
20
21/**
22 * POST /logistics-objects. The spec marks it "internal only": it exists for
23 * hosts that create objects over HTTP from their own systems, so the access
24 * policy must say yes (it says no by default). A body with an @id under this
25 * server is honoured (predefined URIs, as NE:ONE allows); any other @id is
26 * refused; without one the server mints a URI.
27 */
28final class CreateLogisticsObjectEndpoint extends AbstractEndpoint
29{
30    public function handle(ServerRequestInterface $request, Agent $agent, Negotiated $negotiated, array $parameters): ResponseInterface
31    {
32        $this->decide($agent, Action::CreateLogisticsObject, null);
33        (new \LambdaTwelve\OneRecord\Server\Http\ContentNegotiation($this->services->config))->bodyVersion($request, $negotiated);
34        $json = $this->services->body->json($request);
35        if (\array_key_exists('@graph', $json)) {
36            throw HttpException::badRequest('The body must not contain @graph; a single logistics object is expected.', '@graph');
37        }
38
39        $iri = null;
40        if (\array_key_exists('@id', $json)) {
41            // A present @id is honoured or refused, never silently replaced: a number, a list or an empty or
42            // malformed string is the client's mistake, not an omission (R14-004).
43            $declared = $json['@id'];
44            $message = 'An @id must be a logistics object URI under this server, or be left out so the server assigns one.';
45            if (!\is_string($declared) || trim($declared) === '') {
46                throw HttpException::badRequest($message, '@id');
47            }
48            try {
49                $relative = $this->services->config->relativePath(new \LambdaTwelve\OneRecord\Rdf\Iri(trim($declared)));
50            } catch (InvalidArgumentException) {
51                throw HttpException::badRequest($message, '@id');
52            }
53            if ($relative === null || preg_match('#^logistics-objects/[^/?]+$#', $relative) !== 1) {
54                throw HttpException::badRequest($message, '@id');
55            }
56            $iri = $this->services->config->logisticsObjectIri(substr($relative, \strlen('logistics-objects/')));
57            unset($json['@id']);
58        }
59        $iri ??= $this->services->config->logisticsObjectIri($this->services->ids->next());
60
61        try {
62            $object = LogisticsObject::fromJsonLd($json, $iri)->withEmbeddedIds($this->services->embeddedIds);
63        } catch (JsonLdException|ModelException $e) {
64            throw HttpException::badRequest($e->getMessage());
65        }
66        $this->validate($object);
67
68        try {
69            $stored = $this->services->objects->create($object, $this->services->clock->now());
70        } catch (StoreException $e) {
71            throw HttpException::conflict('A logistics object with this URI already exists.', $iri->value);
72        }
73        \LambdaTwelve\OneRecord\Server\Deprecations::log($stored->object->graph, $stored->object->iri, $this->services->vocabulary, $this->services->logger);
74        $this->services->dispatcher->dispatch(new LogisticsObjectCreated($stored, $agent->iri));
75        // No action request causes a creation; fan-out then names the subscription request (AR-021).
76        (new Fanout($this->services))->logisticsObjectCreated($stored);
77
78        return $this->services->responder->empty(201, $negotiated, ['Location' => $iri->value, 'Type' => $stored->object->mostSpecificType($this->services->vocabulary) ?? 'https://onerecord.iata.org/ns/cargo#LogisticsObject']);
79    }
80
81    /**
82     * Model validation per the spec's implementation guidelines: the object
83     * must be typed with a logistics object class; properties the ontology does
84     * not know are refused (a 400), properties the class does not accept too.
85     */
86    private function validate(LogisticsObject $object): void
87    {
88        $types = $object->types();
89        if ($types === []) {
90            throw HttpException::badRequest('A logistics object must declare its @type.', '@type', 'Invalid resource');
91        }
92        $vocabulary = $this->services->vocabulary;
93        if (array_filter($types, static fn(string $t): bool => $vocabulary->isLogisticsObjectClass($t)) === []) {
94            throw HttpException::badRequest(\sprintf('%s is not a logistics object class.', implode(', ', $types)), '@type', 'Invalid resource');
95        }
96        // Root and every embedded node, properties, kinds, ranges and grammars: the same validator that
97        // judges a change or a posted event (R10-004).
98        // A nested logistics object (the spec's example A2 posts a Company with a Person inside) is kept as
99        // an embedded node rather than refused or split into objects of its own: spec question 33.
100        $violations = (new \LambdaTwelve\OneRecord\Model\GraphValidator($vocabulary))->validate($object->graph, $object->iri, nestedLogisticsObjects: true);
101        if ($violations !== []) {
102            throw HttpException::badRequest($violations[0]->message, $violations[0]->property, 'Invalid resource');
103        }
104    }
105}