Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
94.03% |
63 / 67 |
|
90.91% |
10 / 11 |
CRAP | |
0.00% |
0 / 1 |
| ServerConfig | |
94.03% |
63 / 67 |
|
90.91% |
10 / 11 |
50.53 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| problems | |
90.91% |
40 / 44 |
|
0.00% |
0 / 1 |
35.92 | |||
| endpoint | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| highestApiVersion | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| supports | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| validationModel | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| logisticsObjectIri | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| actionRequestIri | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| logisticsEventIri | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| relativePath | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| isLocal | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Server; |
| 6 | |
| 7 | use InvalidArgumentException; |
| 8 | use LambdaTwelve\OneRecord\Rdf\Iri; |
| 9 | use LambdaTwelve\OneRecord\Spec\ApiVersion; |
| 10 | use LambdaTwelve\OneRecord\Spec\DataModelVersion; |
| 11 | |
| 12 | /** |
| 13 | * What a host tells the server about itself. Everything else comes through |
| 14 | * the SPI. |
| 15 | */ |
| 16 | final 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 | } |