Zum Inhalt springen

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

Aus OBS Wiki
Rademacker (Diskussion | Beiträge)
Keine Bearbeitungszusammenfassung
Rademacker (Diskussion | Beiträge)
Keine Bearbeitungszusammenfassung
 
Zeile 22: Zeile 22:
==JWT-Authentifizierungs-Skript (Zugang)==
==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'') 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'''.
{{Hinweis|Eine '''eigene Sperrtabelle für Refresh-jti ist nicht nötig'''.
Einmalgebrauch, Rotation und Widerruf führt der Server in ''RESTSRV_TOKEN'' -
Einmalgebrauch, Rotation und Widerruf führt der Server in ''RESTSRV_TOKEN'' -
das Skript entscheidet nur, '''ob''' und '''mit welchen Rechten''' die Sitzung
das Skript entscheidet nur, '''ob''' und '''mit welchen Rechten''' die Sitzung
fortgesetzt wird. Frühere Fassungen dieses Beispiels zeigten eine Skript-Tabelle;
fortgesetzt wird.}}
wer sie übernommen hat, kann sie entfernen.}}


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


function Refresh(oParams: TStrings; oBody: TJSONObject): string;
procedure Refresh(oReader: TxRestReader; oWriter: TxRestWriter);
var oRes : TJSONObject;
var cUser: string;
    cUser: string;
begin
begin
     oRes := TJSONObject.Create();
     cUser := oReader.Subject();
    try
        cUser := oParams.Values['_OBS_JWT_SUBJECT'];


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


        // Mandant und Rollen NEU lesen, nicht aus dem alten Token uebernehmen -
    // Mandant und Rollen NEU lesen, nicht aus dem alten Token uebernehmen -
        // sonst wirkt ein Rechteentzug erst beim naechsten Login.
    // sonst wirkt ein Rechteentzug erst beim naechsten Login.
        oRes.AddPair('status'              , 1);
    oWriter.Int('status'              , 1);
        oRes.AddPair('_OBS_JWT_ID'          , cUser);
    oWriter.Str('_OBS_JWT_ID'          , cUser);
        oRes.AddPair('_OBS_JWT_SUBJECT'    , cUser);
    oWriter.Str('_OBS_JWT_SUBJECT'    , cUser);
        oRes.AddPair('_OBS_JWT_CLAIM_tenant', TechnikerMandant(cUser));
    oWriter.Str('_OBS_JWT_CLAIM_tenant', TechnikerMandant(cUser));
        oRes.AddPair('_OBS_JWT_CLAIM_roles' , TechnikerRollen(cUser));
    oWriter.Str('_OBS_JWT_CLAIM_roles' , TechnikerRollen(cUser));
        oRes.AddPair('_OBS_JWT_REFRESH_ID'  , GlobalUID());
    oWriter.Str('_OBS_JWT_REFRESH_ID'  , GlobalUID());
        result := oRes.ToJSON();
    finally
        MyFreeAndNil(oRes);
    end;
end;
end;
</syntaxhighlight>
</syntaxhighlight>
Zeile 102: Zeile 85:


<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.
        // Mit Trennzeichen suchen: ein blosses Pos('lead', ...) wuerde auch in
    if (Pos(',lead,', ',' + oReader.Claim('roles') + ',') = 0) then begin
        // "leadless" oder "teamlead" treffen.
        oWriter.Error(403, 'FORBIDDEN_ROLE', 'Diese Rolle darf nicht aendern');
        if (Pos(',lead,', ',' + oParams.Values['_OBS_JWT_CLAIM_roles'] + ',') = 0) then begin
        exit;
            oRes.AddPair('_OBS_HTTP_STATUS', 403);
    end;
            oRes.AddPair('error', TJSONObject.Create.AddPair('code', 'FORBIDDEN_ROLE'));
            result := oRes.ToJSON();
            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 gibt den Rueckgabewert von SaveData durch. Ohne
    // AuftragSpeichern gibt den Rueckgabewert von SaveData durch. Ohne
        // diese Pruefung antwortet der Endpunkt mit 200 und einem neuen ETag,
    // diese Pruefung antwortet der Endpunkt mit 200 und einem neuen ETag,
        // obwohl nichts geschrieben wurde - siehe Scripting, Schreibfehler
    // obwohl nichts geschrieben wurde - siehe Scripting, Schreibfehler
        // erkennen.
    // erkennen.
        if (not AuftragSpeichern(cUid, oBody)) then begin  // setzt Version auf nAktuell + 1
    if (not AuftragSpeichern(cUid, oReader)) then begin  // setzt Version auf nAktuell + 1
            oRes.AddPair('_OBS_HTTP_STATUS', 500);
        oWriter.Error(500, 'INTERNAL_ERROR', 'Der Auftrag konnte nicht gespeichert werden');
            oRes.AddPair('error', TJSONObject.Create.AddPair('code', 'INTERNAL_ERROR'));
        exit;
            result := oRes.ToJSON();
    end;
            exit;
        end;


        oHdr := TJSONObject.Create();
    oWriter.Header('ETag', xStr(nAktuell + 1));
        oHdr.AddPair('ETag', xStr(nAktuell + 1));
        oRes.AddPair('_OBS_HEADERS', oHdr);
        result := oRes.ToJSON();
    finally
        MyFreeAndNil(oRes);
    end;
end;
end;
</syntaxhighlight>
</syntaxhighlight>
Zeile 181: Zeile 143:
==Endpunkt ''orders/{uid}/material'' - idempotentes Anlegen==
==Endpunkt ''orders/{uid}/material'' - idempotentes Anlegen==


''POST'' erfasst eine Materialposition: Validierungsfehler -> 422, erfolgreiches Anlegen -> 201.
''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
Gegen doppelte Sendungen schickt der Client den Header ''Idempotency-Key''. '''Das
Zeile 191: Zeile 153:


<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);
     cUid, cArtikel, 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'];
    // 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;


        // 1) Validierung -> 422 mit traceId.
    // 2) ALLE Positionen pruefen, BEVOR die erste gebucht wird. Sonst
        //    Wichtig: fachliche Ablehnung als 4xx melden, nicht als 200 mit
    //    haette ein Fehler in Position 3 zwei gebuchte Zeilen und eine
        //    Fehlertext - nur dann gibt der Server den Idempotency-Key wieder
    //    Fehlerantwort hinterlassen - der Client wiederholt und bucht die
        //    frei und der Client darf ihn nach Korrektur erneut verwenden.
    //    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);


        // 2) Buchen. Die Transaktionssteuerung bleibt beim Skript; der
        //    Idempotenz-Speicher des Servers läuft getrennt davon und
        //    umschliesst diesen Block nicht.
         cNeueUid := MaterialAnlegen(cUid, cArtikel, nMenge);
         cNeueUid := MaterialAnlegen(cUid, cArtikel, nMenge);
        //    MaterialAnlegen liefert Leerstring, wenn SaveData fehlgeschlagen
        //    ist. Diese Pruefung ist Pflicht: ein 201 auf einen gescheiterten
        //    Schreibvorgang ist der Fehler, den niemand findet - der Client
        //    haelt die Position fuer gebucht und wiederholt nicht einmal.
         if (Empty(cNeueUid)) then begin
         if (Empty(cNeueUid)) then begin
             oErr := TJSONObject.Create();
             // Der Stapel wird GESCHLOSSEN, bevor der Fehler gesetzt wird:
             oErr.AddPair('code'  , 'INTERNAL_ERROR');
             // oWriter.Error verwirft zwar den begonnenen Koerper, aber ein
             oErr.AddPair('message', 'Die Position konnte nicht gebucht werden');
            // offener ArrBegin bleibt ein Strukturfehler - dann antwortet der
             oErr.AddPair('traceId', oParams.Values['_OBS_TRACE_ID']);
             // Server 500 mit "Verschachtelung nicht geschlossen" statt mit
             oRes.AddPair('_OBS_HTTP_STATUS', 500);
             // dieser Meldung.
            oRes.AddPair('error', oErr);
            oWriter.ArrEnd;
            result := oRes.ToJSON();
             oWriter.Error(500, 'INTERNAL_ERROR',
                          'Position ' + IntToStr(nI + 1) + ' konnte nicht gebucht werden');
             exit;
             exit;
         end;
         end;


         // 3) Antwort vollständig aufbauen - genau sie wird eingefroren und bei
         nPreis  := ArtikelPreis(cArtikel) * nMenge;
         //   einer Wiederholung erneut ausgeliefert.
        nGesamt := nGesamt + nPreis;
        oRes.AddPair('_OBS_HTTP_STATUS', 201);
 
        oRes.AddPair('uid', cNeueUid);
         oWriter.ObjBegin('');            // leerer Name = Listeneintrag
        result := oRes.ToJSON();
            oWriter.Str('uid'   , cNeueUid);
    finally
            oWriter.Str('artikel', cArtikel);
        MyFreeAndNil(oRes);
            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;
     end;
    oWriter.ArrEnd;
    oWriter.Num('gesamt', nGesamt, 2);
    oWriter.Status(201);
end;
end;
</syntaxhighlight>
</syntaxhighlight>
Zeile 267: Zeile 261:
Nur wenn beim Abmelden zusätzlich etwas passieren soll - hier: die
Nur wenn beim Abmelden zusätzlich etwas passieren soll - hier: die
Geräteregistrierung für Push löschen -, braucht es einen eigenen Endpunkt. Er
Geräteregistrierung für Push löschen -, braucht es einen eigenen Endpunkt. Er
erledigt seine Fachlogik und setzt ''_OBS_JWT_REVOKE'':
erledigt seine Fachlogik und ruft ''oWriter.JwtRevoke'':


<syntaxhighlight lang="pascal" line>
<syntaxhighlight lang="pascal" line>
function Post(oParams: TStrings; oBody: TJSONObject): string;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);
var oRes : TJSONObject;
var cAlle : string;
    oVal : TJSONValue;
     lAlle : Boolean;
     cAlle: string;
begin
begin
     oRes := TJSONObject.Create();
     PushRegistrierungLoeschen(oReader.Claim('technikerUid'));
    try
        PushRegistrierungLoeschen(oParams.Values['_OBS_JWT_CLAIM_technikerUid']);


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


        oRes.AddPair('_OBS_JWT_REVOKE' , cAlle);
    oWriter.JwtRevoke(cAlle);
        oRes.AddPair('_OBS_HTTP_STATUS', 204);
    oWriter.Status(204);
        result := oRes.ToJSON();
    oWriter.NoBody();
    finally
        MyFreeAndNil(oRes);
    end;
end;
end;
</syntaxhighlight>
</syntaxhighlight>
Zeile 330: 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


Zeile 376: Zeile 360:


* Echte HTTP-Statuscodes aus dem Skript: 201, 403, 404, 409, 422, 500.
* 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 [[OBS/Kostenpflichtige Module/RESTServer/Scripting#Schreibfehler erkennen|Schreibfehler erkennen]]).
* 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.
* 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.
* Idempotente Schreibzugriffe über den ''Idempotency-Key'' - '''vom Server erledigt''', das Skript enthält dafür keine Zeile Code.
* Rollenprüfung aus dem Token-Claim ''_OBS_JWT_CLAIM_roles'', nicht aus dem Body.
* 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.
* 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.
* Anmelden, Erneuern und Abmelden über '''einen''' Endpunkt - Abmelden als ''DELETE'', ohne eigene Endpunkt-Zeile und ohne Skript.
* ''_OBS_JWT_REVOKE'' für den Fall, dass beim Abmelden zusätzlich Fachlogik laufen soll oder alle Geräte gemeint sind.
* ''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'').
* ''traceId'' im Fehler-Body für die Support-Nachverfolgung (auch als Header ''X-Trace-Id'').

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

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