Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.49% |
110 / 114 |
|
72.73% |
8 / 11 |
CRAP | |
0.00% |
0 / 1 |
| ActionRequest | |
96.49% |
110 / 114 |
|
72.73% |
8 / 11 |
52 | |
0.00% |
0 / 1 |
| __construct | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
6.07 | |||
| create | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| notifyRequestStatusChange | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| logisticsObjects | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
5 | |||
| lastModified | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| canTransitionTo | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| withStatus | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
| toStorageJsonLd | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| toJsonLd | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| node | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
12 | |||
| fromJsonLd | |
95.24% |
40 / 42 |
|
0.00% |
0 / 1 |
15 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Api; |
| 6 | |
| 7 | use DateTimeImmutable; |
| 8 | use InvalidArgumentException; |
| 9 | use LambdaTwelve\OneRecord\Change\Change; |
| 10 | use LambdaTwelve\OneRecord\JsonLd\ExpandedDocument; |
| 11 | use LambdaTwelve\OneRecord\JsonLd\JsonLd; |
| 12 | use LambdaTwelve\OneRecord\JsonLd\JsonLdException; |
| 13 | use LambdaTwelve\OneRecord\JsonLd\Nodes; |
| 14 | use LambdaTwelve\OneRecord\Rdf\Iri; |
| 15 | use LambdaTwelve\OneRecord\Spec\ApiFeatures; |
| 16 | use LambdaTwelve\OneRecord\Spec\ApiVersion; |
| 17 | use LambdaTwelve\OneRecord\Vocabulary\Generated\Api; |
| 18 | use 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 | */ |
| 26 | final 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 | } |