Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.49% covered (success)
96.49%
110 / 114
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ActionRequest
96.49% covered (success)
96.49%
110 / 114
72.73% covered (warning)
72.73%
8 / 11
52
0.00% covered (danger)
0.00%
0 / 1
 __construct
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 create
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 notifyRequestStatusChange
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logisticsObjects
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
5
 lastModified
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canTransitionTo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withStatus
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 toStorageJsonLd
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 toJsonLd
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 node
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
12
 fromJsonLd
95.24% covered (success)
95.24%
40 / 42
0.00% covered (danger)
0.00%
0 / 1
15
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Api;
6
7use DateTimeImmutable;
8use InvalidArgumentException;
9use LambdaTwelve\OneRecord\Change\Change;
10use LambdaTwelve\OneRecord\JsonLd\ExpandedDocument;
11use LambdaTwelve\OneRecord\JsonLd\JsonLd;
12use LambdaTwelve\OneRecord\JsonLd\JsonLdException;
13use LambdaTwelve\OneRecord\JsonLd\Nodes;
14use LambdaTwelve\OneRecord\Rdf\Iri;
15use LambdaTwelve\OneRecord\Spec\ApiFeatures;
16use LambdaTwelve\OneRecord\Spec\ApiVersion;
17use LambdaTwelve\OneRecord\Vocabulary\Generated\Api;
18use LogicException;
19
20/**
21 * api:ActionRequest and its four subclasses: one organisation asks another
22 * for something (a change, a subscription, access, a verification) and the
23 * holder decides. Immutable; status changes produce a new instance so the
24 * history the 2.3 spec asks for is a by-product.
25 */
26final readonly class ActionRequest
27{
28    /**
29     * @param list<Error> $errors
30     * @param list<RequestStatusEntry> $history previous statuses, oldest first (never the current one)
31     */
32    public function __construct(
33        public Iri $iri,
34        public ActionRequestType $type,
35        public Change|Subscription|AccessDelegation|Verification $payload,
36        public Iri $requestedBy,
37        public DateTimeImmutable $requestedAt,
38        public RequestStatus $status = RequestStatus::Pending,
39        public ?DateTimeImmutable $statusSince = null,
40        public array $history = [],
41        public array $errors = [],
42        public ?Iri $revokedBy = null,
43        public ?DateTimeImmutable $revokedAt = null,
44    ) {
45        $expected = match (true) {
46            $payload instanceof Change => ActionRequestType::Change,
47            $payload instanceof Subscription => ActionRequestType::Subscription,
48            $payload instanceof AccessDelegation => ActionRequestType::AccessDelegation,
49            $payload instanceof Verification => ActionRequestType::Verification,
50        };
51        if ($expected !== $type) {
52            throw new InvalidArgumentException(\sprintf('A %s cannot carry a %s.', $type->name, $payload::class));
53        }
54    }
55
56    public static function create(Iri $iri, Change|Subscription|AccessDelegation|Verification $payload, Iri $requestedBy, DateTimeImmutable $at): self
57    {
58        $type = match (true) {
59            $payload instanceof Change => ActionRequestType::Change,
60            $payload instanceof Subscription => ActionRequestType::Subscription,
61            $payload instanceof AccessDelegation => ActionRequestType::AccessDelegation,
62            $payload instanceof Verification => ActionRequestType::Verification,
63        };
64
65        return new self($iri, $type, $payload, $requestedBy, $at, RequestStatus::Pending, $at);
66    }
67
68    public function notifyRequestStatusChange(): bool
69    {
70        return $this->payload->notifyRequestStatusChange;
71    }
72
73    /**
74     * The logistics objects this request concerns (none for a type subscription).
75     *
76     * @return list<Iri>
77     */
78    public function logisticsObjects(): array
79    {
80        return match (true) {
81            $this->payload instanceof Change, $this->payload instanceof Verification => [$this->payload->logisticsObject],
82            $this->payload instanceof AccessDelegation => $this->payload->logisticsObjects,
83            $this->payload instanceof Subscription => $this->payload->topicType === TopicType::Identifier ? [new Iri($this->payload->topic)] : [],
84        };
85    }
86
87    public function lastModified(): DateTimeImmutable
88    {
89        return $this->statusSince ?? $this->requestedAt;
90    }
91
92    public function canTransitionTo(RequestStatus $next): bool
93    {
94        return $this->status->canTransitionTo($next, $this->type);
95    }
96
97    /**
98     * @param list<Error> $errors
99     */
100    public function withStatus(RequestStatus $next, DateTimeImmutable $at, ?Iri $changedBy = null, array $errors = []): self
101    {
102        if (!$this->canTransitionTo($next)) {
103            throw new LogicException(\sprintf('A %s cannot go from %s to %s.', $this->type->name, $this->status->shortName(), $next->shortName()));
104        }
105
106        return new self(
107            $this->iri,
108            $this->type,
109            $this->payload,
110            $this->requestedBy,
111            $this->requestedAt,
112            $next,
113            $at,
114            [...$this->history, new RequestStatusEntry($this->status, $this->statusSince ?? $this->requestedAt, $changedBy)],
115            [...$this->errors, ...$errors],
116            $next === RequestStatus::Revoked ? $changedBy : $this->revokedBy,
117            $next === RequestStatus::Revoked ? $at : $this->revokedAt,
118        );
119    }
120
121    /**
122     * The form a store keeps: every property this package knows, whatever
123     * version partners negotiate. Read back with fromJsonLd(), which accepts
124     * every edition's properties as optional.
125     *
126     * @return array<string, mixed>
127     */
128    public function toStorageJsonLd(): array
129    {
130        return $this->toJsonLd(ApiVersion::latest());
131    }
132
133    /**
134     * @return array<string, mixed>
135     */
136    public function toJsonLd(ApiVersion $version): array
137    {
138        return [
139            '@context' => [...Nodes::context(), 'api:hasDatatype' => ['@type' => 'xsd:anyURI'], 'api:p' => ['@type' => 'xsd:anyURI'], 'api:hasProperty' => ['@type' => 'xsd:anyURI'], 'api:hasResource' => ['@type' => 'xsd:anyURI']],
140            ...$this->node($version),
141        ];
142    }
143
144    /**
145     * @return array<string, mixed>
146     */
147    public function node(ApiVersion $version): array
148    {
149        $payload = match (true) {
150            $this->payload instanceof Change => $this->payload->toJsonLd(),
151            $this->payload instanceof Subscription => $this->payload->node(),
152            $this->payload instanceof AccessDelegation => $this->payload->node($version),
153            $this->payload instanceof Verification => $this->payload->node($version),
154        };
155        unset($payload['@context']);
156
157        $node = [
158            '@type' => Nodes::compact($this->type->value),
159            '@id' => $this->iri->value,
160            Nodes::compact($this->type->payloadProperty()) => $payload,
161            'api:isRequestedBy' => Nodes::ref($this->requestedBy),
162            'api:isRequestedAt' => Nodes::dateTimeValue($this->requestedAt),
163            'api:hasRequestStatus' => Nodes::ref(Nodes::compact($this->status->value)),
164        ];
165        if ($this->errors !== []) {
166            $node['api:hasError'] = ErrorDocument::nodes($this->errors, $version);
167        }
168        if ($this->revokedBy !== null) {
169            $node['api:isRevokedBy'] = Nodes::ref($this->revokedBy);
170        }
171        if ($this->revokedAt !== null) {
172            $node['api:isRevokedAt'] = Nodes::dateTimeValue($this->revokedAt);
173        }
174        if (ApiFeatures::available($version, ApiFeatures::REQUEST_STATUS_SINCE)) {
175            $node['api:hasRequestStatusSince'] = Nodes::dateTimeValue($this->statusSince ?? $this->requestedAt);
176        }
177        if ($this->history !== [] && ApiFeatures::available($version, ApiFeatures::REQUEST_STATUS_HISTORY)) {
178            $node['api:hasRequestStatusHistory'] = array_map(static function (RequestStatusEntry $entry): array {
179                $item = ['@type' => 'api:RequestStatusEntry', 'api:hasRequestStatus' => Nodes::ref(Nodes::compact($entry->status->value)), 'api:hasRequestStatusSince' => Nodes::dateTimeValue($entry->since)];
180                if ($entry->changedBy !== null) {
181                    $item['api:isChangedBy'] = Nodes::ref($entry->changedBy);
182                }
183
184                return $item;
185            }, $this->history);
186        }
187
188        return $node;
189    }
190
191    /**
192     * Reads an action request as a partner's server returns it (the client side).
193     *
194     * @param string|array<string, mixed>|ExpandedDocument $document
195     */
196    public static function fromJsonLd(string|array|ExpandedDocument $document): self
197    {
198        try {
199            $expanded = $document instanceof ExpandedDocument ? $document : JsonLd::expand($document);
200        } catch (JsonLdException $e) {
201            throw InvalidDocument::because('Invalid body request', $e->getMessage());
202        }
203        $graph = $expanded->graph;
204        $root = $expanded->root;
205        $type = null;
206        foreach ($expanded->rootTypes() as $typeIri) {
207            $type = ActionRequestType::tryFrom($typeIri) ?? $type;
208        }
209        if ($type === null || !$root instanceof Iri) {
210            throw InvalidDocument::because('Invalid resource', 'The body is not an identified api:ActionRequest.');
211        }
212        $payloadNode = Nodes::node($graph, $root, $type->payloadProperty())
213            ?? throw InvalidDocument::because('Invalid resource', 'The action request has no payload.', $type->payloadProperty());
214        $payload = match ($type) {
215            ActionRequestType::Change => Change::fromJsonLd(new ExpandedDocument($graph, $payloadNode, $expanded->context)),
216            ActionRequestType::Subscription => Subscription::readNode($graph, $payloadNode),
217            ActionRequestType::AccessDelegation => AccessDelegation::readNode($graph, $payloadNode),
218            ActionRequestType::Verification => Verification::readNode($graph, $payloadNode),
219        };
220        $statusIri = Nodes::iri($graph, $root, Api::hasRequestStatus);
221        $status = $statusIri !== null ? RequestStatus::tryFromString($statusIri->value) : null;
222        $requestedAt = Nodes::dateTime($graph, $root, Api::isRequestedAt) ?? new DateTimeImmutable('@0');
223        $history = [];
224        foreach (Nodes::nodes($graph, $root, Api::hasRequestStatusHistory) as $entry) {
225            $entryStatusIri = Nodes::iri($graph, $entry, Api::hasRequestStatus);
226            $entryStatus = $entryStatusIri !== null ? RequestStatus::tryFromString($entryStatusIri->value) : null;
227            $since = Nodes::dateTime($graph, $entry, Api::hasRequestStatusSince);
228            if ($entryStatus !== null && $since !== null) {
229                $history[] = new RequestStatusEntry($entryStatus, $since, Nodes::iri($graph, $entry, Api::isChangedBy));
230            }
231        }
232        usort($history, static fn(RequestStatusEntry $a, RequestStatusEntry $b): int => $a->since <=> $b->since);
233
234        return new self(
235            $root,
236            $type,
237            $payload,
238            Nodes::iri($graph, $root, Api::isRequestedBy) ?? throw InvalidDocument::because('Invalid resource', 'api:isRequestedBy is required.', Api::isRequestedBy),
239            $requestedAt,
240            $status ?? RequestStatus::Pending,
241            Nodes::dateTime($graph, $root, Api::hasRequestStatusSince),
242            $history,
243            ErrorDocument::readFrom($graph, $root, Api::hasError),
244            Nodes::iri($graph, $root, Api::isRevokedBy),
245            Nodes::dateTime($graph, $root, Api::isRevokedAt),
246        );
247    }
248}