Zum Inhalt springen

OBS/Kostenpflichtige Module/RESTServer/Beispiel3

Aus OBS Wiki
Version vom 9. September 2026, 13:31 Uhr von Rademacker (Diskussion | Beiträge)
(Unterschied) ← Nächstältere Version | Aktuelle Version (Unterschied) | Nächstjüngere Version → (Unterschied)
Kostenpflichtige Module

Internet-Shop
UPS
IMS Professional
SMS
Mehrlager-Verwaltung
Mehrsprachen Modul
Multilanguage Modul
EVA Marketing Tool
Termin-Projekte
Edifact-Schnittstelle
Backup Überwachung Email
OBS Geo Daten
DeliSprint / DPD
Filialen
Cashback
Moebelschnittstelle
Dokumenten Manager
DocuWare-Schnittstelle
OFML-Kalkulation
Versicherungsschaden
Gutschriftsanzeigen
Kameraverwaltung
DataInOut
OpenMasterData / IDS
Sammelpositionen



Beispiel 3: Datensatz anlegen mit JSON-Body

Dieses Beispiel zeigt einen Endpunkt mit allen vier CRUD-Zugriffen (GET, POST, PUT und - anstelle von DELETE - PATCH, siehe den Hinweis beim Schliessen). Es geht um Tickets eines externen Servicedesks, der Tickets ueber den OBS REST-Server in OBS einliefern, abrufen, aktualisieren und schliessen kann.

HINWEIS: Dieses Beispiel adressiert ein einzelnes Ticket über einen Query-Parameter (?id...). Alternativ lässt sich dieselbe Ressource über ein Pfad-Template wie /tickets/{id} ansprechen; die ID steht dann als oReader.Path('id') bereit. Ein vollständiges Beispiel dazu zeigt Beispiel 4 - Pfad-Parameter.

Einrichtung in OBS

  • Server-Profil: Standard-TLS-Profil Public-API, Bindung 0.0.0.0:443
  • Zugang: Servicedesk-X
    • API-Key: zufaellig generiert
    • Host: servicedesk.kunde.de (DNS-gebunden, IP-Zugriff wird abgelehnt)
    • JWT: nicht aktiv
  • Endpunkt: tickets/v1, Profil Public-API
  • Berechtigung: Zugang Servicedesk-X fuer Endpunkt tickets/v1 freigeschaltet

Endpunkt-Skript tickets/v1

//------------------------------------------------------------------------------
// Hilfsprozedur: die FELDER eines Tickets. Sie bekommt den Writer und schreibt
// in das Objekt, das der Aufrufer geoeffnet hat - einmal als Listeneintrag,
// einmal in die Wurzel. Ein Helfer, der ein Fragment als String zurueckgibt,
// waere der alte Weg; dabei ging die Reihenfolge im Zusammenbau verloren.
//------------------------------------------------------------------------------

procedure _TicketFelder(oWriter: TxRestWriter; qTicket: TxFQuery;
                        const cOffset: string);
begin
    oWriter.Str ('id'     , qTicket.A2UID());
    oWriter.Str ('nr'     , qTicket.A2C('ti_nr'));
    oWriter.Str ('betreff', qTicket.A2C('ti_betreff'));
    oWriter.Str ('status' , qTicket.A2C('ti_status'));
    oWriter.Str ('beschr' , qTicket.A2C('ti_beschr'));
    // Zeitpunkte gehen als ISO-8601 MIT OFFSET ueber die Leitung, nicht im
    // SQL-Format: DTToSQL liefert 'JJJJ-MM-TT hh:mm:ss' ohne Zeitzone, und der
    // Konsument muss dann raten, in welcher er steht. RestIsoFromDate nimmt den
    // Offset des Servers dazu. Ein leeres Datum ergibt null, nicht 1899.
    oWriter.StrN('aenderung', RestIsoFromDate(qTicket.A2D('ti_aend_dat'), cOffset));
end;

// Fuer den Body-Zugriff und fuer Fehlerantworten braucht es keine eigenen
// Hilfsfunktionen mehr: oReader liest typisiert, oWriter.Error baut die
// vollstaendige Fehlerhuelle samt traceId.

//------------------------------------------------------------------------------
// GET   /tickets/v1?id=...   - einzelnes Ticket
// GET   /tickets/v1          - alle offenen Tickets
//------------------------------------------------------------------------------

procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);
var cSql     : string;
    qData    : TxFQuery;
    cId      : string;
    cOffset  : string;
    lGefunden: Boolean;
begin
    cId     := oReader.Param('id');
    // Der Offset des Servers geht in jeden Zeitstempel der Antwort.
    cOffset := oReader.Offset();

    if (not Empty(cId)) then begin
        lGefunden := false;
        cSql := 'SELECT * FROM tickets WHERE sys_uid = ' + DB_SQLVal(cId);
        if (DB_SOpen(oDB, cSql, qData)) then begin
            // EoF gehoert dazu: DB_SOpen sagt nur, dass die ABFRAGE lief.
            if (not qData.EoF) then begin
                _TicketFelder(oWriter, qData, cOffset);
                lGefunden := true;
            end;
        end;
        DB_Close(qData);

        if (not lGefunden) then begin
            oWriter.Error(404, 'NOT_FOUND', 'Ticket nicht gefunden');
        end;
    end else begin
        // Die Liste ist eine nackte Liste. RootArr steht vor der Abfrage:
        // kein Treffer heisst leere Liste, nicht leeres Objekt.
        oWriter.RootArr();
        cSql := 'SELECT * FROM tickets WHERE ti_status <> "9" ORDER BY ti_aend_dat DESC';
        if (DB_SOpen(oDB, cSql, qData)) then begin
            while (not qData.EoF) do begin
                oWriter.ObjBegin('');
                    _TicketFelder(oWriter, qData, cOffset);
                oWriter.ObjEnd;
                qData.Next();
            end;
        end;
        DB_Close(qData);
    end;
end;

//------------------------------------------------------------------------------
// POST  /tickets/v1          - neues Ticket anlegen
// Body: { "betreff":"...", "beschr":"..." }
//------------------------------------------------------------------------------

procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);
var xTicket : TqSQL;
    cId     : string;
    cBetreff: string;
    cBeschr : string;
    lOk     : Boolean;
begin
    // HasBody unterscheidet "kein Koerper" von "Koerper ohne das Feld" - das
    // erste ist ein 400, das zweite ein 422.
    if (not oReader.HasBody()) then begin
        oWriter.Error(400, 'BAD_REQUEST', 'JSON-Body erforderlich');
        exit;
    end;

    cBetreff := '';
    cBeschr  := '';
    oReader.Str('betreff', cBetreff);
    oReader.Str('beschr' , cBeschr);

    if (Empty(cBetreff)) then begin
        oWriter.Error(422, 'VALIDATION_FAILED', 'Feld "betreff" fehlt');
        exit;
    end;

    cId := GetNewId(oDB);

    xTicket := qSqlInit(oDB, 'tickets');
    xTicket.lNoSysUID := True;
    try
        xTicket.qSet('sys_uid'    , cId);
        xTicket.qSet('ti_nr'      , DB_NeuNum(oDB, 'tickets', 'ti_nr', NEUNUM_HOLE, '', '', 1, 999999, '0'));
        xTicket.qSet('ti_betreff' , cBetreff);
        xTicket.qSet('ti_beschr'  , cBeschr);
        xTicket.qSet('ti_status'  , '1');
        xTicket.qSet('ti_anl_dat' , Now());
        xTicket.qSet('ti_aend_dat', Now());
        // SaveData liefert einen Boolean. Ein SQL-Fehler - etwa Duplicate entry
        // auf einem eindeutigen Index - wirft KEINE Exception und steht in
        // keinem Protokoll. Ohne diese Auswertung antwortet der Endpunkt mit
        // 201, ohne etwas angelegt zu haben.
        lOk := xTicket.SaveData(NEW_RECORD);
    finally
        qSqlFree(xTicket);
    end;

    if (not lOk) then begin
        oWriter.Error(500, 'INTERNAL_ERROR', 'Ticket konnte nicht angelegt werden');
        exit;
    end;

    oWriter.Str('id'    , cId);
    oWriter.Str('status', 'created');
    oWriter.Status(201);
    oWriter.Header('Location', '/tickets/v1?id=' + cId);
end;

//------------------------------------------------------------------------------
// PUT   /tickets/v1          - Ticket aktualisieren
// Body: { "id":"...", "status":"...", "beschr":"..." }
//------------------------------------------------------------------------------

procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);
var xTicket: TqSQL;
    lOk    : Boolean;
    cId    : string;
    cStat  : string;
    cBeschr: string;
begin
    if (not oReader.HasBody()) then begin
        oWriter.Error(400, 'BAD_REQUEST', 'JSON-Body erforderlich');
        exit;
    end;

    cId     := '';
    cStat   := '';
    cBeschr := '';
    oReader.Str('id'    , cId);
    oReader.Str('status', cStat);
    oReader.Str('beschr', cBeschr);

    if (Empty(cId)) then begin
        oWriter.Error(422, 'VALIDATION_FAILED', 'Feld "id" fehlt');
        exit;
    end;

    if (not DB_LSeek(oDB, 'tickets', 'sys_uid = ' + DB_SQLVal(cId))) then begin
        oWriter.Error(404, 'NOT_FOUND', 'Ticket nicht gefunden');
        exit;
    end;

    // Zum Aendern qSqlInit mit sys_uid, nicht qSqlRead: qSqlRead liest den
    // Altsatz und schreibt nur Aenderungen - ein unveraenderter Wert erzeugt
    // gar kein Write und SaveData liefert False.
    xTicket := qSqlInit(oDB, 'tickets');
    xTicket.qSet('sys_uid', cId);
    try
        if (not Empty(cStat))   then xTicket.qSet('ti_status'  , cStat);
        if (not Empty(cBeschr)) then xTicket.qSet('ti_beschr'  , cBeschr);
        xTicket.qSet('ti_aend_dat', Now());
        lOk := xTicket.SaveData(UPDATE_RECORD);
    finally
        qSqlFree(xTicket);
    end;

    if (not lOk) then begin
        oWriter.Error(500, 'INTERNAL_ERROR', 'Ticket konnte nicht geaendert werden');
        exit;
    end;

    oWriter.Str('status', 'updated');
end;

//------------------------------------------------------------------------------
// PATCH /tickets/v1?id=...   - Ticket schliessen (Soft-Delete via Status=9)
//
// NICHT als DELETE: 'Delete' ist in der Skriptsprache eine Standardprozedur,
// eine eigene Prozedur dieses Namens laesst sich nicht uebersetzen. Solange das
// so ist, laeuft ein Loeschen ueber PATCH oder POST. Siehe den Hinweis unten.
//------------------------------------------------------------------------------

procedure Patch(oReader: TxRestReader; oWriter: TxRestWriter);
var xTicket: TqSQL;
    cId    : string;
    lOk    : Boolean;
begin
    cId := oReader.Param('id');
    if (Empty(cId)) then begin
        oWriter.Error(422, 'VALIDATION_FAILED', 'Parameter "id" fehlt');
        exit;
    end;

    if (not DB_LSeek(oDB, 'tickets', 'sys_uid = ' + DB_SQLVal(cId))) then begin
        oWriter.Error(404, 'NOT_FOUND', 'Ticket nicht gefunden');
        exit;
    end;

    xTicket := qSqlInit(oDB, 'tickets');
    xTicket.qSet('sys_uid', cId);
    try
        xTicket.qSet('ti_status'  , '9');
        xTicket.qSet('ti_aend_dat', Now());
        lOk := xTicket.SaveData(UPDATE_RECORD);
    finally
        qSqlFree(xTicket);
    end;

    if (not lOk) then begin
        oWriter.Error(500, 'INTERNAL_ERROR', 'Ticket konnte nicht geschlossen werden');
        exit;
    end;

    oWriter.Str('status', 'closed');
end;
ACHTUNG: Ein DELETE-Endpunkt kann derzeit kein Skript haben. Der Server

ruft die Prozedur, die genauso heisst wie das HTTP-Verb - fuer DELETE also Delete. Dieser Name ist in der Skriptsprache belegt (Standardprozedur zum Loeschen aus einer Zeichenkette), und die Deklaration laesst sich nicht uebersetzen; die Meldung nennt dabei nur die Zeile, nicht die Ursache. Bis das geloest ist: das Loeschen als PATCH oder POST mit einer Aktion abbilden. Das Abmelden am JWT-Endpunkt ist davon nicht betroffen - es laeuft als DELETE ohne Skript vollstaendig in

der Engine.

Aufruf mit curl

Liste aller offenen Tickets:

curl -H "apikey: [API-KEY]" https://api.meinserver.de/tickets/v1

Einzelnes Ticket:

curl -H "apikey: [API-KEY]" "https://api.meinserver.de/tickets/v1?id=abc-123"

Neues Ticket anlegen:

curl -X POST -H "apikey: [API-KEY]" -H "Content-Type: application/json" ^
     -d "{\"betreff\":\"Drucker offline\",\"beschr\":\"Etage 2, Raum 23\"}" ^
     https://api.meinserver.de/tickets/v1

Ticket aktualisieren:

curl -X PUT -H "apikey: [API-KEY]" -H "Content-Type: application/json" ^
     -d "{\"id\":\"abc-123\",\"status\":\"2\"}" ^
     https://api.meinserver.de/tickets/v1

Ticket schliessen:

curl -X PATCH -H "apikey: [API-KEY]" "https://api.meinserver.de/tickets/v1?id=abc-123"

Aufruf aus PHP

<?php
$apikey = '[API-KEY]';
$base   = 'https://api.meinserver.de/tickets/v1';

function rest($method, $url, $apikey, $body = null) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL           , $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST , $method);
    $headers = ['apikey: ' . $apikey];
    if ($body !== null) {
        $headers[] = 'Content-Type: application/json';
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    $res = curl_exec($ch);
    curl_close($ch);
    return json_decode($res, true);
}

// Neues Ticket anlegen
$neu = rest('POST', $base, $apikey, [
    'betreff' => 'Drucker offline',
    'beschr'  => 'Etage 2, Raum 23'
]);
$id = $neu['id'];

// Status aktualisieren
rest('PUT', $base, $apikey, ['id' => $id, 'status' => '2']);

// Liste auslesen
$alle = rest('GET', $base, $apikey);
foreach ($alle as $t) {
    echo $t['nr'] . ' - ' . $t['betreff'] . PHP_EOL;
}
?>

Was zeigt das Beispiel?

  • Saubere Trennung der CRUD-Methoden in einem Endpunkt.
  • Verwendung des JSON-Body fuer komplexere Eingangsdaten (POST, PUT).
  • Verwendung von Query-Parametern fuer einfache Werte (GET, PATCH).
  • Soft-Delete ueber Status-Aenderung statt physischer Loeschung - und warum er als PATCH und nicht als DELETE laeuft.
  • Eindeutige Audit-UIDs (Y00CXXXXxx) fuer jeden DB-Zugriff zur Nachvollziehbarkeit.
  • Host-gebundener Zugang als zweite Sicherheitsebene neben dem API-Key.
  • Alternativ: ID per Pfad-Parameter statt Query-Parameter (siehe Beispiel 4).