adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-29 01:14:29 +02:00
parent f1b41e07cb
commit cea88963da
2 changed files with 217 additions and 57 deletions

View File

@@ -13,9 +13,9 @@
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false }, { "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "DB User", "type": "text", "required": false }, { "name": "db.user", "label": "DB User", "type": "text", "required": false },
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false }, { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
{ "name": "provider", "label": "FX Provider", "type": "text", "required": false, "help": "Aktuell getestet mit currencyapi." }, { "name": "provider", "label": "FX Provider", "type": "text", "required": false, "help": "Unterstuetzt legacy currencyapi.net und currencyapi.com v3." },
{ "name": "api_url", "label": "FX API URL", "type": "text", "required": false }, { "name": "api_url", "label": "FX API URL", "type": "text", "required": false, "help": "Nur die Basis-URL eintragen, z.B. https://api.currencyapi.com oder https://currencyapi.net." },
{ "name": "currencies_url", "label": "FX Currencies URL", "type": "text", "required": false }, { "name": "currencies_url", "label": "FX Currencies URL", "type": "text", "required": false, "help": "Nur die Basis-URL fuer den Waehrungskatalog, z.B. https://api.currencyapi.com." },
{ "name": "api_key", "label": "FX API Key", "type": "password", "required": false }, { "name": "api_key", "label": "FX API Key", "type": "password", "required": false },
{ "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false }, { "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false },
{ "name": "cache_ttl_sec", "label": "Datei-Cache TTL (Sek.)", "type": "number", "required": false }, { "name": "cache_ttl_sec", "label": "Datei-Cache TTL (Sek.)", "type": "number", "required": false },

View File

@@ -342,35 +342,15 @@ final class FxRatesService
private function fetchLatestPayload(string $baseCurrency, ?array $currencies = null): array private function fetchLatestPayload(string $baseCurrency, ?array $currencies = null): array
{ {
if (!function_exists('curl_init')) { $request = $this->buildLatestRequest($baseCurrency, $currencies);
throw new \RuntimeException('curl_init ist nicht verfuegbar.'); if ($request === null) {
}
$url = $this->buildLatestUrl($baseCurrency);
if ($url === null) {
throw new \RuntimeException('FX-URL oder API-Key fehlt.'); throw new \RuntimeException('FX-URL oder API-Key fehlt.');
} }
$ch = curl_init(); $payload = $this->requestJson($request['url'], $request['headers'] ?? [], 'FX-Kurse konnten nicht geladen werden.');
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds(),
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false || $curlError !== '' || $httpStatus >= 400) { if ($this->isCurrencyApiCom()) {
throw new \RuntimeException('FX-Kurse konnten nicht geladen werden.'); return $this->normalizeCurrencyApiComLatestPayload($payload, $baseCurrency, $currencies);
}
$payload = json_decode((string) $response, true);
if (!is_array($payload)) {
throw new \RuntimeException('FX-Antwort ist kein gueltiges JSON.');
} }
$rates = []; $rates = [];
@@ -416,20 +396,107 @@ final class FxRatesService
private function fetchCurrenciesPayload(): array private function fetchCurrenciesPayload(): array
{ {
if (!function_exists('curl_init')) { $request = $this->buildCurrenciesRequest();
throw new \RuntimeException('curl_init ist nicht verfuegbar.'); if ($request === null) {
}
$apiKey = $this->apiKey();
if ($apiKey === '') {
throw new \RuntimeException('FX-API-Key fehlt.'); throw new \RuntimeException('FX-API-Key fehlt.');
} }
$url = sprintf( $payload = $this->requestJson($request['url'], $request['headers'] ?? [], 'Waehrungskatalog konnte nicht geladen werden.');
if ($this->isCurrencyApiCom()) {
return $this->normalizeCurrencyApiComCurrenciesPayload($payload);
}
if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) {
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
}
return $payload;
}
private function buildLatestRequest(string $baseCurrency, ?array $currencies = null): ?array
{
$apiKey = $this->apiKey();
if ($this->isCurrencyApiCom()) {
if ($apiKey === '') {
return null;
}
$query = ['base_currency=' . rawurlencode($baseCurrency)];
$normalizedCurrencies = [];
foreach ($currencies ?? [] as $currency) {
$currency = $this->normalizeCurrency((string) $currency);
if ($currency !== '' && $currency !== $baseCurrency) {
$normalizedCurrencies[] = $currency;
}
}
if ($normalizedCurrencies !== []) {
$query[] = 'currencies=' . rawurlencode(implode(',', array_values(array_unique($normalizedCurrencies))));
}
return [
'url' => $this->apiUrl() . '/v3/latest?' . implode('&', $query),
'headers' => [
'Accept: application/json',
'apikey: ' . $apiKey,
],
];
}
if ($this->provider() === 'currencyapi') {
if ($apiKey === '') {
return null;
}
return [
'url' => sprintf(
'%s/api/v2/rates?base=%s&output=json&key=%s',
$this->apiUrl(),
rawurlencode($baseCurrency),
rawurlencode($apiKey)
),
'headers' => ['Accept: application/json'],
];
}
return [
'url' => sprintf('%s/latest?base=%s', $this->apiUrl(), rawurlencode($baseCurrency)),
'headers' => ['Accept: application/json'],
];
}
private function buildCurrenciesRequest(): ?array
{
$apiKey = $this->apiKey();
if ($apiKey === '') {
return null;
}
if ($this->isCurrencyApiCom()) {
return [
'url' => $this->currenciesApiUrl() . '/v3/currencies',
'headers' => [
'Accept: application/json',
'apikey: ' . $apiKey,
],
];
}
return [
'url' => sprintf(
'%s/api/v2/currencies?output=json&key=%s', '%s/api/v2/currencies?output=json&key=%s',
$this->currenciesApiUrl(), $this->currenciesApiUrl(),
rawurlencode($apiKey) rawurlencode($apiKey)
); ),
'headers' => ['Accept: application/json'],
];
}
private function requestJson(string $url, array $headers, string $fallbackError): array
{
if (!function_exists('curl_init')) {
throw new \RuntimeException('curl_init ist nicht verfuegbar.');
}
$ch = curl_init(); $ch = curl_init();
curl_setopt_array($ch, [ curl_setopt_array($ch, [
@@ -437,7 +504,7 @@ final class FxRatesService
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds(), CURLOPT_TIMEOUT => $this->timeoutSeconds(),
CURLOPT_HTTPHEADER => ['Accept: application/json'], CURLOPT_HTTPHEADER => $headers !== [] ? $headers : ['Accept: application/json'],
]); ]);
$response = curl_exec($ch); $response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
@@ -445,38 +512,119 @@ final class FxRatesService
curl_close($ch); curl_close($ch);
if ($response === false || $curlError !== '' || $httpStatus >= 400) { if ($response === false || $curlError !== '' || $httpStatus >= 400) {
throw new \RuntimeException('Waehrungskatalog konnte nicht geladen werden.'); $payload = is_string($response) ? json_decode($response, true) : null;
throw new \RuntimeException($this->extractProviderError(is_array($payload) ? $payload : [], $fallbackError));
} }
$payload = json_decode((string) $response, true); $payload = json_decode((string) $response, true);
if (!is_array($payload) || ($payload['valid'] ?? false) !== true) { if (!is_array($payload)) {
throw new \RuntimeException($this->extractProviderError(is_array($payload) ? $payload : [], 'Waehrungskatalog konnte nicht geladen werden.')); throw new \RuntimeException('FX-Antwort ist kein gueltiges JSON.');
} }
return $payload; return $payload;
} }
private function buildLatestUrl(string $baseCurrency): ?string private function normalizeCurrencyApiComLatestPayload(array $payload, string $baseCurrency, ?array $currencies = null): array
{ {
$apiKey = $this->apiKey(); $rawRates = is_array($payload['data'] ?? null) ? $payload['data'] : null;
if ($this->provider() === 'currencyapi') { if ($rawRates === null) {
if ($apiKey === '') { throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
return null;
} }
return sprintf( $filter = [];
'%s/api/v2/rates?base=%s&output=json&key=%s', foreach ($currencies ?? [] as $currency) {
$this->apiUrl(), $currency = $this->normalizeCurrency((string) $currency);
rawurlencode($baseCurrency), if ($currency !== '' && $currency !== $baseCurrency) {
rawurlencode($apiKey) $filter[$currency] = true;
); }
} }
return sprintf('%s/latest?base=%s', $this->apiUrl(), rawurlencode($baseCurrency)); $rates = [];
foreach ($rawRates as $code => $rateData) {
$code = $this->normalizeCurrency((string) $code);
if ($code === '' || $code === $baseCurrency) {
continue;
}
if ($filter !== [] && !isset($filter[$code])) {
continue;
}
$value = is_array($rateData) ? ($rateData['value'] ?? null) : null;
if (!is_numeric($value)) {
continue;
}
$rates[$code] = (float) $value;
}
return [
'base' => $baseCurrency,
'date' => $payload['meta']['last_updated_at'] ?? null,
'rates' => $rates,
];
}
private function normalizeCurrencyApiComCurrenciesPayload(array $payload): array
{
$rawCurrencies = is_array($payload['data'] ?? null) ? $payload['data'] : null;
if ($rawCurrencies === null) {
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
}
$currencies = [];
foreach ($rawCurrencies as $code => $currencyData) {
$normalizedCode = $this->normalizeCurrency((string) $code);
if ($normalizedCode === '') {
continue;
}
$name = '';
if (is_array($currencyData)) {
$name = trim((string) ($currencyData['name'] ?? $currencyData['name_plural'] ?? $currencyData['code'] ?? ''));
}
if ($name === '') {
continue;
}
$currencies[$normalizedCode] = $name;
}
return [
'valid' => true,
'currencies' => $currencies,
];
} }
private function extractProviderError(array $payload, string $fallback): string private function extractProviderError(array $payload, string $fallback): string
{ {
$error = $payload['error'] ?? null;
if (is_array($error)) {
foreach (['message', 'info', 'code'] as $field) {
$value = $error[$field] ?? null;
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
}
$errors = $payload['errors'] ?? null;
if (is_array($errors)) {
foreach ($errors as $entry) {
if (is_string($entry) && trim($entry) !== '') {
return trim($entry);
}
if (is_array($entry)) {
foreach (['message', 'detail', 'title'] as $field) {
$value = $entry[$field] ?? null;
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
}
}
}
foreach (['error', 'message', 'msg'] as $field) { foreach (['error', 'message', 'msg'] as $field) {
$value = $payload[$field] ?? null; $value = $payload[$field] ?? null;
if (is_string($value) && trim($value) !== '') { if (is_string($value) && trim($value) !== '') {
@@ -539,6 +687,18 @@ final class FxRatesService
return rtrim((string) ($this->settings['currencies_url'] ?? $this->apiUrl()), '/'); return rtrim((string) ($this->settings['currencies_url'] ?? $this->apiUrl()), '/');
} }
private function isCurrencyApiCom(): bool
{
foreach ([$this->apiUrl(), $this->currenciesApiUrl()] as $url) {
$host = strtolower((string) parse_url($url, PHP_URL_HOST));
if ($host !== '' && str_contains($host, 'currencyapi.com')) {
return true;
}
}
return false;
}
private function apiKey(): string private function apiKey(): string
{ {
return trim((string) ($this->settings['api_key'] ?? '')); return trim((string) ($this->settings['api_key'] ?? ''));