From 4f1a1cad8e98f9e1c1ad899c6bf3ffdbd99083e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 27 Nov 2025 12:04:15 +0100 Subject: [PATCH] feat: request replay command for bpn api --- composer.json | 1 + src/BusProNet/ApiClient.php | 76 ++++++++++++++++ src/Command/BpnReplayCommand.php | 144 +++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 src/Command/BpnReplayCommand.php diff --git a/composer.json b/composer.json index aba4bb8..572056f 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,7 @@ "require": { "php": ">=8.1", "ext-ctype": "*", + "ext-dom": "*", "ext-iconv": "*", "ext-simplexml": "*", "ext-sockets": "*", diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index 6f32a84..53839de 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -468,6 +468,82 @@ class ApiClient return $this->sendRequest(static::TYPE_PROMO_VOUCHER, $data); } + /** + * Sends raw XML to the BPN API with key regeneration. + * + * Parses the XML to extract the request type, regenerates the authentication key + * with the current date, and sends the request. Returns the raw XML response. + * + * @param string $xml The raw XML request body + * @param bool $debug Enable debug mode (XML dumps) + * + * @return string The raw XML response + * + * @throws ApiClientException If the request fails or XML is invalid + */ + public function sendRawXml(string $xml, bool $debug = false): string + { + $requestId = date(DATE_ATOM).uniqid(); + + $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'); + } + + $newKey = $this->createKey( + $this->config['bpn_username'], + $this->config['bpn_password'], + $type + ); + $keyNode->nodeValue = $newKey; + + $body = $doc->saveXML(); + + $this->logger->info('Sending raw XML request to BPN API', [ + 'requestId' => $requestId, + 'type' => $type, + ]); + + if (true === $debug || true === $this->config['debug']) { + $this->dumpXmlToFile('request', $requestId, $body); + } + + $socket = $this->connect( + $this->config['bpn_api_ip'], + $this->config['bpn_api_port'], + $this->config['max_retries'], + $this->config['connection_timeout'], + $this->config['stream_timeout'], + $this->config['total_timeout'] + ); + $this->send($socket, $body, $this->config['total_timeout']); + $response = $this->receive($socket, $this->config['total_timeout']); + $this->disconnect($socket); + + $responseXml = substr($response, 10); + + if (true === $debug || true === $this->config['debug']) { + $this->dumpXmlToFile('response', $requestId, $responseXml); + } + + return $responseXml; + } + /** * @throws ApiClientException */ diff --git a/src/Command/BpnReplayCommand.php b/src/Command/BpnReplayCommand.php new file mode 100644 index 0000000..8a06739 --- /dev/null +++ b/src/Command/BpnReplayCommand.php @@ -0,0 +1,144 @@ +addArgument('file', InputArgument::REQUIRED, 'Path to the XML request file') + ->addOption('dry-run', 'd', InputOption::VALUE_NONE, 'Show regenerated XML without sending') + ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Save response to file') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $filePath = $input->getArgument('file'); + if (false === file_exists($filePath)) { + $io->error(sprintf('File not found: %s', $filePath)); + + return Command::FAILURE; + } + + $xml = file_get_contents($filePath); + if (false === $xml) { + $io->error(sprintf('Unable to read file: %s', $filePath)); + + return Command::FAILURE; + } + + $io->info(sprintf('Loaded XML from: %s', $filePath)); + + $type = $this->extractRequestType($xml); + if (null !== $type) { + $io->info(sprintf('Request type: %s', $type)); + } + + if (true === $input->getOption('dry-run')) { + $regeneratedXml = $this->regenerateKey($xml); + if (null === $regeneratedXml) { + $io->error('Failed to regenerate key in XML'); + + return Command::FAILURE; + } + + $io->section('Regenerated XML (dry-run)'); + $io->writeln($this->formatXml($regeneratedXml)); + + return Command::SUCCESS; + } + + $io->info('Sending request to BPN API...'); + + try { + $response = $this->apiClient->sendRawXml($xml); + } catch (ApiClientException $e) { + $io->error(sprintf('API request failed: %s', $e->getMessage())); + + return Command::FAILURE; + } + + $outputFile = $input->getOption('output'); + if (null !== $outputFile) { + if (false === file_put_contents($outputFile, $response)) { + $io->error(sprintf('Failed to write response to: %s', $outputFile)); + + return Command::FAILURE; + } + $io->success(sprintf('Response saved to: %s', $outputFile)); + } + + $io->section('Response'); + $io->writeln($this->formatXml($response)); + + return Command::SUCCESS; + } + + private function extractRequestType(string $xml): ?string + { + $doc = new \DOMDocument(); + if (false === @$doc->loadXML($xml)) { + return null; + } + + $satzNode = $doc->getElementsByTagName('satz')->item(0); + + return $satzNode?->getAttribute('typ'); + } + + private function regenerateKey(string $xml): ?string + { + $doc = new \DOMDocument(); + if (false === @$doc->loadXML($xml)) { + return null; + } + + $keyNode = $doc->getElementsByTagName('key')->item(0); + if (null === $keyNode) { + return null; + } + + $keyNode->nodeValue = '[KEY_WOULD_BE_REGENERATED]'; + + return $doc->saveXML(); + } + + private function formatXml(string $xml): string + { + $doc = new \DOMDocument(); + $doc->preserveWhiteSpace = false; + $doc->formatOutput = true; + + if (false === @$doc->loadXML($xml)) { + return $xml; + } + + return $doc->saveXML() ?: $xml; + } +}