Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.03% covered (success)
94.03%
63 / 67
90.91% covered (success)
90.91%
10 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ServerConfig
94.03% covered (success)
94.03%
63 / 67
90.91% covered (success)
90.91%
10 / 11
50.53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 problems
90.91% covered (success)
90.91%
40 / 44
0.00% covered (danger)
0.00%
0 / 1
35.92
 endpoint
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 highestApiVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supports
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validationModel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logisticsObjectIri
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionRequestIri
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logisticsEventIri
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 relativePath
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 isLocal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Server;
6
7use InvalidArgumentException;
8use LambdaTwelve\OneRecord\Rdf\Iri;
9use LambdaTwelve\OneRecord\Spec\ApiVersion;
10use LambdaTwelve\OneRecord\Spec\DataModelVersion;
11
12/**
13 * What a host tells the server about itself. Everything else comes through
14 * the SPI.
15 */
16final readonly class ServerConfig
17{
18    /** @var non-empty-list<ApiVersion> */
19    public array $apiVersions;
20
21    /** @var non-empty-list<DataModelVersion> */
22    public array $dataModelVersions;
23
24    /**
25     * @param string $baseUrl scheme and host, e.g. https://1r.example.com
26     * @param Iri $dataHolder the Organization URI of the data holder (also a logistics object this server serves)
27     * @param string $basePath path prefix the server is mounted under, e.g. /onerecord (empty for the root)
28     * @param ?list<ApiVersion> $apiVersions API versions served, highest first; default: every version the package knows
29     * @param ?list<DataModelVersion> $dataModelVersions ontology versions advertised; the newest is used for validation
30     * @param list<string> $languages at least en-US, which the spec requires
31     * @param int $maxBodyBytes request bodies above this are refused with 413
32     * @param int $embeddedDepth how deep ?embedded=true follows links on the same server
33     * @param bool $bulkLogisticsEvents serve the optional 2.3 POST /logistics-events endpoint
34     */
35    public function __construct(
36        public string $baseUrl,
37        public Iri $dataHolder,
38        public string $basePath = '',
39        ?array $apiVersions = null,
40        ?array $dataModelVersions = null,
41        public array $languages = ['en-US'],
42        public int $maxBodyBytes = 1_048_576,
43        public int $embeddedDepth = 3,
44        public bool $bulkLogisticsEvents = false,
45        public ?string $dataHolderType = null,
46    ) {
47        $problems = self::problems(['baseUrl' => $baseUrl, 'dataHolder' => $dataHolder, 'basePath' => $basePath, 'apiVersions' => $apiVersions, 'dataModelVersions' => $dataModelVersions, 'languages' => $languages, 'maxBodyBytes' => $maxBodyBytes, 'embeddedDepth' => $embeddedDepth]);
48        if ($problems !== []) {
49            throw new InvalidArgumentException($problems[0]);
50        }
51        $versions = $apiVersions ?? ApiVersion::allDescending();
52        usort($versions, static fn(ApiVersion $a, ApiVersion $b): int => version_compare($b->value, $a->value));
53        $this->apiVersions = $versions === [] ? ApiVersion::allDescending() : $versions;
54        $models = $dataModelVersions ?? array_reverse(DataModelVersion::cases());
55        usort($models, static fn(DataModelVersion $a, DataModelVersion $b): int => version_compare($b->value, $a->value));
56        $this->dataModelVersions = $models === [] ? array_reverse(DataModelVersion::cases()) : $models;
57    }
58
59    /**
60     * What is wrong with a set of settings, in plain sentences, without
61     * constructing anything: for a host's status page on an install that is
62     * not configured yet. Empty means the constructor would accept them.
63     * Values may be the typed objects the constructor takes or the raw
64     * strings a settings form holds (version strings, an IRI as a string).
65     *
66     * @param array<string, mixed> $settings keys as the constructor's parameters; a missing key means "not set"
67     * @return list<string>
68     */
69    public static function problems(array $settings): array
70    {
71        $problems = [];
72        $baseUrl = $settings['baseUrl'] ?? null;
73        if (!\is_string($baseUrl) || $baseUrl === '') {
74            $problems[] = 'The base URL is not set.';
75        } elseif (preg_match('#^https?://[^/\s]+$#', $baseUrl) !== 1) {
76            $problems[] = \sprintf('The base URL must be scheme and host only, got "%s".', $baseUrl);
77        }
78        $holder = $settings['dataHolder'] ?? null;
79        if ($holder === null || $holder === '') {
80            $problems[] = 'The data holder is not set: the IRI of the organisation this server speaks for.';
81        } elseif (!$holder instanceof Iri) {
82            if (!\is_string($holder)) {
83                $problems[] = 'The data holder must be an IRI.';
84            } else {
85                try {
86                    new Iri($holder);
87                } catch (InvalidArgumentException $e) {
88                    $problems[] = 'The data holder is not a valid IRI: ' . $e->getMessage();
89                }
90            }
91        }
92        $basePath = $settings['basePath'] ?? '';
93        if (!\is_string($basePath)) {
94            $problems[] = 'The base path must be a string.';
95        } elseif ($basePath !== '' && (!str_starts_with($basePath, '/') || str_ends_with($basePath, '/'))) {
96            $problems[] = 'The base path must start with "/" and not end with one.';
97        }
98        $versions = $settings['apiVersions'] ?? null;
99        if ($versions !== null) {
100            if (!\is_array($versions) || $versions === []) {
101                $problems[] = 'At least one API version must be served.';
102            } else {
103                foreach ($versions as $version) {
104                    if (!$version instanceof ApiVersion && (!\is_string($version) || ApiVersion::tryFrom($version) === null)) {
105                        $problems[] = \sprintf('Unknown API version "%s"; this package knows %s.', \is_scalar($version) ? (string) $version : \gettype($version), implode(', ', array_map(static fn(ApiVersion $v): string => $v->value, ApiVersion::cases())));
106                    }
107                }
108            }
109        }
110        $models = $settings['dataModelVersions'] ?? null;
111        if ($models !== null) {
112            if (!\is_array($models) || $models === []) {
113                $problems[] = 'At least one data model version must be advertised.';
114            } else {
115                foreach ($models as $model) {
116                    if (!$model instanceof DataModelVersion && (!\is_string($model) || DataModelVersion::tryFrom($model) === null)) {
117                        $problems[] = \sprintf('Unknown data model version "%s"; this package knows %s.', \is_scalar($model) ? (string) $model : \gettype($model), implode(', ', array_map(static fn(DataModelVersion $v): string => $v->value, DataModelVersion::cases())));
118                    }
119                }
120            }
121        }
122        $languages = $settings['languages'] ?? ['en-US'];
123        if (!\is_array($languages) || !\in_array('en-US', $languages, true)) {
124            $problems[] = 'en-US must be among the supported languages (the spec requires it).';
125        }
126        $maxBody = $settings['maxBodyBytes'] ?? 1;
127        if (!\is_int($maxBody) || $maxBody < 1) {
128            $problems[] = 'The request body limit must be a positive number of bytes.';
129        }
130        $depth = $settings['embeddedDepth'] ?? 0;
131        if (!\is_int($depth) || $depth < 0) {
132            $problems[] = 'The embedding depth must be zero or more.';
133        }
134
135        return $problems;
136    }
137
138    /**
139     * The URL every resource path hangs off (base URL plus base path, no trailing slash).
140     */
141    public function endpoint(): string
142    {
143        return $this->baseUrl . $this->basePath;
144    }
145
146    public function highestApiVersion(): ApiVersion
147    {
148        return $this->apiVersions[0];
149    }
150
151    public function supports(ApiVersion $version): bool
152    {
153        return \in_array($version, $this->apiVersions, true);
154    }
155
156    public function validationModel(): DataModelVersion
157    {
158        return $this->dataModelVersions[0];
159    }
160
161    public function logisticsObjectIri(string $id): Iri
162    {
163        return new Iri($this->endpoint() . '/logistics-objects/' . $id);
164    }
165
166    public function actionRequestIri(string $id): Iri
167    {
168        return new Iri($this->endpoint() . '/action-requests/' . $id);
169    }
170
171    public function logisticsEventIri(string $objectId, string $eventId): Iri
172    {
173        return new Iri($this->endpoint() . '/logistics-objects/' . $objectId . '/logistics-events/' . $eventId);
174    }
175
176    /**
177     * The path-relative part of a URI under this server, or null if it is not ours.
178     */
179    public function relativePath(Iri $iri): ?string
180    {
181        $prefix = $this->endpoint() . '/';
182        if (!str_starts_with($iri->value, $prefix)) {
183            return null;
184        }
185        $rest = substr($iri->value, \strlen($prefix));
186        $query = strpos($rest, '?');
187
188        return $query === false ? $rest : substr($rest, 0, $query);
189    }
190
191    public function isLocal(Iri $iri): bool
192    {
193        return $this->relativePath($iri) !== null;
194    }
195}