Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.55% covered (success)
93.55%
58 / 62
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Subscription
93.55% covered (success)
93.55%
58 / 62
75.00% covered (warning)
75.00%
6 / 8
30.24
0.00% covered (danger)
0.00%
0 / 1
 __construct
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 fromJsonLd
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 readNode
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
10
 toJsonLd
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 node
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
7.27
 includes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isExpiredAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 covers
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Api;
6
7use DateTimeImmutable;
8use LambdaTwelve\OneRecord\JsonLd\ExpandedDocument;
9use LambdaTwelve\OneRecord\JsonLd\JsonLd;
10use LambdaTwelve\OneRecord\JsonLd\JsonLdException;
11use LambdaTwelve\OneRecord\JsonLd\Nodes;
12use LambdaTwelve\OneRecord\Rdf\BlankNode;
13use LambdaTwelve\OneRecord\Rdf\Graph;
14use LambdaTwelve\OneRecord\Rdf\Iri;
15use 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 */
21final 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}