Files
myep/src/BusProNet/ApiClient.php
T

994 lines
35 KiB
PHP

<?php
namespace App\BusProNet;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Agency;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\PromoVoucher;
use App\BusProNet\Model\PurchaseVoucher;
use App\BusProNet\Model\RegistrationResponse;
use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlParser\ApiResponseParser;
use App\Form\Model\BookingDto;
use App\Form\Model\RegistrationDto;
use App\Service\RequestIdGenerator;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\SerializerInterface;
class ApiClient
{
public const TYPE_NOTIFICATION = 'HINWEIS';
public const TYPE_CUSTOMER_DATA = 'KUNDENKONTO';
public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER';
public const TYPE_MUTABLE_DATA = 'MOEGLICHEAENDERUNGEN';
public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT';
public const TYPE_AVAILABILITY_EXTENDED = 'VERFUEGBARKEIT2';
public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL';
public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG';
public const TYPE_BOOKING = 'BUCHUNG';
public const TYPE_PRODUCTS = 'PRODUKTE';
public const TYPE_PRODUCT_DATA = 'PRODUKTDATEN';
public const TYPE_AGENCIES = 'AGENTUREN';
public const TYPE_PURCHASE_VOUCHER = 'GUTSCHEINPRUEFUNGEINLOESUNG';
public const TYPE_PROMO_VOUCHER = 'AKTIONSGUTSCHEIN';
/** @var array<string, mixed> */
private array $config;
private float $operationStartTime;
private int $selectedPort;
private int $requestCounter = 0;
/** @param array<string, mixed> $options */
public function __construct(
private readonly SerializerInterface $serializer,
private readonly ApiResponseParser $responseParser,
private readonly FilesystemOperator $xmlDump,
private readonly LoggerInterface $logger,
private readonly BookingDataProcessor $bookingDataProcessor,
private readonly RequestStack $requestStack,
private readonly RequestIdGenerator $requestIdGenerator,
array $options,
) {
$this->config = $this->resolveOptions($options);
}
/**
* @throws ApiClientException
*/
public function getPersonalData(string $email, string $password): Notification|PersonalData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Adressdaten',
'email' => $email,
'passwort' => $password,
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function register(RegistrationDto $registrationData): Notification|RegistrationResponse
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Adresse_Neu',
'adressdaten' => $registrationData->toPayload(),
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function resetPassword(string $email): Notification
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Passwort_Anfrage',
'email' => $email,
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function updatePersonalData(
string $email,
string $password,
PersonalData $personalData,
bool $debug = false,
): Notification|PersonalData {
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Adressdaten_Ändern',
'email' => $email,
'passwort' => $password,
'idadresse' => $personalData->addressId,
'adressdaten' => $personalData->toPayload(),
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, [], $debug);
}
/**
* @throws ApiClientException
*/
public function createAddress(
PersonalData $personalData,
bool $debug = false,
): RegistrationResponse|Notification {
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Adresse_Neu',
'adressdaten' => $personalData->toPayload(false),
'ohnemailversand' => 'True',
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, [], $debug);
}
/**
* @throws ApiClientException
*/
public function updateNewsletterRegistration(string $email, string $password, PersonalData $personalData): Notification|PersonalData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Newsletter',
'email' => $email,
'passwort' => $password,
'idadresse' => $personalData->addressId,
'newsletter' => [
'email' => $email,
'anmeldung' => $personalData->communication->newsletter ? 'True' : 'False',
],
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function getBookings(string $email, string $password): Notification|BaseData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Vorgänge',
'email' => $email,
'passwort' => $password,
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function getBooking(string $email, string $password, int $id): Notification|Booking
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Vorgang_Details',
'email' => $email,
'passwort' => $password,
'idbuchung' => $id,
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function updateBooking(BookingDto $formData, bool $debug = false): Notification|BookingUpdate
{
$payload = $this->bookingDataProcessor->createUpdateRequestPayload($formData);
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING_UPDATE),
'satz' => ['@typ' => static::TYPE_BOOKING_UPDATE],
'buchungsart' => Constants::BOOKING_TYPE_BOOKING,
...$payload,
];
return $this->sendRequest(static::TYPE_BOOKING_UPDATE, $data, [], $debug);
}
/**
* Submits a booking inquiry for validation.
*
* First phase of the two-phase booking process. Validates all booking data
* and returns pricing information without creating an actual booking.
*
* @param BookingDto $bookingDto The booking creation form data
* @param bool $debug Enable debug mode (XML dumps)
*
* @return Notification|BookingResponse Notification on error, BookingResponse on success
*
* @throws ApiClientException If the API request fails
*/
public function createBookingInquiry(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
{
$payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, Constants::BOOKING_TYPE_INQUIRY);
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING),
'satz' => ['@typ' => static::TYPE_BOOKING],
...$payload,
];
return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug);
}
/**
* Submits the final booking request.
*
* Second phase of the two-phase booking process. Creates the actual booking
* after successful inquiry validation.
*
* @param BookingDto $bookingDto The booking creation form data
* @param bool $debug Enable debug mode (XML dumps)
*
* @return Notification|BookingResponse Notification on error, BookingResponse with booking number on success
*
* @throws ApiClientException If the API request fails
*/
public function createBooking(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
{
$payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, Constants::BOOKING_TYPE_BOOKING);
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING),
'satz' => ['@typ' => static::TYPE_BOOKING],
...$payload,
];
return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug);
}
/**
* @throws ApiClientException
*/
public function getMutableData(int $dateId): Notification|BaseData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_MUTABLE_DATA),
'satz' => ['@typ' => static::TYPE_MUTABLE_DATA],
'idreise' => $dateId,
];
return $this->sendRequest(static::TYPE_MUTABLE_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function getAvailabilities(int $dateId): Notification|ServiceAvailabilityResponse
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY),
'satz' => ['@typ' => static::TYPE_AVAILABILITY],
'idreise' => $dateId,
];
return $this->sendRequest(static::TYPE_AVAILABILITY, $data);
}
/**
* @throws ApiClientException
*/
public function getAvailabilitiesExtended(int $dateId): Notification|ExtendedServiceAvailabilityResponse
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_EXTENDED),
'satz' => ['@typ' => static::TYPE_AVAILABILITY_EXTENDED],
'idreise' => $dateId,
];
return $this->sendRequest(static::TYPE_AVAILABILITY_EXTENDED, $data);
}
/**
* @throws ApiClientException
*/
public function getHotelAvailability(int $dateId, int $hotelId, \DateTimeInterface $dateTo): Notification|BaseData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_HOTEL),
'satz' => ['@typ' => static::TYPE_AVAILABILITY_HOTEL],
'idreise' => $dateId,
'idpartner' => $hotelId,
'terminbis' => $dateTo->format('d.m.Y'),
];
return $this->sendRequest(static::TYPE_AVAILABILITY_HOTEL, $data);
}
/**
* @throws ApiClientException
*/
public function getCrmAttributes(string $email, string $password): Notification|CrmAttributes
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'SelektionCRM',
'email' => $email,
'passwort' => $password,
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function getBaseData(string $type): Notification|BaseData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], $type),
'satz' => ['@typ' => $type],
];
return $this->sendRequest($type, $data);
}
/**
* @throws ApiClientException
*/
public function getDocuments(string $email, string $password, int $bookingId, string $type): mixed
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => $type,
'email' => $email,
'passwort' => $password,
'idbuchung' => $bookingId,
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function getTravelData(int $travelId, ?int $hotelId = null): Notification|Travel
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCT_DATA),
'satz' => ['@typ' => static::TYPE_PRODUCT_DATA],
'idprodukt' => $travelId,
];
return $this->sendRequest(static::TYPE_PRODUCT_DATA, $data, ['hotelId' => $hotelId]);
}
/**
* Fetches raw XML travel data from the BusProNet API.
*
* Returns the unprocessed XML response for direct storage in the XML export directory.
* The response format matches the XML export structure from BusPro.
*
* @param int $travelId The travel product ID to fetch
*
* @return string The raw XML response
*
* @throws ApiClientException If the API request fails
*/
public function getTravelDataXml(int $travelId): string
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCT_DATA),
'satz' => ['@typ' => static::TYPE_PRODUCT_DATA],
'idprodukt' => $travelId,
];
return $this->sendRequestRaw($data);
}
/**
* @throws ApiClientException
*/
public function getProducts(): Notification|BaseData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCTS),
'satz' => ['@typ' => static::TYPE_PRODUCTS],
];
return $this->sendRequest(static::TYPE_PRODUCTS, $data);
}
/**
* Fetches all available agencies from the BusProNet API.
*
* Returns a list of all agencies with their contact information.
* This data is typically cached for long periods as it changes infrequently.
*
* @return Agency[]|Notification Array of Agency objects on success, Notification on error
*
* @throws ApiClientException If the API request fails
*/
public function getAgencies(): array|Notification
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AGENCIES),
'satz' => ['@typ' => static::TYPE_AGENCIES],
];
return $this->sendRequest(static::TYPE_AGENCIES, $data);
}
/**
* Validates a purchase voucher by redemption code.
*
* Purchase vouchers (Gutscheine) have a remaining balance that reduces with each redemption.
* They can be regular purchased vouchers or goodwill vouchers (Kulanz).
* Returns the voucher with remaining balance, or a Notification if not found or invalid.
*
* @param string $redemptionCode The voucher redemption code (einloesecode)
*
* @return Notification|PurchaseVoucher Notification on error, PurchaseVoucher on success
*
* @throws ApiClientException If the API request fails
*/
public function validatePurchaseVoucher(string $redemptionCode): Notification|PurchaseVoucher
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PURCHASE_VOUCHER),
'satz' => ['@typ' => static::TYPE_PURCHASE_VOUCHER],
'einloesecode' => $redemptionCode,
];
return $this->sendRequest(static::TYPE_PURCHASE_VOUCHER, $data);
}
/**
* Validates a promo voucher for a specific travel and participant price.
*
* Promo vouchers (Aktionsgutscheine) provide absolute discounts per person or per booking.
* Applicability is indicated by the pro_buchung_person field: "P" = per person, "B" = per booking.
*
* @param string $promoCode The promo voucher code
* @param int $travelId The travel ID this voucher applies to
* @param float $participantPrice Current participant price for validation
*
* @return Notification|PromoVoucher Notification on error, PromoVoucher on success
*
* @throws ApiClientException If the API request fails
*/
public function validatePromoVoucher(string $promoCode, int $travelId, float $participantPrice): Notification|PromoVoucher
{
// Format price to German format (comma decimal separator)
$priceFormatted = number_format($participantPrice, 2, ',', '');
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PROMO_VOUCHER),
'satz' => ['@typ' => static::TYPE_PROMO_VOUCHER],
'aktionsgutschein' => $promoCode,
'idreise' => $travelId,
'preis' => $priceFormatted,
];
return $this->sendRequest(static::TYPE_PROMO_VOUCHER, $data);
}
/**
* Sends raw XML to the BPN API with key regeneration and automatic retry.
*
* 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.
* Automatically retries on immediate connection close (server busy).
*
* @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
{
return $this->executeWithRetry(fn () => $this->doSendRawXml($xml, $debug));
}
/**
* Performs the actual raw XML request to the BPN API.
*
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
private function doSendRawXml(string $xml, bool $debug = false): string
{
$requestId = $this->getRequestId();
$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 <satz> element in XML');
}
$type = $satzNode->getAttribute('typ');
if ('' === $type) {
throw new ApiClientException('Missing typ attribute on <satz> element');
}
$keyNode = $doc->getElementsByTagName('key')->item(0);
if (null === $keyNode) {
throw new ApiClientException('Missing <key> element in XML');
}
$newKey = $this->createKey(
$this->config['bpn_username'],
$this->config['bpn_password'],
$type
);
$keyNode->nodeValue = $newKey;
$body = $doc->saveXML();
if (true === $debug || true === $this->config['debug']) {
$this->dumpXmlToFile('request', $requestId, $body);
}
$socket = $this->connect();
$this->logger->info('Sending raw XML request to BPN API', [
'requestId' => $requestId,
'type' => $type,
'port' => $this->selectedPort,
]);
$this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
$responseXml = substr($response, 10);
if (true === $debug || true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $responseXml);
}
return $responseXml;
}
/**
* @throws ApiClientException
*/
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $additionalArgs
*/
private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
{
return $this->executeWithRetry(fn () => $this->doSendRequest($type, $data, $additionalArgs, $debug), $type);
}
/**
* @throws ApiClientException
*/
/** @param array<string, mixed> $data */
private function sendRequestRaw(array $data): string
{
return $this->executeWithRetry(fn () => $this->doSendRequestRaw($data));
}
/**
* 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'];
$lastException = null;
for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
try {
return $operation();
} catch (ImmediateConnectionCloseException $e) {
$lastException = $e;
if ($attempt < $maxAttempts) {
$context = [
'attempt' => $attempt,
'maxAttempts' => $maxAttempts,
'retryDelay' => $retryDelay,
];
if (null !== $type) {
$context['type'] = $type;
}
$this->logger->warning('BPN server busy, retrying request', $context);
sleep($retryDelay);
}
}
}
$context = ['attempts' => $maxAttempts];
if (null !== $type) {
$context['type'] = $type;
}
$this->logger->error('BPN server busy after all retry attempts', $context);
throw $lastException;
}
/**
* Performs the actual request to the BPN API.
*
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $additionalArgs
*/
private function doSendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
{
$requestId = $this->getRequestId();
$body = $this
->serializer
->serialize($data, 'xml', [
XmlEncoder::ROOT_NODE_NAME => 'anfrage',
XmlEncoder::ENCODING => 'UTF-8',
])
;
if (true === $debug || true === $this->config['debug']) {
$this->dumpXmlToFile('request', $requestId, $body);
}
$socket = $this->connect();
$this->logger->info('Sending request to BPN API', [
'requestId' => $requestId,
'type' => $data['satz']['@typ'],
'port' => $this->selectedPort,
]);
$this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
// message length (10 bytes) is prepended to actual message
$xml = substr($response, 10);
if ('' === $xml) {
$this->logger->error('Empty response body received from API (header-only response)', [
'request_id' => $requestId,
'raw_length' => strlen($response),
]);
throw new ApiClientException('Empty response body received from API');
}
if (true === $debug || true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
try {
return $this->responseParser->parseXmlString($type, $xml, $additionalArgs);
} catch (ResponseParserException $e) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
$this->logger->error('Unexpected response received from API', [
'request_id' => $requestId,
]);
throw new ApiClientException('Unexpected response received from API');
}
/**
* @param array<string, mixed> $data
*
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
private function doSendRequestRaw(array $data): string
{
$requestId = $this->getRequestId();
$body = $this->serializer->serialize($data, 'xml', [
XmlEncoder::ROOT_NODE_NAME => 'anfrage',
XmlEncoder::ENCODING => 'UTF-8',
]);
if (true === $this->config['debug']) {
$this->dumpXmlToFile('request', $requestId, $body);
}
$socket = $this->connect();
$this->logger->info('Sending raw request to BPN API', [
'requestId' => $requestId,
'type' => $data['satz']['@typ'],
'port' => $this->selectedPort,
]);
$this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
$xml = substr($response, 10);
if (true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
return $xml;
}
private function dumpXmlToFile(string $type, string $requestId, string $body): void
{
try {
$this->xmlDump->write($requestId.'_'.$type.'.xml', $body);
} catch (FilesystemException $e) {
}
}
private function getRequestId(): string
{
$baseId = $this->requestStack->getMainRequest()?->attributes->get('request_id')
?? $this->requestIdGenerator->generateBaseId();
return $baseId.'_'.++$this->requestCounter;
}
private function createKey(string $username, string $password, string $type): string
{
$date = (new \DateTimeImmutable())->format('Ymd');
return md5($username.$password.$date.$type);
}
/**
* @return resource
*
* @throws ApiClientException
* @throws TimeoutException
*/
private function connect()
{
// Randomly select a port from the available ports for load balancing
$this->selectedPort = $this->config['bpn_api_ports'][array_rand($this->config['bpn_api_ports'])];
$this->operationStartTime = microtime(true);
$tries = 1;
$errNo = $errStr = '';
$errorCodesForRetry = [
SOCKET_ECONNREFUSED,
SOCKET_EBADF,
];
$openSocket = function (&$errNo, &$errStr) {
return @fsockopen(
$this->config['bpn_api_ip'],
$this->selectedPort,
$errNo,
$errStr,
$this->config['connection_timeout']
);
};
$socket = $openSocket($errNo, $errStr);
while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $this->config['max_retries'] > $tries) {
// Check if we've exceeded total timeout during retries
if (microtime(true) - $this->operationStartTime > $this->config['total_timeout']) {
$this->logger->error('Connection retry timeout exceeded', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
'total_timeout' => $this->config['total_timeout'],
]);
throw new TimeoutException('Connection timeout exceeded during retries');
}
$this->logger->warning('Could not connect to socket, retrying', [
'error_message' => $errStr,
'error_number' => $errNo,
'attempt' => $tries,
]);
++$tries;
sleep(1);
$socket = $openSocket($errNo, $errStr);
}
if (false !== $socket) {
stream_set_timeout($socket, $this->config['stream_timeout']);
} else {
$this->logger->error('Unable to open socket', [
'error_message' => $errStr,
'error_number' => $errNo,
'elapsed_time' => microtime(true) - $this->operationStartTime,
]);
throw new ApiClientException('Unable to open socket');
}
return $socket;
}
/**
* @param resource $socket
*
* @throws TimeoutException
*/
private function send($socket, string $data): void
{
// Check total timeout before sending
if (microtime(true) - $this->operationStartTime > $this->config['total_timeout']) {
$this->logger->error('Total timeout exceeded before send', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
]);
throw new TimeoutException('Total operation timeout exceeded before send');
}
// message length is prepended to actual message
$send = sprintf('%010s', strlen($data)).$data;
fwrite($socket, $send);
}
/**
* @param resource $socket
*
* @throws TimeoutException
* @throws ImmediateConnectionCloseException
*/
private function receive($socket): string
{
$response = '';
$readAttempts = 0;
$totalTimeout = $this->config['total_timeout'];
while (false === feof($socket)) {
// Check total timeout before each read
$elapsedTime = microtime(true) - $this->operationStartTime;
if ($elapsedTime > $totalTimeout) {
$this->logger->error('Total timeout exceeded during receive', [
'elapsed_time' => $elapsedTime,
'total_timeout' => $totalTimeout,
'bytes_received' => strlen($response),
'read_attempts' => $readAttempts,
]);
throw new TimeoutException('Total operation timeout exceeded while receiving data');
}
$chunk = fread($socket, 4096);
++$readAttempts;
// Check if stream timed out on this specific read
$metadata = stream_get_meta_data($socket);
if (true === $metadata['timed_out']) {
// Check if this is an immediate rejection (0 bytes, < 1 second)
// This indicates the server is busy rather than a true timeout
if (0 === strlen($response) && $elapsedTime < 1.0) {
$this->logger->warning('Server closed connection immediately', [
'elapsed_time' => $elapsedTime,
'bytes_received' => 0,
'read_attempts' => $readAttempts,
]);
throw new ImmediateConnectionCloseException('Server closed connection immediately - server may be busy');
}
$this->logger->error('Stream read timeout detected', [
'elapsed_time' => $elapsedTime,
'bytes_received' => strlen($response),
'read_attempts' => $readAttempts,
]);
throw new TimeoutException('Stream timeout while reading from socket');
}
$response .= $chunk;
}
if (0 === strlen($response)) {
$elapsedTime = microtime(true) - $this->operationStartTime;
$this->logger->warning('Server closed connection without sending data', [
'elapsed_time' => $elapsedTime,
'read_attempts' => $readAttempts,
]);
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
}
return $response;
}
/** @param resource $socket */
private function disconnect($socket): void
{
@fclose($socket);
}
/**
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
$optionsResolver->setRequired([
'bpn_username',
'bpn_password',
'bpn_api_ip',
'bpn_api_ports',
]);
$optionsResolver->setAllowedTypes('bpn_api_ports', ['array']);
$optionsResolver->setDefaults([
'max_retries' => 25,
'debug' => false,
'connection_timeout' => 5,
'stream_timeout' => 30,
'total_timeout' => 45,
'busy_retry_attempts' => 3,
'busy_retry_delay' => 1,
]);
return $optionsResolver->resolve($options);
}
}