*/ private array $config; /** @param array $options */ public function __construct( private readonly SerializerInterface $serializer, private readonly ApiResponseParser $responseParser, private readonly XmlDumpWriter $dumpWriter, private readonly LoggerInterface $logger, private readonly RequestIdSequence $requestIds, private readonly SocketTransport $transport, array $options, ) { $this->config = $this->resolveOptions($options); } /** * Sends command $type with $fields and returns the parsed response. * * @param array $fields * @param array $parserArgs * * @throws ApiClientException */ public function request(MessageType $type, array $fields = [], array $parserArgs = [], bool $debug = false): mixed { $data = $this->envelope($type, $fields); return $this->executeWithRetry(function () use ($type, $data, $parserArgs, $debug) { $requestId = $this->requestIds->next(); $dump = $debug || true === $this->config['debug']; $xml = $this->exchange($requestId, $this->serializeXml($data), $type->value, 'Sending request to BPN API', $dump); return $this->parseResponse($type, $xml, $parserArgs, $requestId); }, $type->value); } /** * Like request(), but returns the unparsed response. * * @param array $fields * * @throws ApiClientException */ public function requestRaw(MessageType $type, array $fields = []): string { $data = $this->envelope($type, $fields); return $this->executeWithRetry(function () use ($type, $data) { $requestId = $this->requestIds->next(); $body = $this->serializeXml($data); return $this->exchange($requestId, $body, $type->value, 'Sending raw request to BPN API', true === $this->config['debug']); }); } /** * Sends a recorded request as is, except for the key, which is regenerated for the current * date. Returns the unparsed response. * * @throws ApiClientException */ public function replay(string $xml, bool $debug = false): string { return $this->executeWithRetry(fn () => $this->doReplay($xml, $debug)); } /** * The authentication every request starts with, followed by the command's own fields. * * @param array $fields * * @return array */ private function envelope(MessageType $type, array $fields): array { return [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($type->value), 'satz' => ['@typ' => $type->value], ...$fields, ]; } private function createKey(string $type): string { $date = (new \DateTimeImmutable())->format('Ymd'); return md5($this->config['bpn_username'].$this->config['bpn_password'].$date.$type); } /** * Executes an operation with automatic retry on immediate connection close. * * When the BPN server is busy, it may close connections immediately without responding. * This method detects such conditions and automatically retries after a short delay. * * @throws ImmediateConnectionCloseException If all retry attempts fail */ private function executeWithRetry(callable $operation, ?string $type = null): mixed { $maxAttempts = $this->config['busy_retry_attempts']; $retryDelay = $this->config['busy_retry_delay']; $typeContext = null !== $type ? ['type' => $type] : []; $lastException = null; for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) { try { return $operation(); } catch (ImmediateConnectionCloseException $e) { $lastException = $e; if ($attempt < $maxAttempts) { $this->logger->warning('BPN server busy, retrying request', [ 'attempt' => $attempt, 'maxAttempts' => $maxAttempts, 'retryDelay' => $retryDelay, ...$typeContext, ]); sleep($retryDelay); } } } $this->logger->error('BPN server busy after all retry attempts', ['attempts' => $maxAttempts, ...$typeContext]); throw $lastException; } /** * @throws ApiClientException * @throws ImmediateConnectionCloseException */ private function doReplay(string $xml, bool $debug): string { $requestId = $this->requestIds->next(); $doc = new \DOMDocument(); if (false === @$doc->loadXML($xml)) { throw new ApiClientException('Invalid XML provided'); } $satzNode = $doc->getElementsByTagName('satz')->item(0); if (null === $satzNode) { throw new ApiClientException('Missing element in XML'); } $type = $satzNode->getAttribute('typ'); if ('' === $type) { throw new ApiClientException('Missing typ attribute on element'); } $keyNode = $doc->getElementsByTagName('key')->item(0); if (null === $keyNode) { throw new ApiClientException('Missing element in XML'); } $keyNode->nodeValue = $this->createKey($type); return $this->exchange($requestId, $doc->saveXML(), $type, 'Sending raw XML request to BPN API', $debug || true === $this->config['debug']); } /** * @throws ApiClientException * @throws ImmediateConnectionCloseException */ private function exchange(string $requestId, string $body, string $type, string $logMessage, bool $dump): string { if ($dump) { $this->dumpWriter->write($requestId, XmlDumpWriter::REQUEST, $body); } $response = $this->transport->exchange($body, $logMessage, ['requestId' => $requestId, 'type' => $type]); if ($dump) { $this->dumpWriter->write($requestId, XmlDumpWriter::RESPONSE, $response); } return $response; } /** * @param array $parserArgs * * @throws ApiClientException */ private function parseResponse(MessageType $type, string $xml, array $parserArgs, string $requestId, bool $dumpOnFailure = true): mixed { try { return $this->responseParser->parseXmlString($type, $xml, $parserArgs); } catch (ResponseParserException $e) { // Keep the evidence even outside debug mode: without the raw response a parse // failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it. if ($dumpOnFailure) { $this->dumpWriter->write($requestId, XmlDumpWriter::RESPONSE, $xml); } $this->logger->error('Unable to parse response received from API', [ 'request_id' => $requestId, 'type' => $type->value, 'response_length' => strlen($xml), 'error_message' => $e->getMessage(), ]); throw new ApiClientException('Unable to parse response received from API: '.$e->getMessage(), 0, $e); } } /** @param array $data */ private function serializeXml(array $data): string { return $this->serializer->serialize($data, 'xml', [ XmlEncoder::ROOT_NODE_NAME => 'anfrage', XmlEncoder::ENCODING => 'UTF-8', ]); } /** * @param array $options * * @return array */ private function resolveOptions(array $options): array { $optionsResolver = new OptionsResolver(); $optionsResolver->setRequired([ 'bpn_username', 'bpn_password', ]); $optionsResolver->setDefaults([ 'debug' => false, 'busy_retry_attempts' => 3, 'busy_retry_delay' => 1, ]); return $optionsResolver->resolve($options); } }