respond(['ok' => true, 'module' => 'fx-rates']); } if ($path === 'v1/endpoints' && $method === 'GET') { $this->respond(['data' => $this->endpointCatalog()]); } if ($path === 'v1/status' && $method === 'GET') { $this->respond(['data' => $this->service->latestStatuses()]); } if ($path === 'v1/recent-fetches' && $method === 'GET') { $limit = max(1, min(50, (int) ($_GET['limit'] ?? 12))); $this->respond(['data' => $this->service->recentFetches($limit)]); } if ($path === 'v1/latest' && $method === 'GET') { $symbols = $this->parseCsv($_GET['symbols'] ?? null); $base = $this->stringOrNull($_GET['base'] ?? null); if ($symbols === null) { $settings = module_fn('fx-rates', 'settings'); $symbols = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : null; } $snapshot = $this->service->snapshot($base, null, $symbols, null); $this->respond(['data' => $snapshot]); } if ($path === 'v1/fetch' && $method === 'GET') { $fetchId = max(0, (int) ($_GET['fetch_id'] ?? 0)); $base = $this->stringOrNull($_GET['base'] ?? null); $symbols = $this->parseCsv($_GET['symbols'] ?? null); $snapshot = $this->service->snapshotByFetchId($fetchId, $base, $symbols); $this->respond(['data' => $snapshot]); } if ($path === 'v1/nearest' && $method === 'GET') { $base = $this->stringOrNull($_GET['base'] ?? null); $symbols = $this->parseCsv($_GET['symbols'] ?? null); $at = $this->stringOrNull($_GET['at'] ?? null); $windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null); $snapshot = $this->service->nearestSnapshot($base, (string) $at, $symbols, $windowMinutes); $this->respond(['data' => $snapshot]); } if ($path === 'v1/snapshot' && $method === 'GET') { $symbols = $this->parseCsv($_GET['symbols'] ?? null); $base = $this->stringOrNull($_GET['base'] ?? null); $at = $this->stringOrNull($_GET['at'] ?? null); $windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null); $snapshot = $this->service->snapshot($base, $at, $symbols, $windowMinutes); $this->respond(['data' => $snapshot]); } if ($path === 'v1/rate' && $method === 'GET') { $from = $this->stringOrNull($_GET['from'] ?? null); $to = $this->stringOrNull($_GET['to'] ?? null); $at = $this->stringOrNull($_GET['at'] ?? null); $windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null); $rate = $this->service->findRate($from, $to, $at, $windowMinutes); $this->respond(['data' => $rate]); } if ($path === 'v1/history' && $method === 'GET') { $from = $this->stringOrNull($_GET['from'] ?? null); $to = $this->stringOrNull($_GET['to'] ?? null); $fromAt = $this->stringOrNull($_GET['from_at'] ?? null); $toAt = $this->stringOrNull($_GET['to_at'] ?? null); $limit = max(1, min(1000, (int) ($_GET['limit'] ?? 200))); $history = $this->service->history((string) $from, (string) $to, $fromAt, $toAt, $limit); $this->respond(['data' => $history]); } if ($path === 'v1/refresh' && $method === 'POST') { $input = $this->input(); $base = $this->stringOrNull($input['base'] ?? null); $force = !empty($input['force']); $maxAgeMinutes = is_numeric($input['max_age_minutes'] ?? null) ? (int) $input['max_age_minutes'] : null; $result = $force ? $this->service->refreshLatestRates(null, $base, 'api') : $this->service->autoRefreshLatestRates($base, null, $maxAgeMinutes, 'api'); $this->respond(['data' => $result], 201); } if ($path === 'v1/probe' && $method === 'GET') { $base = $this->stringOrNull($_GET['base'] ?? null); $this->respond(['data' => $this->service->probeLatestRates($base)]); } if ($path === 'v1/settings' && $method === 'GET') { $this->respond(['data' => module_fn('fx-rates', 'settings')]); } if ($path === 'v1/settings' && $method === 'PUT') { $this->respond(['data' => module_fn('fx-rates', 'save_runtime_settings', $this->input())]); } $this->respond(['error' => 'Unbekannter API-Pfad.'], 404); } catch (\Throwable $exception) { $this->respond([ 'error' => 'FX-API Fehler.', 'context' => ['message' => $exception->getMessage()], ], 500); } } private function respond(array $payload, int $statusCode = 200): never { http_response_code($statusCode); header('Content-Type: application/json; charset=utf-8'); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } private function input(): array { $raw = file_get_contents('php://input'); $decoded = json_decode((string) $raw, true); return is_array($decoded) ? $decoded : []; } private function parseCsv(mixed $value): ?array { if (is_array($value)) { $items = $value; } else { $value = trim((string) $value); if ($value === '') { return null; } $items = explode(',', $value); } $result = []; foreach ($items as $item) { $item = strtoupper(trim((string) $item)); if ($item !== '') { $result[] = $item; } } $result = array_values(array_unique($result)); return $result !== [] ? $result : null; } private function stringOrNull(mixed $value): ?string { $value = trim((string) $value); return $value !== '' ? $value : null; } private function intOrNull(mixed $value): ?int { return is_numeric($value) ? (int) $value : null; } private function endpointCatalog(): array { return [ 'module' => 'fx-rates', 'version' => 'v1', 'languages' => ['de', 'en'], 'endpoints' => [ [ 'path' => '/api/fx-rates/v1/endpoints', 'method' => 'GET', 'description_de' => 'Gibt alle verfuegbaren FX-API-Endpunkte mit deutscher und englischer Erklaerung zurueck.', 'description_en' => 'Returns all available FX API endpoints with German and English explanations.', ], [ 'path' => '/api/fx-rates/v1/latest', 'method' => 'GET', 'params' => ['base', 'symbols'], 'description_de' => 'Liefert den neuesten gespeicherten Snapshot, optional auf eine Zielbasis umgerechnet und auf ausgewaehlte Waehrungen gefiltert.', 'description_en' => 'Returns the latest stored snapshot, optionally rebased to a target currency and filtered to selected symbols.', ], [ 'path' => '/api/fx-rates/v1/fetch', 'method' => 'GET', 'params' => ['fetch_id', 'base', 'symbols'], 'description_de' => 'Liefert einen gespeicherten Snapshot anhand der fetch_id, optional umgerechnet auf eine Zielbasis und gefiltert auf einzelne Waehrungen.', 'description_en' => 'Returns a stored snapshot by fetch_id, optionally rebased to a target currency and filtered to selected symbols.', ], [ 'path' => '/api/fx-rates/v1/nearest', 'method' => 'GET', 'params' => ['at', 'base', 'symbols', 'window_minutes'], 'description_de' => 'Liefert den zeitlich naechsten gespeicherten Snapshot zu einem Datum/Uhrzeit-Wert.', 'description_en' => 'Returns the stored snapshot nearest to a given date/time value.', ], [ 'path' => '/api/fx-rates/v1/snapshot', 'method' => 'GET', 'params' => ['at', 'base', 'symbols', 'window_minutes'], 'description_de' => 'Liefert einen Snapshot zur Zielbasis und sucht fuer einen Zeitpunkt den naechsten passenden gespeicherten Kurs.', 'description_en' => 'Returns a snapshot for the requested base and finds the nearest matching stored rate for a given timestamp.', ], [ 'path' => '/api/fx-rates/v1/rate', 'method' => 'GET', 'params' => ['from', 'to', 'at', 'window_minutes'], 'description_de' => 'Liefert einen Einzelkurs zwischen zwei Waehrungen, direkt oder als Kreuzkurs aus gespeicherten Snapshots.', 'description_en' => 'Returns a single rate between two currencies, directly or as a cross-rate from stored snapshots.', ], [ 'path' => '/api/fx-rates/v1/history', 'method' => 'GET', 'params' => ['from', 'to', 'from_at', 'to_at', 'limit'], 'description_de' => 'Liefert den gespeicherten Kursverlauf zwischen zwei Waehrungen fuer einen Zeitraum.', 'description_en' => 'Returns the stored rate history between two currencies for a given time range.', ], [ 'path' => '/api/fx-rates/v1/refresh', 'method' => 'POST', 'body' => ['base', 'force', 'max_age_minutes'], 'description_de' => 'Aktualisiert Kurse nur dann neu, wenn der letzte Abruf aelter als die erlaubte Zeitspanne ist. Die Antwort enthaelt immer die fetch_id des verwendeten Snapshots.', 'description_en' => 'Refreshes rates only if the last fetch is older than the allowed age. The response always includes the fetch_id of the snapshot used.', ], [ 'path' => '/api/fx-rates/v1/status', 'method' => 'GET', 'description_de' => 'Liefert den neuesten gespeicherten Abruf je Basiswaehrung.', 'description_en' => 'Returns the most recent stored fetch per base currency.', ], [ 'path' => '/api/fx-rates/v1/recent-fetches', 'method' => 'GET', 'params' => ['limit'], 'description_de' => 'Liefert die zuletzt gespeicherten Abrufe mit fetch_id und Zeitstempel.', 'description_en' => 'Returns the most recently stored fetches including fetch_id and timestamp.', ], [ 'path' => '/api/fx-rates/v1/probe', 'method' => 'GET', 'params' => ['base'], 'description_de' => 'Prueft, ob der konfigurierte Provider aktuelle Kurse liefern kann.', 'description_en' => 'Checks whether the configured provider can return current rates.', ], [ 'path' => '/api/fx-rates/v1/settings', 'method' => 'GET', 'description_de' => 'Liefert die aktuellen Modul-Settings.', 'description_en' => 'Returns the current module settings.', ], ], ]; } }