Zum Inhalt springen

OBS/Kostenpflichtige Module/RESTServer/Beispiel5

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 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz

Dieses Beispiel zeigt einen schreibenden Endpunkt für eine mobile App: Aufträge werden mit echten HTTP-Statuscodes aktualisiert, konkurrierende Änderungen über ETag/If-Match abgesichert (Optimistic Concurrency) und doppelte Sendungen über einen Idempotency-Key abgefangen. Die Anmeldung nutzt JWT mit Custom-Claims (Mandant, Rollen) und einem Refresh-Token.

Einrichtung in OBS

  • Server-Profil: Standard-TLS-Profil Public-API (Port 443)
  • Zugang: Mobile-App
    • API-Key: zufällig generiert
    • JWT aktiv, JWT-Endpunkt auth, JWT-Key zufällig, JWT-Exp 60 (Minuten)
  • Endpunkte (beide dem Profil Public-API zugeordnet):
    • orders/{uid} - Auftrag lesen/Ändern
    • orders/{uid}/material - Material erfassen
  • Berechtigung: Zugang Mobile-App für beide Endpunkte freigeschaltet
HINWEIS: Für das Abmelden gibt es keinen eigenen Endpunkt. Es läuft über

ein DELETE auf den JWT-Endpunkt auth und braucht daher weder eine Zeile

in RESTSRV_ENDPOINTS noch eine Berechtigung.

JWT-Authentifizierungs-Skript (Zugang)

Stellt Mandant und Rollen als Custom-Claims aus und löst über _OBS_JWT_REFRESH_ID ein Refresh-Token aus. Bei einem Refresh ruft der Server im selben Skript die Methode Refresh auf. Die Hilfsfunktionen (PasswortPasst, TechnikerMandant, TechnikerRollen, TechnikerAktiv, AuftragLesen, AuftragVersion, AuftragSpeichern, MaterialAnlegen, ArtikelPreis, PushRegistrierungLoeschen) sind illustrativ und projektabhängig - sie stehen für Ihre Fachlogik, nicht für eine Server-API.

HINWEIS: Eine eigene Sperrtabelle für Refresh-jti ist nicht nötig.

Einmalgebrauch, Rotation und Widerruf führt der Server in RESTSRV_TOKEN - das Skript entscheidet nur, ob und mit welchen Rechten die Sitzung

fortgesetzt wird.
procedure Authenticate(oReader: TxRestReader; oWriter: TxRestWriter);
var cUser: string;
    cPass: string;
begin
    cUser := '';
    cPass := '';
    oReader.Str('username', cUser);
    oReader.Str('password', cPass);

    if (PasswortPasst(cUser, cPass)) then begin
        oWriter.Int('status'               , 1);
        oWriter.Str('_OBS_JWT_ID'          , cUser);
        oWriter.Str('_OBS_JWT_SUBJECT'     , cUser);
        oWriter.Str('_OBS_JWT_CLAIM_tenant', TechnikerMandant(cUser));
        oWriter.Str('_OBS_JWT_CLAIM_roles' , TechnikerRollen(cUser));   // z.B. "tech,lead"
        oWriter.Str('_OBS_JWT_REFRESH_ID'  , GlobalUID());   // loest das Refresh-Token aus
        // _OBS_JWT_REFRESH_EXP weggelassen -> Default 90 Tage
    end else begin
        oWriter.Int('status', 9);
        oWriter.Str('error' , 'Login fehlgeschlagen');
    end;
end;

//------------------------------------------------------------------------------

procedure Refresh(oReader: TxRestReader; oWriter: TxRestWriter);
var cUser: string;
begin
    cUser := oReader.Subject();

    // Das vorgelegte Refresh-Token hat der Server bereits geprueft und
    // entwertet. Hier wird nur entschieden, ob die Sitzung fortgesetzt
    // werden darf - und mit welchen Rechten.
    if (not TechnikerAktiv(cUser)) then begin
        oWriter.Int('status', 9);
        oWriter.Str('error' , 'Konto ist nicht mehr aktiv, bitte neu anmelden');
        exit;
    end;

    // Mandant und Rollen NEU lesen, nicht aus dem alten Token uebernehmen -
    // sonst wirkt ein Rechteentzug erst beim naechsten Login.
    oWriter.Int('status'               , 1);
    oWriter.Str('_OBS_JWT_ID'          , cUser);
    oWriter.Str('_OBS_JWT_SUBJECT'     , cUser);
    oWriter.Str('_OBS_JWT_CLAIM_tenant', TechnikerMandant(cUser));
    oWriter.Str('_OBS_JWT_CLAIM_roles' , TechnikerRollen(cUser));
    oWriter.Str('_OBS_JWT_REFRESH_ID'  , GlobalUID());
end;

Endpunkt orders/{uid} - ändern mit ETag / If-Match

GET liefert den Auftrag samt ETag (Version), PUT prüft Rolle und If-Match.

// AuftragLesen bekommt den WRITER und schreibt die Felder des Auftrags in die
// Wurzel; nVer kommt als var-Parameter zurueck. Vorher wurde ihm das
// Antwortobjekt hineingegeben - derselbe Gedanke, nur ohne Objekt.
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);
var cUid: string;
    nVer: Integer;
begin
    cUid := oReader.Path('uid');
    if (not AuftragLesen(oWriter, oReader.Claim('tenant'), cUid, nVer)) then begin
        oWriter.Error(404, 'NOT_FOUND', 'Auftrag nicht gefunden');
        exit;
    end;
    oWriter.Header('ETag', xStr(nVer));
end;

//------------------------------------------------------------------------------

procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);
var cUid    : string;
    nAktuell: Integer;
    nIfMatch: Integer;
begin
    // nur Rolle "lead" darf ändern - Rolle kommt aus dem Token, nicht aus dem Body.
    // Mit Trennzeichen suchen: ein blosses Pos('lead', ...) wuerde auch in
    // "leadless" oder "teamlead" treffen.
    if (Pos(',lead,', ',' + oReader.Claim('roles') + ',') = 0) then begin
        oWriter.Error(403, 'FORBIDDEN_ROLE', 'Diese Rolle darf nicht aendern');
        exit;
    end;

    cUid     := oReader.Path('uid');
    nAktuell := AuftragVersion(cUid);
    nIfMatch := iVal(oReader.Param('if-match'));

    if (nIfMatch <> nAktuell) then begin
        // Der ETag gehoert AUCH an die Ablehnung - sonst kennt der Client den
        // gueltigen Wert nicht und sein naechster Versuch scheitert wieder.
        // Header und Error stoeren sich nicht: Error ersetzt nur den Koerper.
        oWriter.Header('ETag', xStr(nAktuell));
        oWriter.Error(409, 'VERSION_CONFLICT', 'Der Auftrag wurde zwischenzeitlich geaendert');
        exit;
    end;

    // AuftragSpeichern gibt den Rueckgabewert von SaveData durch. Ohne
    // diese Pruefung antwortet der Endpunkt mit 200 und einem neuen ETag,
    // obwohl nichts geschrieben wurde - siehe Scripting, Schreibfehler
    // erkennen.
    if (not AuftragSpeichern(cUid, oReader)) then begin   // setzt Version auf nAktuell + 1
        oWriter.Error(500, 'INTERNAL_ERROR', 'Der Auftrag konnte nicht gespeichert werden');
        exit;
    end;

    oWriter.Header('ETag', xStr(nAktuell + 1));
end;

Endpunkt orders/{uid}/material - idempotentes Anlegen

POST erfasst mehrere Materialpositionen in einem Aufruf: Validierungsfehler -> 422, erfolgreiches Anlegen -> 201 mit den vergebenen Positionen.

Gegen doppelte Sendungen schickt der Client den Header Idempotency-Key. Das Skript muss dafür nichts tun - der Server erkennt den Header, merkt sich das Ergebnis und liefert bei einer Wiederholung mit demselben Schlüssel die gespeicherte Antwort zurück, ohne das Skript erneut zu starten (siehe Endpunkte, Abschnitt Idempotenz). Das Skript kümmert sich nur um seine Fachlogik:

// Koerper:  {"positionen":[{"artikel":"4711","menge":2},
//                          {"artikel":"0815","menge":1}]}
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);
var cUid     : string;
    cPos     : string;
    cArtikel : string;
    cNeueUid : string;
    nMenge   : Integer;
    nPreis   : Double;
    nGesamt  : Double;
    nAnz     : Integer;
    nI       : Integer;
begin
    cUid := oReader.Path('uid');

    // 1) Gestalt pruefen. Count liefert 0, wenn das Feld fehlt ODER keine
    //    Liste ist - IsArr unterscheidet die beiden Faelle. Ein Objekt, wo
    //    eine Liste erwartet wird, ist etwas anderes als eine leere Liste.
    nAnz := oReader.Count('positionen');
    if (not oReader.IsArr('positionen')) then begin
        oWriter.Error(422, 'VALIDATION_FAILED', 'positionen muss eine Liste sein');
        exit;
    end;
    if (nAnz = 0) then begin
        oWriter.Error(422, 'VALIDATION_FAILED', 'positionen ist leer');
        exit;
    end;

    // 2) ALLE Positionen pruefen, BEVOR die erste gebucht wird. Sonst
    //    haette ein Fehler in Position 3 zwei gebuchte Zeilen und eine
    //    Fehlerantwort hinterlassen - der Client wiederholt und bucht die
    //    ersten zwei erneut.
    //
    //    Verschachtelung steht im Pfad: Punkt fuer Felder, eckige Klammern
    //    fuer Listeneintraege.
    for nI := 0 to nAnz - 1 do begin
        cPos     := 'positionen[' + IntToStr(nI) + ']';
        cArtikel := '';
        nMenge   := 0;
        oReader.Str(cPos + '.artikel', cArtikel);
        oReader.Int(cPos + '.menge'  , nMenge);
        if ((Empty(cArtikel)) or (nMenge <= 0)) then begin
            oWriter.Error(422, 'VALIDATION_FAILED',
                          'Position ' + IntToStr(nI + 1) + ': artikel und menge sind Pflicht');
            exit;
        end;
    end;

    // 3) Buchen und antworten in EINEM Durchgang. Die Positionsnummern
    //    entstehen beim Buchen, also schreibt die Schleife sie an der Stelle,
    //    an die sie gehoeren - gesammelt und am Ende eingesetzt werden muss
    //    nichts.
    nGesamt := 0;
    oWriter.ArrBegin('positionen');
    for nI := 0 to nAnz - 1 do begin
        cPos     := 'positionen[' + IntToStr(nI) + ']';
        cArtikel := '';
        nMenge   := 0;
        oReader.Str(cPos + '.artikel', cArtikel);
        oReader.Int(cPos + '.menge'  , nMenge);

        cNeueUid := MaterialAnlegen(cUid, cArtikel, nMenge);
        if (Empty(cNeueUid)) then begin
            // Der Stapel wird GESCHLOSSEN, bevor der Fehler gesetzt wird:
            // oWriter.Error verwirft zwar den begonnenen Koerper, aber ein
            // offener ArrBegin bleibt ein Strukturfehler - dann antwortet der
            // Server 500 mit "Verschachtelung nicht geschlossen" statt mit
            // dieser Meldung.
            oWriter.ArrEnd;
            oWriter.Error(500, 'INTERNAL_ERROR',
                          'Position ' + IntToStr(nI + 1) + ' konnte nicht gebucht werden');
            exit;
        end;

        nPreis  := ArtikelPreis(cArtikel) * nMenge;
        nGesamt := nGesamt + nPreis;

        oWriter.ObjBegin('');            // leerer Name = Listeneintrag
            oWriter.Str('uid'    , cNeueUid);
            oWriter.Str('artikel', cArtikel);
            oWriter.Int('menge'  , nMenge);
            // Zwei Nachkommastellen, immer mit Dezimalpunkt. Selbst
            // formatiert stand bei Werten ab 1000 der Tausendertrenner im
            // JSON - gueltiges JSON, falscher Wert, keine Meldung.
            oWriter.Num('preis'  , nPreis, 2);
        oWriter.ObjEnd;
    end;
    oWriter.ArrEnd;

    oWriter.Num('gesamt', nGesamt, 2);
    oWriter.Status(201);
end;

Abmelden - ohne Endpunkt und ohne Skript

Beim Abmelden soll das Gerät seine Sitzung wirklich verlieren, nicht erst mit dem Ablauf des Tokens. Dafür ist nichts einzurichten: ein DELETE auf den JWT-Endpunkt mit dem Access-Token genügt, der Server sperrt die Sitzung in RESTSRV_TOKEN und antwortet 204.

Gesperrt wird die ganze Sitzung, also auch die Access-Token vorheriger Erneuerungen. Ein zweiter Aufruf ist unschädlich und bleibt 204. Kann der Server die Sitzung nicht sperren, antwortet er 503 mit Retry-After - die App wiederholt den Abmeldevorgang dann.

Nur wenn beim Abmelden zusätzlich etwas passieren soll - hier: die Geräteregistrierung für Push löschen -, braucht es einen eigenen Endpunkt. Er erledigt seine Fachlogik und ruft oWriter.JwtRevoke:

procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);
var cAlle : string;
    lAlle : Boolean;
begin
    PushRegistrierungLoeschen(oReader.Claim('technikerUid'));

    // Optional: {"alleGeraete": true} meldet den Techniker ueberall ab.
    // Diese Entscheidung gehoert bewusst in ein Skript - sie braucht eine
    // Berechtigungspruefung, die der JWT-Endpunkt nicht leisten kann.
    lAlle := false;
    oReader.Bool('alleGeraete', lAlle);
    cAlle := IIF(lAlle, 'all', 'session');

    oWriter.JwtRevoke(cAlle);
    oWriter.Status(204);
    oWriter.NoBody();
end;

Test mit curl

Token holen:

curl -X POST -H "apikey: [API-KEY]" -H "Content-Type: application/json" ^
     -d "{\"username\":\"tech1\",\"password\":\"geheim\"}" ^
     https://api.meinserver.de/auth/
{"token":"eyJ...","accessToken":"eyJ...","refreshToken":"eyJ...",
 "expiresIn":3600,"serverTime":"2026-06-29T15:30:12+02:00"}

Bei falschen Zugangsdaten antwortet der Server mit 401 und dem Text aus dem Authenticate-Skript als error.message.

Auftrag lesen (liefert den ETag-Header):

curl -i -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
     https://api.meinserver.de/orders/4711
... ETag: 7

ändern mit korrektem If-Match -> 200, mit veraltetem If-Match -> 409:

curl -i -X PUT -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
     -H "If-Match: 7" -H "Content-Type: application/json" ^
     -d "{\"status\":\"erledigt\"}" ^
     https://api.meinserver.de/orders/4711

Material idempotent erfassen (zweiter Aufruf mit gleichem Key -> gleiche Antwort, keine Doppelbuchung):

curl -i -X POST -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
     -H "Idempotency-Key: 9c84-7f2a-..." -H "Content-Type: application/json" ^
     -d "{\"positionen\":[{\"artikel\":\"A100\",\"menge\":3},{\"artikel\":\"B200\",\"menge\":1}]}" ^
     https://api.meinserver.de/orders/4711/material

Die Antwort des zweiten Aufrufs trägt zusätzlich den Header Idempotent-Replay: true - daran ist erkennbar, dass sie aus dem Speicher kam und nichts erneut gebucht wurde.

Token erneuern (Refresh-Token im Authorization-Header an denselben JWT-Endpunkt):

curl -X POST -H "apikey: [API-KEY]" -H "Authorization: Bearer <refreshToken>" ^
     https://api.meinserver.de/auth/

Die Antwort enthält ein neues Paar. Das alte Refresh-Token ist damit verbraucht: ein zweiter Aufruf mit demselben Token antwortet 401. Erfolgt er innerhalb von 60 Sekunden, bleibt die Sitzung bestehen (paralleler Refresh der App); später gilt er als Wiedervorlage und die ganze Sitzung wird gesperrt - der Techniker muss sich neu anmelden, und der Vorfall steht mit IP im Protokoll.

Abmelden - DELETE auf denselben JWT-Endpunkt, Access-Token im Header:

curl -i -X DELETE -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
     https://api.meinserver.de/auth/
HTTP/1.1 204 No Content

Derselbe Token danach noch einmal verwendet:

curl -i -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
     https://api.meinserver.de/orders/4711
HTTP/1.1 401 Unauthorized
{
  "error": {
    "code":    "AUTH_EXPIRED",
    "uid":     "Y00G9TOK09",
    "message": "Die Sitzung ist nicht mehr gültig, bitte neu anmelden",
    "traceId": "20260819T091233123-00001A"
  }
}

Was zeigt das Beispiel?

  • Echte HTTP-Statuscodes aus dem Skript: 201, 403, 404, 409, 422, 500.
  • Eine Liste aus dem Körper lesen über oReader.Count und Pfade wie positionen[0].artikel - ohne Cast, ohne .Items[i].
  • Eine benannte Liste von Objekten schreiben (ArrBegin / ObjBegin() / ArrEnd) und Kommazahlen über oWriter.Num(name, wert, 2).
  • Erst prüfen, dann schreiben: Alle Positionen werden validiert, bevor die erste gebucht wird - sonst hinterlässt ein Fehler in Position 3 zwei gebuchte Zeilen und eine Fehlerantwort.
  • Den Stapel schließen, bevor ein Fehler gesetzt wird - ein offener ArrBegin ist auch bei oWriter.Error ein Strukturfehler.
  • Jeder Schreibzugriff wird auf Erfolg geprüft - ein gescheitertes SaveData darf nicht als 200 oder 201 beim Client ankommen (siehe Schreibfehler erkennen).
  • Optimistic Concurrency über ETag (GET) und If-Match (PUT) -> 409 VERSION_CONFLICT.
  • Idempotente Schreibzugriffe über den Idempotency-Key - vom Server erledigt, das Skript enthält dafür keine Zeile Code.
  • Rollenprüfung aus dem Token-Claim über oReader.Claim('roles'), nicht aus dem Body.
  • JWT mit Custom-Claims (tenant/roles) und Refresh-Token mit Rotation - vom Server erledigt, das Skript führt keine Sperrtabelle.
  • Anmelden, Erneuern und Abmelden über einen Endpunkt - Abmelden als DELETE, ohne eigene Endpunkt-Zeile und ohne Skript.
  • oWriter.JwtRevoke für den Fall, dass beim Abmelden zusätzlich Fachlogik laufen soll oder alle Geräte gemeint sind.
  • traceId im Fehler-Body für die Support-Nachverfolgung (auch als Header X-Trace-Id).