271 lines
12 KiB
PHP
271 lines
12 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Modules\FxRates\Api;
|
|
|
|
use Modules\FxRates\Domain\FxRatesService;
|
|
|
|
final class Router
|
|
{
|
|
public function __construct(
|
|
private FxRatesService $service
|
|
) {
|
|
}
|
|
|
|
public function handle(string $relativePath): never
|
|
{
|
|
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
|
$path = trim($relativePath, '/');
|
|
|
|
try {
|
|
if ($path === 'v1/health' && $method === 'GET') {
|
|
$this->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.',
|
|
],
|
|
],
|
|
];
|
|
}
|
|
}
|