From 38d575cbe9321f7fbffe8e77103415f882f0ad2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 23 Sep 2026 09:25:22 +0200 Subject: [PATCH] fix: point the application form finisher at epteam --- .../ep_products/Classes/EpTeam/ApiClient.php | 244 ++++++++++++++++++ .../Classes/EpTeam/ApiException.php | 7 + .../ep_products/Classes/MyEP/ApiClient.php | 15 -- .../ext/ep_products/ext_conf_template.txt | 12 + .../Classes/Form/ApplicationApiFinisher.php | 10 +- 5 files changed, 268 insertions(+), 20 deletions(-) create mode 100644 public/typo3conf/ext/ep_products/Classes/EpTeam/ApiClient.php create mode 100644 public/typo3conf/ext/ep_products/Classes/EpTeam/ApiException.php diff --git a/public/typo3conf/ext/ep_products/Classes/EpTeam/ApiClient.php b/public/typo3conf/ext/ep_products/Classes/EpTeam/ApiClient.php new file mode 100644 index 00000000..b3485928 --- /dev/null +++ b/public/typo3conf/ext/ep_products/Classes/EpTeam/ApiClient.php @@ -0,0 +1,244 @@ +request('POST', $uri, [ + 'json' => $payload, + ]); + + $statusCode = $this->readStatusCode($response, 'POST', $uri); + + if (false === in_array($statusCode, self::ACCEPTED_STATUS_CODES, true)) { + $message = sprintf( + 'EP Team API returned unexpected status %d for POST %s: %s', + $statusCode, + $uri, + $this->readErrorBody($response) + ); + $this->logWarning($message); + + throw new ApiException($message, $statusCode); + } + + $this->logAcceptedSubmission($response, $payload); + } + + /** + * @throws ApiException + */ + private function request(string $method, string $uri, array $options): ResponseInterface + { + try { + return $this->getHttpClient()->request($method, $uri, $options); + } catch (TransportExceptionInterface $e) { + $message = sprintf('EP Team API transport error for %s %s: %s', $method, $uri, $e->getMessage()); + $this->logError($message, ['exception' => $e]); + + throw new ApiException($message, 0, $e); + } + } + + /** + * Reading the status completes the transfer, so this is also what turns the lazy response + * returned by the client into a finished request. + * + * @throws ApiException + */ + private function readStatusCode(ResponseInterface $response, string $method, string $uri): int + { + try { + return $response->getStatusCode(); + } catch (TransportExceptionInterface $e) { + $message = sprintf('EP Team API transport error while reading status for %s %s: %s', $method, $uri, $e->getMessage()); + $this->logError($message, ['exception' => $e]); + + throw new ApiException($message, 0, $e); + } + } + + private function readErrorBody(ResponseInterface $response): string + { + try { + $body = trim($response->getContent(false)); + } catch (TransportExceptionInterface $e) { + return ''; + } + + if ('' === $body) { + return ''; + } + + if (mb_strlen($body) > self::ERROR_BODY_EXCERPT_LENGTH) { + $body = mb_substr($body, 0, self::ERROR_BODY_EXCERPT_LENGTH) . '…'; + } + + return $body; + } + + /** + * The submission uuid the endpoint returns is what makes a support question ("did our + * application arrive?") answerable from the TYPO3 log alone. Reading the body also drains + * the response, which returns the connection to the pool instead of cancelling the transfer. + * Deliberately without any of the submitted values: these payloads are personal data. + */ + private function logAcceptedSubmission(ResponseInterface $response, array $payload): void + { + try { + $data = $response->toArray(false); + } catch (\Throwable $e) { + // A body that is absent (204) or not JSON costs nothing here - the submission is + // stored either way, which the asserted status has already established. + return; + } + + if (null !== $this->logger) { + $this->logger->info('Application form submission accepted by the EP Team API.', [ + 'form' => $payload['form'] ?? null, + 'pageUid' => $payload['pageUid'] ?? null, + 'uuid' => $data['uuid'] ?? null, + ]); + } + } + + /** + * @throws ApiException + */ + private function getHttpClient(): HttpClientInterface + { + if (null !== static::$httpClient) { + return static::$httpClient; + } + + $config = $this->getConfiguration(); + $baseUrl = trim((string)($config['epTeamApiBaseUrl'] ?? '')); + $apiKey = trim((string)($config['epTeamApiKey'] ?? '')); + + if ('' === $baseUrl) { + throw new ApiException('EP Team API base URL is not configured.'); + } + if ('' === $apiKey) { + throw new ApiException('EP Team API key is not configured.'); + } + if (0 !== strpos($baseUrl, 'https://')) { + // The application answers an http request with a 301 to the https URL, and this + // client does not follow redirects, so a plain http base URL fails every submission. + $this->logWarning(sprintf('EP Team API base URL is not https (%s); submissions will fail on the redirect.', $baseUrl)); + } + + $options = [ + 'headers' => [ + 'X-Api-Key' => $apiKey, + ], + ]; + + // Without these the client falls back to PHP's default_socket_timeout (commonly 60s), + // so a stalled API pins a PHP worker for a minute per request - and this call happens + // inside the visitor's form submission, with the visitor waiting for it. The defaults + // live in code rather than in ext_conf_template.txt: ExtensionConfiguration::get() + // returns the stored configuration verbatim and only syncs the template when an + // extension has no configuration at all, so a newly added template key is absent until + // someone saves extension configuration by hand. Falling back to 0 there would silently + // mean "no timeout" - the opposite of what this guard is for. An explicit 0 still + // disables. + $timeout = (int)($config['epTeamApiTimeout'] ?? self::DEFAULT_TIMEOUT); + if ($timeout > 0) { + $options['timeout'] = $timeout; + } + $maxDuration = (int)($config['epTeamApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION); + if ($maxDuration > 0) { + $options['max_duration'] = $maxDuration; + } + + return static::$httpClient = HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', $options); + } + + private function getConfiguration(): array + { + if (null === $this->configuration) { + $this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class) + ->get('ep_products'); + } + + return $this->configuration; + } + + private function logError(string $message, array $context = []): void + { + if (null !== $this->logger) { + $this->logger->error($message, $context); + } + } + + private function logWarning(string $message, array $context = []): void + { + if (null !== $this->logger) { + $this->logger->warning($message, $context); + } + } +} diff --git a/public/typo3conf/ext/ep_products/Classes/EpTeam/ApiException.php b/public/typo3conf/ext/ep_products/Classes/EpTeam/ApiException.php new file mode 100644 index 00000000..e0d7d2c1 --- /dev/null +++ b/public/typo3conf/ext/ep_products/Classes/EpTeam/ApiException.php @@ -0,0 +1,7 @@ +drainResponse($response); } - /** - * @throws ApiException - */ - public function submitApplication(array $applicationData): void - { - $response = $this->request('POST', 'applications', [ - 'json' => $applicationData, - ]); - - // A conflict means the submission is already known, which is the normal answer to a - // double-clicked submit button and not worth reporting. - $this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'applications'); - $this->drainResponse($response); - } - /** * @throws ApiException */ diff --git a/public/typo3conf/ext/ep_products/ext_conf_template.txt b/public/typo3conf/ext/ep_products/ext_conf_template.txt index 7ed08e6a..f1093b96 100644 --- a/public/typo3conf/ext/ep_products/ext_conf_template.txt +++ b/public/typo3conf/ext/ep_products/ext_conf_template.txt @@ -40,6 +40,18 @@ bpnConnectApiTimeout = 5 # cat=MyEpAPI; type=int; label=Bpn Connect API max total request duration in seconds (0 = unlimited) bpnConnectApiMaxDuration = 15 +# cat=EpTeamAPI; type=string; label=MyE&P Team base URL (https, e.g. https://team.ep-reisen.de) - receives application form submissions +epTeamApiBaseUrl = + +# cat=EpTeamAPI; type=string; label=MyE&P Team application form API key (sent as X-Api-Key) +epTeamApiKey = + +# cat=EpTeamAPI; type=int; label=MyE&P Team API inactivity timeout in seconds (0 = PHP default_socket_timeout) +epTeamApiTimeout = 5 + +# cat=EpTeamAPI; type=int; label=MyE&P Team API max total request duration in seconds (0 = unlimited) +epTeamApiMaxDuration = 15 + # cat=SftpImport; type=options[Remote SFTP server=sftp,Local directory (fileadmin/xmlexport)=local]; label=Product/date import source productImportSource = sftp diff --git a/public/typo3conf/ext/ep_theme/Classes/Form/ApplicationApiFinisher.php b/public/typo3conf/ext/ep_theme/Classes/Form/ApplicationApiFinisher.php index 0f9817ca..1a30cdaf 100644 --- a/public/typo3conf/ext/ep_theme/Classes/Form/ApplicationApiFinisher.php +++ b/public/typo3conf/ext/ep_theme/Classes/Form/ApplicationApiFinisher.php @@ -2,7 +2,7 @@ namespace EP\EpTheme\Form; -use EP\EpProducts\MyEP\ApiClient; +use EP\EpProducts\EpTeam\ApiClient; use TYPO3\CMS\Core\Site\SiteFinder; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -26,7 +26,7 @@ class ApplicationApiFinisher extends AbstractMappedFinisher try { $payload = $this->buildPayload(); if ([] === $payload) { - $this->logWarning('Application API finisher skipped: the mapping produced no values.'); + $this->logWarning('Application form API finisher skipped: the mapping produced no values.'); return; } @@ -43,9 +43,9 @@ class ApplicationApiFinisher extends AbstractMappedFinisher $payload = array_merge($payload, $this->resolvePageContext()); $payload['details'] = $this->buildLabeledValues(); - $this->apiClient->submitApplication($payload); + $this->apiClient->submitApplicationForm($payload); } catch (\Throwable $e) { - $this->logWarning('Application API finisher request failed.', [ + $this->logWarning('Application form API finisher request failed.', [ 'exception' => $e, ]); } @@ -72,7 +72,7 @@ class ApplicationApiFinisher extends AbstractMappedFinisher ]); } catch (\Throwable $e) { // An unroutable page must not cost the submission, so the uid is sent without a URL. - $this->logWarning('Application API finisher could not resolve the page URL.', [ + $this->logWarning('Application form API finisher could not resolve the page URL.', [ 'pageUid' => $pageUid, 'exception' => $e, ]);