Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.61% covered (warning)
81.61%
71 / 87
66.67% covered (warning)
66.67%
12 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Nodes
81.61% covered (warning)
81.61%
71 / 87
66.67% covered (warning)
66.67%
12 / 18
94.92
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 iri
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 iris
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 string
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 strings
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 bool
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 int
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 dateTime
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 parseDateTime
63.16% covered (warning)
63.16%
12 / 19
0.00% covered (danger)
0.00%
0 / 1
40.00
 nodes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 node
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 asIri
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
8.83
 dateTimeValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 anyUri
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 positiveInteger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ref
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 context
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 compact
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\JsonLd;
6
7use DateTimeImmutable;
8use DateTimeInterface;
9use Exception;
10use InvalidArgumentException;
11use LambdaTwelve\OneRecord\Rdf\BlankNode;
12use LambdaTwelve\OneRecord\Rdf\Graph;
13use LambdaTwelve\OneRecord\Rdf\Iri;
14use LambdaTwelve\OneRecord\Rdf\Literal;
15use LambdaTwelve\OneRecord\Rdf\Term;
16use LambdaTwelve\OneRecord\Spec\Namespaces;
17
18/**
19 * Reading typed values out of an expanded API document, leniently: partners
20 * write IRIs as {"@id"} references, as xsd:anyURI literals or as plain
21 * strings, and dates with or without milliseconds. Writing helpers produce
22 * the shapes IATA's examples use.
23 */
24final class Nodes
25{
26    private function __construct() {}
27
28    public static function iri(Graph $graph, Iri|BlankNode $node, string $predicate): ?Iri
29    {
30        foreach ($graph->objects($node, $predicate) as $term) {
31            $iri = self::asIri($term);
32            if ($iri !== null) {
33                return $iri;
34            }
35        }
36
37        return null;
38    }
39
40    /**
41     * @return list<Iri>
42     */
43    public static function iris(Graph $graph, Iri|BlankNode $node, string $predicate): array
44    {
45        $out = [];
46        foreach ($graph->objects($node, $predicate) as $term) {
47            $iri = self::asIri($term);
48            if ($iri !== null) {
49                $out[$iri->value] = $iri;
50            }
51        }
52        ksort($out, SORT_STRING);
53
54        return array_values($out);
55    }
56
57    public static function string(Graph $graph, Iri|BlankNode $node, string $predicate): ?string
58    {
59        $term = $graph->firstObject($node, $predicate);
60
61        return match (true) {
62            $term instanceof Literal => $term->lexical,
63            $term instanceof Iri => $term->value,
64            default => null,
65        };
66    }
67
68    /**
69     * @return list<string>
70     */
71    public static function strings(Graph $graph, Iri|BlankNode $node, string $predicate): array
72    {
73        $out = [];
74        foreach ($graph->objects($node, $predicate) as $term) {
75            if ($term instanceof Literal) {
76                $out[] = $term->lexical;
77            } elseif ($term instanceof Iri) {
78                $out[] = $term->value;
79            }
80        }
81        sort($out, SORT_STRING);
82
83        return $out;
84    }
85
86    public static function bool(Graph $graph, Iri|BlankNode $node, string $predicate): ?bool
87    {
88        $term = $graph->firstObject($node, $predicate);
89        if (!$term instanceof Literal) {
90            return null;
91        }
92
93        return match ($term->lexical) {
94            'true', '1' => true,
95            'false', '0' => false,
96            default => null,
97        };
98    }
99
100    public static function int(Graph $graph, Iri|BlankNode $node, string $predicate): ?int
101    {
102        $term = $graph->firstObject($node, $predicate);
103        if (!$term instanceof Literal || preg_match('/^[+-]?\d+$/', $term->lexical) !== 1) {
104            return null;
105        }
106
107        return (int) $term->lexical;
108    }
109
110    /**
111     * The one dateTime value of a property, or null when the property is absent.
112     * A value that is present but not an xsd:dateTime is an error: an expiry
113     * that fails to parse must never read as "no expiry" (AR-016).
114     *
115     * @throws JsonLdException
116     */
117    public static function dateTime(Graph $graph, Iri|BlankNode $node, string $predicate): ?DateTimeImmutable
118    {
119        $terms = $graph->objects($node, $predicate);
120        if ($terms === []) {
121            return null;
122        }
123        if (\count($terms) > 1) {
124            throw JsonLdException::at(self::compact($predicate), 'Expected one dateTime value, got ' . \count($terms));
125        }
126        $term = $terms[0];
127        if (!$term instanceof Literal) {
128            throw JsonLdException::at(self::compact($predicate), 'Expected a dateTime literal, got a node');
129        }
130
131        return self::parseDateTime($term->lexical) ?? throw JsonLdException::at(self::compact($predicate), \sprintf('"%s" is not an xsd:dateTime (YYYY-MM-DDThh:mm:ss[.fff](Z|±hh:mm))', $term->lexical));
132    }
133
134    /**
135     * Strict xsd:dateTime lexical form; PHP's free-form date grammar would
136     * accept "tomorrow" and "0", which no partner means.
137     */
138    public static function parseDateTime(string $lexical): ?DateTimeImmutable
139    {
140        // XML Schema 1.1 dateTime: ranges checked here, because PHP would normalise 12:00:60 to 12:01:00
141        // and 2026-02-30 to March, and an offset of +14:59 is outside the datatype (R2-008).
142        if (preg_match('/^(-?\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|([+-])(\d{2}):(\d{2}))?$/', $lexical, $m) !== 1) {
143            return null;
144        }
145        [$year, $month, $day, $hour, $minute, $second] = [(int) $m[1], (int) $m[2], (int) $m[3], (int) $m[4], (int) $m[5], (int) $m[6]];
146        $fraction = $m[7] ?? '';
147        if (!checkdate($month, $day, $year) || $minute > 59 || $second > 59) {
148            return null;
149        }
150        $endOfDay = $hour === 24;
151        if ($hour > 24 || ($endOfDay && ($minute !== 0 || $second !== 0 || ltrim($fraction, '.0') !== ''))) {
152            return null;
153        }
154        $zone = $m[8] ?? '';
155        if ($zone !== '' && $zone !== 'Z') {
156            $offsetHours = (int) ($m[10] ?? '0');
157            $offsetMinutes = (int) ($m[11] ?? '0');
158            if ($offsetHours > 14 || $offsetMinutes > 59 || ($offsetHours === 14 && $offsetMinutes !== 0)) {
159                return null;
160            }
161        }
162        try {
163            // A zoneless value has no timezone in XSD; UTC is the only reading that does not depend on the host.
164            $parsed = new DateTimeImmutable(\sprintf('%s-%02d-%02dT%02d:%02d:%02d%s%s', $m[1], $month, $day, $endOfDay ? 0 : $hour, $minute, $second, $fraction, $zone === '' ? 'Z' : $zone));
165        } catch (Exception) {
166            return null;
167        }
168
169        // 24:00:00 is the end of the day, which is midnight starting the next one.
170        return $endOfDay ? $parsed->modify('+1 day') : $parsed;
171    }
172
173    /**
174     * @return list<Iri|BlankNode>
175     */
176    public static function nodes(Graph $graph, Iri|BlankNode $node, string $predicate): array
177    {
178        $out = [];
179        foreach ($graph->objects($node, $predicate) as $term) {
180            if ($term instanceof Iri || $term instanceof BlankNode) {
181                $out[] = $term;
182            }
183        }
184
185        return $out;
186    }
187
188    public static function node(Graph $graph, Iri|BlankNode $node, string $predicate): Iri|BlankNode|null
189    {
190        return self::nodes($graph, $node, $predicate)[0] ?? null;
191    }
192
193    private static function asIri(Term $term): ?Iri
194    {
195        if ($term instanceof Iri) {
196            return $term;
197        }
198        if ($term instanceof Literal && Context::isAbsoluteIri($term->lexical) && preg_match('/\s/', $term->lexical) !== 1) {
199            try {
200                return new Iri($term->lexical);
201            } catch (InvalidArgumentException) {
202                return null;
203            }
204        }
205
206        return null;
207    }
208
209    /**
210     * @return array{'@type': string, '@value': string}
211     */
212    public static function dateTimeValue(DateTimeInterface $value): array
213    {
214        return ['@type' => Namespaces::XSD . 'dateTime', '@value' => Literal::dateTime($value)->lexical];
215    }
216
217    /**
218     * @return array{'@type': string, '@value': string}
219     */
220    public static function anyUri(string $iri): array
221    {
222        return ['@type' => Namespaces::XSD . 'anyURI', '@value' => $iri];
223    }
224
225    /**
226     * @return array{'@type': string, '@value': string}
227     */
228    public static function positiveInteger(int $value): array
229    {
230        return ['@type' => Namespaces::XSD . 'positiveInteger', '@value' => (string) $value];
231    }
232
233    /**
234     * @return array{'@id': string}
235     */
236    public static function ref(Iri|string $iri): array
237    {
238        return ['@id' => $iri instanceof Iri ? $iri->value : $iri];
239    }
240
241    /**
242     * @return array<string, string>
243     */
244    public static function context(): array
245    {
246        return ['cargo' => Namespaces::CARGO, 'api' => Namespaces::API, 'xsd' => Namespaces::XSD];
247    }
248
249    /**
250     * Compacts an api: or cargo: IRI for @id references to individuals (api:REQUEST_PENDING).
251     */
252    public static function compact(string $iri): string
253    {
254        foreach (['api' => Namespaces::API, 'cargo' => Namespaces::CARGO, 'xsd' => Namespaces::XSD] as $prefix => $namespace) {
255            if (str_starts_with($iri, $namespace)) {
256                return $prefix . ':' . substr($iri, \strlen($namespace));
257            }
258        }
259
260        return $iri;
261    }
262}