monitor/api/src/Application.php
fds2610-bot 076dca2aef Tickets eines entfernten Projekts aus dem Kanban entfernen (Issue #43)
Bislang hat RepoConfigController::delete() nur die Projektkonfiguration
geloescht - die Tickets des Projekts blieben (nur ohne Projektfarbe,
Issue #34) dauerhaft im Kanban sichtbar. Jetzt loescht der Endpunkt
zusaetzlich alle Tickets, deren repo-Feld auf das entfernte Projekt
zeigt.
2026-08-09 20:14:14 +02:00

176 lines
8.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Monitor\Api;
use InvalidArgumentException;
use Monitor\Api\Auth\AdminAuthenticator;
use Monitor\Api\Auth\AgentAuthenticator;
use Monitor\Api\Auth\WebhookAuthenticator;
use Monitor\Api\Controller\AgentController;
use Monitor\Api\Controller\EventController;
use Monitor\Api\Controller\KpiController;
use Monitor\Api\Controller\RepoConfigController;
use Monitor\Api\Controller\StreamController;
use Monitor\Api\Controller\SystemController;
use Monitor\Api\Controller\TicketController;
use Monitor\Api\Controller\WebhookController;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\MethodNotAllowedException;
use Monitor\Api\Http\NotFoundException;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Http\Router;
use Monitor\Api\Metrics\HookCounter;
use Monitor\Api\Realtime\ChangeFeed;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository;
use Monitor\Api\Repository\KpiRepository;
use Monitor\Api\Repository\LegacyStatusFileReader;
use Monitor\Api\Repository\RepoConfigRepository;
use Monitor\Api\Repository\SystemRepository;
use Monitor\Api\Repository\TicketRepository;
use Monitor\Api\Sync\SyncTrigger;
use Throwable;
/**
* Verdrahtet Router, Controller und Auth fuer alle /api/v1/*-Endpunkte aus
* Visualization.md Abschnitt 6.1. Bewusst ohne Framework/DI-Container - eine handvoll
* Objekte reichen fuer den Umfang von Phase 1.
*/
final class Application
{
private readonly Router $router;
private readonly AgentAuthenticator $agentAuth;
private readonly AdminAuthenticator $adminAuth;
private readonly WebhookAuthenticator $webhookAuth;
public function __construct(private readonly Database $db, private readonly Config $config)
{
$agents = new AgentRepository($db, $config->deskCount());
$tickets = new TicketRepository($db);
$events = new EventRepository($db);
$systems = new SystemRepository($db);
$kpis = new KpiRepository($db);
$repoConfigs = new RepoConfigRepository($db);
$hooks = new HookCounter($config->hooksCounterPath());
$this->agentAuth = new AgentAuthenticator($agents);
$this->adminAuth = new AdminAuthenticator($config->adminApiKey());
$this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret());
$agentController = new AgentController($agents);
$ticketController = new TicketController($tickets);
$eventController = new EventController($events);
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
$kpiController = new KpiController($kpis);
$repoConfigController = new RepoConfigController($repoConfigs, $tickets);
$webhookController = new WebhookController(new SyncTrigger($config->syncTriggerPath()), $hooks);
$streamController = new StreamController(
new ChangeFeed($tickets, $events, $systems, $kpis, $agents, $hooks),
$config->sseMaxIterations(),
$config->ssePollIntervalSeconds(),
);
$this->router = new Router();
$this->router->add('GET', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->index($r));
$this->router->add('POST', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->create($r), 'agent');
$this->router->add('PATCH', '/api/v1/tickets/{id}', fn (Request $r, array $p) => $ticketController->update($r, $p), 'agent');
$this->router->add('GET', '/api/v1/events', fn (Request $r, array $p) => $eventController->index($r));
$this->router->add('POST', '/api/v1/events', fn (Request $r, array $p) => $eventController->create($r), 'agent');
$this->router->add('GET', '/api/v1/systems', fn (Request $r, array $p) => $systemController->index($r));
$this->router->add('POST', '/api/v1/systems/{id}/status', fn (Request $r, array $p) => $systemController->reportStatus($r, $p), 'agent');
$this->router->add('GET', '/api/v1/kpis', fn (Request $r, array $p) => $kpiController->index($r));
$this->router->add('POST', '/api/v1/kpis/{key}', fn (Request $r, array $p) => $kpiController->report($r, $p), 'agent');
// Issue #27: Welche Codeberg-Projekte der Sync liest, ist ueber ein Modal im Frontend
// (Klick auf den PO) editierbar - schreibend daher hinter dem Admin-Key wie die
// Agenten-Registrierung, nicht hinter einem Agenten-Key (kein Bot-Vorgang).
$this->router->add('GET', '/api/v1/repo-configs', fn (Request $r, array $p) => $repoConfigController->index($r));
$this->router->add('POST', '/api/v1/repo-configs', fn (Request $r, array $p) => $repoConfigController->create($r), 'admin');
$this->router->add('PATCH', '/api/v1/repo-configs/{id}', fn (Request $r, array $p) => $repoConfigController->update($r, $p), 'admin');
$this->router->add('DELETE', '/api/v1/repo-configs/{id}', fn (Request $r, array $p) => $repoConfigController->delete($r, $p), 'admin');
$this->router->add('GET', '/api/v1/agents', fn (Request $r, array $p) => $agentController->index($r));
$this->router->add('POST', '/api/v1/agents', fn (Request $r, array $p) => $agentController->register($r), 'admin');
$this->router->add(
'POST',
'/api/v1/agents/{id}/activity',
fn (Request $r, array $p, array $agent) => $agentController->activity($r, $p, $agent),
'agent'
);
// SSE-Push (F13): oeffentlich wie die anderen GET-Endpunkte, liefert nur bereits
// oeffentliche Daten (welche Ressourcen sich geaendert haben, keine Inhalte).
$this->router->add('GET', '/api/v1/stream', fn (Request $r, array $p) => $streamController->stream($r));
// Codeberg-Webhook: kein API-Key, sondern HMAC-Signatur - Codeberg kennt unsere
// Keys nicht, signiert den Body aber mit dem im Webhook hinterlegten Secret.
$this->router->add(
'POST',
'/api/v1/webhooks/codeberg',
fn (Request $r, array $p) => $webhookController->codeberg($r),
'webhook'
);
// Hooks-KPI (Issue #38): oeffentlich wie die anderen GET-Endpunkte, liefert nur die
// Anzahl seit dem letzten Start des api-Containers, keine Payload-Details.
$this->router->add('GET', '/api/v1/webhooks/stats', fn (Request $r, array $p) => $webhookController->stats($r));
}
public function handle(Request $request): Response
{
// CORS-Preflight generisch beantworten, bevor der Router laeuft: OPTIONS ist fuer
// keinen Pfad registriert, der Router wuerde also MethodNotAllowedException (405)
// werfen. Der Browser sendet den eigentlichen POST/PATCH/DELETE dann gar nicht mehr
// und meldet nur einen Netzwerkfehler - genau das brach das Projekte-Modal am PO.
if ($request->method === 'OPTIONS') {
return Response::preflight();
}
try {
$match = $this->router->match($request);
} catch (MethodNotAllowedException) {
return Response::error('Methode nicht erlaubt.', 405);
}
if ($match === null) {
return Response::error('Nicht gefunden.', 404);
}
$agent = null;
if ($match->auth === 'agent') {
$agent = $this->agentAuth->authenticate($request);
if ($agent === null) {
return Response::error('Ungueltiger oder fehlender X-Api-Key-Header.', 401);
}
} elseif ($match->auth === 'admin') {
if (!$this->adminAuth->authenticate($request)) {
return Response::error('Ungueltiger oder fehlender X-Admin-Key-Header.', 401);
}
} elseif ($match->auth === 'webhook') {
if (!$this->webhookAuth->authenticate($request)) {
return Response::error('Ungueltige oder fehlende Webhook-Signatur.', 401);
}
}
try {
return ($match->handler)($request, $match->params, $agent);
} catch (NotFoundException $e) {
return Response::error($e->getMessage(), 404);
} catch (InvalidArgumentException $e) {
return Response::error($e->getMessage(), 422);
} catch (Throwable $e) {
// Unerwartete Fehler (z.B. PDOException) duerfen nie als PHP-Fatal-Error mit
// Stacktrace und Dateipfaden im Response-Body landen - Details nur ins Log.
error_log(sprintf('[monitor-api] %s: %s in %s:%d', $e::class, $e->getMessage(), $e->getFile(), $e->getLine()));
return Response::error('Interner Serverfehler.', 500);
}
}
}