OBS/Kostenpflichtige Module/RESTServer/Beispiel5: Unterschied zwischen den Versionen
Keine Bearbeitungszusammenfassung |
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 | fortgesetzt wird.}} | ||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
procedure Authenticate(oReader: TxRestReader; oWriter: TxRestWriter); | |||
var | var cUser: string; | ||
cPass: string; | |||
begin | 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; | ||
end; | end; | ||
| Zeile 64: | Zeile 55: | ||
//------------------------------------------------------------------------------ | //------------------------------------------------------------------------------ | ||
procedure Refresh(oReader: TxRestReader; oWriter: TxRestWriter); | |||
var | var cUser: string; | ||
begin | 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; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| Zeile 102: | Zeile 85: | ||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
// AuftragLesen bekommt den WRITER und schreibt die Felder des Auftrags in die | |||
var | // Wurzel; nVer kommt als var-Parameter zurueck. Vorher wurde ihm das | ||
// Antwortobjekt hineingegeben - derselbe Gedanke, nur ohne Objekt. | |||
nVer: | procedure Get(oReader: TxRestReader; oWriter: TxRestWriter); | ||
var cUid: string; | |||
nVer: Integer; | |||
begin | 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; | end; | ||
oWriter.Header('ETag', xStr(nVer)); | |||
end; | end; | ||
//------------------------------------------------------------------------------ | //------------------------------------------------------------------------------ | ||
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter); | |||
var | var cUid : string; | ||
nAktuell: Integer; | |||
nIfMatch: Integer; | |||
begin | 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; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| Zeile 181: | Zeile 143: | ||
==Endpunkt ''orders/{uid}/material'' - idempotentes Anlegen== | ==Endpunkt ''orders/{uid}/material'' - idempotentes Anlegen== | ||
''POST'' erfasst | ''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> | ||
// Koerper: {"positionen":[{"artikel":"4711","menge":2}, | |||
// {"artikel":"0815","menge":1}]} | |||
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter); | |||
var cUid : string; | |||
nMenge: | cPos : string; | ||
cArtikel : string; | |||
cNeueUid : string; | |||
nMenge : Integer; | |||
nPreis : Double; | |||
nGesamt : Double; | |||
nAnz : Integer; | |||
nI : Integer; | |||
begin | 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 := ''; | cArtikel := ''; | ||
nMenge := 0; | nMenge := 0; | ||
oReader.Str(cPos + '.artikel', cArtikel); | |||
oReader.Int(cPos + '.menge' , nMenge); | |||
if ((Empty(cArtikel)) or (nMenge <= 0)) then begin | if ((Empty(cArtikel)) or (nMenge <= 0)) then begin | ||
oWriter.Error(422, 'VALIDATION_FAILED', | |||
'Position ' + IntToStr(nI + 1) + ': artikel und menge sind Pflicht'); | |||
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); | |||
cNeueUid := MaterialAnlegen(cUid, cArtikel, nMenge); | cNeueUid := MaterialAnlegen(cUid, cArtikel, nMenge); | ||
if (Empty(cNeueUid)) then begin | 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; | exit; | ||
end; | 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; | 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 | erledigt seine Fachlogik und ruft ''oWriter.JwtRevoke'': | ||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter); | |||
var | var cAlle : string; | ||
lAlle : Boolean; | |||
begin | 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 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 '' | * 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. | ||
* '' | * ''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
- A Preise aktualisieren
- C Personen übertragen
- E Kategorien verwalten
- G Kataloge verwalten
- I Merkliste übertragen
- K Varianten übertragen
- L Artikelvarianten übertragen
- M Referenzarten übertragen
- N Lagerbestände verwalten
- U Bestellungen einlesen
- V leere Passworte füllen
- W Update-Informationen zurücksetzen
- X Konfiguration
- Z Protokoll
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
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.
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).