*/ final class InputRequiredShim { /** * Re-entries per originating request. Deliberately below the modern * client driver's allowance: this loop holds a live request open. */ public const DEFAULT_MAX_ROUNDS = 8; /** Seconds to wait for one answer. Legs are human-paced, so the protocol's 120s default is wrong here. */ public const DEFAULT_ROUND_TIMEOUT = 600; public function __construct( private readonly int $maxRounds = self::DEFAULT_MAX_ROUNDS, private readonly int $roundTimeout = self::DEFAULT_ROUND_TIMEOUT, private readonly LoggerInterface $logger = new NullLogger(), ) { } /** * Runs a handler to a result the client can be given. * * Returns whatever the handler returned when it asks for nothing, which is * every call that is not multi round-trip. * * @param Response|Error $result what the handler returned on its first entry * @param RequestHandlerInterface> $handler the handler that produced it * * @return Response|Error */ public function fulfill( Response|Error $result, RequestHandlerInterface $handler, Request $request, SessionInterface $session, ?RequestStateCodec $codec, ): Response|Error { $round = 0; while (($ask = self::askOf($result)) instanceof InputRequiredResult) { if (++$round > $this->maxRounds) { $this->logger->warning('A handler kept asking for input past the round limit; the call was failed instead.', [ 'method' => $request::getMethod(), 'rounds' => $this->maxRounds, ]); return Error::forInternalError( \sprintf('The server asked for input more than %d times without reaching a result.', $this->maxRounds), $request->getId(), ); } if (null !== $refusal = $this->refuseUndeclared($ask, $request, $session)) { return $refusal; } try { $session->set(InputContext::class, new InputContext( $this->collect($ask, $session), self::payloadOf($ask, $codec), )); } catch (RequestStateException $e) { $this->logger->error('A handler minted a requestState this server cannot verify.', ['exception' => $e]); return Error::forInternalError('The server could not carry its own state across a round of input.', $request->getId()); } $result = $handler->handle($request, $session); } return $result; } /** * Sends each embedded request and keeps the answer under the key it was * asked under. * * Answers are stored as the raw result arrays {@see InputContext} parses, * so this needs to know nothing about the kinds it is carrying — which is * also why an extension's future kind rides through unchanged. * * @return array */ private function collect(InputRequiredResult $ask, SessionInterface $session): array { $gateway = new ClientGateway($session); $responses = []; foreach ($ask->inputRequests as $key => $embedded) { $answer = $gateway->request($embedded, $this->roundTimeout); if ($answer instanceof Error) { // Not fatal to the call: a client that refuses one ask has // answered it, and the handler decides what that means. $this->logger->info('The client failed an input request; the handler is re-entered without it.', [ 'key' => $key, 'error' => $answer->message, ]); continue; } $responses[$key] = $answer->result; } return $responses; } /** * Refuses an ask the client never said it could answer, the way the modern * era does — rather than sending a request that can only come back as an * error. */ private function refuseUndeclared(InputRequiredResult $ask, Request $request, SessionInterface $session): ?Error { $declared = ClientCapabilities::fromArray((array) $session->get('client_capabilities', [])); $missing = InputRequestCapabilities::missing($ask, $declared); if (null === $missing) { return null; } $this->logger->warning('A handler asked for input the client did not declare it could provide; the ask was replaced with -32021.', [ 'method' => $request::getMethod(), 'required' => $missing->jsonSerialize(), ]); return Error::forMissingRequiredClientCapability( 'The server needs input this client did not declare it can provide.', $missing, $request->getId(), ); } /** * The ask a handler returned, if it returned one. * * @param Response|Error $result */ private static function askOf(Response|Error $result): ?InputRequiredResult { return $result instanceof Response && $result->result instanceof InputRequiredResult ? $result->result : null; } /** * The state the handler sealed last round, verified. * * Verified rather than trusted even though it never left this process: the * handler reads it back through the same accessor either era, so it has to * have been through the same check. * * @return array * * @throws RequestStateException when a state is present but does not verify */ private static function payloadOf(InputRequiredResult $ask, ?RequestStateCodec $codec): array { if (null === $ask->requestState) { return []; } if (null === $codec) { throw new RequestStateException('mac'); } return $codec->verify($ask->requestState); } }