Lines
85.36%
35 / 41
Methods
71.42%
5 / 7
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| handleRequest | 85.00% | 17 / 20 | 0.00% | 0 / 1 | 7.17 | ||
| answerRoute | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 8 | ||
| isAuthorized | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 3 | ||
| selectFailureStatus | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 4 | ||
| respondWithFailure | 25.00% | 1 / 4 | 0.00% | 0 / 1 | 3.69 | ||
| respond | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| 30 | final readonly class AdminRequestHandler implements RequestHandler | |
| 31 | { | |
| 32 | private const string BEARER_TOKEN = '~\ABearer +(\S+)\z~i'; | |
| 33 | ||
| 34 | private const array JSON_HEADERS = ['content-type' => 'application/json', 'cache-control' => 'no-store']; | |
| 35 | ||
| 36 | private const array METRICS_HEADERS = ['content-type' => PrometheusTextEncoder::CONTENT_TYPE, 'cache-control' => 'no-store']; | |
| 37 | ||
| 38 | public function __construct( | |
| 39 | private RuntimeAdmin $admin, | |
| 40 | private AdminApiCodec $codec, | |
| 41 | private RuntimeMetricsExporter $metrics, | |
| 42 | private LoggerInterface $logger, | |
| 43 | #[SensitiveParameter] | |
| 44 | private string $adminApiToken, | |
| 45 | ) {} | |
| 46 | ||
| 47 | public function handleRequest(Request $request): Response | |
| 48 | { | |
| 49 | if (!$this->isAuthorized($request)) { | |
| 50 | return $this->respondWithFailure(HttpStatus::UNAUTHORIZED, new RequestFailed('unauthorized', 'A valid bearer token is required.'), ['www-authenticate' => 'Bearer']); | |
| 51 | } | |
| 52 | ||
| 53 | $path = $request->getUri()->getPath(); | |
| 54 | $route = AdminRoute::fromPath($path); | |
| 55 | ||
| 56 | if ($route === null) { | |
| 57 | return $this->respondWithFailure(HttpStatus::NOT_FOUND, new RequestFailed('not_found', \sprintf('No admin endpoint at %s.', $path))); | |
| 58 | } | |
| 59 | ||
| 60 | if (!$route->allowsMethod($request->getMethod())) { | |
| 61 | return $this->respondWithFailure( | |
| 62 | HttpStatus::METHOD_NOT_ALLOWED, | |
| 63 | new RequestFailed('method_not_allowed', \sprintf('%s does not accept %s.', $path, $request->getMethod())), | |
| 64 | ['allow' => implode(', ', $route->action->listAllowedMethods())], | |
| 65 | ); | |
| 66 | } | |
| 67 | ||
| 68 | try { | |
| 69 | return $route->action === AdminAction::ShowMetrics | |
| 70 | ? new Response(HttpStatus::OK, self::METRICS_HEADERS, $this->metrics->exportMetrics()) | |
| 71 | : $this->respond(HttpStatus::OK, $this->answerRoute($route)); | |
| 72 | } catch (StewartException $e) { | |
| 73 | return $this->respondWithFailure($this->selectFailureStatus($e->reason), new RequestFailed((string) $e->reason->value, $e->getMessage())); | |
| 74 | } catch (Throwable $e) { | |
| 75 | $this->logger->error('Could not answer the admin request', ['path' => $path, 'exception' => $e]); | |
| 76 | ||
| 77 | return $this->respondWithFailure(HttpStatus::INTERNAL_SERVER_ERROR, new RequestFailed('internal_error', 'The admin request failed; the daemon log has the cause.')); | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | /** @throws StewartException */ | |
| 82 | private function answerRoute(AdminRoute $route): AdminAppList|AdminAppView|CommandResult | |
| 83 | { | |
| 84 | return match ($route->action) { | |
| 85 | AdminAction::ListApps => new AdminAppList(array_map(AdminAppView::fromAppStatus(...), $this->admin->listHostedAppStatuses())), | |
| 86 | AdminAction::ShowApp => AdminAppView::fromAppStatus($this->admin->requireAppStatus(new AppId((string) $route->resourceId))), | |
| 87 | AdminAction::PauseApp => $this->admin->pauseApp(new AppId((string) $route->resourceId), AppPauseSource::Http), | |
| 88 | AdminAction::ResumeApp => $this->admin->resumeApp(new AppId((string) $route->resourceId), AppPauseSource::Http), | |
| 89 | AdminAction::ResetApp => $this->admin->resetApp(new AppId((string) $route->resourceId), AppPauseSource::Http), | |
| 90 | AdminAction::RestartWorker => $this->admin->restartWorker((string) $route->resourceId), | |
| 91 | AdminAction::ShowMetrics => throw new LogicException('Metrics are answered before the JSON routes.'), | |
| 92 | }; | |
| 93 | } | |
| 94 | ||
| 95 | private function isAuthorized(Request $request): bool | |
| 96 | { | |
| 97 | $authorization = $request->getHeader('authorization'); | |
| 98 | ||
| 99 | return $authorization !== null | |
| 100 | && preg_match(self::BEARER_TOKEN, $authorization, $matches) === 1 | |
| 101 | && hash_equals($this->adminApiToken, $matches[1]); | |
| 102 | } | |
| 103 | ||
| 104 | private function selectFailureStatus(ExceptionReason $reason): int | |
| 105 | { | |
| 106 | return match ($reason) { | |
| 107 | AppError::Unknown, IdentifierError::AppIdInvalid, IdentifierError::AppIdTooLong, WorkerError::Unknown => HttpStatus::NOT_FOUND, | |
| 108 | AppError::Disabled, WorkerError::ShuttingDown => HttpStatus::CONFLICT, | |
| 109 | default => HttpStatus::INTERNAL_SERVER_ERROR, | |
| 110 | }; | |
| 111 | } | |
| 112 | ||
| 113 | /** @param array<non-empty-string, string> $headers */ | |
| 114 | private function respondWithFailure(int $status, RequestFailed $failure, array $headers = []): Response | |
| 115 | { | |
| 116 | try { | |
| 117 | return $this->respond($status, $failure, $headers); | |
| 118 | } catch (Throwable $e) { | |
| 119 | $this->logger->error('Could not encode the admin failure', ['exception' => $e]); | |
| 120 | ||
| 121 | return new Response($status, $headers); | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * @param array<non-empty-string, string> $headers | |
| 127 | * @throws Throwable | |
| 128 | */ | |
| 129 | private function respond(int $status, AdminAppList|AdminAppView|CommandResult|RequestFailed $body, array $headers = []): Response | |
| 130 | { | |
| 131 | return new Response($status, [...self::JSON_HEADERS, ...$headers], $this->codec->encodeResponse($body)); | |
| 132 | } | |
| 133 | } |