Zum Inhalt springen

OBS/Kostenpflichtige Module/RESTServer/Beispiel5: Unterschied zwischen den Versionen

Aus OBS Wiki
Rademacker (Diskussion | Beiträge)
Die Seite wurde neu angelegt: „{{Kostenpflichtige Module}} =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.…“
Rademacker (Diskussion | Beiträge)
Keine Bearbeitungszusammenfassung
 
(6 dazwischenliegende Versionen desselben Benutzers werden nicht angezeigt)
Zeile 3: Zeile 3:
=Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz=
=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.
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==
==Einrichtung in OBS==
Zeile 9: Zeile 9:
* '''Server-Profil:''' Standard-TLS-Profil ''Public-API'' (Port 443)
* '''Server-Profil:''' Standard-TLS-Profil ''Public-API'' (Port 443)
* '''Zugang:''' ''Mobile-App''
* '''Zugang:''' ''Mobile-App''
** API-Key: zufÄllig generiert
** API-Key: zufällig generiert
** JWT aktiv, JWT-Endpunkt ''auth'', JWT-Key zufÄllig, JWT-Exp 60 (Minuten)
** JWT aktiv, JWT-Endpunkt ''auth'', JWT-Key zufällig, JWT-Exp 60 (Minuten)
* '''Endpunkte''' (beide dem Profil ''Public-API'' zugeordnet):
* '''Endpunkte''' (beide dem Profil ''Public-API'' zugeordnet):
** ''orders/{uid}'' - Auftrag lesen/Ändern
** ''orders/{uid}'' - Auftrag lesen/Ändern
** ''orders/{uid}/material'' - Material erfassen
** ''orders/{uid}/material'' - Material erfassen
* '''Berechtigung:''' Zugang ''Mobile-App'' fÜr beide Endpunkte freigeschaltet
* '''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)==
==JWT-Authentifizierungs-Skript (Zugang)==


Stellt Mandant und Rollen als Custom-Claims aus und legt ein Refresh-Token an (das jti wird in einer eigenen Sperrtabelle abgelegt). Bei einem Refresh ruft der Server im selben Skript die Methode ''Refresh'' auf. Die Hilfsfunktionen (''PasswortPasst'', ''RefreshJti...'') sind illustrativ und projektabhÄngig.
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.}}


<syntaxhighlight lang="pascal" line>
<syntaxhighlight lang="pascal" line>
function Authenticate(oParams: TStrings; oBody: TJSONObject): string;
procedure Authenticate(oReader: TxRestReader; oWriter: TxRestWriter);
var oRes : TJSONObject;
var cUser: string;
     oVal : TJSONValue;
     cPass: string;
    cUser, cPass, cRefreshJti: string;
begin
begin
     oRes := TJSONObject.Create();
     cUser := '';
    try
    cPass := '';
        cUser := ''; cPass := '';
    oReader.Str('username', cUser);
        if (Assigned(oBody)) then begin
    oReader.Str('password', cPass);
            oVal := oBody.GetValue('username'); if (Assigned(oVal)) then cUser := oVal.Value;
            oVal := oBody.GetValue('password'); if (Assigned(oVal)) then cPass := oVal.Value;
        end;
 
        if (PasswortPasst(cUser, cPass)) then begin
            cRefreshJti := GlobalUID();
            RefreshJtiAblegen(cUser, cRefreshJti);   // eigene Sperrtabelle


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


function Refresh(oParams: TStrings; oBody: TJSONObject): string;
procedure Refresh(oReader: TxRestReader; oWriter: TxRestWriter);
var oRes: TJSONObject;
var cUser: string;
    cAltesJti, cNeuesJti, cUser: string;
begin
begin
     oRes := TJSONObject.Create();
     cUser := oReader.Subject();
    try
        cAltesJti := oParams.Values['_OBS_JWT_ID'];      // altes Refresh-jti
        cUser    := oParams.Values['_OBS_JWT_SUBJECT'];


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


        cNeuesJti := GlobalUID();
    // Mandant und Rollen NEU lesen, nicht aus dem alten Token uebernehmen -
        RefreshJtiRotieren(cAltesJti, cNeuesJti);        // altes sperren, neues ablegen
    // sonst wirkt ein Rechteentzug erst beim naechsten Login.
 
    oWriter.Int('status'              , 1);
        oRes.AddPair('status'              , 1);
    oWriter.Str('_OBS_JWT_ID'          , cUser);
        oRes.AddPair('_OBS_JWT_ID'          , cUser);
    oWriter.Str('_OBS_JWT_SUBJECT'    , cUser);
        oRes.AddPair('_OBS_JWT_SUBJECT'    , cUser);
    oWriter.Str('_OBS_JWT_CLAIM_tenant', TechnikerMandant(cUser));
        oRes.AddPair('_OBS_JWT_CLAIM_tenant', oParams.Values['_OBS_JWT_CLAIM_tenant']);
    oWriter.Str('_OBS_JWT_CLAIM_roles' , TechnikerRollen(cUser));
        oRes.AddPair('_OBS_JWT_CLAIM_roles' , oParams.Values['_OBS_JWT_CLAIM_roles']);
    oWriter.Str('_OBS_JWT_REFRESH_ID'  , GlobalUID());
        oRes.AddPair('_OBS_JWT_REFRESH_ID'  , cNeuesJti);
        result := oRes.ToJSON();
    finally
        MyFreeAndNil(oRes);
    end;
end;
end;
</syntaxhighlight>
</syntaxhighlight>
Zeile 91: Zeile 82:
==Endpunkt ''orders/{uid}'' - ändern mit ETag / If-Match==
==Endpunkt ''orders/{uid}'' - ändern mit ETag / If-Match==


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


<syntaxhighlight lang="pascal" line>
<syntaxhighlight lang="pascal" line>
function Get(oParams: TStrings; oBody: TJSONObject): string;
// AuftragLesen bekommt den WRITER und schreibt die Felder des Auftrags in die
var oRes, oHdr: TJSONObject;
// Wurzel; nVer kommt als var-Parameter zurueck. Vorher wurde ihm das
    cUid: string;
// Antwortobjekt hineingegeben - derselbe Gedanke, nur ohne Objekt.
     nVer: integer;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);
var cUid: string;
     nVer: Integer;
begin
begin
     oRes := TJSONObject.Create();
     cUid := oReader.Path('uid');
     try
     if (not AuftragLesen(oWriter, oReader.Claim('tenant'), cUid, nVer)) then begin
        cUid := oParams.Values['_OBS_PATH_uid'];
        oWriter.Error(404, 'NOT_FOUND', 'Auftrag nicht gefunden');
        if (not AuftragLesen(oParams.Values['_OBS_JWT_CLAIM_tenant'], cUid, oRes, nVer)) then begin
         exit;
            oRes.AddPair('_OBS_HTTP_STATUS', 404);
            oRes.AddPair('error', TJSONObject.Create.AddPair('code', 'NOT_FOUND'));
            result := oRes.ToJSON();
            exit;
        end;
        oHdr := TJSONObject.Create();
        oHdr.AddPair('ETag', xStr(nVer));
        oRes.AddPair('_OBS_HEADERS', oHdr);
         result := oRes.ToJSON();
    finally
        MyFreeAndNil(oRes);
     end;
     end;
    oWriter.Header('ETag', xStr(nVer));
end;
end;


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


function Put(oParams: TStrings; oBody: TJSONObject): string;
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);
var oRes, oHdr: TJSONObject;
var cUid    : string;
     cUid: string;
     nAktuell: Integer;
     nAktuell, nIfMatch: integer;
     nIfMatch: Integer;
begin
begin
     oRes := TJSONObject.Create();
     // nur Rolle "lead" darf ändern - Rolle kommt aus dem Token, nicht aus dem Body.
    try
    // Mit Trennzeichen suchen: ein blosses Pos('lead', ...) wuerde auch in
        // nur Rolle "lead" darf Ändern - Rolle kommt aus dem Token, nicht aus dem Body
    // "leadless" oder "teamlead" treffen.
        if (Pos('lead', oParams.Values['_OBS_JWT_CLAIM_roles']) = 0) then begin
    if (Pos(',lead,', ',' + oReader.Claim('roles') + ',') = 0) then begin
            oRes.AddPair('_OBS_HTTP_STATUS', 403);
        oWriter.Error(403, 'FORBIDDEN_ROLE', 'Diese Rolle darf nicht aendern');
            oRes.AddPair('error', TJSONObject.Create.AddPair('code', 'FORBIDDEN_ROLE'));
        exit;
            result := oRes.ToJSON();
    end;
            exit;
        end;


        cUid    := oParams.Values['_OBS_PATH_uid'];
    cUid    := oReader.Path('uid');
        nAktuell := AuftragVersion(cUid);
    nAktuell := AuftragVersion(cUid);
        nIfMatch := iVal(oParams.Values['if-match']);
    nIfMatch := iVal(oReader.Param('if-match'));


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


        AuftragSpeichern(cUid, oBody);   // setzt Version auf nAktuell + 1
    // AuftragSpeichern gibt den Rueckgabewert von SaveData durch. Ohne
         oHdr := TJSONObject.Create();
    // diese Pruefung antwortet der Endpunkt mit 200 und einem neuen ETag,
        oHdr.AddPair('ETag', xStr(nAktuell + 1));
    // obwohl nichts geschrieben wurde - siehe Scripting, Schreibfehler
        oRes.AddPair('_OBS_HEADERS', oHdr);
    // erkennen.
        result := oRes.ToJSON();
    if (not AuftragSpeichern(cUid, oReader)) then begin   // setzt Version auf nAktuell + 1
    finally
         oWriter.Error(500, 'INTERNAL_ERROR', 'Der Auftrag konnte nicht gespeichert werden');
         MyFreeAndNil(oRes);
         exit;
     end;
     end;
    oWriter.Header('ETag', xStr(nAktuell + 1));
end;
end;
</syntaxhighlight>
</syntaxhighlight>
Zeile 161: Zeile 143:
==Endpunkt ''orders/{uid}/material'' - idempotentes Anlegen==
==Endpunkt ''orders/{uid}/material'' - idempotentes Anlegen==


''POST'' erfasst eine Materialposition: Validierungsfehler -> 422, erfolgreiches Anlegen -> 201. Ein wiederholter Aufruf mit gleichem ''Idempotency-Key'' liefert dieselbe Antwort ohne Zweitbuchung. Der Idempotenz-Eintrag wird in derselben Transaktion wie die Buchung gespeichert.
''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
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]], Abschnitt
Idempotenz). Das Skript kümmert sich nur um seine Fachlogik:


<syntaxhighlight lang="pascal" line>
<syntaxhighlight lang="pascal" line>
function Post(oParams: TStrings; oBody: TJSONObject): string;
// Koerper:  {"positionen":[{"artikel":"4711","menge":2},
var oRes, oErr: TJSONObject;
//                          {"artikel":"0815","menge":1}]}
     oVal: TJSONValue;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);
     cKey, cUid, cArtikel, cGespeichert, cNeueUid: string;
var cUid    : string;
     nMenge: integer;
    cPos    : string;
     cArtikel : string;
     cNeueUid : string;
     nMenge   : Integer;
    nPreis  : Double;
    nGesamt  : Double;
    nAnz    : Integer;
    nI      : Integer;
begin
begin
     oRes := TJSONObject.Create();
     cUid := oReader.Path('uid');
    try
        cUid := oParams.Values['_OBS_PATH_uid'];
        cKey := oParams.Values['idempotency-key'];


        // 1) Idempotenz: gleicher Key bereits verarbeitet?
    // 1) Gestalt pruefen. Count liefert 0, wenn das Feld fehlt ODER keine
        if ((not Empty(cKey)) and IdempotenzAntwort(cKey, cGespeichert)) then begin
    //    Liste ist - IsArr unterscheidet die beiden Faelle. Ein Objekt, wo
            result := cGespeichert;   // identische Antwort, keine Zweitbuchung
    //    eine Liste erwartet wird, ist etwas anderes als eine leere Liste.
            exit;
    nAnz := oReader.Count('positionen');
        end;
    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) Validierung -> 422 mit traceId
    // 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 := '';
         cArtikel := '';
         nMenge  := 0;
         nMenge  := 0;
         if (Assigned(oBody)) then begin
         oReader.Str(cPos + '.artikel', cArtikel);
            oVal := oBody.GetValue('artikel'); if (Assigned(oVal)) then cArtikel := oVal.Value;
        oReader.Int(cPos + '.menge' , nMenge);
            oVal := oBody.GetValue('menge');  if (Assigned(oVal)) then nMenge   := iVal(oVal.Value);
        end;
         if ((Empty(cArtikel)) or (nMenge <= 0)) then begin
         if ((Empty(cArtikel)) or (nMenge <= 0)) then begin
             oErr := TJSONObject.Create();
             oWriter.Error(422, 'VALIDATION_FAILED',
            oErr.AddPair('code'  , 'VALIDATION_FAILED');
                          'Position ' + IntToStr(nI + 1) + ': artikel und menge sind Pflicht');
            oErr.AddPair('message', 'artikel und menge sind Pflicht');
            oErr.AddPair('traceId', oParams.Values['_OBS_TRACE_ID']);
            oRes.AddPair('_OBS_HTTP_STATUS', 422);
            oRes.AddPair('error', oErr);
            result := oRes.ToJSON();
             exit;
             exit;
         end;
         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);


        // 3) In EINER Transaktion: Position anlegen + Idempotenz-Eintrag committen
        //    (Transaktionssteuerung Über die OBS-Skript-DB-API)
         cNeueUid := MaterialAnlegen(cUid, cArtikel, 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;


         oRes.AddPair('_OBS_HTTP_STATUS', 201);
         nPreis  := ArtikelPreis(cArtikel) * nMenge;
         oRes.AddPair('uid', cNeueUid);
         nGesamt := nGesamt + nPreis;
        result := oRes.ToJSON();


         if (not Empty(cKey)) then begin
         oWriter.ObjBegin('');            // leerer Name = Listeneintrag
             IdempotenzSpeichern(cKey, result);   // in derselben Transaktion
            oWriter.Str('uid'    , cNeueUid);
        end;
            oWriter.Str('artikel', cArtikel);
    finally
             oWriter.Int('menge'  , nMenge);
        MyFreeAndNil(oRes);
            // 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;
     end;
    oWriter.ArrEnd;
    oWriter.Num('gesamt', nGesamt, 2);
    oWriter.Status(201);
end;
</syntaxhighlight>
==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'':
<syntaxhighlight lang="pascal" line>
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;
end;
</syntaxhighlight>
</syntaxhighlight>
Zeile 218: Zeile 285:
==Test mit curl==
==Test mit curl==


Token holen (Antwort enthÄlt token, refreshToken und serverTime):
Token holen:


  curl -X POST -H "apikey: [API-KEY]" -H "Content-Type: application/json" ^
  curl -X POST -H "apikey: [API-KEY]" -H "Content-Type: application/json" ^
Zeile 224: Zeile 291:
       https://api.meinserver.de/auth/
       https://api.meinserver.de/auth/


  {"token":"eyJ...","refreshToken":"eyJ...","serverTime":"2026-06-29T15:30:12+02:00"}
  {"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):
Auftrag lesen (liefert den ETag-Header):
Zeile 243: Zeile 314:
  curl -i -X POST -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
  curl -i -X POST -H "apikey: [API-KEY]" -H "Authorization: Bearer eyJ..." ^
       -H "Idempotency-Key: 9c84-7f2a-..." -H "Content-Type: application/json" ^
       -H "Idempotency-Key: 9c84-7f2a-..." -H "Content-Type: application/json" ^
       -d "{\"artikel\":\"A100\",\"menge\":3}" ^
       -d "{\"positionen\":[{\"artikel\":\"A100\",\"menge\":3},{\"artikel\":\"B200\",\"menge\":1}]}" ^
       https://api.meinserver.de/orders/4711/material
       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):
Token erneuern (Refresh-Token im Authorization-Header an denselben JWT-Endpunkt):
Zeile 250: Zeile 325:
  curl -X POST -H "apikey: [API-KEY]" -H "Authorization: Bearer <refreshToken>" ^
  curl -X POST -H "apikey: [API-KEY]" -H "Authorization: Bearer <refreshToken>" ^
       https://api.meinserver.de/auth/
       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
<pre>
{
  "error": {
    "code":    "AUTH_EXPIRED",
    "uid":    "Y00G9TOK09",
    "message": "Die Sitzung ist nicht mehr gültig, bitte neu anmelden",
    "traceId": "20260819T091233123-00001A"
  }
}
</pre>


==Was zeigt das Beispiel?==
==Was zeigt das Beispiel?==


* Echte HTTP-Statuscodes aus dem Skript: 201, 403, 404, 409, 422.
* Echte HTTP-Statuscodes aus dem Skript: 201, 403, 404, 409, 422, 500.
* Optimistic Concurrency Über ''ETag'' (GET) und ''If-Match'' (PUT) -> 409 VERSION_CONFLICT.
* Eine '''Liste aus dem Körper lesen''' über ''oReader.Count'' und Pfade wie ''positionen[0].artikel'' - ohne Cast, ohne ''.Items[i]''.
* Idempotente Schreibzugriffe Über den ''Idempotency-Key'' (Schutz gegen Doppelbuchung).
* Eine '''benannte Liste von Objekten schreiben''' (''ArrBegin'' / ''ObjBegin('')'' / ''ArrEnd'') und '''Kommazahlen''' über ''oWriter.Num(name, wert, 2)''.
* RollenprÜfung aus dem Token-Claim ''_OBS_JWT_CLAIM_roles'', nicht aus dem Body.
* '''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.
* JWT mit Custom-Claims (''tenant''/''roles'') und Refresh-Token mit Rotation.
* '''Den Stapel schließen, bevor ein Fehler gesetzt wird''' - ein offener ''ArrBegin'' ist auch bei ''oWriter.Error'' ein Strukturfehler.
* ''traceId'' im Fehler-Body fÜr die Support-Nachverfolgung (auch als Header ''X-Trace-Id'').
* Jeder Schreibzugriff wird auf Erfolg geprüft - ein gescheitertes ''SaveData'' darf nicht als 200 oder 201 beim Client ankommen (siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting#Schreibfehler erkennen|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'').

Aktuelle Version vom 9. September 2026, 13:31 Uhr

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).