diff --git a/composer.json b/composer.json index ceb587a..cc9c08f 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "php": ">=8.1", "ext-ctype": "*", "ext-iconv": "*", + "ext-simplexml": "*", "doctrine/doctrine-bundle": "^2.10", "doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/orm": "^2.15", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6c4bfed..6915d12 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -32,7 +32,4 @@ - - - diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index 048fe38..4ec9a8b 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -2,16 +2,9 @@ namespace App\BusProNet; -use App\BusProNet\Model\CrmAttributeSelection; -use App\BusProNet\Model\Profile; -use App\BusProNet\Model\ErrorResponse; -use App\BusProNet\Model\Result; +use App\BusProNet\Model\BaseResponse; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; class ApiClient @@ -29,7 +22,7 @@ class ApiClient /** * @throws ApiClientException */ - public function getProfile(string $email, string $password): mixed + public function getProfile(string $email, string $password): BaseResponse { $data = [ 'anfrage' => [ @@ -56,11 +49,7 @@ class ApiClient $xml = $response->getContent(); - if (str_contains($xml, 'HINWEIS')) { - return $this->serializer->deserialize($xml, ErrorResponse::class, 'xml'); - } - - return $this->serializer->deserialize($xml, Profile::class, 'xml'); + return (new ResponseParser())->parseXmlString($xml); } catch (\Throwable $e) { } @@ -73,7 +62,7 @@ class ApiClient /** * @throws ApiClientException */ - public function getCrmSelection(string $email, string $password): mixed + public function getCrmAttributes(string $email, string $password): BaseResponse { $data = [ 'anfrage' => [ @@ -100,20 +89,13 @@ class ApiClient $xml = $response->getContent(); - if (str_contains($xml, 'HINWEIS')) { - return $this->serializer->deserialize($xml, ErrorResponse::class, 'xml'); - } - - return $this->serializer->deserialize($xml, CrmAttributeSelection::class, 'xml'); + return (new ResponseParser())->parseXmlString($xml); } catch (\Throwable $e) { } throw new ApiClientException($e->getMessage()); } - /** - * @Creates key for BusPro API access according to documentation - */ private function createKey(string $username, string $password, string $type): string { $date = (new \DateTimeImmutable())->format('Ymd'); diff --git a/src/BusProNet/Model/Address.php b/src/BusProNet/Model/Address.php index 6b1c5d6..d91fe00 100644 --- a/src/BusProNet/Model/Address.php +++ b/src/BusProNet/Model/Address.php @@ -2,20 +2,11 @@ namespace App\BusProNet\Model; -use Symfony\Component\Serializer\Annotation\SerializedName; - class Address { - #[SerializedName('strasse')] private ?string $street = null; - - #[SerializedName('plz')] private ?string $postCode = null; - - #[SerializedName('ort')] private ?string $city = null; - - #[SerializedName('land')] private ?string $country = null; public function getStreet(): ?string diff --git a/src/BusProNet/Model/BaseResponse.php b/src/BusProNet/Model/BaseResponse.php new file mode 100644 index 0000000..11fde4f --- /dev/null +++ b/src/BusProNet/Model/BaseResponse.php @@ -0,0 +1,41 @@ +code = $code; + $this->message = $message; + } + + public function getCode(): ?int + { + return $this->code; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function isSuccessful(): bool + { + // Successful responses don't carry codes and messages + if (null === $this->getCode() && null === $this->getMessage()) { + return true; + } + + // These weird and contradictory looking assertions are required because + // of the stupid API implementation that doesn't distinguish between error + // and success responses. + $responseIsError = 100 <= $this->getCode() || $this->getMessage() === 'Daten konnten nicht gesendet werden.'; + $responseIsSuccess = 650 === $this->getCode() && false === stripos($this->getMessage(), 'fehler'); + + return false === $responseIsError && true === $responseIsSuccess; + } +} \ No newline at end of file diff --git a/src/BusProNet/Model/Communication.php b/src/BusProNet/Model/Communication.php index 8193d30..511e032 100644 --- a/src/BusProNet/Model/Communication.php +++ b/src/BusProNet/Model/Communication.php @@ -6,13 +6,8 @@ use Symfony\Component\Serializer\Annotation\SerializedName; class Communication { - #[SerializedName('telefonprivat')] private ?string $phone = null; - - #[SerializedName('telefonmobil')] private ?string $mobile = null; - - #[SerializedName('email')] private ?string $email = null; public function getPhone(): ?string diff --git a/src/BusProNet/Model/CrmAttribute.php b/src/BusProNet/Model/CrmAttribute.php index 1559c4b..22b6900 100644 --- a/src/BusProNet/Model/CrmAttribute.php +++ b/src/BusProNet/Model/CrmAttribute.php @@ -2,19 +2,11 @@ namespace App\BusProNet\Model; -use Symfony\Component\Serializer\Annotation\Ignore; -use Symfony\Component\Serializer\Annotation\SerializedName; - class CrmAttribute { - #[SerializedName('@id')] private ?int $id = null; - - #[SerializedName('@bezeichnung')] private ?string $label = null; - - #[SerializedName('@auswahl')] - private ?string $selectedAsString = null; + private bool $selected = false; public function getId(): ?int { @@ -40,26 +32,14 @@ class CrmAttribute return $this; } - public function getSelectedAsString(): ?string - { - return $this->selectedAsString; - } - - public function setSelectedAsString(?string $selectedAsString): static - { - $this->selectedAsString = $selectedAsString; - - return $this; - } - public function isSelected(): bool { - return 'true' === strtolower($this->selectedAsString); + return $this->selected; } public function setSelected(bool $selected): static { - $this->selectedAsString = $selected ? 'True' : 'False'; + $this->selected = $selected; return $this; } diff --git a/src/BusProNet/Model/CrmAttributeSelectionGroup.php b/src/BusProNet/Model/CrmAttributeGroup.php similarity index 64% rename from src/BusProNet/Model/CrmAttributeSelectionGroup.php rename to src/BusProNet/Model/CrmAttributeGroup.php index 5f054bb..e5b67ec 100644 --- a/src/BusProNet/Model/CrmAttributeSelectionGroup.php +++ b/src/BusProNet/Model/CrmAttributeGroup.php @@ -2,14 +2,9 @@ namespace App\BusProNet\Model; -use Symfony\Component\Serializer\Annotation\SerializedName; - -class CrmAttributeSelectionGroup +class CrmAttributeGroup { - #[SerializedName('@bezeichnung')] private ?string $label = null; - - #[SerializedName('selektion')] private ?array $attributes = null; public function getLabel(): ?string @@ -24,18 +19,11 @@ class CrmAttributeSelectionGroup return $this; } - /** - * @return CrmAttribute[]|null - */ public function getAttributes(): ?array { return $this->attributes; } - /** - * @param CrmAttribute[]|null $attributes - * @return $this - */ public function setAttributes(?array $attributes): static { $this->attributes = $attributes; diff --git a/src/BusProNet/Model/CrmAttributeSelection.php b/src/BusProNet/Model/CrmAttributeSelection.php deleted file mode 100644 index 8fbd199..0000000 --- a/src/BusProNet/Model/CrmAttributeSelection.php +++ /dev/null @@ -1,30 +0,0 @@ -selectionGroups; - } - - /** - * @param CrmAttributeSelectionGroup[]|null $selectionGroups - * @return $this - */ - public function setSelectionGroups(?array $selectionGroups): static - { - $this->selectionGroups = $selectionGroups; - - return $this; - } -} \ No newline at end of file diff --git a/src/BusProNet/Model/CrmAttributesResponse.php b/src/BusProNet/Model/CrmAttributesResponse.php new file mode 100644 index 0000000..73a7809 --- /dev/null +++ b/src/BusProNet/Model/CrmAttributesResponse.php @@ -0,0 +1,32 @@ +attributeGroups; + } + + public function setAttributeGroups(?array $attributeGroups): static{ + $this->attributeGroups = $attributeGroups; + + return $this; + } + + public function isTeamer(): bool + { + return $this->teamer; + } + + public function setTeamer(bool $teamer): static + { + $this->teamer = $teamer; + + return $this; + } +} \ No newline at end of file diff --git a/src/BusProNet/Model/ErrorResponse.php b/src/BusProNet/Model/ErrorResponse.php deleted file mode 100644 index 14c5f00..0000000 --- a/src/BusProNet/Model/ErrorResponse.php +++ /dev/null @@ -1,38 +0,0 @@ -code; - } - - public function setCode(?string $code): static - { - $this->code = $code; - - return $this; - } - - public function getType(): ?string - { - return $this->type; - } - - public function setType(?string $type): static - { - $this->type = $type; - - return $this; - } -} \ No newline at end of file diff --git a/src/BusProNet/Model/Profile.php b/src/BusProNet/Model/ProfileResponse.php similarity index 78% rename from src/BusProNet/Model/Profile.php rename to src/BusProNet/Model/ProfileResponse.php index 0cb966e..93ab3f0 100644 --- a/src/BusProNet/Model/Profile.php +++ b/src/BusProNet/Model/ProfileResponse.php @@ -2,41 +2,17 @@ namespace App\BusProNet\Model; -use Symfony\Component\Serializer\Annotation\Context; -use Symfony\Component\Serializer\Annotation\SerializedPath; -use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; - -class Profile +class ProfileResponse extends BaseResponse { - #[SerializedPath('[idadresse]')] private ?int $addressId = null; - - #[SerializedPath('[idperson]')] private ?int $personId = null; - - #[SerializedPath('[adressdaten][name]')] private ?string $name = null; - - #[SerializedPath('[adressdaten][vorname]')] private ?string $firstName = null; - - #[SerializedPath('[adressdaten][anrede]')] private ?string $salutation = null; - - #[SerializedPath('[adressdaten][titel]')] private ?string $title = null; - - #[SerializedPath('[adressdaten][geschlecht]')] private ?string $gender = null; - - #[SerializedPath('[adressdaten][geburtsdatum]')] - #[Context([DateTimeNormalizer::FORMAT_KEY => 'd.m.Y'])] private ?\DateTimeImmutable $dateOfBirth = null; - - #[SerializedPath('[adressdaten][anschrift]')] private ?Address $address = null; - - #[SerializedPath('[adressdaten][kommunikation]')] private ?Communication $communication = null; public function getAddressId(): ?int diff --git a/src/BusProNet/ResponseParser.php b/src/BusProNet/ResponseParser.php new file mode 100644 index 0000000..304a1f8 --- /dev/null +++ b/src/BusProNet/ResponseParser.php @@ -0,0 +1,143 @@ +xpath('satz/@typ')[0]; + + switch ($type) { + case 'HINWEIS': + return $this->createBaseResponse($xml); + case 'KUNDENKONTO': + $subType = (string) $xml->xpath('art')[0]; + switch ($subType) { + case 'Adressdaten': + return $this->createProfileResponse($xml); + case 'SelektionCRM': + return $this->createCrmAttributesResponse($xml); + } + } + + throw new ResponseParserException('Unable to parse XML response'); + } + + public function createBaseResponse(\SimpleXMLElement $xml): BaseResponse + { + $code = (int) $xml->xpath('satz/nr')[0]; + $message = (string) $xml->xpath('satz/text')[0]; + + return new BaseResponse($code, $message); + } + + public function createProfileResponse(\SimpleXMLElement $xml): ProfileResponse + { + $addressId = (int) $xml->xpath('idadresse')[0]; + $personId = (int) $xml->xpath('idperson')[0]; + + $lastName = (string) $xml->xpath('adressdaten/name')[0]; + $firstName = (string) $xml->xpath('adressdaten/vorname')[0]; + $salutation = (string) $xml->xpath('adressdaten/anrede')[0]; + $title = (string) $xml->xpath('adressdaten/titel')[0]; + + $gender = (string) $xml->xpath('adressdaten/geschlecht')[0]; + $gender = $gender === 'W' ? 'f' : strtolower($gender); + + $date = $xml->xpath('adressdaten/geburtsdatum'); + $dateOfBirth = $date ?\DateTimeImmutable::createFromFormat('d.m.Y', (string) $date[0]) : null; + + $response = new ProfileResponse(); + $response + ->setAddressId($addressId) + ->setPersonId($personId) + ->setFirstName($firstName) + ->setName($lastName) + ->setTitle($title) + ->setSalutation($salutation) + ->setGender($gender) + ->setDateOfBirth($dateOfBirth) + ; + + $postalAddressStreet = $xml->xpath('adressdaten/anschrift/strasse'); + $postalAddressPostcode = $xml->xpath('adressdaten/anschrift/plz'); + $postalAddressCity = $xml->xpath('adressdaten/anschrift/ort'); + $postalAddressCountry = $xml->xpath('adressdaten/anschrift/land'); + + $address = new Address(); + $address + ->setStreet($postalAddressStreet ? (string) $postalAddressStreet[0] : null) + ->setPostCode($postalAddressPostcode ? (string) $postalAddressPostcode[0] : null) + ->setCity($postalAddressCity ? (string) $postalAddressCity[0] : null) + ->setCountry($postalAddressCountry ? (string) $postalAddressCountry[0] : null) + ; + + $response->setAddress($address); + + $phone = $xml->xpath('adressdaten/kommunikation/telefonprivat'); + $mobile = $xml->xpath('adressdaten/kommunikation/telefonmobil'); + $email = $xml->xpath('adressdaten/kommunikation/email'); + + $communication = new Communication(); + $communication + ->setPhone($phone ? (string) $phone[0] : null) + ->setMobile($mobile ? (string) $mobile[0] : null) + ->setEmail($email ? (string) $email[0] : null) + ; + + $response->setCommunication($communication); + + return $response; + } + + public function createCrmAttributesResponse(\SimpleXMLElement $xml): CrmAttributesResponse + { + $groups = []; + $isTeam = false; + + foreach ($xml->xpath('selektionsmerkmale/selektionsgruppe') as $item) { + $group = new CrmAttributeGroup(); + $group->setLabel($item->attributes()['bezeichnung']); + $attributes = []; + + foreach ($item->xpath('selektion') as $subItem) { + $attribute = new CrmAttribute(); + $attribute + ->setId((int) $subItem->attributes()['id']) + ->setLabel((string) $subItem->attributes()['bezeichnung']) + ->setSelected('True' === (string) $subItem->attributes()['auswahl']) + ; + $attributes[] = $attribute; + + if (static::ATTR_ID_TEAMER === $attribute->getId() && true === $attribute->isSelected()) { + $isTeam = true; + } + } + $group->setAttributes($attributes); + $groups[] = $group; + } + + $response = new CrmAttributesResponse(); + $response + ->setAttributeGroups($groups) + ->setTeamer($isTeam) + ; + + return $response; + } +} \ No newline at end of file diff --git a/src/BusProNet/ResponseParserException.php b/src/BusProNet/ResponseParserException.php new file mode 100644 index 0000000..5c22453 --- /dev/null +++ b/src/BusProNet/ResponseParserException.php @@ -0,0 +1,7 @@ +getRole()]; } public function eraseCredentials(): void diff --git a/src/Security/BpnAuthenticator.php b/src/Security/BpnAuthenticator.php index a437164..fab56da 100644 --- a/src/Security/BpnAuthenticator.php +++ b/src/Security/BpnAuthenticator.php @@ -4,8 +4,8 @@ namespace App\Security; use App\BusProNet\ApiClient; use App\BusProNet\ApiClientException; -use App\BusProNet\Model\ErrorResponse; -use App\BusProNet\Model\Profile; +use App\BusProNet\Model\CrmAttributesResponse; +use App\BusProNet\Model\ProfileResponse; use App\Entity\User; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -53,7 +53,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent return null; } - if ($response instanceof ErrorResponse) { + if (false === $response instanceof ProfileResponse) { return null; } @@ -81,7 +81,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent return new RedirectResponse($url); } - private function getOrCreateLocalUser(Profile $profile, string $email, string $password): User + private function getOrCreateLocalUser(ProfileResponse $profile, string $email, string $password): ?User { $repository = $this->entityManager->getRepository(User::class); @@ -95,20 +95,25 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent } try { - $response = $this->apiClient->getCrmSelection($email, $password); + /** @var CrmAttributesResponse $crmAttributes */ + $crmAttributes = $this->apiClient->getCrmAttributes($email, $password); } catch (ApiClientException $e) { + return null; } $user = new User(); $user ->setBusProAddressId($profile->getAddressId()) ->setBusProPersonId($profile->getPersonId()) - ->setRole('ROLE_FOO') ->setEmail($profile->getCommunication()->getEmail()) ->setFirstName($profile->getFirstName()) ->setLastName($profile->getName()) ; + if ($crmAttributes->isTeamer()) { + $user->setRole('ROLE_TEAMER'); + } + $this->entityManager->persist($user); $this->entityManager->flush(); diff --git a/tests/BusProNet/ResponseParserTest.php b/tests/BusProNet/ResponseParserTest.php new file mode 100644 index 0000000..08166a0 --- /dev/null +++ b/tests/BusProNet/ResponseParserTest.php @@ -0,0 +1,51 @@ +853ID, EMail oder Passwort falsch'; + + $parser = new ResponseParser(); + $response = $parser->parseXmlString($content); + + $this->assertEquals(853, $response->getCode()); + $this->assertEquals('ID, EMail oder Passwort falsch', $response->getMessage()); + $this->assertFalse($response->isSuccessful()); + } + + public function testParseSuccessfulProfileResponse(): void + { + $content = 'Adressdaten141747224526FrommeBjörnHerrM16.04.1972D119447Emilienstraße 5742853RemscheidDmail@fromme.orgFalse02191-4615837'; + + $parser = new ResponseParser(); + $response = $parser->parseXmlString($content); + + $this->assertInstanceOf(ProfileResponse::class, $response); + $this->assertNull($response->getCode()); + $this->assertNull($response->getMessage()); + $this->assertTrue($response->isSuccessful()); + } + + public function testParseSuccessfulCrmAttributesResponse(): void + { + $content = 'SelektionCRM141747224526'; + + $parser = new ResponseParser(); + $response = $parser->parseXmlString($content); + + $this->assertInstanceOf(CrmAttributesResponse::class, $response); + $this->assertNull($response->getCode()); + $this->assertNull($response->getMessage()); + $this->assertTrue($response->isSuccessful()); + $this->assertCount(3, $response->getAttributeGroups()); + $this->assertTrue($response->isTeamer()); + } +} \ No newline at end of file