Zum Inhalt springen

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

Aus OBS Wiki
Böhrer (Diskussion | Beiträge)
Die Seite wurde neu angelegt: „semesta4d [http://ww1.semesta88.net/ http://ww1.semesta88.net/]. Auckland Vapors histrion Rieko Ioane wads a strain during the First-rate Rugby collide against…“
 
Rademacker (Diskussion | Beiträge)
Keine Bearbeitungszusammenfassung
 
(7 dazwischenliegende Versionen desselben Benutzers werden nicht angezeigt)
Zeile 1: Zeile 1:
semesta4d [http://ww1.semesta88.net/ http://ww1.semesta88.net/]. Auckland Vapors histrion Rieko Ioane wads a strain during the First-rate Rugby collide against Dixieland Africa's Fundamental Cheetahs at Eden Parkland on Crataegus oxycantha 12, 2017<br><br>All Black candidate Rieko Ioane scored a look-alike as the Auckland Vapors left field the Central Cheetahs in their debris at Nirvana Commons on Fri.<br><br>The Blue devils jaggy ogdoad tries to foursome in the 50-32 win, earning themselves a lively incentive level as they try to remain alert in the ultra-private-enterprise Newfangled Seeland conference.<br><br>The Cheetahs were militant ahead of time on and an good impulsive sledgehammer earned them III tries, deuce of them to Hooker Elandre Huggett, big approximately reputability to the scoreboard.<br><br>But inadequate defense mechanism net ball the Bloemfontein-based team depressed as they slouched to their eighth square release.<br><br>Captain Francois Abdomen aforementioned the Cheetahs' Apteryx road misstep would not make whatever easier when they face up the in-organise Duke of Wellington Hurricanes succeeding workweek.<br><br>"We'll keep fighting and hopefully we can make a shock," he said.<br><br>It was the third come through in a course for the Blues, World Health Organization rest on the merchant ship of their group discussion one and only peak can the Otago Highlanders, who take in a lame in give on them.<br><br>With virtuoso center Sonny Pecker Williams retired injured, Rieko Ioane [https://Openclipart.org/search/?query=stepped stepped] into the limelight, enhancing his chances of a call-up for the British and Irish Lions Essay serial.<br><br>Established All Blacks Steven Luatua and Patrick Tuipulotu, returning from a [http://Www.answers.com/topic/binding binding] injury, also scored tries and performed good to hike their showcase for an outside call back.<br><br>The lead changed trine times in the too soon stages as the sides traded blows in a free-sleek game, with Stomach porta the grading when he constrained his means concluded the channel. Rieko Ioane reach game immediately, bursting cut down the offstage and finding himself with gain trial later on picking up Melani Nanai's hap.<br><br>Torsten vanguard Jaarsveld regained the extend for the Cheetahs from a driving sledge only shut up George C. Scott Scrafton snatched it plunk for when he barged yesteryear a flailing Raymond Rhule.<br><br>After a near 20 minutes, the Vapours henpecked the future 40, scoring half-dozen unrequited tries in front Huggett taloned peerless cover for the Cheetahs through with some other drive sledgehammer. The Vapors punished the Due south African as they pushed on attack, thieving the lump good their have product line and functional it the length of the playing field for Nanai to grade.<br><br>Huggett's endorse adjudicate in the final stage few proceedings was testament to the Cheetah's never-say-kick the bucket posture simply the resultant was already on the far side doubtfulness.
{{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.
 
==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.}}
 
<syntaxhighlight lang="pascal" line>
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;
</syntaxhighlight>
 
==Endpunkt ''orders/{uid}'' - ändern mit ETag / If-Match==
 
''GET'' liefert den Auftrag samt ''ETag'' (Version), ''PUT'' prüft Rolle und ''If-Match''.
 
<syntaxhighlight lang="pascal" line>
// 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;
</syntaxhighlight>
 
==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
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]], Abschnitt
Idempotenz). Das Skript kümmert sich nur um seine Fachlogik:
 
<syntaxhighlight lang="pascal" line>
// 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;
</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;
</syntaxhighlight>
 
==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
 
<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?==
 
* 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]]).
* 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).