Ein typisierter LimeSurvey API-Client

Wie man die RemoteControl API mit einem sauberen PHP-Client kapselt

Im vorherigen Artikel haben wir drei grundlegende Probleme der LSRC2-API identifiziert: ein zustandsbehaftetes Session-Modell, das der Aufrufer selbst verwalten muss, ein inkonsistentes Rückgabeformat das Erfolg und Fehler nicht unterscheidet, und einen empfohlenen Client, der LimeSurvey-Konzepte nicht kennt.

Dieser Artikel zeigt, wie ein typisierter Client diese Probleme löst – nicht als vollständige Implementierung, sondern als Lösungsvorschlag anhand konkreter Beispiele.


Die Ausgangslage

Ein typischer Aufruf mit dem rohen JsonRPCClient sieht so aus:

$client = new JsonRPCClient('https://example.com/index.php/admin/remotecontrol');

$sessionKey = $client->get_session_key('admin', 'password');

$result = $client->add_survey($sessionKey, 0, 'Meine Umfrage', 'de');

if (is_array($result) && isset($result['status'])) {
    // Fehler
    echo $result['status'];
} else {
    // Erfolg – $result ist ein int
    $surveyId = $result;
}

$client->release_session_key($sessionKey);

Drei Probleme in acht Zeilen: Der Session-Key muss manuell übergeben und am Ende freigegeben werden. Der Rückgabetyp ist unbekannt bis man ihn prüft. Und was genau in $result['status'] steht, weiß man erst zur Laufzeit.


Schritt 1: Session-Management kapseln

Der erste und wirkungsvollste Schritt ist, den Session-Key vollständig aus dem Aufrufer zu entfernen. Die Session ist ein Implementierungsdetail des Transports – sie hat im Anwendungscode nichts zu suchen.

final class LimeSurveyClient
{
    private ?string $sessionKey = null;

    public function __construct(
        private readonly JsonRPCClient $transport,
        private readonly string $username,
        private readonly string $password
    ) {}

    private function getSessionKey(): string
    {
        if ($this->sessionKey === null) {
            $result = $this->transport->get_session_key(
                $this->username,
                $this->password
            );

            if (is_array($result) && isset($result['status'])) {
                throw new AuthenticationException($result['status']);
            }

            $this->sessionKey = $result;
        }

        return $this->sessionKey;
    }

    public function release(): void
    {
        if ($this->sessionKey !== null) {
            $this->transport->release_session_key($this->sessionKey);
            $this->sessionKey = null;
        }
    }

    public function __destruct()
    {
        $this->release();
    }
}

Die Session wird beim ersten Aufruf transparent geöffnet – Lazy Initialization. Der Destruktor stellt sicher, dass release_session_key auch dann aufgerufen wird, wenn der Aufrufer es vergisst. Der Anwendungscode sieht keinen Session-Key mehr.


Schritt 2: Fehlerbehandlung vereinheitlichen

Das zweite Problem ist das inkonsistente Rückgabeformat. Die Lösung ist eine private Hilfsmethode, die das rohe API-Ergebnis interpretiert und bei Fehlern eine Exception wirft:

private function call(string $method, mixed ...$params): mixed
{
    $result = $this->transport->$method(
        $this->getSessionKey(),
        ...$params
    );

    if (is_array($result) && isset($result['status']) && $result['status'] !== 'OK') {
        throw new LimeSurveyApiException($method, $result['status']);
    }

    return $result;
}

Ab jetzt gilt: wenn call() zurückkommt, war der Aufruf erfolgreich. Fehler werden als Exceptions kommuniziert – mit Methodenname und Fehlermeldung. Der Aufrufer muss kein Array mehr auf status-Schlüssel prüfen.


Schritt 3: Typisierte Methoden

Mit Session-Management und Fehlerbehandlung als Grundlage lassen sich typisierte API-Methoden bauen, die LimeSurvey-Konzepte ausdrücken:

final class SurveyApi
{
    public function __construct(
        private readonly LimeSurveyClient $client
    ) {}

    public function create(SurveyDraft $draft): SurveyId
    {
        $result = $this->client->call(
            'add_survey',
            0,
            $draft->title,
            $draft->language->value,
            $draft->format->value
        );

        return new SurveyId((int) $result);
    }

    public function activate(SurveyId $surveyId): void
    {
        $this->client->call('activate_survey', $surveyId->value);
    }

    public function delete(SurveyId $surveyId): void
    {
        $this->client->call('delete_survey', $surveyId->value);
    }
}

SurveyDraft kapselt die Parameter für eine neue Umfrage. SurveyId ist ein Value Object – kein nackter Integer, sondern ein Typ der eine Survey-ID repräsentiert. Language und SurveyFormat sind Enums.

Der Aufruf im Anwendungscode:

$api = new SurveyApi($client);

$surveyId = $api->create(new SurveyDraft(
    title: 'Mitarbeiterbefragung 2026',
    language: Language::German,
    format: SurveyFormat::GroupByGroup
));

$api->activate($surveyId);

Kein Session-Key. Keine Array-Prüfung. Keine Hungarian Notation. Der Code spricht die Sprache der Domäne.


Die Value Objects

SurveyId schützt vor einem klassischen Fehler den wir im Hungarian-Notation-Artikel beschrieben haben: vertauschte primitive Typen.

final class SurveyId
{
    public function __construct(
        public readonly int $value
    ) {
        if ($value <= 0) {
            throw new \InvalidArgumentException(
                "SurveyId must be positive, got {$value}"
            );
        }
    }
}

Eine Methode die SurveyId $surveyId erwartet, akzeptiert keine QuestionId – auch wenn beide intern int sind. Die API der rohen Klasse übergibt überall nackte Integer. Unser Client macht daraus Typen.


Fehlerbehandlung aus Aufrufer-Sicht

Mit diesem Client sieht die Fehlerbehandlung so aus:

try {
    $surveyId = $api->create(new SurveyDraft(
        title: 'Kundenbefragung',
        language: Language::German,
        format: SurveyFormat::GroupByGroup
    ));
} catch (AuthenticationException $e) {
    // Anmeldung fehlgeschlagen
} catch (LimeSurveyApiException $e) {
    // API hat einen Fehler gemeldet: $e->getMethod(), $e->getApiStatus()
}

Keine Typ-Checks, keine isset($result['status']), kein implizites Wissen darüber welches Format diese konkrete Methode bei Fehler zurückgibt. PHP's Exception-Mechanismus übernimmt die Fehlerbehandlung – so wie es in modernem PHP üblich ist.


Was dieser Ansatz nicht löst

Zwei Einschränkungen sind ehrlich zu benennen.

Das Session-Modell der API selbst bleibt unverändert. Unser Client kapselt es – aber er kann es nicht abschaffen. Solange LSRC2 zustandsbehaftet ist, ist auch unser Client zustandsbehaftet. Bei parallelen Anfragen aus mehreren Prozessen muss man das berücksichtigen.

Die Inkonsistenz der Rückgabeformate ist im Client abgefangen, aber nicht behoben. Wenn add_survey bei Erfolg einen Integer zurückgibt und delete_survey ein Array mit status => OK, dann muss call() beide Fälle kennen. Bei neuen API-Methoden muss man prüfen, welches Format sie im Erfolgsfall verwenden – das Wissen bleibt im Client, nicht im Protokoll.


Die Architektur im Überblick

LimeSurveyClient          ← Session-Management, Fehlerbehandlung, Transport-Kapselung
    ↑
SurveyApi                 ← Survey-spezifische Operationen, typisierte Parameter
TokenApi                  ← Token-Management, Teilnehmer-Operationen
ResponseApi               ← Antworten lesen, exportieren, schreiben

SurveyId, TokenId         ← Value Objects für Domänen-IDs
SurveyDraft, Language     ← Typisierte Eingabe-Objekte
SurveyFormat              ← Enum für Umfrage-Format

Jede *Api-Klasse hat genau eine Verantwortlichkeit. LimeSurveyClient ist der gemeinsame Unterbau – er kennt das Protokoll, die anderen kennen die Domäne. Der JsonRPCClient bleibt als Transport, unsichtbar für den Anwendungscode.


Fazit

Der JsonRPCClient ist kein schlechter Transport – er tut was er soll. Das Problem liegt eine Ebene höher: zwischen Transport und Anwendungscode fehlt eine Schicht, die LimeSurvey-Konzepte ausdrückt und die Eigenheiten der API kapselt.

Session-Management als Implementierungsdetail, Fehler als Exceptions, Parameter als typisierte Objekte – das sind keine neuen Ideen. Es sind die Grundprinzipien modernen PHP-Codes, angewendet auf eine Schnittstelle, die sie bisher nicht hatte. Der Aufwand ist überschaubar. Der Gewinn ist ein Client, der die Sprache der Domäne spricht – statt die Sprache eines JSON-RPC-Protokolls aus dem Jahr 2007.