Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.60% covered (warning)
86.60%
181 / 209
36.84% covered (danger)
36.84%
7 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Context
86.60% covered (warning)
86.60%
181 / 209
36.84% covered (danger)
36.84%
7 / 19
177.18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 oneRecord
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fromRaw
92.16% covered (success)
92.16%
47 / 51
0.00% covered (danger)
0.00%
0 / 1
25.30
 expandIri
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
15.10
 resolveReference
84.00% covered (warning)
84.00%
21 / 25
0.00% covered (danger)
0.00%
0 / 1
28.77
 removeDotSegments
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
8.15
 coercionOfTerm
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 coercionOf
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 compactIri
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 keyCandidates
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
13
 compactWithPrefix
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 readsBackAs
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 toRaw
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
10.39
 withLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 resolveDefinitionIri
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
10.75
 termDefinition
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 requireString
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 isAbsoluteIri
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 looksLikeAbsoluteIri
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\JsonLd;
6
7use LambdaTwelve\OneRecord\Spec\Namespaces;
8
9/**
10 * The part of a JSON-LD @context that ONE Record uses: prefix definitions,
11 * @vocab, @base, a default @language, and term definitions that coerce a
12 * property's values to IRIs (`"@type": "@id"`) or to a datatype.
13 *
14 * Remote contexts (a URL string), arrays of contexts, @container, @reverse,
15 * @nest, @protected and @propagate are refused: IATA's documents inline a
16 * small object context, and nothing in the standard needs more.
17 */
18final readonly class Context
19{
20    public const string JSON_LD_ID = '@id';
21
22    /**
23     * @param array<string, string> $prefixes prefix => IRI (the empty prefix is allowed)
24     * @param array<string, array{id: string, type: ?string}> $terms term => expanded IRI and coercion ("@id" or a datatype IRI)
25     */
26    public function __construct(
27        public array $prefixes = [],
28        public array $terms = [],
29        public ?string $vocab = null,
30        public ?string $base = null,
31        public ?string $language = null,
32    ) {}
33
34    /**
35     * The context every object this package emits starts from.
36     */
37    public static function oneRecord(): self
38    {
39        return new self(['cargo' => Namespaces::CARGO, 'api' => Namespaces::API, 'xsd' => Namespaces::XSD]);
40    }
41
42    /**
43     * @param mixed $raw the value of "@context"
44     */
45    public static function fromRaw(mixed $raw, string $path = '@context'): self
46    {
47        if ($raw === null) {
48            return new self();
49        }
50        if (\is_string($raw)) {
51            throw JsonLdException::unsupported($path, 'A remote context (a URL)');
52        }
53        if (!\is_array($raw) || array_is_list($raw)) {
54            throw JsonLdException::unsupported($path, 'A context that is not a single JSON object');
55        }
56
57        /** @var array<string, string> $prefixes */
58        $prefixes = [];
59        /** @var array<string, array{id: string, type: ?string}> $terms */
60        $terms = [];
61        $vocab = null;
62        $base = null;
63        $language = null;
64        foreach ($raw as $key => $value) {
65            $key = (string) $key;
66            $here = $path . '.' . $key;
67            if ($key === '@vocab') {
68                $vocab = self::requireString($value, $here);
69            } elseif ($key === '@base') {
70                $base = self::requireString($value, $here);
71            } elseif ($key === '@language') {
72                $language = self::requireString($value, $here);
73            } elseif ($key === '@version') {
74                // JSON-LD 1.1 processing mode marker; nothing to do.
75            } elseif (str_starts_with($key, '@')) {
76                throw JsonLdException::unsupported($here, "The context keyword {$key}");
77            } elseif (\is_string($value)) {
78                if (!self::isAbsoluteIri($value) && !str_contains($value, ':')) {
79                    throw JsonLdException::at($here, 'A term must map to an IRI or a compact IRI');
80                }
81                $prefixes[$key] = $value;
82            } elseif (\is_array($value) && !array_is_list($value)) {
83                /** @var array<string, mixed> $value */
84                $terms[$key] = self::termDefinition($value, $here, $key);
85            } elseif ($value === null) {
86                // Explicitly undefining a term: nothing is defined, so nothing to record.
87            } else {
88                throw JsonLdException::at($here, 'A term definition must be an IRI string or an object');
89            }
90        }
91
92        // Term ids and datatypes are compact IRIs of the prefixes ("cargo:Piece") or other terms
93        // ({"str": {"@id": "xsd:string"}, "name": {"@type": "str"}}, JSON-LD's create-term-definition
94        // dependency); each is resolved once, through the other terms as needed, never itself (R4-002).
95        $context = new self($prefixes, [], $vocab, $base, $language);
96        /** @var array<string, array{id: string, type: ?string}> $resolvedTerms */
97        $resolvedTerms = [];
98        $resolving = [];
99        foreach ($terms as $term => $definition) {
100            $id = self::resolveDefinitionIri($definition['id'], $term, $terms, $context, $path . '.' . $term, $resolving);
101            // A term spelled as a compact IRI of a defined prefix, or as an absolute IRI, already means
102            // that IRI; a definition saying otherwise is the inconsistency JSON-LD 1.1 calls an invalid
103            // IRI mapping (create term definition, step 14.2.4). Accepting it would let a writer's
104            // prefix-only output be read back as something else (R5-001).
105            $colon = strpos($term, ':');
106            if ($colon !== false && (isset($prefixes[substr($term, 0, $colon)]) || self::isAbsoluteIri($term))) {
107                $alreadyMeans = $context->expandIri($term, $path . '.' . $term, vocabRelative: true);
108                if ($alreadyMeans !== $id) {
109                    throw JsonLdException::at($path . '.' . $term, \sprintf('"%s" already denotes %s and cannot be redefined as %s', $term, $alreadyMeans, $id));
110                }
111            }
112            $resolvedTerms[$term] = [
113                'id' => $id,
114                'type' => $definition['type'] === null || $definition['type'] === self::JSON_LD_ID
115                    ? $definition['type']
116                    : self::resolveDefinitionIri($definition['type'], $term, $terms, $context, $path . '.' . $term . '.@type', $resolving),
117            ];
118        }
119        /** @var array<string, string> $resolvedPrefixes */
120        $resolvedPrefixes = [];
121        foreach ($prefixes as $prefix => $iri) {
122            $resolvedPrefixes[$prefix] = $context->expandIri($iri, $path . '.' . $prefix, vocabRelative: false);
123        }
124
125        return new self($resolvedPrefixes, $resolvedTerms, $vocab, $base, $language);
126    }
127
128    /**
129     * Expands a key or value to a full IRI: term definitions first, then
130     * compact IRIs, then absolute IRIs, then @vocab for bare words (keys and
131     * types) or @base for relative references (values).
132     */
133    public function expandIri(string $value, string $path, bool $vocabRelative): string
134    {
135        if ($value === '') {
136            throw JsonLdException::at($path, 'An empty string is not an IRI');
137        }
138        if (preg_match('/[\s<>"{}|\\^`]/', $value) === 1) {
139            throw JsonLdException::at($path, \sprintf('"%s" is not a valid IRI', $value));
140        }
141        // A term applies to keys, types and coerced values, never to a document-relative identifier:
142        // "target" as an @id is the IRI "target" against @base, whatever term "target" means (R7-004).
143        if ($vocabRelative && isset($this->terms[$value])) {
144            return $this->terms[$value]['id'];
145        }
146        if (isset($this->prefixes[$value]) && $vocabRelative) {
147            return $this->prefixes[$value];
148        }
149        if (str_starts_with($value, '_:')) {
150            return $value;
151        }
152        $colon = strpos($value, ':');
153        if ($colon !== false) {
154            $prefix = substr($value, 0, $colon);
155            $local = substr($value, $colon + 1);
156            if (isset($this->prefixes[$prefix]) && !str_starts_with($local, '//')) {
157                return $this->prefixes[$prefix] . $local;
158            }
159            if (self::looksLikeAbsoluteIri($value)) {
160                return $value;
161            }
162            throw JsonLdException::at($path, \sprintf('"%s" uses an undefined prefix', $value));
163        }
164        if ($vocabRelative) {
165            if ($this->vocab !== null) {
166                return $this->vocab . $value;
167            }
168            throw JsonLdException::at($path, \sprintf('"%s" is not a defined term and the context has no @vocab', $value));
169        }
170        if ($this->base !== null) {
171            // RFC 3986 reference resolution, not concatenation: "../c" against "https://x/a/b" is "https://x/c" (AR-008).
172            return self::resolveReference($this->base, $value);
173        }
174
175        throw JsonLdException::at($path, \sprintf('"%s" is a relative IRI and the context has no @base', $value));
176    }
177
178    /**
179     * RFC 3986 Â§5.2 for the references JSON-LD allows against @base: absolute,
180     * network-path, absolute-path, relative-path, query-only and fragment-only.
181     */
182    public static function resolveReference(string $base, string $reference): string
183    {
184        if (preg_match('/^[A-Za-z][A-Za-z0-9+.-]*:/', $reference) === 1) {
185            return $reference;
186        }
187        $b = parse_url($base);
188        if ($b === false || !isset($b['scheme'])) {
189            return $base . $reference;
190        }
191        $authority = isset($b['host']) ? '//' . (isset($b['user']) ? $b['user'] . (isset($b['pass']) ? ':' . $b['pass'] : '') . '@' : '') . $b['host'] . (isset($b['port']) ? ':' . $b['port'] : '') : '';
192        if (str_starts_with($reference, '//')) {
193            // The reference supplies the authority; its path still gets dot-segment removal (RFC 3986 Â§5.2.2, R2-009).
194            $n = parse_url($b['scheme'] . ':' . $reference);
195            if ($n === false || !isset($n['host'])) {
196                return $b['scheme'] . ':' . $reference;
197            }
198            $networkAuthority = '//' . (isset($n['user']) ? $n['user'] . (isset($n['pass']) ? ':' . $n['pass'] : '') . '@' : '') . $n['host'] . (isset($n['port']) ? ':' . $n['port'] : '');
199
200            return $b['scheme'] . ':' . $networkAuthority . self::removeDotSegments($n['path'] ?? '') . (isset($n['query']) ? '?' . $n['query'] : '') . (isset($n['fragment']) ? '#' . $n['fragment'] : '');
201        }
202        $r = parse_url($reference);
203        if ($r === false) {
204            return $base . $reference;
205        }
206        $basePath = $b['path'] ?? '';
207        if ($reference === '' || str_starts_with($reference, '#')) {
208            return $b['scheme'] . ':' . $authority . self::removeDotSegments($basePath) . (isset($b['query']) ? '?' . $b['query'] : '') . $reference;
209        }
210        if (str_starts_with($reference, '?')) {
211            return $b['scheme'] . ':' . $authority . $basePath . $reference;
212        }
213        $path = $r['path'] ?? '';
214        if (!str_starts_with($path, '/')) {
215            $directory = $authority !== '' && $basePath === '' ? '/' : substr($basePath, 0, (int) strrpos($basePath, '/') + 1);
216            $path = $directory . $path;
217        }
218        return $b['scheme'] . ':' . $authority . self::removeDotSegments($path) . (isset($r['query']) ? '?' . $r['query'] : '') . (isset($r['fragment']) ? '#' . $r['fragment'] : '');
219    }
220
221    /**
222     * RFC 3986 Â§5.2.4.
223     */
224    private static function removeDotSegments(string $path): string
225    {
226        if ($path === '') {
227            return '';
228        }
229        $output = [];
230        foreach (explode('/', $path) as $segment) {
231            if ($segment === '.') {
232                continue;
233            }
234            if ($segment === '..') {
235                if (\count($output) > 1) {
236                    array_pop($output);
237                }
238                continue;
239            }
240            $output[] = $segment;
241        }
242        $resolved = implode('/', $output);
243        if (str_ends_with($path, '/.') || str_ends_with($path, '/..')) {
244            $resolved .= '/';
245        }
246
247        return $resolved;
248    }
249
250    /**
251     * The coercion the term used as a key declares ("@id", a datatype IRI, or
252     * null). Only the active term counts: another alias of the same property
253     * with a different coercion must not change how this key's values read (AR-008).
254     */
255    public function coercionOfTerm(string $key): ?string
256    {
257        return $this->terms[$key]['type'] ?? null;
258    }
259
260    /**
261     * The coercion a term definition declares for a property: "@id", a
262     * datatype IRI, or null.
263     */
264    public function coercionOf(string $propertyIri): ?string
265    {
266        foreach ($this->terms as $definition) {
267            if ($definition['id'] === $propertyIri) {
268                return $definition['type'];
269            }
270        }
271
272        return null;
273    }
274
275    /**
276     * The shortest compact form the context allows. For keys and types
277     * ($vocabRelative): a defined term, @vocab, or a compact IRI through the
278     * longest matching prefix. For node identifiers (@id values) terms and
279     * @vocab do not apply (JSON-LD 1.1 IRI compaction), so only prefixes are
280     * used; otherwise "Piece" would be written where a reader sees a relative
281     * reference (AR-007).
282     */
283    /**
284     * The shortest form of an IRI that this context reads back as that IRI.
285     * In vocabulary position (keys and types) that is the first of
286     * keyCandidates(); as a node identifier only compact IRIs and the IRI
287     * itself qualify, since a bare term or prefix name would read as a
288     * relative IRI (R2-002).
289     */
290    public function compactIri(string $iri, bool $vocabRelative = true): string
291    {
292        if ($vocabRelative) {
293            return $this->keyCandidates($iri)[0];
294        }
295        $compact = $this->compactWithPrefix($iri);
296        if ($compact !== null && $this->readsBackAs($compact, $iri, false)) {
297            return $compact;
298        }
299
300        return $iri;
301    }
302
303    /**
304     * Every way to write a property or type IRI in this context that expands
305     * back to exactly that IRI, shortest first: term aliases, the bare prefix
306     * name for a namespace IRI, the @vocab-relative name, the compact IRI, the
307     * IRI itself. A candidate shadowed by a term definition for something else
308     * is left out, because it would read back as that something else (R3-002).
309     *
310     * @return non-empty-list<string>
311     */
312    public function keyCandidates(string $iri): array
313    {
314        $candidates = [];
315        $aliases = [];
316        foreach ($this->terms as $term => $definition) {
317            if ($definition['id'] === $iri) {
318                $aliases[] = (string) $term;
319            }
320        }
321        sort($aliases, SORT_STRING);
322        array_push($candidates, ...$aliases);
323        $exact = array_search($iri, $this->prefixes, true);
324        if ($exact !== false && $exact !== '') {
325            $candidates[] = (string) $exact;
326        }
327        if ($this->vocab !== null && str_starts_with($iri, $this->vocab)) {
328            $local = substr($iri, \strlen($this->vocab));
329            if ($local !== '' && !str_contains($local, ':') && !str_contains($local, '/') && !str_contains($local, '#')) {
330                $candidates[] = $local;
331            }
332        }
333        $compact = $this->compactWithPrefix($iri);
334        if ($compact !== null) {
335            $candidates[] = $compact;
336        }
337        $candidates[] = $iri;
338        $valid = array_values(array_filter(array_unique($candidates), fn(string $c): bool => $this->readsBackAs($c, $iri, true)));
339        if ($valid === []) {
340            throw JsonLdException::at('', \sprintf('"%s" cannot be written in this context: every form of it reads back as something else.', $iri));
341        }
342
343        return $valid;
344    }
345
346    private function compactWithPrefix(string $iri): ?string
347    {
348        $best = null;
349        $bestLength = 0;
350        foreach ($this->prefixes as $prefix => $namespace) {
351            if ($prefix !== '' && str_starts_with($iri, $namespace) && \strlen($namespace) > $bestLength && \strlen($iri) > \strlen($namespace)) {
352                $best = $prefix . ':' . substr($iri, \strlen($namespace));
353                $bestLength = \strlen($namespace);
354            }
355        }
356
357        return $best;
358    }
359
360    private function readsBackAs(string $candidate, string $iri, bool $vocabRelative): bool
361    {
362        try {
363            return $this->expandIri($candidate, '', $vocabRelative) === $iri;
364        } catch (JsonLdException) {
365            return false;
366        }
367    }
368
369    /**
370     * @return array<string, mixed> the @context value to write
371     */
372    public function toRaw(): array
373    {
374        $raw = [];
375        foreach ($this->prefixes as $prefix => $iri) {
376            $raw[$prefix] = $iri;
377        }
378        if ($this->vocab !== null) {
379            $raw['@vocab'] = $this->vocab;
380        }
381        if ($this->base !== null) {
382            $raw['@base'] = $this->base;
383        }
384        if ($this->language !== null) {
385            $raw['@language'] = $this->language;
386        }
387        foreach ($this->terms as $term => $definition) {
388            // A definition is written with prefixes only, never through a term or @vocab: that is
389            // what every reader of a context can resolve while the terms are still being defined
390            // (AR-007, R4-002).
391            $entry = ['@id' => $this->compactWithPrefix($definition['id']) ?? $definition['id']];
392            if ($definition['type'] !== null) {
393                $entry['@type'] = $definition['type'] === self::JSON_LD_ID ? self::JSON_LD_ID : ($this->compactWithPrefix($definition['type']) ?? $definition['type']);
394            }
395            // A term whose name is already its compact IRI needs no @id (IATA writes {"api:p": {"@type": "xsd:anyURI"}}).
396            if ($entry['@id'] === $term) {
397                unset($entry['@id']);
398            }
399            if ($entry === []) {
400                // Nothing left to say: the prefix already gives the term this meaning, and an empty
401                // definition would serialise as [] which no reader accepts (R5-002).
402                continue;
403            }
404            $raw[$term] = $entry;
405        }
406
407        return $raw;
408    }
409
410    public function withLanguage(?string $language): self
411    {
412        return new self($this->prefixes, $this->terms, $this->vocab, $this->base, $language);
413    }
414
415    /**
416     * @return array{id: string, type: ?string}
417     */
418    /**
419     * @param array<string, array{id: string, type: ?string}> $terms the raw definitions
420     * @param array<string, true> $resolving the terms being resolved up the call chain, to refuse a cycle
421     */
422    private static function resolveDefinitionIri(string $value, string $term, array $terms, self $prefixesOnly, string $path, array &$resolving): string
423    {
424        if ($value !== $term && isset($terms[$value])) {
425            if (isset($resolving[$value])) {
426                throw JsonLdException::at($path, \sprintf('Term "%s" is defined through itself', $value));
427            }
428            $resolving[$value] = true;
429            $resolved = self::resolveDefinitionIri($terms[$value]['id'], $value, $terms, $prefixesOnly, $path, $resolving);
430            unset($resolving[$value]);
431
432            return $resolved;
433        }
434
435        return $prefixesOnly->expandIri($value, $path, vocabRelative: true);
436    }
437
438    /**
439     * @param array<string, mixed> $definition
440     * @return array{id: string, type: ?string}
441     */
442    private static function termDefinition(array $definition, string $path, string $term): array
443    {
444        $id = $term;
445        $type = null;
446        foreach ($definition as $key => $value) {
447            match ($key) {
448                '@id' => $id = self::requireString($value, $path . '.@id'),
449                '@type' => $type = self::requireString($value, $path . '.@type'),
450                '@container', '@reverse', '@nest', '@context', '@protected', '@prefix', '@index', '@language', '@direction' => throw JsonLdException::unsupported($path . '.' . $key, "The term definition keyword {$key}"),
451                default => throw JsonLdException::at($path . '.' . $key, 'Unknown key in a term definition'),
452            };
453        }
454
455        return ['id' => $id, 'type' => $type];
456    }
457
458    private static function requireString(mixed $value, string $path): string
459    {
460        if (!\is_string($value) || $value === '') {
461            throw JsonLdException::at($path, 'Expected a non-empty string');
462        }
463
464        return $value;
465    }
466
467    public static function isAbsoluteIri(string $value): bool
468    {
469        return preg_match('/^[A-Za-z][A-Za-z0-9+.-]*:/', $value) === 1 && !str_starts_with($value, '_:');
470    }
471
472    /**
473     * Schemes a bare "scheme:rest" value is taken to be when the prefix is not
474     * defined in the context. JSON-LD would treat any such value as an
475     * absolute IRI; in ONE Record documents "api:Change" with a forgotten
476     * "api" prefix is far more likely than a URI with scheme "api", so only
477     * well-known schemes (and the embedded-object schemes servers use) pass.
478     */
479    private const array KNOWN_SCHEMES = ['http', 'https', 'urn', 'mailto', 'tel', 'did', 'file', 'ftp', 'ws', 'wss', 'internal', 'neone', 'local'];
480
481    public static function looksLikeAbsoluteIri(string $value): bool
482    {
483        if (!self::isAbsoluteIri($value)) {
484            return false;
485        }
486        if (str_contains($value, '//')) {
487            return true;
488        }
489
490        return \in_array(strtolower(substr($value, 0, (int) strpos($value, ':'))), self::KNOWN_SCHEMES, true);
491    }
492}