Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.23% covered (success)
96.23%
51 / 53
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ActionRequestEndpoint
96.23% covered (success)
96.23%
51 / 53
50.00% covered (danger)
50.00%
2 / 4
30
0.00% covered (danger)
0.00%
0 / 1
 handle
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
9
 update
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 transition
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
10.03
 isParty
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Server\Endpoint;
6
7use LambdaTwelve\OneRecord\Api\AccessDelegation;
8use LambdaTwelve\OneRecord\Api\ActionRequest;
9use LambdaTwelve\OneRecord\Api\RequestStatus;
10use LambdaTwelve\OneRecord\Api\Subscription;
11use LambdaTwelve\OneRecord\Server\ActionRequests;
12use LambdaTwelve\OneRecord\Server\Http\HttpException;
13use LambdaTwelve\OneRecord\Server\Http\Negotiated;
14use LambdaTwelve\OneRecord\Server\Http\Responder;
15use LambdaTwelve\OneRecord\Server\IllegalTransition;
16use LambdaTwelve\OneRecord\Server\Spi\Action;
17use LambdaTwelve\OneRecord\Server\Spi\Agent;
18use LambdaTwelve\OneRecord\Server\Spi\Decision;
19use LambdaTwelve\OneRecord\Server\Spi\StoreException;
20use LambdaTwelve\OneRecord\Spec\ApiFeatures;
21use Psr\Http\Message\ResponseInterface;
22use Psr\Http\Message\ServerRequestInterface;
23
24/**
25 * /action-requests/{id}: GET and HEAD for the requestor, the parties it
26 * concerns and the holder; PATCH ?status= for the holder to decide
27 * ("internal only" per spec, so the policy must allow it); DELETE to revoke,
28 * by the requestor or the holder.
29 */
30final class ActionRequestEndpoint extends AbstractEndpoint
31{
32    public function handle(ServerRequestInterface $request, Agent $agent, Negotiated $negotiated, array $parameters): ResponseInterface
33    {
34        $iri = $this->services->config->actionRequestIri($parameters['id']);
35        $actionRequest = $this->services->actionRequests->get($iri);
36        $method = strtoupper($request->getMethod());
37
38        if ($method === 'PATCH') {
39            $this->decide($agent, Action::DecideActionRequest, $iri);
40            $actionRequest ??= throw HttpException::notFound('Action Request', $iri->value);
41
42            return $this->update($request, $agent, $negotiated, $actionRequest);
43        }
44
45        if ($actionRequest === null) {
46            throw HttpException::notFound('Action Request', $iri->value);
47        }
48        if ($method === 'DELETE') {
49            // Being a party lets you read a request; revoking it is the requestor's right, or the
50            // policy's call. A delegate of a shared delegation must not be able to cut off the others (AR-028).
51            // The spec tells a subscriber to revoke its SubscriptionRequest to unsubscribe, even when a
52            // third party created it (spec question 30); a delegate of a shared delegation gets no such right.
53            $subscriber = $actionRequest->payload instanceof Subscription && $agent->is($actionRequest->payload->subscriber);
54            if (!$agent->is($actionRequest->requestedBy) && !$subscriber) {
55                $this->decide($agent, Action::RevokeActionRequest, $iri);
56            }
57
58            return $this->transition($actionRequest, RequestStatus::Revoked, $agent, $negotiated);
59        }
60
61        if (!$this->isParty($agent, $actionRequest)) {
62            $decision = $this->services->policy->decide($agent, Action::ReadActionRequest, $iri);
63            if ($decision !== Decision::Allow) {
64                // Someone else's request: say nothing about it, whichever way the policy denies.
65                throw HttpException::notFound('Action Request', $iri->value);
66            }
67        }
68        $headers = ['Location' => $iri->value, 'Last-Modified' => Responder::httpDate($actionRequest->lastModified())];
69
70        return $this->services->responder->jsonLd(200, $actionRequest->toJsonLd($negotiated->version), $negotiated, $actionRequest->type->value, $headers, self::isHead($request));
71    }
72
73    private function update(ServerRequestInterface $request, Agent $agent, Negotiated $negotiated, ActionRequest $actionRequest): ResponseInterface
74    {
75        $query = self::query($request);
76        $value = $query['status'] ?? '';
77        $status = $value === '' ? null : RequestStatus::tryFromString($value);
78        if ($status === null) {
79            throw HttpException::invalidQuery('status must be REQUEST_ACCEPTED, REQUEST_REJECTED, REQUEST_REVOKED or REQUEST_ACKNOWLEDGED (as a short name or an api: IRI).', 'status');
80        }
81        if (!\in_array($status, [RequestStatus::Accepted, RequestStatus::Rejected, RequestStatus::Revoked, RequestStatus::Acknowledged], true)) {
82            throw HttpException::invalidQuery(\sprintf('A request cannot be set to %s through the API.', $status->shortName()), 'status');
83        }
84
85        return $this->transition($actionRequest, $status, $agent, $negotiated);
86    }
87
88    private function transition(ActionRequest $actionRequest, RequestStatus $status, Agent $agent, Negotiated $negotiated): ResponseInterface
89    {
90        $requests = new ActionRequests($this->services);
91        try {
92            match ($status) {
93                RequestStatus::Accepted => $requests->accept($actionRequest, $agent->iri),
94                RequestStatus::Rejected => $requests->reject($actionRequest, $agent->iri),
95                RequestStatus::Acknowledged => $requests->acknowledge($actionRequest, $agent->iri),
96                RequestStatus::Revoked => $requests->revoke($actionRequest, $agent->iri),
97                default => throw HttpException::invalidQuery('Unsupported status.', 'status'),
98            };
99        } catch (IllegalTransition $e) {
100            // 2.3 specifies 422 for an impossible revocation; 2.2 says nothing, and 400 was the common reading.
101            throw ApiFeatures::available($negotiated->version, ApiFeatures::REVOCATION_422)
102                ? HttpException::unprocessable($e->getMessage(), null)
103                : HttpException::badRequest($e->getMessage(), null, 'Invalid resource');
104        } catch (StoreException $e) {
105            if ($e->kind !== StoreException::STATUS_CONFLICT) {
106                throw $e;
107            }
108            // Another worker decided first: the unit of work has unwound, the client retries against the new state (R2-012).
109            throw HttpException::conflict($e->getMessage(), $actionRequest->iri->value);
110        }
111
112        return $this->services->responder->empty(204, $negotiated, ['Location' => $actionRequest->iri->value, 'Type' => $actionRequest->type->value]);
113    }
114
115    /**
116     * The requestor, and the organisations a request is about (the delegate of
117     * an access delegation, the subscriber of a subscription), may always see it.
118     */
119    private function isParty(Agent $agent, ActionRequest $actionRequest): bool
120    {
121        if ($agent->is($actionRequest->requestedBy)) {
122            return true;
123        }
124        $payload = $actionRequest->payload;
125        if ($payload instanceof Subscription && $agent->is($payload->subscriber)) {
126            return true;
127        }
128        if ($payload instanceof AccessDelegation) {
129            foreach ($payload->delegates as $delegate) {
130                if ($agent->is($delegate)) {
131                    return true;
132                }
133            }
134        }
135
136        return false;
137    }
138}