fix: point the application form finisher at epteam
This commit is contained in:
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace EP\EpProducts\EpTeam;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerAwareInterface;
|
||||||
|
use Psr\Log\LoggerAwareTrait;
|
||||||
|
use Symfony\Component\HttpClient\HttpClient;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||||
|
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MyE&P Team application (Symfony). Separate from EP\EpProducts\MyEP\ApiClient on purpose:
|
||||||
|
* this is a different application on its own host, authenticated with a shared key in a header
|
||||||
|
* instead of the OAuth client-credentials flow MyE&P uses, so it shares neither the base URI nor
|
||||||
|
* the token handling.
|
||||||
|
*/
|
||||||
|
class ApiClient implements LoggerAwareInterface
|
||||||
|
{
|
||||||
|
use LoggerAwareTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applied when extension configuration does not override them. Seconds.
|
||||||
|
*/
|
||||||
|
private const DEFAULT_TIMEOUT = 5;
|
||||||
|
private const DEFAULT_MAX_DURATION = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status codes the endpoint answers a stored submission with. A conflict is included for the
|
||||||
|
* dedupe the team application may add later - it is not returned today - because a
|
||||||
|
* double-clicked submit button is not something to report.
|
||||||
|
*/
|
||||||
|
private const ACCEPTED_STATUS_CODES = [
|
||||||
|
Response::HTTP_OK,
|
||||||
|
Response::HTTP_CREATED,
|
||||||
|
Response::HTTP_ACCEPTED,
|
||||||
|
Response::HTTP_NO_CONTENT,
|
||||||
|
Response::HTTP_CONFLICT,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of a rejected response is carried into the exception message. A 400 names the
|
||||||
|
* offending field in its body, which is the only way to tell from a log why a submission
|
||||||
|
* was refused.
|
||||||
|
*/
|
||||||
|
private const ERROR_BODY_EXCERPT_LENGTH = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared, like the MyE&P client: Symfony's CurlHttpClient keeps its connection pool and TLS
|
||||||
|
* session cache per instance. The API key is a constant, so unlike a bearer token it can be
|
||||||
|
* a client default header and the instance can live as long as the request.
|
||||||
|
*/
|
||||||
|
private static ?HttpClientInterface $httpClient = null;
|
||||||
|
|
||||||
|
private ?array $configuration = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a submitted TYPO3 form to POST /api/application-forms.
|
||||||
|
*
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
public function submitApplicationForm(array $payload): void
|
||||||
|
{
|
||||||
|
$uri = 'api/application-forms';
|
||||||
|
|
||||||
|
// 'json' also sets the Content-Type the endpoint requires - without it the body is not
|
||||||
|
// read as JSON and the answer is a 400.
|
||||||
|
$response = $this->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 '<no response body>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('' === $body) {
|
||||||
|
return '<empty response body>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace EP\EpProducts\EpTeam;
|
||||||
|
|
||||||
|
class ApiException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -120,21 +120,6 @@ class ApiClient implements LoggerAwareInterface
|
|||||||
$this->drainResponse($response);
|
$this->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
|
* @throws ApiException
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ bpnConnectApiTimeout = 5
|
|||||||
# cat=MyEpAPI; type=int; label=Bpn Connect API max total request duration in seconds (0 = unlimited)
|
# cat=MyEpAPI; type=int; label=Bpn Connect API max total request duration in seconds (0 = unlimited)
|
||||||
bpnConnectApiMaxDuration = 15
|
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
|
# cat=SftpImport; type=options[Remote SFTP server=sftp,Local directory (fileadmin/xmlexport)=local]; label=Product/date import source
|
||||||
productImportSource = sftp
|
productImportSource = sftp
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace EP\EpTheme\Form;
|
namespace EP\EpTheme\Form;
|
||||||
|
|
||||||
use EP\EpProducts\MyEP\ApiClient;
|
use EP\EpProducts\EpTeam\ApiClient;
|
||||||
use TYPO3\CMS\Core\Site\SiteFinder;
|
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ class ApplicationApiFinisher extends AbstractMappedFinisher
|
|||||||
try {
|
try {
|
||||||
$payload = $this->buildPayload();
|
$payload = $this->buildPayload();
|
||||||
if ([] === $payload) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -43,9 +43,9 @@ class ApplicationApiFinisher extends AbstractMappedFinisher
|
|||||||
$payload = array_merge($payload, $this->resolvePageContext());
|
$payload = array_merge($payload, $this->resolvePageContext());
|
||||||
$payload['details'] = $this->buildLabeledValues();
|
$payload['details'] = $this->buildLabeledValues();
|
||||||
|
|
||||||
$this->apiClient->submitApplication($payload);
|
$this->apiClient->submitApplicationForm($payload);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logWarning('Application API finisher request failed.', [
|
$this->logWarning('Application form API finisher request failed.', [
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ class ApplicationApiFinisher extends AbstractMappedFinisher
|
|||||||
]);
|
]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// An unroutable page must not cost the submission, so the uid is sent without a URL.
|
// 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,
|
'pageUid' => $pageUid,
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user