Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.05% covered (warning)
78.05%
32 / 41
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
OneRecordServer
78.05% covered (warning)
78.05%
32 / 41
20.00% covered (danger)
20.00%
1 / 5
22.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
 handle
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
11.29
 path
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
6.32
 config
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 versionLabel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Server;
6
7use LambdaTwelve\OneRecord\Api\Error;
8use LambdaTwelve\OneRecord\Api\InvalidDocument;
9use LambdaTwelve\OneRecord\JsonLd\JsonLdException;
10use LambdaTwelve\OneRecord\Server\Http\ContentNegotiation;
11use LambdaTwelve\OneRecord\Server\Http\HttpException;
12use LambdaTwelve\OneRecord\Server\Http\Negotiated;
13use LambdaTwelve\OneRecord\Server\Http\Responder;
14use LambdaTwelve\OneRecord\Server\Spi\Authenticator;
15use LambdaTwelve\OneRecord\Server\Spi\UnitOfWork;
16use LambdaTwelve\OneRecord\Spec\ApiVersion;
17use Psr\Http\Message\ResponseInterface;
18use Psr\Http\Message\ServerRequestInterface;
19use Psr\Http\Server\RequestHandlerInterface;
20use Psr\Log\LoggerInterface;
21use Psr\Log\NullLogger;
22use Throwable;
23
24/**
25 * The ONE Record server as one PSR-15 request handler: mount it under any
26 * path and every endpoint of the API is served below it.
27 *
28 * Request pipeline: negotiate the API version and language, authenticate,
29 * route, run the endpoint. Every failure becomes an api:Error body with the
30 * spec's status code. Nothing here knows about storage, frameworks or
31 * business rules; those arrive through the SPI the endpoints are built with.
32 */
33final class OneRecordServer implements RequestHandlerInterface
34{
35    public function __construct(
36        private readonly ServerConfig $config,
37        private readonly Router $router,
38        private readonly Authenticator $authenticator,
39        private readonly Responder $responder,
40        private readonly LoggerInterface $logger = new NullLogger(),
41        private readonly UnitOfWork $unitOfWork = new IdentityUnitOfWork(),
42    ) {}
43
44    public function handle(ServerRequestInterface $request): ResponseInterface
45    {
46        $negotiation = new ContentNegotiation($this->config);
47        $head = strtoupper($request->getMethod()) === 'HEAD';
48        try {
49            $negotiated = $negotiation->negotiate($request);
50        } catch (HttpException $e) {
51            // Without an acceptable format we still answer JSON-LD in our highest version: there is nothing else to answer in.
52            return $this->responder->error($e->status, $e->errors, new Negotiated($this->config->highestApiVersion(), 'en-US', false), $e->headers, $head);
53        }
54
55        try {
56            $path = $this->path($request);
57            $match = $this->router->match($request->getMethod(), $path);
58            if ($match === null) {
59                throw HttpException::notFound('The requested resource', $this->config->endpoint() . $path);
60            }
61            if (isset($match['allowed'])) {
62                throw HttpException::methodNotAllowed($match['allowed']);
63            }
64            if (!$negotiated->version->isAtLeast($match['since'])) {
65                throw HttpException::notFound(\sprintf('This endpoint exists from API %s; the request negotiated %s. The resource', $match['since']->value, $negotiated->version->value), $this->config->endpoint() . $path);
66            }
67
68            $agent = $this->authenticator->authenticate($request);
69            if ($agent === null) {
70                throw HttpException::unauthenticated();
71            }
72
73            $endpoint = $match['endpoint'];
74            if (\in_array(strtoupper($request->getMethod()), ['GET', 'HEAD', 'OPTIONS'], true)) {
75                return $endpoint->handle($request, $agent, $negotiated, $match['parameters']);
76            }
77
78            // Everything a mutating request writes stands or falls together; a host binds its transaction here.
79            return $this->unitOfWork->run(static fn(): ResponseInterface => $endpoint->handle($request, $agent, $negotiated, $match['parameters']));
80        } catch (HttpException $e) {
81            return $this->responder->error($e->status, $e->errors, $negotiated, $e->headers, $head);
82        } catch (InvalidDocument $e) {
83            return $this->responder->error(400, $e->errors, $negotiated, [], $head);
84        } catch (JsonLdException $e) {
85            return $this->responder->error(400, [Error::of('Invalid body request', '400', $e->getMessage())], $negotiated, [], $head);
86        } catch (Throwable $e) {
87            $this->logger->error('Unhandled error while serving a ONE Record request', ['exception' => $e, 'method' => $request->getMethod(), 'path' => $request->getUri()->getPath()]);
88            $internal = HttpException::internal();
89
90            return $this->responder->error($internal->status, $internal->errors, $negotiated, [], $head);
91        }
92    }
93
94    /**
95     * The request path relative to the base path, always starting with "/".
96     */
97    private function path(ServerRequestInterface $request): string
98    {
99        $path = $request->getUri()->getPath();
100        $base = $this->config->basePath;
101        if ($base !== '') {
102            // Nothing exists outside the base path the host mounted us under.
103            if ($path !== $base && !str_starts_with($path, $base . '/')) {
104                throw HttpException::notFound('The requested resource', $this->config->baseUrl . $path);
105            }
106            $path = substr($path, \strlen($base));
107        }
108        $path = '/' . ltrim($path, '/');
109
110        return $path === '/' ? '/' : rtrim($path, '/');
111    }
112
113    public function config(): ServerConfig
114    {
115        return $this->config;
116    }
117
118    /** @internal */
119    public static function versionLabel(ApiVersion $version): string
120    {
121        return $version->value;
122    }
123}