Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
93.55% |
58 / 62 |
|
75.00% |
6 / 8 |
CRAP | |
0.00% |
0 / 1 |
| Subscription | |
93.55% |
58 / 62 |
|
75.00% |
6 / 8 |
30.24 | |
0.00% |
0 / 1 |
| __construct | |
50.00% |
1 / 2 |
|
0.00% |
0 / 1 |
2.50 | |||
| fromJsonLd | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| readNode | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
10 | |||
| toJsonLd | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| node | |
82.35% |
14 / 17 |
|
0.00% |
0 / 1 |
7.27 | |||
| includes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isExpiredAt | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| covers | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Api; |
| 6 | |
| 7 | use DateTimeImmutable; |
| 8 | use LambdaTwelve\OneRecord\JsonLd\ExpandedDocument; |
| 9 | use LambdaTwelve\OneRecord\JsonLd\JsonLd; |
| 10 | use LambdaTwelve\OneRecord\JsonLd\JsonLdException; |
| 11 | use LambdaTwelve\OneRecord\JsonLd\Nodes; |
| 12 | use LambdaTwelve\OneRecord\Rdf\BlankNode; |
| 13 | use LambdaTwelve\OneRecord\Rdf\Graph; |
| 14 | use LambdaTwelve\OneRecord\Rdf\Iri; |
| 15 | use LambdaTwelve\OneRecord\Vocabulary\Generated\Api; |
| 16 | |
| 17 | /** |
| 18 | * api:Subscription: who wants to be told about what. Immutable by spec; to |
| 19 | * change one, revoke the request and make a new one. |
| 20 | */ |
| 21 | final readonly class Subscription |
| 22 | { |
| 23 | /** |
| 24 | * @param non-empty-list<SubscriptionEventType> $eventTypes |
| 25 | * @param list<string> $contentTypes |
| 26 | */ |
| 27 | public function __construct( |
| 28 | public Iri $subscriber, |
| 29 | public TopicType $topicType, |
| 30 | public string $topic, |
| 31 | public array $eventTypes, |
| 32 | public bool $sendLogisticsObjectBody = false, |
| 33 | public bool $notifyRequestStatusChange = false, |
| 34 | public array $contentTypes = ['application/ld+json'], |
| 35 | public ?string $description = null, |
| 36 | public ?DateTimeImmutable $expiresAt = null, |
| 37 | public ?Iri $id = null, |
| 38 | ) { |
| 39 | if ($eventTypes === []) { |
| 40 | throw InvalidDocument::because('Invalid resource', 'A subscription must include at least one event type.', Api::includeSubscriptionEventType); |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * @param string|array<string, mixed>|ExpandedDocument $document |
| 46 | */ |
| 47 | public static function fromJsonLd(string|array|ExpandedDocument $document): self |
| 48 | { |
| 49 | try { |
| 50 | $expanded = $document instanceof ExpandedDocument ? $document : JsonLd::expand($document); |
| 51 | } catch (JsonLdException $e) { |
| 52 | throw InvalidDocument::because('Invalid body request', $e->getMessage()); |
| 53 | } |
| 54 | if (!\in_array(Api::Subscription, $expanded->rootTypes(), true)) { |
| 55 | throw InvalidDocument::because('Invalid resource', 'The body is not an api:Subscription.'); |
| 56 | } |
| 57 | |
| 58 | return self::readNode($expanded->graph, $expanded->root); |
| 59 | } |
| 60 | |
| 61 | public static function readNode(Graph $graph, Iri|BlankNode $node): self |
| 62 | { |
| 63 | $subscriber = Nodes::iri($graph, $node, Api::hasSubscriber) |
| 64 | ?? throw InvalidDocument::because('Invalid resource', 'api:hasSubscriber must reference the subscriber organisation.', Api::hasSubscriber); |
| 65 | $topicTypeIri = Nodes::iri($graph, $node, Api::hasTopicType); |
| 66 | $topicType = $topicTypeIri !== null ? TopicType::tryFromString($topicTypeIri->value) : null; |
| 67 | if ($topicType === null) { |
| 68 | throw InvalidDocument::because('Invalid resource', 'api:hasTopicType must be LOGISTICS_OBJECT_IDENTIFIER or LOGISTICS_OBJECT_TYPE.', Api::hasTopicType); |
| 69 | } |
| 70 | $topic = Nodes::string($graph, $node, Api::hasTopic); |
| 71 | if ($topic === null || preg_match('/^[a-z][a-z0-9+.-]*:/i', $topic) !== 1) { |
| 72 | throw InvalidDocument::because('Invalid resource', 'api:hasTopic must be a URI.', Api::hasTopic); |
| 73 | } |
| 74 | $eventTypes = []; |
| 75 | foreach (Nodes::iris($graph, $node, Api::includeSubscriptionEventType) as $iri) { |
| 76 | $type = SubscriptionEventType::tryFromString($iri->value); |
| 77 | if ($type === null) { |
| 78 | throw InvalidDocument::because('Invalid resource', \sprintf('"%s" is not a subscription event type.', $iri->value), Api::includeSubscriptionEventType); |
| 79 | } |
| 80 | $eventTypes[$type->value] = $type; |
| 81 | } |
| 82 | if ($eventTypes === []) { |
| 83 | throw InvalidDocument::because('Invalid resource', 'A subscription must include at least one event type.', Api::includeSubscriptionEventType); |
| 84 | } |
| 85 | $contentTypes = Nodes::strings($graph, $node, Api::hasContentType); |
| 86 | |
| 87 | return new self( |
| 88 | $subscriber, |
| 89 | $topicType, |
| 90 | $topic, |
| 91 | array_values($eventTypes), |
| 92 | Nodes::bool($graph, $node, Api::sendLogisticsObjectBody) ?? false, |
| 93 | Nodes::bool($graph, $node, Api::notifyRequestStatusChange) ?? false, |
| 94 | $contentTypes === [] ? ['application/ld+json'] : $contentTypes, |
| 95 | Nodes::string($graph, $node, Api::hasDescription), |
| 96 | Nodes::dateTime($graph, $node, Api::expiresAt), |
| 97 | $node instanceof Iri ? $node : null, |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * @return array<string, mixed> |
| 103 | */ |
| 104 | public function toJsonLd(): array |
| 105 | { |
| 106 | return ['@context' => Nodes::context(), ...$this->node()]; |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * @return array<string, mixed> |
| 111 | */ |
| 112 | public function node(): array |
| 113 | { |
| 114 | $node = ['@type' => 'api:Subscription']; |
| 115 | if ($this->id !== null) { |
| 116 | $node['@id'] = $this->id->value; |
| 117 | } |
| 118 | $node['api:hasContentType'] = \count($this->contentTypes) === 1 ? $this->contentTypes[0] : $this->contentTypes; |
| 119 | if ($this->description !== null) { |
| 120 | $node['api:hasDescription'] = $this->description; |
| 121 | } |
| 122 | if ($this->expiresAt !== null) { |
| 123 | $node['api:expiresAt'] = Nodes::dateTimeValue($this->expiresAt); |
| 124 | } |
| 125 | $node['api:hasSubscriber'] = Nodes::ref($this->subscriber); |
| 126 | $node['api:hasTopicType'] = Nodes::ref(Nodes::compact($this->topicType->value)); |
| 127 | $node['api:includeSubscriptionEventType'] = array_map(static fn(SubscriptionEventType $t): array => Nodes::ref(Nodes::compact($t->value)), $this->eventTypes); |
| 128 | $node['api:hasTopic'] = Nodes::anyUri($this->topic); |
| 129 | if ($this->sendLogisticsObjectBody) { |
| 130 | $node['api:sendLogisticsObjectBody'] = true; |
| 131 | } |
| 132 | if ($this->notifyRequestStatusChange) { |
| 133 | $node['api:notifyRequestStatusChange'] = true; |
| 134 | } |
| 135 | |
| 136 | return $node; |
| 137 | } |
| 138 | |
| 139 | public function includes(SubscriptionEventType $type): bool |
| 140 | { |
| 141 | return \in_array($type, $this->eventTypes, true); |
| 142 | } |
| 143 | |
| 144 | public function isExpiredAt(DateTimeImmutable $now): bool |
| 145 | { |
| 146 | return $this->expiresAt !== null && $this->expiresAt <= $now; |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Does this subscription cover the given object (by URI or by any of its types)? |
| 151 | * |
| 152 | * @param list<string> $types class IRIs of the object |
| 153 | */ |
| 154 | public function covers(Iri $logisticsObject, array $types): bool |
| 155 | { |
| 156 | return match ($this->topicType) { |
| 157 | TopicType::Identifier => $this->topic === $logisticsObject->value, |
| 158 | TopicType::Type => \in_array($this->topic, $types, true), |
| 159 | }; |
| 160 | } |
| 161 | } |