config = $this->resolveOptions($options); } /** * @throws ResponseParserException */ public function parseXmlString(string $type, string $content): mixed { $xml = $this->loadXml($content); // Override type when present in XML to catch error responses $responseType = $type; $responseTypeXml = $xml->xpath('satz/@typ') ?: []; if (0 < count($responseTypeXml)) { $responseType = (string) $responseTypeXml[0]; } switch ($responseType) { case ApiClient::TYPE_NOTIFICATION: return $this->createNotificationResponse($xml); case ApiClient::TYPE_CUSTOMER_DATA: $subType = (string) $xml->art; switch ($subType) { case 'Adressdaten': return $this->createProfileResponse($xml); case 'Adressdaten_Ändern': return $this->createProfileUpdateResponse($xml); case 'SelektionCRM': return $this->createCrmAttributesResponse($xml); case 'SelektionCRM_Ändern': // Not a CRM attribute tree: the write answers idadresse + idperson + // änderung, exactly the shape createProfileUpdateResponse() reads. It used // to be routed to createCrmAttributesResponse(), which walks a // "selektionsmerkmale" node that is absent here and would have returned an // empty response with every role flag false - dead code until something // sent the art. return $this->createProfileUpdateResponse($xml); case 'Adresse_Neu': return $this->createRegistrationResponse($xml); } break; } throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType)); } public function createNotificationResponse(\SimpleXMLElement $xml): NotificationResponse { $recordXml = $xml->satz; $code = (int) $recordXml->nr; $message = (string) $recordXml->text; return new NotificationResponse($code, $message); } public function createProfileResponse(\SimpleXMLElement $xml): ProfileResponse { $addressId = (int) $xml->idadresse; $personId = (int) $xml->idperson; $addressXml = $xml->adressdaten; $lastName = (string) $addressXml->name; $firstName = (string) $addressXml->vorname; $salutation = (string) $addressXml->anrede; $title = (string) $addressXml->titel; $gender = (string) $addressXml->geschlecht; $gender = 'W' === strtoupper($gender) ? 'F' : strtoupper($gender); $dateString = (string) $addressXml->geburtsdatum; $dateOfBirth = null; if ('' !== $dateString) { $parsedDate = \DateTimeImmutable::createFromFormat('d.m.Y', $dateString); $dateOfBirth = false === $parsedDate ? null : $parsedDate; } $response = new ProfileResponse(); $response ->setAddressId($addressId) ->setPersonId($personId) ->setFirstName($firstName) ->setName($lastName) ->setTitle($title) ->setSalutation($salutation) ->setGender($gender) ->setDateOfBirth($dateOfBirth) ; $postalXml = $addressXml->anschrift; $postalAddressStreet = (string) $postalXml->strasse; $postalAddressPostcode = (string) $postalXml->plz; $postalAddressCity = (string) $postalXml->ort; $postalAddressCountry = (string) $postalXml->land; $address = new Address(); $address ->setStreet($postalAddressStreet) ->setPostCode($postalAddressPostcode) ->setCity($postalAddressCity) ->setCountry($postalAddressCountry) ; $response->setAddress($address); $contactXml = $addressXml->kommunikation; $phone = (string) $contactXml->telefonprivat; $mobile = (string) $contactXml->telefonmobil; $email = (string) $contactXml->email; $communication = new Communication(); $communication ->setPhone($phone) ->setMobile($mobile) ->setEmail($email) ; $response->setCommunication($communication); return $response; } public function createProfileUpdateResponse(\SimpleXMLElement $xml): ProfileUpdateResponse { $addressId = (int) $xml->idadresse; $personId = (int) $xml->idperson; $updated = false; $updatedXml = $xml->xpath('änderung|aenderung') ?: []; if (0 < count($updatedXml)) { $updated = 'true' === strtolower((string) $updatedXml[0]); } $response = new ProfileUpdateResponse(); $response ->setAddressId($addressId) ->setPersonId($personId) ->setUpdated($updated) ; return $response; } /** * @throws ResponseParserException */ public function createRegistrationResponse(\SimpleXMLElement $xml): RegistrationResponse { $addressIdXml = $xml->xpath('idadresse') ?: []; $personIdXml = $xml->xpath('idperson') ?: []; // Without both ids there is nothing to carry on with: the CRM selection is set by id, and // a local account without them cannot be matched again on the next login. Better a parse // error naming the response than a half-provisioned applicant. if (0 === count($addressIdXml) || 0 === count($personIdXml)) { throw new ResponseParserException('Missing idadresse or idperson in the Adresse_Neu response'); } $isNewRecord = false; $isNewRecordXml = $xml->xpath('neuanlage') ?: []; if (0 < count($isNewRecordXml)) { $isNewRecord = 'true' === strtolower((string) $isNewRecordXml[0]); } return new RegistrationResponse( (int) $addressIdXml[0], (int) $personIdXml[0], $isNewRecord, ); } public function createCrmAttributesResponse(\SimpleXMLElement $xml): CrmAttributesResponse { $groups = []; $isAdmin = $isManager = $isHouseManager = $isTeamer = false; $hotelCodes = []; foreach ($xml->selektionsmerkmale->selektionsgruppe as $item) { $group = new CrmAttributeGroup(); $group->setLabel($item->attributes()['bezeichnung']); $attributes = []; foreach ($item->selektion as $subItem) { $subItemAttributes = $subItem->attributes(); $attributeId = (int) $subItemAttributes['id']; $attributeLabel = (string) $subItemAttributes['bezeichnung']; $attributeSelected = 'True' === (string) $subItemAttributes['auswahl']; $attribute = new CrmAttribute(); $attribute ->setId($attributeId) ->setLabel($attributeLabel) ->setSelected($attributeSelected) ; $attributes[] = $attribute; // Matched by id, like every other role: the "Hausleitung XXX" label is BusPro // wording and must not decide who gets access to which house. $houseManagerCode = $this->config['bpn_crm_house_manager_ids'][$attribute->getId()] ?? null; if (null !== $houseManagerCode && true === $attribute->isSelected()) { $isHouseManager = true; $hotelCodes[] = $houseManagerCode; } if ($this->config['bpn_crm_id_team_admin'] === $attribute->getId() && true === $attribute->isSelected()) { $isAdmin = true; } if ($this->config['bpn_crm_id_manager'] === $attribute->getId() && true === $attribute->isSelected()) { $isManager = true; } if ($this->config['bpn_crm_id_teamer'] === $attribute->getId() && true === $attribute->isSelected()) { $isTeamer = true; } } $group->setAttributes($attributes); $groups[] = $group; } // Apply additional role and hotel code for testing purposes when provided if ($isAdmin && null !== $this->config['bpn_default_hotel_code']) { $isHouseManager = true; $hotelCodes[] = $this->config['bpn_default_hotel_code']; } $response = new CrmAttributesResponse(); $response ->setAttributeGroups($groups) ->setAdmin($isAdmin) ->setManager($isManager) ->setTeamer($isTeamer) ->setHouseManager($isHouseManager) ->setHotelCodes($hotelCodes) ; return $response; } /** * The BusProNet endpoint can answer with an empty or truncated body. Keep the libxml * reason instead of collapsing every shape of broken response into one message. * * @throws ResponseParserException */ private function loadXml(string $content): \SimpleXMLElement { if ('' === trim($content)) { throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): empty response', strlen($content))); } $previousUseErrors = libxml_use_internal_errors(true); libxml_clear_errors(); try { $xml = simplexml_load_string($content); if (false === $xml) { throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): %s', strlen($content), $this->describeLibxmlErrors())); } return $xml; } finally { libxml_clear_errors(); libxml_use_internal_errors($previousUseErrors); } } private function describeLibxmlErrors(): string { $messages = []; foreach (libxml_get_errors() as $error) { $messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column); } if ([] === $messages) { return 'unknown XML error'; } return implode('; ', array_unique($messages)); } private function resolveOptions(array $options): array { $optionsResolver = new OptionsResolver(); $optionsResolver->setRequired(['bpn_crm_id_team_admin', 'bpn_crm_id_manager', 'bpn_crm_id_teamer', 'bpn_crm_house_manager_ids']); $optionsResolver->setDefaults([ 'bpn_default_hotel_code' => null, ]); $optionsResolver->setAllowedTypes('bpn_crm_id_team_admin', 'int'); $optionsResolver->setAllowedTypes('bpn_crm_id_manager', 'int'); $optionsResolver->setAllowedTypes('bpn_crm_id_teamer', 'int'); $optionsResolver->setAllowedTypes('bpn_crm_house_manager_ids', 'array'); $optionsResolver->setAllowedTypes('bpn_default_hotel_code', ['string', 'null']); return $optionsResolver->resolve($options); } }