Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.78% covered (warning)
83.78%
62 / 74
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContentNegotiation
83.78% covered (warning)
83.78%
62 / 74
20.00% covered (danger)
20.00%
1 / 5
57.82
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
 negotiate
93.02% covered (success)
93.02%
40 / 43
0.00% covered (danger)
0.00%
0 / 1
29.29
 bodyVersion
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
9.37
 language
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
16.67
 splitMediaType
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Server\Http;
6
7use LambdaTwelve\OneRecord\Server\ServerConfig;
8use LambdaTwelve\OneRecord\Spec\ApiVersion;
9use Psr\Http\Message\ServerRequestInterface;
10
11/**
12 * The spec's versioning through content negotiation: the API version rides in
13 * the `version` parameter of Accept (and of Content-Type on bodies) and the
14 * server answers in the version it picked. No version means the highest the
15 * server supports. Only application/ld+json is served or read.
16 */
17final class ContentNegotiation
18{
19    public const string JSON_LD = 'application/ld+json';
20
21    public function __construct(private readonly ServerConfig $config) {}
22
23    public function negotiate(ServerRequestInterface $request): Negotiated
24    {
25        $accept = trim($request->getHeaderLine('Accept'));
26        if ($accept === '') {
27            return new Negotiated($this->config->highestApiVersion(), $this->language($request), false);
28        }
29        // RFC 9110 ยง12.5.1 applied per representation: for each version this server serves, the
30        // most specific range that matches it (type, then version parameter) decides its quality;
31        // then the best available representation wins. A range naming an unknown version simply
32        // matches nothing, so it cannot veto a representation another range accepts (R2-007).
33        $ranges = [];
34        $unknownVersion = null;
35        foreach (explode(',', $accept) as $range) {
36            [$type, $parameters] = self::splitMediaType($range);
37            $specificity = match ($type) {
38                self::JSON_LD => 3,
39                'application/json' => 2,
40                'application/*' => 1,
41                '*/*' => 0,
42                default => -1,
43            };
44            if ($specificity < 0) {
45                continue;
46            }
47            $version = null;
48            if (isset($parameters['version'])) {
49                $version = ApiVersion::tryFromString($parameters['version']);
50                if ($version === null || !$this->config->supports($version)) {
51                    $unknownVersion ??= $parameters['version'];
52                    continue;
53                }
54            }
55            $ranges[] = ['specificity' => $specificity, 'version' => $version, 'q' => isset($parameters['q']) && is_numeric($parameters['q']) ? (float) $parameters['q'] : 1.0];
56        }
57        if ($ranges === [] && $unknownVersion === null) {
58            throw HttpException::notAcceptable(\sprintf('This server answers in %s only.', self::JSON_LD));
59        }
60        $best = null;
61        foreach ($this->config->apiVersions as $candidate) {
62            $match = null;
63            foreach ($ranges as $range) {
64                if ($range['version'] !== null && $range['version'] !== $candidate) {
65                    continue;
66                }
67                $score = $range['specificity'] * 2 + ($range['version'] === null ? 0 : 1);
68                if ($match === null || $score > $match['score']) {
69                    $match = ['score' => $score, 'q' => $range['q'], 'explicit' => $range['version'] !== null];
70                }
71            }
72            if ($match === null || $match['q'] <= 0) {
73                continue;
74            }
75            // Versions are listed highest first, so on equal quality the higher one stays.
76            if ($best === null || $match['q'] > $best['q']) {
77                $best = ['version' => $candidate, 'q' => $match['q'], 'explicit' => $match['explicit']];
78            }
79        }
80        if ($best === null) {
81            throw $unknownVersion !== null
82                ? HttpException::notAcceptable(\sprintf('API version %s is not supported; this server speaks %s.', $unknownVersion, implode(', ', array_map(static fn(ApiVersion $v): string => $v->value, $this->config->apiVersions))))
83                : HttpException::notAcceptable(\sprintf('This server answers in %s only.', self::JSON_LD));
84        }
85
86        return new Negotiated($best['version'], $this->language($request), $best['explicit']);
87    }
88
89    /**
90     * Checks the Content-Type of a request that carries a body. A version
91     * parameter there is read too, so a client declaring a 2.2 body on a 2.3
92     * server has its body read with 2.2 rules.
93     */
94    public function bodyVersion(ServerRequestInterface $request, Negotiated $negotiated): ApiVersion
95    {
96        $contentType = $request->getHeaderLine('Content-Type');
97        if ($contentType === '') {
98            throw HttpException::unsupportedMediaType('A Content-Type header of ' . self::JSON_LD . ' is required.');
99        }
100        [$type, $parameters] = self::splitMediaType($contentType);
101        if ($type !== self::JSON_LD && $type !== 'application/json') {
102            throw HttpException::unsupportedMediaType(\sprintf('Bodies must be %s, not %s.', self::JSON_LD, $type));
103        }
104        $version = isset($parameters['version']) ? ApiVersion::tryFromString($parameters['version']) : null;
105        if (isset($parameters['version']) && $version === null) {
106            throw HttpException::unsupportedMediaType(\sprintf('Unknown API version "%s" in Content-Type.', $parameters['version']));
107        }
108        if ($version !== null && !\in_array($version, $this->config->apiVersions, true)) {
109            // A version this server was configured not to serve is not accepted on input either (AR-020).
110            throw HttpException::unsupportedMediaType(\sprintf('API version %s is not served here; this server speaks %s.', $version->value, implode(', ', array_map(static fn(ApiVersion $v): string => $v->value, $this->config->apiVersions))));
111        }
112
113        return $version ?? $negotiated->version;
114    }
115
116
117    private function language(ServerRequestInterface $request): string
118    {
119        $header = $request->getHeaderLine('Accept-Language');
120        if (trim($header) === '') {
121            return 'en-US';
122        }
123        foreach (explode(',', $header) as $range) {
124            [$tag] = self::splitMediaType($range);
125            foreach ($this->config->languages as $supported) {
126                if (strcasecmp($tag, $supported) === 0 || strcasecmp($tag, explode('-', $supported)[0]) === 0) {
127                    return $supported;
128                }
129            }
130        }
131
132        return 'en-US';
133    }
134
135    /**
136     * @return array{string, array<string, string>}
137     */
138    public static function splitMediaType(string $value): array
139    {
140        $parts = array_map(trim(...), explode(';', $value));
141        $type = strtolower((string) array_shift($parts));
142        $parameters = [];
143        foreach ($parts as $part) {
144            if ($part === '') {
145                continue;
146            }
147            [$name, $parameterValue] = array_pad(explode('=', $part, 2), 2, '');
148            $parameters[strtolower(trim($name))] = trim(trim($parameterValue), '"');
149        }
150
151        return [$type, $parameters];
152    }
153}