Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.31% covered (success)
92.31%
36 / 39
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SubscriptionsEndpoint
92.31% covered (success)
92.31%
36 / 39
60.00% covered (warning)
60.00%
3 / 5
19.16
0.00% covered (danger)
0.00%
0 / 1
 handle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 offers
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 subscribe
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 validateTopic
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
8.30
 compactTopicType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Server\Endpoint;
6
7use InvalidArgumentException;
8use LambdaTwelve\OneRecord\Api\Collection;
9use LambdaTwelve\OneRecord\Api\Subscription;
10use LambdaTwelve\OneRecord\Api\TopicType;
11use LambdaTwelve\OneRecord\JsonLd\Nodes;
12use LambdaTwelve\OneRecord\Rdf\Iri;
13use LambdaTwelve\OneRecord\Server\ActionRequests;
14use LambdaTwelve\OneRecord\Server\Http\ContentNegotiation;
15use LambdaTwelve\OneRecord\Server\Http\HttpException;
16use LambdaTwelve\OneRecord\Server\Http\Negotiated;
17use LambdaTwelve\OneRecord\Server\Spi\Action;
18use LambdaTwelve\OneRecord\Server\Spi\Agent;
19use LambdaTwelve\OneRecord\Server\Spi\Decision;
20use LambdaTwelve\OneRecord\Vocabulary\Generated\Api;
21use Psr\Http\Message\ResponseInterface;
22use Psr\Http\Message\ServerRequestInterface;
23
24/**
25 * /subscriptions. GET ?topicType&topic: a publisher asks what subscription
26 * this host wants for an object or a type (answered from the store's offers).
27 * POST: a subscriber asks to be notified; becomes a pending
28 * SubscriptionRequest the holder decides (201 with its Location).
29 */
30final class SubscriptionsEndpoint extends AbstractEndpoint
31{
32    public function handle(ServerRequestInterface $request, Agent $agent, Negotiated $negotiated, array $parameters): ResponseInterface
33    {
34        return strtoupper($request->getMethod()) === 'POST'
35            ? $this->subscribe($request, $agent, $negotiated)
36            : $this->offers($request, $negotiated);
37    }
38
39    private function offers(ServerRequestInterface $request, Negotiated $negotiated): ResponseInterface
40    {
41        $query = self::query($request);
42        $topicType = isset($query['topicType']) ? TopicType::tryFromString($query['topicType']) : null;
43        if ($topicType === null) {
44            throw HttpException::invalidQuery('topicType must be LOGISTICS_OBJECT_TYPE or LOGISTICS_OBJECT_IDENTIFIER.', 'topicType');
45        }
46        $topic = trim($query['topic'] ?? '');
47        if ($topic === '') {
48            throw HttpException::invalidQuery('topic is required.', 'topic');
49        }
50        // A publisher asks about its own object, which lives on its server, not here: only the
51        // shape is checked, not local existence (AR-006). Creating a subscription is the other direction.
52        if ($topicType === TopicType::Type) {
53            $this->validateTopic($topicType, $topic);
54        } else {
55            try {
56                new Iri($topic);
57            } catch (InvalidArgumentException) {
58                throw HttpException::invalidQuery('topic must be a logistics object URI.', 'topic');
59            }
60        }
61
62        $offers = $this->services->subscriptions->offered($topicType, $topic);
63        if (\count($offers) === 1) {
64            return $this->services->responder->jsonLd(200, $offers[0]->toJsonLd(), $negotiated, Api::Subscription, [], self::isHead($request));
65        }
66        $items = array_map(static fn(Subscription $s): array => $s->node(), $offers);
67        $collection = Collection::write(new Iri($this->services->config->endpoint() . '/subscriptions'), $items);
68
69        return $this->services->responder->jsonLd(200, $collection, $negotiated, Api::Collection, [], self::isHead($request));
70    }
71
72    private function subscribe(ServerRequestInterface $request, Agent $agent, Negotiated $negotiated): ResponseInterface
73    {
74        (new ContentNegotiation($this->services->config))->bodyVersion($request, $negotiated);
75        $subscription = Subscription::fromJsonLd($this->services->body->json($request));
76        $this->validateTopic($subscription->topicType, $subscription->topic, $agent);
77        $created = (new ActionRequests($this->services))->create($subscription, $agent->iri);
78
79        return $this->services->responder->empty(201, $negotiated, ['Location' => $created->iri->value, 'Type' => Api::SubscriptionRequest]);
80    }
81
82    /**
83     * A type topic must be a logistics object class; an identifier topic must
84     * be an object this server holds (hidden ones look non-existent).
85     */
86    private function validateTopic(TopicType $topicType, string $topic, ?Agent $agent = null): void
87    {
88        if ($topicType === TopicType::Type) {
89            if (!$this->services->vocabulary->isLogisticsObjectClass($topic)) {
90                throw HttpException::badRequest(\sprintf('"%s" is not a logistics object type of the ONE Record ontology.', $topic), Api::hasTopic, 'Invalid resource');
91            }
92
93            return;
94        }
95        try {
96            $iri = new Iri($topic);
97        } catch (InvalidArgumentException) {
98            throw HttpException::badRequest('topic must be a logistics object URI.', Api::hasTopic, 'Invalid resource');
99        }
100        if (!$this->services->config->isLocal($iri)) {
101            throw HttpException::badRequest(\sprintf('"%s" is not a logistics object of this server.', $topic), Api::hasTopic, 'Invalid resource');
102        }
103        $hidden = $agent !== null && $this->services->policy->decide($agent, Action::ReadLogisticsObject, $iri) === Decision::Hide;
104        if ($hidden || !$this->services->objects->exists($iri)) {
105            throw HttpException::badRequest(\sprintf('"%s" is not a logistics object of this server.', $topic), Api::hasTopic, 'Invalid resource');
106        }
107    }
108
109    /** @internal */
110    public static function compactTopicType(TopicType $type): string
111    {
112        return Nodes::compact($type->value);
113    }
114}