Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.45% covered (success)
92.45%
49 / 53
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
AccessDelegation
92.45% covered (success)
92.45%
49 / 53
75.00% covered (warning)
75.00%
6 / 8
23.23
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 uniqueIris
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 uniquePermissions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fromJsonLd
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
6.00
 readNode
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
7
 toJsonLd
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 node
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 isExpiredAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
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\Spec\ApiFeatures;
16use LambdaTwelve\OneRecord\Spec\ApiVersion;
17use LambdaTwelve\OneRecord\Vocabulary\Generated\Api;
18
19/**
20 * api:AccessDelegation: permissions on logistics objects for organisations.
21 * 2.2 allowed several delegates in one request; 2.3 requires exactly one and
22 * adds an expiry. Both shapes are read; the negotiated version decides what
23 * is written and whether several delegates are accepted.
24 */
25final readonly class AccessDelegation
26{
27    /**
28     * @param non-empty-list<Permission> $permissions
29     * @param non-empty-list<Iri> $delegates the organisations access is requested for (api:isRequestedFor)
30     * @param non-empty-list<Iri> $logisticsObjects
31     */
32    public function __construct(
33        array $permissions,
34        array $delegates,
35        array $logisticsObjects,
36        public ?string $description = null,
37        public bool $notifyRequestStatusChange = false,
38        public ?DateTimeImmutable $expiresAt = null,
39    ) {
40        // A document may repeat an object or a delegate; a store projecting (request, object) rows
41        // must not see the repeat, so the lists are unique from construction, order kept.
42        $this->permissions = self::uniquePermissions($permissions);
43        $this->delegates = self::uniqueIris($delegates);
44        $this->logisticsObjects = self::uniqueIris($logisticsObjects);
45    }
46
47    /** @var non-empty-list<Permission> */
48    public array $permissions;
49
50    /** @var non-empty-list<Iri> the organisations access is requested for (api:isRequestedFor) */
51    public array $delegates;
52
53    /** @var non-empty-list<Iri> */
54    public array $logisticsObjects;
55
56    /**
57     * @param non-empty-list<Iri> $iris
58     * @return non-empty-list<Iri>
59     */
60    private static function uniqueIris(array $iris): array
61    {
62        $unique = [];
63        foreach ($iris as $iri) {
64            $unique[$iri->value] ??= $iri;
65        }
66
67        return array_values($unique);
68    }
69
70    /**
71     * @param non-empty-list<Permission> $permissions
72     * @return non-empty-list<Permission>
73     */
74    private static function uniquePermissions(array $permissions): array
75    {
76        $unique = [];
77        foreach ($permissions as $permission) {
78            $unique[$permission->value] ??= $permission;
79        }
80
81        return array_values($unique);
82    }
83
84    /**
85     * @param string|array<string, mixed>|ExpandedDocument $document
86     */
87    public static function fromJsonLd(string|array|ExpandedDocument $document, ApiVersion $version = ApiVersion::V2_2_0): self
88    {
89        try {
90            $expanded = $document instanceof ExpandedDocument ? $document : JsonLd::expand($document);
91        } catch (JsonLdException $e) {
92            throw InvalidDocument::because('Invalid body request', $e->getMessage());
93        }
94        if (!\in_array(Api::AccessDelegation, $expanded->rootTypes(), true)) {
95            throw InvalidDocument::because('Invalid resource', 'The body is not an api:AccessDelegation.');
96        }
97
98        return self::readNode($expanded->graph, $expanded->root, $version);
99    }
100
101    public static function readNode(Graph $graph, Iri|BlankNode $node, ApiVersion $version = ApiVersion::V2_2_0): self
102    {
103        $permissions = [];
104        foreach (Nodes::iris($graph, $node, Api::hasPermission) as $iri) {
105            $permission = Permission::tryFromString($iri->value)
106                ?? throw InvalidDocument::because('Invalid resource', \sprintf('"%s" is not a permission.', $iri->value), Api::hasPermission);
107            $permissions[$permission->value] = $permission;
108        }
109        if ($permissions === []) {
110            throw InvalidDocument::because('Invalid resource', 'An access delegation needs at least one permission.', Api::hasPermission);
111        }
112        $delegates = Nodes::iris($graph, $node, Api::isRequestedFor);
113        if ($delegates === []) {
114            throw InvalidDocument::because('Invalid resource', 'api:isRequestedFor must name the organisation(s) to grant access to.', Api::isRequestedFor);
115        }
116        if (\count($delegates) > 1 && ApiFeatures::available($version, ApiFeatures::SINGLE_DELEGATE)) {
117            throw InvalidDocument::because('Invalid resource', 'api:isRequestedFor names exactly one organisation in API ' . $version->value . '.', Api::isRequestedFor);
118        }
119        $objects = Nodes::iris($graph, $node, Api::hasLogisticsObject);
120        if ($objects === []) {
121            throw InvalidDocument::because('Invalid resource', 'api:hasLogisticsObject must name at least one logistics object.', Api::hasLogisticsObject);
122        }
123
124        return new self(
125            array_values($permissions),
126            $delegates,
127            $objects,
128            Nodes::string($graph, $node, Api::hasDescription),
129            Nodes::bool($graph, $node, Api::notifyRequestStatusChange) ?? false,
130            Nodes::dateTime($graph, $node, Api::expiresAt),
131        );
132    }
133
134    /**
135     * @return array<string, mixed>
136     */
137    public function toJsonLd(?ApiVersion $version = null): array
138    {
139        $version ??= ApiVersion::latest();
140
141        return ['@context' => Nodes::context(), ...$this->node($version)];
142    }
143
144    /**
145     * @return array<string, mixed>
146     */
147    public function node(ApiVersion $version): array
148    {
149        $node = ['@type' => 'api:AccessDelegation'];
150        if ($this->description !== null) {
151            $node['api:hasDescription'] = $this->description;
152        }
153        $node['api:hasPermission'] = array_map(static fn(Permission $p): array => Nodes::ref(Nodes::compact($p->value)), $this->permissions);
154        $node['api:isRequestedFor'] = array_map(static fn(Iri $i): array => Nodes::ref($i), $this->delegates);
155        $node['api:notifyRequestStatusChange'] = $this->notifyRequestStatusChange;
156        $node['api:hasLogisticsObject'] = array_map(static fn(Iri $i): array => Nodes::ref($i), $this->logisticsObjects);
157        if ($this->expiresAt !== null && ApiFeatures::available($version, ApiFeatures::ACCESS_DELEGATION_EXPIRY)) {
158            $node['api:expiresAt'] = Nodes::dateTimeValue($this->expiresAt);
159        }
160
161        return $node;
162    }
163
164    public function isExpiredAt(DateTimeImmutable $now): bool
165    {
166        return $this->expiresAt !== null && $this->expiresAt <= $now;
167    }
168}