Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
78.05% |
32 / 41 |
|
20.00% |
1 / 5 |
CRAP | |
0.00% |
0 / 1 |
| OneRecordServer | |
78.05% |
32 / 41 |
|
20.00% |
1 / 5 |
22.82 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| handle | |
86.67% |
26 / 30 |
|
0.00% |
0 / 1 |
11.29 | |||
| path | |
62.50% |
5 / 8 |
|
0.00% |
0 / 1 |
6.32 | |||
| config | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| versionLabel | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Server; |
| 6 | |
| 7 | use LambdaTwelve\OneRecord\Api\Error; |
| 8 | use LambdaTwelve\OneRecord\Api\InvalidDocument; |
| 9 | use LambdaTwelve\OneRecord\JsonLd\JsonLdException; |
| 10 | use LambdaTwelve\OneRecord\Server\Http\ContentNegotiation; |
| 11 | use LambdaTwelve\OneRecord\Server\Http\HttpException; |
| 12 | use LambdaTwelve\OneRecord\Server\Http\Negotiated; |
| 13 | use LambdaTwelve\OneRecord\Server\Http\Responder; |
| 14 | use LambdaTwelve\OneRecord\Server\Spi\Authenticator; |
| 15 | use LambdaTwelve\OneRecord\Server\Spi\UnitOfWork; |
| 16 | use LambdaTwelve\OneRecord\Spec\ApiVersion; |
| 17 | use Psr\Http\Message\ResponseInterface; |
| 18 | use Psr\Http\Message\ServerRequestInterface; |
| 19 | use Psr\Http\Server\RequestHandlerInterface; |
| 20 | use Psr\Log\LoggerInterface; |
| 21 | use Psr\Log\NullLogger; |
| 22 | use 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 | */ |
| 33 | final 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 | } |