From e51664df6cd003820554ea490d242b7039deb36d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 2 Oct 2026 13:50:48 +0200 Subject: [PATCH] refactor: deduplicate request building in buspro api client --- src/BusProNet/ApiClient.php | 684 +++++++++++------------------------- 1 file changed, 211 insertions(+), 473 deletions(-) diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index 8b6728c9..d54120df 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -88,16 +88,7 @@ class ApiClient */ 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); + return $this->customerRequest('Adressdaten', ['email' => $email, 'passwort' => $password]); } /** @@ -105,15 +96,7 @@ class ApiClient */ 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); + return $this->customerRequest('Adresse_Neu', ['adressdaten' => $registrationData->toPayload()]); } /** @@ -121,15 +104,7 @@ class ApiClient */ 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); + return $this->customerRequest('Passwort_Anfrage', ['email' => $email]); } /** @@ -141,18 +116,12 @@ class ApiClient PersonalData $personalData, bool $debug = false, ): Notification|PersonalDataUpdateResponse { - $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', + return $this->customerRequest('Adressdaten_Ändern', [ 'email' => $email, 'passwort' => $password, 'idadresse' => $personalData->addressId, 'adressdaten' => $personalData->toPayload(), - ]; - - return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, [], $debug); + ], $debug); } /** @@ -162,16 +131,10 @@ class ApiClient 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', + return $this->customerRequest('Adresse_Neu', [ 'adressdaten' => $personalData->toPayload(false), 'ohnemailversand' => 'True', - ]; - - return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, [], $debug); + ], $debug); } /** @@ -179,11 +142,7 @@ class ApiClient */ 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', + return $this->customerRequest('Newsletter', [ 'email' => $email, 'passwort' => $password, 'idadresse' => $personalData->addressId, @@ -191,9 +150,7 @@ class ApiClient 'email' => $email, 'anmeldung' => $personalData->communication->newsletter ? 'True' : 'False', ], - ]; - - return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); + ]); } /** @@ -201,16 +158,7 @@ class ApiClient */ 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); + return $this->customerRequest('Vorgänge', ['email' => $email, 'passwort' => $password]); } /** @@ -218,17 +166,7 @@ class ApiClient */ 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); + return $this->customerRequest('Vorgang_Details', ['email' => $email, 'passwort' => $password, 'idbuchung' => $id]); } /** @@ -236,71 +174,32 @@ class ApiClient */ 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], + return $this->sendRequest(static::TYPE_BOOKING_UPDATE, [ 'buchungsart' => Constants::BOOKING_TYPE_BOOKING, - ...$payload, - ]; - - return $this->sendRequest(static::TYPE_BOOKING_UPDATE, $data, [], $debug); + ...$this->bookingDataProcessor->createUpdateRequestPayload($formData), + ], [], $debug); } /** - * Submits a booking inquiry for validation. + * First phase of the two-phase booking process: validates the booking and returns the + * prices without creating it. * - * 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 + * @throws ApiClientException */ 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); + return $this->sendBooking($bookingDto, Constants::BOOKING_TYPE_INQUIRY, $debug); } /** - * Submits the final booking request. + * Second phase of the two-phase booking process: creates the booking after a successful + * inquiry. * - * 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 + * @throws ApiClientException */ 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); + return $this->sendBooking($bookingDto, Constants::BOOKING_TYPE_BOOKING, $debug); } /** @@ -308,14 +207,7 @@ class ApiClient */ 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); + return $this->sendRequest(static::TYPE_MUTABLE_DATA, ['idreise' => $dateId]); } /** @@ -323,14 +215,7 @@ class ApiClient */ 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); + return $this->sendRequest(static::TYPE_AVAILABILITY, ['idreise' => $dateId]); } /** @@ -338,14 +223,7 @@ class ApiClient */ 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); + return $this->sendRequest(static::TYPE_AVAILABILITY_EXTENDED, ['idreise' => $dateId]); } /** @@ -353,16 +231,11 @@ class ApiClient */ 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], + return $this->sendRequest(static::TYPE_AVAILABILITY_HOTEL, [ 'idreise' => $dateId, 'idpartner' => $hotelId, 'terminbis' => $dateTo->format('d.m.Y'), - ]; - - return $this->sendRequest(static::TYPE_AVAILABILITY_HOTEL, $data); + ]); } /** @@ -370,16 +243,7 @@ class ApiClient */ 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); + return $this->customerRequest('SelektionCRM', ['email' => $email, 'passwort' => $password]); } /** @@ -387,13 +251,7 @@ class ApiClient */ 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); + return $this->sendRequest($type); } /** @@ -401,17 +259,7 @@ class ApiClient */ 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); + return $this->customerRequest($type, ['email' => $email, 'passwort' => $password, 'idbuchung' => $bookingId]); } /** @@ -419,38 +267,20 @@ class ApiClient */ 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]); + return $this->sendRequest(static::TYPE_PRODUCT_DATA, ['idprodukt' => $travelId], ['hotelId' => $hotelId]); } /** - * Fetches raw XML travel data from the BusProNet API. + * Returns the unparsed response, which has the structure of BusPro's XML export and is + * stored in the export directory as is. * - * 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 + * @throws ApiClientException */ 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, - ]; + $data = $this->envelope(static::TYPE_PRODUCT_DATA, ['idprodukt' => $travelId]); - return $this->sendRequestRaw($data); + return $this->executeWithRetry(fn () => $this->doSendRequestRaw($data)); } /** @@ -458,90 +288,46 @@ class ApiClient */ 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); + return $this->sendRequest(static::TYPE_PRODUCTS); } /** - * Fetches all available agencies from the BusProNet API. + * Changes rarely, so callers cache it for long periods. * - * 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 * - * @return Agency[]|Notification Array of Agency objects on success, Notification on error - * - * @throws ApiClientException If the API request fails + * @throws ApiClientException */ 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); + return $this->sendRequest(static::TYPE_AGENCIES); } /** - * Validates a purchase voucher by redemption code. + * Looks up a purchase voucher (Gutschein) by its redemption code. Purchase vouchers, regular + * or goodwill (Kulanz), carry a balance that each redemption reduces. * - * 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 + * @throws ApiClientException */ 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); + return $this->sendRequest(static::TYPE_PURCHASE_VOUCHER, ['einloesecode' => $redemptionCode]); } /** - * Validates a promo voucher for a specific travel and participant price. + * Validates a promo voucher (Aktionsgutschein) for a travel and participant price. Promo + * vouchers are absolute discounts, per person ("P") or per booking ("B") as pro_buchung_person + * says. * - * 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 + * @throws ApiClientException */ 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], + return $this->sendRequest(static::TYPE_PROMO_VOUCHER, [ 'aktionsgutschein' => $promoCode, 'idreise' => $travelId, - 'preis' => $priceFormatted, - ]; - - return $this->sendRequest(static::TYPE_PROMO_VOUCHER, $data); + 'preis' => number_format($participantPrice, 2, ',', ''), + ]); } /** @@ -551,13 +337,7 @@ class ApiClient */ public function getVoucherTemplates(): Notification|VoucherTemplates { - $data = [ - 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_VOUCHER_TEMPLATES), - 'satz' => ['@typ' => static::TYPE_VOUCHER_TEMPLATES], - ]; - - return $this->sendRequest(static::TYPE_VOUCHER_TEMPLATES, $data); + return $this->sendRequest(static::TYPE_VOUCHER_TEMPLATES); } /** @@ -569,13 +349,7 @@ class ApiClient */ public function getVoucherPaymentMethods(): array|Notification { - $data = [ - 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_VOUCHER_PAYMENT_METHODS), - 'satz' => ['@typ' => static::TYPE_VOUCHER_PAYMENT_METHODS], - ]; - - return $this->sendRequest(static::TYPE_VOUCHER_PAYMENT_METHODS, $data); + return $this->sendRequest(static::TYPE_VOUCHER_PAYMENT_METHODS); } /** @@ -585,14 +359,7 @@ class ApiClient */ public function getVoucherPreview(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherPreview { - $data = [ - 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_VOUCHER_PREVIEW), - 'satz' => ['@typ' => static::TYPE_VOUCHER_PREVIEW], - ...$this->voucherSalePayloadBuilder->buildPreviewPayload($voucherPurchaseDto), - ]; - - return $this->sendRequest(static::TYPE_VOUCHER_PREVIEW, $data); + return $this->sendRequest(static::TYPE_VOUCHER_PREVIEW, $this->voucherSalePayloadBuilder->buildPreviewPayload($voucherPurchaseDto)); } /** @@ -621,42 +388,143 @@ class ApiClient } /** + * Replays a request as is, except for the key, which is regenerated for the current date. + * Returns the unparsed response. + * * @throws ApiClientException */ - private function sendVoucherSale(VoucherPurchaseDto $voucherPurchaseDto, string $saleType): Notification|VoucherSaleResponse - { - $data = [ - 'user' => $this->config['bpn_username'], - 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_VOUCHER_SALE), - 'satz' => ['@typ' => static::TYPE_VOUCHER_SALE], - ...$this->voucherSalePayloadBuilder->buildSalePayload($voucherPurchaseDto, $saleType), - ]; - - return $this->sendRequest(static::TYPE_VOUCHER_SALE, $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 + */ + private function sendBooking(BookingDto $bookingDto, string $bookingType, bool $debug): Notification|BookingResponse + { + return $this->sendRequest(static::TYPE_BOOKING, $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, $bookingType), [], $debug); + } + + /** + * @throws ApiClientException + */ + private function sendVoucherSale(VoucherPurchaseDto $voucherPurchaseDto, string $saleType): Notification|VoucherSaleResponse + { + return $this->sendRequest(static::TYPE_VOUCHER_SALE, $this->voucherSalePayloadBuilder->buildSalePayload($voucherPurchaseDto, $saleType)); + } + + /** + * @param array $fields * + * @throws ApiClientException + */ + private function customerRequest(string $art, array $fields, bool $debug = false): mixed + { + return $this->sendRequest(static::TYPE_CUSTOMER_DATA, ['art' => $art, ...$fields], [], $debug); + } + + /** + * @param array $fields + * @param array $additionalArgs + * + * @throws ApiClientException + */ + private function sendRequest(string $type, array $fields = [], array $additionalArgs = [], bool $debug = false): mixed + { + $data = $this->envelope($type, $fields); + + return $this->executeWithRetry(fn () => $this->doSendRequest($type, $data, $additionalArgs, $debug), $type); + } + + /** + * The authentication every request starts with, followed by the command's own fields. + * + * @param array $fields + * + * @return array + */ + private function envelope(string $type, array $fields = []): array + { + return [ + 'user' => $this->config['bpn_username'], + 'key' => $this->createKey($type), + 'satz' => ['@typ' => $type], + ...$fields, + ]; + } + + /** + * 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; + } + + /** + * @param array $data + * @param array $additionalArgs + * + * @throws ApiClientException + * @throws ImmediateConnectionCloseException + */ + private function doSendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed + { + $requestId = $this->getRequestId(); + $dump = $debug || true === $this->config['debug']; + + $xml = $this->exchange($requestId, $this->serializeXml($data), $type, 'Sending request to BPN API', $dump); + + return $this->parseResponse($type, $xml, $additionalArgs, $requestId); + } + + /** + * @param array $data + * + * @throws ApiClientException + * @throws ImmediateConnectionCloseException + */ + private function doSendRequestRaw(array $data): string + { + $requestId = $this->getRequestId(); + + $body = $this->serializeXml($data); + + return $this->exchange($requestId, $body, $data['satz']['@typ'], 'Sending raw request to BPN API', true === $this->config['debug']); + } + + /** * @throws ApiClientException * @throws ImmediateConnectionCloseException */ @@ -684,137 +552,28 @@ class ApiClient throw new ApiClientException('Missing element in XML'); } - $newKey = $this->createKey( - $this->config['bpn_username'], - $this->config['bpn_password'], - $type - ); - $keyNode->nodeValue = $newKey; + $keyNode->nodeValue = $this->createKey($type); - $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); - - try { - $responseXml = $this->receive($socket); - } finally { - $this->disconnect($socket); - } - - if (true === $debug || true === $this->config['debug']) { - $this->dumpXmlToFile('response', $requestId, $responseXml); - } - - return $responseXml; + return $this->exchange($requestId, $doc->saveXML(), $type, 'Sending raw XML request to BPN API', $debug || true === $this->config['debug']); } /** - * @throws ApiClientException - */ - /** - * @param array $data - * @param array $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 $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. + * One request and its response over a fresh connection, both dumped if $dump. * * @throws ApiClientException * @throws ImmediateConnectionCloseException */ - /** - * @param array $data - * @param array $additionalArgs - */ - private function doSendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed + private function exchange(string $requestId, string $body, string $type, string $logMessage, bool $dump): string { - $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']) { + if ($dump) { $this->dumpXmlToFile('request', $requestId, $body); } $socket = $this->connect(); - $this->logger->info('Sending request to BPN API', [ + $this->logger->info($logMessage, [ 'requestId' => $requestId, - 'type' => $data['satz']['@typ'], + 'type' => $type, 'port' => $this->selectedPort, ]); @@ -822,21 +581,33 @@ class ApiClient try { // the length header is consumed and verified by receive(), this is the payload - $xml = $this->receive($socket); + $response = $this->receive($socket); } finally { $this->disconnect($socket); } - if (true === $debug || true === $this->config['debug']) { - $this->dumpXmlToFile('response', $requestId, $xml); + if ($dump) { + $this->dumpXmlToFile('response', $requestId, $response); } + return $response; + } + + /** + * @param array $additionalArgs + * + * @throws ApiClientException + */ + private function parseResponse(string $type, string $xml, array $additionalArgs, string $requestId, bool $dumpOnFailure = true): mixed + { try { return $this->responseParser->parseXmlString($type, $xml, $additionalArgs); } 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. - $this->dumpXmlToFile('response', $requestId, $xml); + if ($dumpOnFailure) { + $this->dumpXmlToFile('response', $requestId, $xml); + } $this->logger->error('Unable to parse response received from API', [ 'request_id' => $requestId, @@ -849,46 +620,13 @@ class ApiClient } } - /** - * @param array $data - * - * @throws ApiClientException - * @throws ImmediateConnectionCloseException - */ - private function doSendRequestRaw(array $data): string + /** @param array $data */ + private function serializeXml(array $data): string { - $requestId = $this->getRequestId(); - - $body = $this->serializer->serialize($data, 'xml', [ + return $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); - - try { - $xml = $this->receive($socket); - } finally { - $this->disconnect($socket); - } - - if (true === $this->config['debug']) { - $this->dumpXmlToFile('response', $requestId, $xml); - } - - return $xml; } private function dumpXmlToFile(string $type, string $requestId, string $body): void @@ -907,11 +645,11 @@ class ApiClient return $baseId.'_'.++$this->requestCounter; } - private function createKey(string $username, string $password, string $type): string + private function createKey(string $type): string { $date = (new \DateTimeImmutable())->format('Ymd'); - return md5($username.$password.$date.$type); + return md5($this->config['bpn_username'].$this->config['bpn_password'].$date.$type); } /**