OBS/Kostenpflichtige Module/RESTServer/Scripting: Unterschied zwischen den Versionen
Keine Bearbeitungszusammenfassung |
Keine Bearbeitungszusammenfassung |
||
| (5 dazwischenliegende Versionen desselben Benutzers werden nicht angezeigt) | |||
| Zeile 23: | Zeile 23: | ||
function Patch (oParams: TStrings; oBody: TJSONObject): string; | function Patch (oParams: TStrings; oBody: TJSONObject): string; | ||
Fehlt die zur Methode passende Funktion, antwortet der Server mit '''405 Method | |||
Not Allowed''' (<code>METHOD_NOT_ALLOWED</code>) und nennt im Header | |||
<code>Allow</code> die Verben, die dieses Skript tatsächlich anbietet - die Liste | |||
stammt aus dem kompilierten Skript und kann deshalb nicht veralten: | |||
HTTP/1.1 405 Method Not Allowed | |||
Allow: GET, PUT | |||
Ein im Vertrag zugesagtes Verb braucht also seine Funktion. Fehlt sie, ist das an | |||
der Antwort sofort zu erkennen und '''kein''' Serverfehler. | |||
==Parameter== | ==Parameter== | ||
| Zeile 47: | Zeile 56: | ||
Enthält den Request-Body, sofern dieser ein gültiges JSON-Objekt ist. Bei leerem oder nicht-JSON-Body wird ''nil'' übergeben. | Enthält den Request-Body, sofern dieser ein gültiges JSON-Objekt ist. Bei leerem oder nicht-JSON-Body wird ''nil'' übergeben. | ||
{{Hinweis|'''Generics funktionieren im Skript nicht.''' Aus Delphi bekannte | |||
Schreibweisen wie ''oBody.TryGetValue<string>('feld', cVar)'' lassen sich nicht | |||
übersetzen. Gelesen wird über ''GetValue''.}} | |||
''GetValue'' liefert ''nil'', wenn das Feld im Body '''nicht enthalten''' ist - | |||
daran lässt sich „nicht gesendet" von „leer gesendet" unterscheiden. Der Wert ist | |||
immer ein '''String''' und wird bei Bedarf selbst gewandelt. | |||
cUser := ''; | cUser := ''; | ||
| Zeile 60: | Zeile 77: | ||
end; | end; | ||
end; | end; | ||
Bei mehr als zwei Feldern lohnt eine kleine Hilfsfunktion, die das Muster einmal | |||
kapselt: | |||
<syntaxhighlight lang="pascal" line> | |||
function _BodyStr(oBody: TJSONObject; const cFeld: string): string; | |||
var oVal: TJSONValue; | |||
begin | |||
result := ''; | |||
if (oBody = nil) then begin | |||
exit; | |||
end; | |||
oVal := oBody.GetValue(cFeld); | |||
if (Assigned(oVal)) then begin | |||
result := oVal.Value; | |||
end; | |||
end; | |||
// Aufruf: | |||
cBetreff := _BodyStr(oBody, 'betreff'); | |||
</syntaxhighlight> | |||
Andere Typen entstehen aus dem String: | |||
{| class="wikitable" | |||
! Zieltyp !! Umwandlung | |||
|- | |||
| Ganzzahl || <code>iVal(_BodyStr(oBody, 'menge'))</code> | |||
|- | |||
| Kommazahl || <code>fVal(StrTran(_BodyStr(oBody, 'preis'), '.', ','))</code> - '''JSON liefert den Dezimalpunkt''', ''fVal'' erwartet die lokale Notation | |||
|- | |||
| Boolean || <code>Lower(_BodyStr(oBody, 'aktiv')) = 'true'</code> | |||
|- | |||
| Datum/Zeit || ISO-8601-String selbst zerlegen (''CToDT'' erwartet das deutsche Format) | |||
|} | |||
Maximale Body-Grösse: 10 MB. | Maximale Body-Grösse: 10 MB. | ||
| Zeile 70: | Zeile 122: | ||
! Parameter !! Inhalt | ! Parameter !! Inhalt | ||
|- | |- | ||
| _OBS_JWT_ID || JWT-Id (''jti''-Claim) - typisch die User-Id | | _OBS_JWT_ID || JWT-Id (''jti''-Claim) - typisch die User-Id. '''Muss nicht eindeutig sein''': die Sitzung führt der Server über eigene Kennungen (siehe unten) | ||
|- | |- | ||
| _OBS_JWT_SUBJECT || Subject (''sub''-Claim) - typisch Benutzername | | _OBS_JWT_SUBJECT || Subject (''sub''-Claim) - typisch Benutzername | ||
| Zeile 79: | Zeile 131: | ||
|- | |- | ||
| _OBS_TRACE_ID || Korrelations-ID des Requests (auch als Response-Header X-Trace-Id) | | _OBS_TRACE_ID || Korrelations-ID des Requests (auch als Response-Header X-Trace-Id) | ||
|} | |||
Unabhängig von JWT stehen ausserdem immer zur Verfügung: | |||
{| class="wikitable" | |||
! Parameter !! Inhalt | |||
|- | |||
| _OBS_SERVER_TIME || Aktuelle Serverzeit als ISO-8601 '''mit UTC-Offset''' (z.B. ''2026-08-17T09:12:33+02:00''). Nützlich, um Datumswerte in derselben Zeitzone auszuliefern, in der der Server arbeitet, und damit ein Client seinen Uhren-Versatz bestimmen kann | |||
|- | |||
| _OBS_JWT_EXP_SEC || Laufzeit des Access-Tokens in Sekunden (''JWT-Exp'' × 60). Damit kann ein Konfigurations-Endpunkt denselben Wert ausliefern, den der Token wirklich hat | |||
|} | |} | ||
Diese Werte werden vom Server gesetzt und können vom Skript für Berechtigungs- und Mandantenprüfungen verwendet werden. | Diese Werte werden vom Server gesetzt und können vom Skript für Berechtigungs- und Mandantenprüfungen verwendet werden. | ||
Zusätzlich trägt jedes Token zwei '''Sitzungskennungen des Servers''', lesbar als | |||
''_OBS_JWT_CLAIM_sid'' und ''_OBS_JWT_CLAIM_rid'': | |||
{| class="wikitable" | |||
! Claim !! Inhalt | |||
|- | |||
| sid || Sitzung. Bleibt über alle Erneuerungen (Refresh) hinweg gleich und ist der Schlüssel für das Abmelden | |||
|- | |||
| rid || Zeilenkennung des einzelnen Token-Paares in ''RESTSRV_TOKEN''. Bei jeder Erneuerung neu | |||
|} | |||
Beide sind OBS-UIDs (10 Zeichen) und werden vom Server '''nach''' den Claims des | |||
Skripts gesetzt - ein Skript kann sie also auch mit ''_OBS_JWT_CLAIM_sid'' nicht | |||
überschreiben. Für Fachlogik sind sie nicht gedacht; sie sind nützlich, um eine | |||
Sitzung im Protokoll wiederzufinden. | |||
===Pfad-Parameter=== | ===Pfad-Parameter=== | ||
| Zeile 120: | Zeile 198: | ||
|- | |- | ||
| _OBS_UPLOAD_CONTENTTYPE || Content-Type der Datei (Default <code>application/octet-stream</code>, wenn der Client keinen angibt) | | _OBS_UPLOAD_CONTENTTYPE || Content-Type der Datei (Default <code>application/octet-stream</code>, wenn der Client keinen angibt) | ||
|- | |||
| _OBS_UPLOAD_SHA256 || SHA-256 der '''gespeicherten''' Datei, hex in Kleinbuchstaben. Damit lässt sich eine vom Client mitgesendete Prüfsumme vergleichen - erst dieser Vergleich macht aus ihr eine Zusicherung | |||
|} | |} | ||
| Zeile 133: | Zeile 213: | ||
Eine Datei pro Request. Name und Content-Type stammen aus der | Eine Datei pro Request. Name und Content-Type stammen aus der | ||
<code>Content-Disposition</code> der Datei-Partie (<code>filename=</code>). Der | <code>Content-Disposition</code> der Datei-Partie (<code>filename=</code>). Der | ||
Server speichert die erste Datei-Partie und ruft das Skript auf: | Server speichert die erste Datei-Partie und ruft das Skript auf. | ||
Die '''übrigen Partien''' des Formulars - alles ohne <code>filename=</code> - | |||
übergibt der Server als <code>_OBS_FORM_<name></code>. Der Wert ist UTF-8 und | |||
auf 1024 Zeichen begrenzt, der Name auf Buchstaben, Ziffern und Unterstrich | |||
reduziert. Zusatzangaben lassen sich damit im selben Request mitschicken, ohne sie | |||
an die URL zu hängen: | |||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
| Zeile 243: | Zeile 329: | ||
==Antwort steuern: Statuscode, Header, traceId== | ==Antwort steuern: Statuscode, Header, traceId== | ||
Ein Skript kann den HTTP-Statuscode und beliebige Response-Header | Ein Skript kann den HTTP-Statuscode und beliebige Response-Header über reservierte Felder in der Antwort setzen. Der Server wertet sie aus und entfernt sie vor dem Senden aus dem Body. | ||
{| class="wikitable" | {| class="wikitable" | ||
| Zeile 251: | Zeile 337: | ||
|- | |- | ||
| _OBS_HEADERS (Objekt) || Beliebige Response-Header, z.B. ETag, Location, Retry-After. CR/LF in Namen/Werten werden entfernt (Schutz vor Header-Injection). | | _OBS_HEADERS (Objekt) || Beliebige Response-Header, z.B. ETag, Location, Retry-After. CR/LF in Namen/Werten werden entfernt (Schutz vor Header-Injection). | ||
|- | |||
| _OBS_JWT_REVOKE (Text) || Sperrt die Sitzung des vorgelegten Tokens (''session'') oder alle Sitzungen des Subjects (''all''). Damit wird ein Endpunkt zum Logout, ohne eigene Verwaltung. Scheitert das Sperren, antwortet der Server mit '''503''' statt mit der Antwort des Skripts - siehe [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]], Abschnitt Abmelden. | |||
|} | |} | ||
Zusätzlich | Zusätzlich trägt jede Antwort den Header '''X-Trace-Id''' (Korrelations-ID). Dieselbe ID liegt dem Skript als ''oParams.Values['_OBS_TRACE_ID']'' vor und erscheint in jeder Server-Fehler-Logzeile in '''RESTSRV_PROTO''' - so lässt sich ein Fehler ohne Gerätezugriff im Log wiederfinden. | ||
===Beispiel: Anlegen mit 201, Location und ETag=== | ===Beispiel: Anlegen mit 201, Location und ETag=== | ||
| Zeile 302: | Zeile 390: | ||
===Beispiel: Optimistic Concurrency (ETag / If-Match)=== | ===Beispiel: Optimistic Concurrency (ETag / If-Match)=== | ||
Mit Statuscode, Headern und dem lesbaren Header ''If-Match'' | Mit Statuscode, Headern und dem lesbaren Header ''If-Match'' führt das Skript je Datensatz einen Versionszähler und lehnt veraltete Schreibzugriffe mit 409 ab: | ||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
| Zeile 324: | Zeile 412: | ||
end; | end; | ||
// ... speichern, Version | // ... speichern, Version hochzählen ... | ||
oHdr := TJSONObject.Create(); | oHdr := TJSONObject.Create(); | ||
oHdr.AddPair('ETag', xStr(nAktuell + 1)); | oHdr.AddPair('ETag', xStr(nAktuell + 1)); | ||
| Zeile 334: | Zeile 422: | ||
end; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
==Idempotenz - was das Skript beachten muss== | |||
Sendet ein Konsument bei ''POST''/''PUT''/''PATCH''/''DELETE'' den Header | |||
<code>Idempotency-Key</code>, fängt der '''Server''' doppelte Sendungen ab. Das | |||
Skript braucht dafür '''keine eigene Logik''' - keine Schlüssel-Tabelle, keine | |||
Prüfung am Anfang der Methode. | |||
Was das Skript wissen muss: | |||
{| class="wikitable" | |||
! Antwort des Skripts !! Wirkung auf den Idempotenz-Speicher | |||
|- | |||
| '''2xx''' || Statuscode, Body und Header werden gespeichert. Eine Wiederholung mit demselben Schlüssel bekommt genau diese Antwort zurück, das Skript läuft nicht erneut | |||
|- | |||
| '''4xx''' || Der Schlüssel wird '''freigegeben'''. Der Konsument darf denselben Schlüssel nach Korrektur des Inhalts erneut verwenden | |||
|- | |||
| '''5xx''' oder Exception || Der Schlüssel bleibt belegt, der Fall wird protokolliert. Eine Wiederholung wird abgelehnt, bis der Fall geklärt ist | |||
|} | |||
Daraus folgen zwei Regeln für Endpunkt-Skripte: | |||
* '''Fachliche Ablehnungen als 4xx melden''' (422, 409, 404), nicht als 2xx mit Fehlertext im Body. Sonst wird die Ablehnung gespeichert und der Konsument bekommt sie bei jedem weiteren Versuch erneut - auch nachdem er den Fehler behoben hat. | |||
* '''Die Antwort muss vollständig sein.''' Was zurückgegeben wird, wird eingefroren. Ein Feld, das erst der zweite Aufruf ergänzen würde, kommt beim Konsumenten nie an. | |||
{{Hinweis|Vor dieser Server-Funktion mussten Skripte die Idempotenz selbst | |||
abbilden. Solche Skripte laufen unverändert weiter - die Doppelprüfung schadet | |||
nicht, ist aber überflüssig und kann beim nächsten Überarbeiten entfallen.}} | |||
Der vollständige Ablauf und die Statuscodes stehen unter | |||
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]. | |||
==JWT-Authentifizierungs-Skript== | ==JWT-Authentifizierungs-Skript== | ||
| Zeile 342: | Zeile 461: | ||
function Authenticate(oParams: TStrings; oBody: TJSONObject): string; | function Authenticate(oParams: TStrings; oBody: TJSONObject): string; | ||
var oRes : TJSONObject; | var oRes : TJSONObject; | ||
oVal : TJSONValue; | |||
cUser : string; | cUser : string; | ||
cPass : string; | cPass : string; | ||
| Zeile 386: | Zeile 506: | ||
| 1 || Erfolgreich, der Server erzeugt aus den ''_OBS_JWT_*''-Werten einen Token (HS256) | | 1 || Erfolgreich, der Server erzeugt aus den ''_OBS_JWT_*''-Werten einen Token (HS256) | ||
|- | |- | ||
| 9 || Misserfolg, der Server antwortet mit | | 9 || Misserfolg, der Server antwortet mit '''401''' und reicht den Wert von ''error'' als ''error.message'' an den Client durch (zusätzlich protokolliert) | ||
|} | |} | ||
{{Hinweis|Der Text aus ''error'' geht bei ''status'' = 9 '''an den Konsumenten''' | |||
und wird typischerweise direkt unter dem Passwortfeld angezeigt. Er sollte für | |||
Endanwender verständlich sein und keine internen Details verraten.}} | |||
Liefert das Skript einen anderen Status als 1 oder 9, ist gar nicht lauffähig oder | |||
gibt kein auswertbares JSON zurück, antwortet der Server mit '''403''' und einer | |||
generischen Meldung - ein defektes Anmeldeskript soll dem Anwender nicht als | |||
„Passwort falsch" erscheinen. | |||
Kann der Server die '''Sitzungszeile''' zum ausgestellten Token nicht schreiben | |||
(Tabelle fehlt, Datenbankproblem), liefert er '''kein Token''' aus und antwortet | |||
mit '''503''' und ''Retry-After''. Auch das ist bewusst kein 401/403: die | |||
Zugangsdaten waren richtig, der Versuch darf wiederholt werden. | |||
===Aufbau der Token-Antwort=== | |||
Bei Erfolg baut der Server die Antwort aus den Token-Feldern '''und allen weiteren | |||
Feldern, die das Skript zurückgibt''': | |||
{"benutzerId": "4711", "tenant": "nord", "roles": ["TECHNIKER"], | |||
"token": "eyJ...", | |||
"accessToken": "eyJ...", | |||
"refreshToken":"eyJ...", | |||
"expiresIn": 28800, | |||
"serverTime": "2026-08-17T09:12:33+02:00"} | |||
{| class="wikitable" | |||
! Feld !! Herkunft | |||
|- | |||
| token / accessToken || Derselbe Access-Token unter zwei Namen. ''accessToken'' erwarten die meisten Client-Bibliotheken, ''token'' bleibt für bestehende Konsumenten erhalten | |||
|- | |||
| refreshToken || Nur wenn das Skript ''_OBS_JWT_REFRESH_ID'' geliefert hat | |||
|- | |||
| expiresIn || Laufzeit des Access-Tokens in '''Sekunden''' (''JWT-Exp'' × 60) | |||
|- | |||
| serverTime || Serverzeit ISO-8601 mit Offset | |||
|- | |||
| alle übrigen Felder || '''Frei vom Skript bestimmt.''' Übernommen wird alles ausser ''status'' und den ''_OBS_''-Steuerfeldern | |||
|} | |||
Damit kann das Anmeldeskript alles mitliefern, was der Client direkt nach dem Login | |||
braucht (Benutzer-Id, Mandant, Rollen, Berechtigungen) - ohne dass dieser dafür | |||
einen zweiten Aufruf absetzen muss. Ein Feld, das genauso heisst wie eines der | |||
Token-Felder oben, wird verworfen; die Engine setzt diese selbst. | |||
===Custom-Claims, serverTime und Refresh=== | ===Custom-Claims, serverTime und Refresh=== | ||
Das Authenticate-Skript kann dem Token | Das Authenticate-Skript kann dem Token zusätzlich '''beliebige Custom-Claims''' mitgeben - z.B. Mandant und Rollen - und optional ein '''Refresh-Token''' anstoßen: | ||
{| class="wikitable" | {| class="wikitable" | ||
| Zeile 403: | Zeile 568: | ||
|} | |} | ||
Jedes Token | Jedes Token trägt automatisch den Claim ''token_use'' (''access'' bzw. ''refresh'') sowie die Sitzungskennungen ''sid'' und ''rid'' des Servers. Die Antwort enthält bei Erfolg ''serverTime'' (ISO-8601 mit Offset), bei ausgestelltem Refresh zusätzlich ''refreshToken'': | ||
{"token":"...", "refreshToken":"...", "serverTime":"2026-06-29T15:30:12+02:00"} | {"token":"...", "refreshToken":"...", "serverTime":"2026-06-29T15:30:12+02:00"} | ||
| Zeile 411: | Zeile 576: | ||
====Refresh-Methode==== | ====Refresh-Methode==== | ||
Der Refresh | Der Refresh läuft über '''denselben JWT-Endpunkt''' und '''dasselbe Skript'''. Liegt am JWT-Endpunkt ein ''Authorization: Bearer <token>'' mit einem Refresh-Token vor, ruft der Server statt ''Authenticate'' die Methode '''Refresh''' auf (ohne Bearer: Login; ''DELETE'' mit Access-Token: Abmelden, ohne Skript). | ||
Bevor das Skript läuft, hat der Server das vorgelegte Refresh-Token verifiziert | |||
(Signatur, Ablauf, ''token_use=refresh'') und in der Sitzung '''entwertet'''. | |||
Einmalgebrauch, Rotation und Sperrliste sind damit Sache des Servers. Das Skript | |||
liefert nur noch die aktuellen Claims - genau das ist der Sinn des Aufrufs: eine | |||
zwischenzeitlich geänderte Rolle oder ein gewechselter Mandant wirken spätestens | |||
mit der nächsten Erneuerung. | |||
{{Achtung|Eine '''eigene Sperrtabelle im Skript ist nicht mehr nötig''' und sollte | |||
entfernt werden. Wer sie weiterführt, rotiert zweimal - einmal im Skript, einmal im | |||
Server - und riskiert, dass beide Seiten unterschiedlicher Meinung sind. Der | |||
Server sieht die Skript-Tabelle nicht, und das Skript sieht die Sitzung nicht.}} | |||
Ein bereits benutztes Refresh-Token wird mit '''401''' abgelehnt. Erfolgt die | |||
erneute Vorlage innerhalb von 60 Sekunden, bleibt die Sitzung bestehen (paralleler | |||
Refresh einer App); danach gilt sie als Wiedervorlage und die '''gesamte Sitzung | |||
wird gesperrt'''. Details in [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]]. | |||
Das alte Refresh-jti steht weiterhin als ''oParams.Values['_OBS_JWT_ID']'' bereit, | |||
falls das Skript es für eigene Protokollierung braucht. | |||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
function Refresh(oParams: TStrings; oBody: TJSONObject): string; | function Refresh(oParams: TStrings; oBody: TJSONObject): string; | ||
var oRes: TJSONObject; | var oRes : TJSONObject; | ||
cUser: string; | |||
begin | begin | ||
oRes := TJSONObject.Create(); | oRes := TJSONObject.Create(); | ||
try | try | ||
cUser := oParams.Values['_OBS_JWT_SUBJECT']; | |||
// | // Der Server hat das vorgelegte Refresh-Token bereits geprueft und | ||
if (not | // entwertet. Hier wird nur entschieden, ob der Benutzer die Sitzung | ||
// fortsetzen darf - und mit welchen Rechten. | |||
if (not BenutzerAktiv(cUser)) then begin | |||
oRes.AddPair('status', 9); | oRes.AddPair('status', 9); | ||
oRes.AddPair('error' , ' | oRes.AddPair('error' , 'Konto ist nicht mehr aktiv, bitte neu anmelden'); | ||
result := oRes.ToJSON(); | result := oRes.ToJSON(); | ||
exit; | exit; | ||
end; | end; | ||
// Rolle und Mandant neu lesen, nicht aus dem alten Token uebernehmen - | |||
// sonst wirkt ein Rechteentzug erst beim naechsten Login. | |||
oRes.AddPair('status' , 1); | |||
oRes.AddPair('_OBS_JWT_ID' , cUser); | |||
oRes.AddPair('_OBS_JWT_SUBJECT' , cUser); | |||
oRes.AddPair('_OBS_JWT_CLAIM_tenant', TechnikerMandant(cUser)); | |||
oRes.AddPair('_OBS_JWT_CLAIM_roles' , TechnikerRollen(cUser)); | |||
oRes.AddPair('_OBS_JWT_REFRESH_ID' , GlobalUID()); // loest das neue Refresh-Token aus | |||
result := oRes.ToJSON(); | |||
finally | |||
MyFreeAndNil(oRes); | |||
end; | |||
end; | |||
</syntaxhighlight> | |||
===Abmelden (Logout)=== | |||
'''Für das Abmelden braucht das Skript keine Methode.''' Es läuft über ein | |||
''DELETE'' auf denselben JWT-Endpunkt (Access-Token im ''Authorization''-Header) | |||
und wird komplett in der Engine erledigt: die Sitzung wird gesperrt, die Antwort | |||
ist 204. Eine Methode ''Logout'' gibt es nicht und wird nicht aufgerufen. | |||
oRes.AddPair(' | Details: [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]], Phase 4. | ||
oRes.AddPair(' | |||
Wer beim Abmelden zusätzlich fachlich etwas tun will - eine Geräteregistrierung | |||
löschen, einen Push-Token verwerfen, den Vorgang protokollieren - nimmt einen | |||
'''gewöhnlichen Endpunkt''' und setzt dort ''_OBS_JWT_REVOKE''. Dasselbe gilt für | |||
"auf allen Geräten abmelden", weil das eine Berechtigungsentscheidung braucht: | |||
<syntaxhighlight lang="pascal" line> | |||
function Post(oParams: TStrings; oBody: TJSONObject): string; | |||
var oRes: TJSONObject; | |||
begin | |||
oRes := TJSONObject.Create(); | |||
try | |||
GeraeteRegistrierungLoeschen(oParams.Values['_OBS_JWT_SUBJECT']); | |||
// 'session' = diese Sitzung, 'all' = alle Sitzungen des Subjects | |||
oRes.AddPair('_OBS_JWT_REVOKE' , 'session'); | |||
oRes.AddPair('_OBS_HTTP_STATUS', 204); | |||
result := oRes.ToJSON(); | result := oRes.ToJSON(); | ||
finally | finally | ||
| Zeile 445: | Zeile 662: | ||
end; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
Gesperrt wird in beiden Wegen die '''ganze Sitzung''', also auch die noch nicht | |||
abgelaufenen Access-Token vorheriger Erneuerungen. Ein wiederholter Aufruf ist | |||
unschädlich. Lässt sich die Sitzung nicht sperren, überschreibt der Server die | |||
Antwort des Skripts mit '''503''' - ein Client darf nicht glauben, er sei | |||
abgemeldet, während sein Token weiterläuft. | |||
==Fehlerbehandlung im Skript== | ==Fehlerbehandlung im Skript== | ||
| Zeile 450: | Zeile 673: | ||
Tritt im Skript eine Exception auf oder schlägt die Syntax-Prüfung fehl, antwortet der Server mit 500 ''Interner Fehler'' und protokolliert die Detail-Meldung in '''RESTSRV_PROTO''' (mit Skript-Fehlertext). Der Konsument sieht keine internen Details. | Tritt im Skript eine Exception auf oder schlägt die Syntax-Prüfung fehl, antwortet der Server mit 500 ''Interner Fehler'' und protokolliert die Detail-Meldung in '''RESTSRV_PROTO''' (mit Skript-Fehlertext). Der Konsument sieht keine internen Details. | ||
Will das Skript einen spezifischen Fehler an den Konsumenten zurückgeben, | Server-eigene Fehler (Routing, Rate-Limit, Token, Auffangnetz) haben ein | ||
einheitliches Format mit einem ''error''-Objekt - Aufbau siehe | |||
[[OBS/Kostenpflichtige Module/RESTServer|Übersicht]]. | |||
Will das Skript einen spezifischen Fehler an den Konsumenten zurückgeben, sollte es | |||
'''dasselbe Format und einen passenden Statuscode''' verwenden: | |||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
function Post(oParams: TStrings; oBody: TJSONObject): string; | function Post(oParams: TStrings; oBody: TJSONObject): string; | ||
var oRes: TJSONObject; | var oRes, oErr: TJSONObject; | ||
begin | begin | ||
oRes := TJSONObject.Create(); | oRes := TJSONObject.Create(); | ||
try | try | ||
if (Empty(oParams.Values['kundennr'])) then begin | if (Empty(oParams.Values['kundennr'])) then begin | ||
oErr := TJSONObject.Create(); | |||
oErr.AddPair('code' , 'VALIDATION_FAILED'); | |||
oErr.AddPair('message', 'Parameter ''kundennr'' fehlt'); | |||
oErr.AddPair('traceId', oParams.Values['_OBS_TRACE_ID']); | |||
oRes.AddPair('_OBS_HTTP_STATUS', 422); | |||
oRes.AddPair('error', oErr); | |||
result := oRes.ToJSON(); | result := oRes.ToJSON(); | ||
exit; | exit; | ||
| Zeile 470: | Zeile 703: | ||
end; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
{{Hinweis|Ein fachlicher Fehler gehört auf einen '''4xx'''-Statuscode, nicht auf | |||
200 mit Fehlertext im Body. Nur so erkennt der Konsument den Fehlschlag ohne den | |||
Body auszuwerten - und nur so gibt der Server einen belegten | |||
''Idempotency-Key'' wieder frei (siehe Abschnitt Idempotenz).}} | |||
===Ändern: qSqlInit statt qSqlRead=== | |||
Zum '''Ändern''' eines vorhandenen Satzes wird ebenfalls <code>qSqlInit</code> | |||
benutzt - der Satz wird über <code>sys_uid</code> adressiert: | |||
<syntaxhighlight lang="pascal" line> | |||
q := qSqlInit(oDB, 'tickets'); | |||
q.qSet('sys_uid' , cSysUid); // Schreib-Index auf den Originalsatz | |||
q.qSet('ti_status', '9'); | |||
if (not q.SaveData(UPDATE_RECORD)) then begin | |||
// ... Fehler behandeln, siehe unten | |||
end; | |||
qSqlFree(q); | |||
</syntaxhighlight> | |||
<code>qSqlRead</code> ist dafür die falsche Wahl, aus zwei Gründen: | |||
* Es liest den '''kompletten Altsatz''' ein - ein zusätzlicher Lesezugriff, der Zeit kostet. | |||
* Es schreibt anschliessend nur die '''Änderungen'''. Ist der neue Wert gleich dem alten, entsteht gar kein Write, und <code>SaveData</code> liefert '''False''' - ununterscheidbar von einem echten Fehlschlag. | |||
{{Hinweis|Ohne <code>sys_uid</code> legt <code>qSqlInit</code> einen '''neuen''' | |||
Datensatz an, statt den vorhandenen zu ändern. Die <code>sys_uid</code> ist der | |||
Schreib-Index und gehört bei jeder Änderung gesetzt.}} | |||
Damit entfällt auch das übliche Paar aus <code>DB_LSeek</code> und | |||
<code>qSqlRead</code>: '''eine''' Abfrage auf die <code>sys_uid</code> liefert | |||
zugleich die Existenzprüfung und den Schreib-Index. | |||
<syntaxhighlight lang="pascal" line> | |||
cUid := ''; | |||
if (DB_SOpen(oDB, 'SELECT sys_uid FROM tickets WHERE ' + cWhere + ' LIMIT 1', q)) then begin | |||
if (not q.EoF) then begin | |||
cUid := q.A2UID(); | |||
end; | |||
end; | |||
DB_Close(q); | |||
if (not Empty(cUid)) then begin | |||
// ändern: qSqlInit + sys_uid | |||
end else begin | |||
// anlegen: qSqlInit ohne sys_uid | |||
end; | |||
</syntaxhighlight> | |||
===Mehrere Felder sind EIN Schreibvorgang=== | |||
Ein <code>qSqlInit</code>-Objekt nimmt beliebig viele <code>qSet</code> entgegen | |||
und schreibt sie mit '''einem''' <code>SaveData</code>. Fallen an derselben | |||
Zeile mehrere Felder an, gehören sie in dasselbe Objekt: | |||
<syntaxhighlight lang="pascal" line> | |||
// FALSCH: fünf Felder, fünf Schreibvorgänge - und jeder Setzer, der sich | |||
// seine sys_uid selbst holt, bringt zusätzlich eine eigene Abfrage mit | |||
FeldSetzen(cUid, 'ti_status' , '9'); | |||
FeldSetzen(cUid, 'ti_grund' , cGrund); | |||
FeldSetzen(cUid, 'ti_von' , cBenutzer); | |||
// RICHTIG: ein Schreibobjekt, ein SaveData | |||
q := qSqlInit(oDB, 'tickets'); | |||
q.qSet('sys_uid' , cUid); | |||
q.qSet('ti_status', '9'); | |||
q.qSet('ti_grund' , cGrund); | |||
q.qSet('ti_von' , cBenutzer); | |||
if (not q.SaveData(UPDATE_RECORD)) then begin | |||
// ... Fehler behandeln | |||
end; | |||
qSqlFree(q); | |||
</syntaxhighlight> | |||
Das ist nicht nur eine Frage der Geschwindigkeit: einzeln geschriebene Felder | |||
sind '''nicht atomar'''. Bricht der dritte Schreibvorgang ab, steht die Zeile | |||
halb geändert da - Grund gesetzt, Zeitpunkt nicht. | |||
Wird nichts gesetzt, darf auch nicht geschrieben werden: das Schreibobjekt dann | |||
mit <code>qSqlFree</code> freigeben, statt ein leeres <code>SaveData</code> | |||
abzusetzen. | |||
{{Hinweis|Ein Setzer für '''ein''' Feld (<code>FeldSetzen(cUid, cFeld, cWert)</code>) | |||
ist bequem und deshalb verführerisch. Er versteckt aber je Aufruf eine eigene | |||
Abfrage und ein eigenes <code>SaveData</code>. Für einen Vorgang mit mehreren | |||
Feldern gehört ein '''Editier-Helfer''' her, der das Schreibobjekt liefert - | |||
geschrieben wird einmal am Ende.}} | |||
===Keine Abfrage je Zeile=== | |||
Was in einer Liste je Zeile nachgeschlagen wird, kostet '''N''' Abfragen für | |||
'''eine''' Antwort. Bei fünfzig Aufträgen sind das fünfzig Abfragen für ein | |||
einziges Feld. Der Ausweg ist immer derselbe: | |||
* Werte aus einer 1:1-Beziehung über einen <code>LEFT JOIN</code> mitnehmen - bei einem '''eindeutigen''' Index auf dem Join-Feld kann er die Zeilen nicht vervielfachen. | |||
* Einzelwerte aus einer 1:n-Beziehung über eine '''Unterabfrage''' in der SELECT-Liste holen, nicht über einen Join (der Join würde die Zeilen vervielfachen). | |||
* Eine echte Unterliste (Positionen, Dateien) nur dann nachladen, wenn ein '''Zählfeld''' in der Hauptabfrage sagt, dass es überhaupt eine gibt. | |||
{{Hinweis|Ein GET soll '''lesen'''. Schreibvorgänge im Lesepfad - etwas | |||
"beim ersten Ausliefern festschreiben" - vervielfachen sich mit der Zeilenzahl | |||
und treffen den Aufrufer, der nur eine Liste angefordert hat.}} | |||
===Schreibfehler erkennen=== | |||
'''<code>SaveData</code> liefert einen Boolean - und der ist die einzige | |||
Fehlermeldung, die es gibt.''' (Auch der '''Parameter''' ist ein Boolean: | |||
''NEW_RECORD'' und ''UPDATE_RECORD'' sind Wahrheitswerte, keine Zahlen.) Schlägt das Schreiben in der Datenbank fehl | |||
(''Duplicate entry'' auf einem eindeutigen Index, zu langer Wert, fehlende | |||
Spalte), dann | |||
* wird '''keine''' Exception ausgelöst, | |||
* steht '''nichts''' in ''RESTSRV_PROTO'', | |||
* läuft das Skript weiter und antwortet mit dem Erfolg, den es selbst formuliert. | |||
Wer den Aufruf als Anweisung schreibt, baut damit einen stillen Datenverlust ein. | |||
Weil in einem Endpunkt schnell ein Dutzend Schreibvorgänge zusammenkommen, gehört | |||
die Prüfung in eine eigene kleine Prozedur - am besten in die gemeinsame Lib: | |||
<syntaxhighlight lang="pascal" line> | |||
procedure Speichern(q: TqSQL; lNeu: Boolean; const cWas: string); | |||
var lOk: Boolean; | |||
begin | |||
lOk := q.SaveData(lNeu); | |||
qSqlFree(q); // auch im Fehlerfall freigeben | |||
if (not lOk) then begin | |||
raise Exception.Create('Schreiben fehlgeschlagen: ' + cWas); | |||
end; | |||
end; | |||
// Aufruf statt SaveData + qSqlFree: | |||
q := qSqlInit(oDB, 'tickets'); | |||
q.qSet('ti_betreff', cBetreff); | |||
Speichern(q, NEW_RECORD, 'Ticket'); | |||
</syntaxhighlight> | |||
Die Exception fängt der Server: er schreibt ''Schreiben fehlgeschlagen: Ticket'' | |||
nach '''RESTSRV_PROTO''' und antwortet mit '''500'''. Liegt ein | |||
''Idempotency-Key'' an, wird er dabei '''freigegeben''' - die Anfrage ist also | |||
wiederholbar (siehe Abschnitt Idempotenz). | |||
{{Hinweis|Ein fehlgeschlagenes Schreiben ist kein Fall, in dem ein Endpunkt | |||
sinnvoll weiterlaufen kann: die Fachwirkung ist dann unvollständig. Wer trotzdem | |||
weitermachen will - etwa weil mehrere unabhängige Sätze geschrieben werden -, | |||
prüft den Rückgabewert und baut selbst eine Antwort. Ignorieren darf man ihn | |||
nie.}} | |||
Gängige Codes, die auch der Server selbst verwendet: ''VALIDATION_FAILED'' (422), | |||
''NOT_FOUND'' (404), ''FORBIDDEN_ROLE'' (403), ''VERSION_CONFLICT'' (409). | |||
Eigene, fachlich sprechende Codes sind erlaubt - wichtig ist, dass sie stabil | |||
bleiben, weil Konsumenten darauf ihre Reaktion aufbauen. | |||
==Gemeinsamen Code auslagern== | |||
Wird Logik in mehreren Endpunkten gebraucht - Berechtigungsprüfungen, | |||
JSON-Bausteine, Hilfsfunktionen -, muss sie nicht in jedes Skript kopiert werden. | |||
Die Skript-Engine lädt Textbausteine beim Laden aus einer beliebigen Tabelle nach: | |||
<pre> | |||
{$L T="restsrv_endpoints" I="re_pathtemplate" V="/meinapp/lib" F="re_script"} | |||
</pre> | |||
{| class="wikitable" | |||
! Attribut !! Bedeutung | |||
|- | |||
| '''T''' || Tabelle, aus der geladen wird | |||
|- | |||
| '''I''' || Spalte, über die gesucht wird | |||
|- | |||
| '''V''' || Wert, der in dieser Spalte stehen muss | |||
|- | |||
| '''F''' || Spalte, deren Inhalt an dieser Stelle eingesetzt wird | |||
|} | |||
Die Direktive steht üblicherweise direkt unter dem Kopfkommentar des Skripts. Der | |||
Compiler sieht danach den zusammengesetzten Text - eine Funktion aus der Lib lässt | |||
sich also aufrufen wie eine im Skript selbst geschriebene. | |||
===Bewährtes Muster: Lib als eigene Endpunkt-Zeile=== | |||
Am wenigsten Verwaltung macht es, die Lib '''als eigene Zeile in | |||
RESTSRV_ENDPOINTS''' abzulegen: | |||
# Endpunkt anlegen mit einem eigenen Pfad-Template, z.B. <code>/meinapp/lib</code>. | |||
# Den Lib-Quelltext ins Skript-Feld dieser Zeile schreiben (F7). | |||
# '''Keine Berechtigung''' in der Berechtigungs-Liste vergeben - das ist der Schutz: ohne Berechtigung beantwortet der Server einen Aufruf mit 403. | |||
# In jedem nutzenden Endpunkt die Direktive oben einfügen. | |||
Damit ist die Lib reiner Ablageort und von aussen nicht nutzbar. Eine Änderung | |||
wirkt auf alle einbindenden Endpunkte, ohne dass ein einziges Endpunkt-Skript | |||
angefasst werden muss. | |||
{{Hinweis|'''Lib und Skripte gemeinsam einspielen.''' Kommt in der Lib eine neue | |||
Funktion hinzu, genügt es nicht, nur das nutzende Skript zu aktualisieren. Eine | |||
ältere Lib lädt fehlerfrei - der Compiler meldet dann ausschliesslich die neuen | |||
Namen als ''Unknown name'', was leicht wie ein Fehler im Skript aussieht.}} | |||
{{Hinweis|Auch hier greift der Endpunkt-Cache: eine Änderung an der Lib wird erst | |||
nach Ablauf des TTL (bis zu 60 Sekunden) wirksam.}} | |||
==Fallstricke im Skript== | |||
Die folgenden Punkte unterscheiden das Skript von normalem Delphi-Code und kosten | |||
sonst eine Runde Fehlersuche: | |||
{| class="wikitable" | |||
! Stolperstein !! Richtig | |||
|- | |||
| ''DB_SOpen('Y00…', oDB, …)'' - der AD-UID-Parameter aus dem Delphi-Code || Im Skript '''ohne UID''': <code>DB_SOpen(oDB, cSql, q)</code>. Gleiches gilt für <code>qSqlInit(oDB, 'tab')</code> und <code>qSqlRead(oDB, 'tab', cWhere)</code> - jede dieser Funktionen beginnt mit ''oDB'' | |||
|- | |||
| ''oBody.TryGetValue<T>(…)'' || Generics gibt es nicht - über ''GetValue'' lesen (siehe oBody) | |||
|- | |||
| ''EMPTY_DATE'' für ein leeres Datum || ''EMPTY_DATE'' ist im Skript ein '''String'''. Für Datumsvariablen und -vergleiche '''MINDATETIME''' verwenden | |||
|- | |||
| ''fVal'' auf einen JSON-Zahlenwert || JSON liefert den Dezimalpunkt, ''fVal'' erwartet die lokale Notation: vorher <code>StrTran(cVal, '.', ',')</code> | |||
|- | |||
| ''TStringList'' für Zwischenlisten || In Skripten unzuverlässig. Kleine Mengen über einen Delimiter-String führen (<code>'|' + wert + '|'</code>) und mit ''Pos'' prüfen | |||
|- | |||
| Default-Parameter in eigenen Funktionen || Werden nicht unterstützt - alle Parameter ausschreiben | |||
|- | |||
| <code>q.SaveData(…)</code> als Anweisung schreiben || ''SaveData'' liefert einen '''Boolean'''. Ein SQL-Fehler beim Schreiben wirft '''keine''' Exception und landet in '''keinem''' Protokoll - der Rückgabewert ist die einzige Meldung. Immer auswerten, siehe [[#Schreibfehler erkennen|Schreibfehler erkennen]] | |||
|- | |||
| Je Feld ein eigener Setzer-Aufruf || Ein <code>qSqlInit</code>-Objekt, beliebig viele <code>qSet</code>, '''ein''' <code>SaveData</code>. Einzeln geschrieben kostet jedes Feld eine eigene Abfrage samt eigenem Write - und der Vorgang ist nicht mehr atomar, siehe [[#Mehrere Felder sind EIN Schreibvorgang|Mehrere Felder]] | |||
|- | |||
| Ein Wert je Zeile nachgeschlagen || In einer Liste sind das N Abfragen für eine Antwort. Über <code>LEFT JOIN</code> (1:1) oder Unterabfrage (1:n) mitnehmen, siehe [[#Keine Abfrage je Zeile|Keine Abfrage je Zeile]] | |||
|- | |||
| Ausgabeparameter vom Typ <code>var … : TDateTime</code> || Bringt die Skript-VM zum '''Absturz''' (Assertion in ''dwsStack.pas''), und zwar beim ersten '''Lesen''' des Werts - nicht schon beim Schreiben, die Ursache liegt also nicht dort, wo der Fehler auffällt. <code>var string</code>, <code>var Integer</code> und <code>var Double</code> laufen. Zeitpunkte als ISO-Zeichenkette zurückgeben und erst beim Aufrufer wandeln | |||
|} | |||
==Skript-Cache== | ==Skript-Cache== | ||
| Zeile 480: | Zeile 941: | ||
* ''Base.Tools'' - String-, Datum-, IIF-, Empty-Helper | * ''Base.Tools'' - String-, Datum-, IIF-, Empty-Helper | ||
* ''Base.DB'' / ''Base.xQuery'' - Datenbank-Operationen | * ''Base.DB'' / ''Base.xQuery'' - Datenbank-Operationen | ||
* ''Base.qSqlReg'' - | * ''Base.qSqlReg'' - Schreibzugriffe | ||
* ''System.JSON'' - JSON-Objekte und -Arrays | * ''System.JSON'' - JSON-Objekte und -Arrays | ||
* ''Base.ToolsConst'' - Konstanten wie ''CRLF'', ''SINGELQUOTE'', '' | * ''Base.ToolsConst'' - Konstanten wie ''CRLF'', ''SINGELQUOTE'', ''MINDATETIME'' | ||
Die wichtigsten Aufrufe mit ihren Skript-Signaturen: | |||
{| class="wikitable" | |||
! Zweck !! Aufruf | |||
|- | |||
| Lesen || <code>DB_SOpen(oDB, cSql, q)</code>, dann <code>q.EoF</code>, <code>q.Next()</code>, <code>q.A2C('feld')</code> / <code>A2I</code> / <code>A2F</code> / <code>A2D</code> / <code>A2UID()</code>, am Ende <code>DB_Close(q)</code> | |||
|- | |||
| Neu anlegen || <code>q := qSqlInit(oDB, 'tab')</code>, <code>q.qSet('feld', wert)</code>, <code>if (not q.SaveData(NEW_RECORD)) then …</code>, <code>qSqlFree(q)</code> – der Rückgabewert ist Pflicht, siehe [[#Schreibfehler erkennen|Schreibfehler erkennen]] | |||
|- | |||
| Ändern || <code>q := qSqlInit(oDB, 'tab')</code>, <code>q.qSet('sys_uid', cUid)</code>, <code>q.qSet(…)</code>, <code>if (not q.SaveData(UPDATE_RECORD)) then …</code>, <code>qSqlFree(q)</code> – '''nicht''' <code>qSqlRead</code>, siehe [[#Ändern: qSqlInit statt qSqlRead|Ändern]]. Alle Felder derselben Zeile in '''ein''' Objekt, siehe [[#Mehrere Felder sind EIN Schreibvorgang|Mehrere Felder]] | |||
|- | |||
| Direkt ausführen || <code>DB_SqlExec(oDB, cSql)</code> | |||
|- | |||
| Existenzprüfung || <code>DB_LSeek(oDB, 'tab', cWhere)</code> | |||
|- | |||
| Werte quoten (Pflicht) || <code>DB_SQLVal(wert)</code> | |||
|} | |||
{{Hinweis|Alle diese Funktionen beginnen im Skript mit ''oDB''. Ein zusätzlicher | |||
AD-UID-Parameter als erstes Argument gehört zur Delphi-Variante und lässt sich im | |||
Skript nicht übersetzen.}} | |||
Konkrete Beispiele: | Konkrete Beispiele: | ||
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel1|Beispiel: Daten-Abruf mit JWT]] | * [[OBS/Kostenpflichtige Module/RESTServer/Beispiel1|Beispiel 1: Daten-Abruf mit JWT]] | ||
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel2|Beispiel: Datensatz anlegen mit JSON-Body]] | * [[OBS/Kostenpflichtige Module/RESTServer/Beispiel2|Beispiel 2: Zeiterfassung als komplette Webanwendung]] | ||
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel: Pfad-Parameter im Routing]] | * [[OBS/Kostenpflichtige Module/RESTServer/Beispiel3|Beispiel 3: Datensatz anlegen mit JSON-Body (CRUD)]] | ||
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel: Schreibzugriff mit Statuscodes, ETag und Idempotenz]] | * [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4: Pfad-Parameter im Routing]] | ||
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz]] | |||
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6: Datei-Upload und Ablage im DMS]] | |||
Aktuelle Version vom 1. September 2026, 10:10 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
Anleitung für Endpunkt-Skripte
Jede Anfrage an einen Endpunkt wird nach erfolgreicher Authentifizierung und Autorisierung an das hinterlegte Skript weitergegeben. Das Skript ist in Object Pascal geschrieben und greift auf die OBS-Bibliothek (DB-Zugriff, Hilfsfunktionen) zu.
Sicherheitshinweise
- Eingaben gegen erwartete Werte prüfen (z.B. Pflichtfelder, erlaubte Typen, Wertebereiche).
- Nur das zurückgeben, was der Konsument wirklich braucht - keine internen IDs, keine Sys-Felder, keine Passwörter.
- Bei Fehlern keine internen Details an den Konsumenten zurückgeben; stattdessen schreibt der Server ohnehin Detail-Einträge in RESTSRV_PROTO.
Methoden-Signatur
Pro HTTP-Methode wird im Skript eine gleichnamige Funktion implementiert. Der Server ruft genau die Funktion auf, die zur Methode des eingehenden Requests passt.
function Get (oParams: TStrings; oBody: TJSONObject): string; function Post (oParams: TStrings; oBody: TJSONObject): string; function Put (oParams: TStrings; oBody: TJSONObject): string; function Delete(oParams: TStrings; oBody: TJSONObject): string; function Patch (oParams: TStrings; oBody: TJSONObject): string;
Fehlt die zur Methode passende Funktion, antwortet der Server mit 405 Method
Not Allowed (METHOD_NOT_ALLOWED) und nennt im Header
Allow die Verben, die dieses Skript tatsächlich anbietet - die Liste
stammt aus dem kompilierten Skript und kann deshalb nicht veralten:
HTTP/1.1 405 Method Not Allowed Allow: GET, PUT
Ein im Vertrag zugesagtes Verb braucht also seine Funktion. Fehlt sie, ist das an der Antwort sofort zu erkennen und kein Serverfehler.
Parameter
oParams (TStrings)
Enthält alle Query-Parameter, POST-Parameter (form-urlencoded) sowie die durchgereichten HTTP-Header. Werte sind immer Strings.
Filterung durch den Server:
- Geblockte Header werden nicht durchgereicht: authorization, cookie, proxy-authorization, x-forwarded-for, x-real-ip, apikey, api_key.
- Parameter mit Präfix _OBS_ können von aussen nicht gesetzt werden; sie sind für interne Werte reserviert.
- Werte werden auf max. 1024 Zeichen begrenzt.
- Null-Bytes und Steuerzeichen (ausser Tab, CR, LF) werden entfernt.
Zugriff im Skript:
if (not Empty(oParams.Values['kundennr'])) then begin
cKundenNr := oParams.Values['kundennr'];
end;
oBody (TJSONObject)
Enthält den Request-Body, sofern dieser ein gültiges JSON-Objekt ist. Bei leerem oder nicht-JSON-Body wird nil übergeben.
Schreibweisen wie oBody.TryGetValue<string>('feld', cVar) lassen sich nicht
übersetzen. Gelesen wird über GetValue.GetValue liefert nil, wenn das Feld im Body nicht enthalten ist - daran lässt sich „nicht gesendet" von „leer gesendet" unterscheiden. Der Wert ist immer ein String und wird bei Bedarf selbst gewandelt.
cUser := ;
cPass := ;
if (Assigned(oBody)) then begin
oVal := oBody.GetValue('username');
if (Assigned(oVal)) then begin
cUser := oVal.Value;
end;
oVal := oBody.GetValue('password');
if (Assigned(oVal)) then begin
cPass := oVal.Value;
end;
end;
Bei mehr als zwei Feldern lohnt eine kleine Hilfsfunktion, die das Muster einmal kapselt:
function _BodyStr(oBody: TJSONObject; const cFeld: string): string;
var oVal: TJSONValue;
begin
result := '';
if (oBody = nil) then begin
exit;
end;
oVal := oBody.GetValue(cFeld);
if (Assigned(oVal)) then begin
result := oVal.Value;
end;
end;
// Aufruf:
cBetreff := _BodyStr(oBody, 'betreff');Andere Typen entstehen aus dem String:
| Zieltyp | Umwandlung |
|---|---|
| Ganzzahl | iVal(_BodyStr(oBody, 'menge'))
|
| Kommazahl | fVal(StrTran(_BodyStr(oBody, 'preis'), '.', ',')) - JSON liefert den Dezimalpunkt, fVal erwartet die lokale Notation
|
| Boolean | Lower(_BodyStr(oBody, 'aktiv')) = 'true'
|
| Datum/Zeit | ISO-8601-String selbst zerlegen (CToDT erwartet das deutsche Format) |
Maximale Body-Grösse: 10 MB.
Reservierte _OBS_-Parameter
Bei aktiver JWT-Authentifizierung stehen die Token-Claims als Parameter zur Verfügung:
| Parameter | Inhalt |
|---|---|
| _OBS_JWT_ID | JWT-Id (jti-Claim) - typisch die User-Id. Muss nicht eindeutig sein: die Sitzung führt der Server über eigene Kennungen (siehe unten) |
| _OBS_JWT_SUBJECT | Subject (sub-Claim) - typisch Benutzername |
| _OBS_JWT_AUDIENCE | Audience (aud-Claim) - typisch Mandant / Rolle |
| _OBS_JWT_CLAIM_<name> | Beliebiger Custom-Claim des Tokens (z.B. _OBS_JWT_CLAIM_tenant, _OBS_JWT_CLAIM_roles); im Folge-Skript lesbar |
| _OBS_TRACE_ID | Korrelations-ID des Requests (auch als Response-Header X-Trace-Id) |
Unabhängig von JWT stehen ausserdem immer zur Verfügung:
| Parameter | Inhalt |
|---|---|
| _OBS_SERVER_TIME | Aktuelle Serverzeit als ISO-8601 mit UTC-Offset (z.B. 2026-08-17T09:12:33+02:00). Nützlich, um Datumswerte in derselben Zeitzone auszuliefern, in der der Server arbeitet, und damit ein Client seinen Uhren-Versatz bestimmen kann |
| _OBS_JWT_EXP_SEC | Laufzeit des Access-Tokens in Sekunden (JWT-Exp × 60). Damit kann ein Konfigurations-Endpunkt denselben Wert ausliefern, den der Token wirklich hat |
Diese Werte werden vom Server gesetzt und können vom Skript für Berechtigungs- und Mandantenprüfungen verwendet werden.
Zusätzlich trägt jedes Token zwei Sitzungskennungen des Servers, lesbar als _OBS_JWT_CLAIM_sid und _OBS_JWT_CLAIM_rid:
| Claim | Inhalt |
|---|---|
| sid | Sitzung. Bleibt über alle Erneuerungen (Refresh) hinweg gleich und ist der Schlüssel für das Abmelden |
| rid | Zeilenkennung des einzelnen Token-Paares in RESTSRV_TOKEN. Bei jeder Erneuerung neu |
Beide sind OBS-UIDs (10 Zeichen) und werden vom Server nach den Claims des Skripts gesetzt - ein Skript kann sie also auch mit _OBS_JWT_CLAIM_sid nicht überschreiben. Für Fachlogik sind sie nicht gedacht; sie sind nützlich, um eine Sitzung im Protokoll wiederzufinden.
Pfad-Parameter
Stammt der Endpunkt aus einem Pfad-Template mit Platzhaltern (siehe Endpunkte), stehen die aus den Platzhaltern erfassten Werte als reservierte Parameter mit Präfix _OBS_PATH_ in oParams bereit:
| Template | Zugriff im Skript |
|---|---|
/orders/{uid} |
oParams.Values['_OBS_PATH_uid'] |
/orders/{uid}/modules/{code} |
oParams.Values['_OBS_PATH_uid'], oParams.Values['_OBS_PATH_code'] |
Da der Präfix _OBS_ für von aussen gelieferte Header- und Query-Parameter gesperrt ist, sind diese Werte nicht durch den Client fälschbar. Ein vollständiges Beispiel zeigt Beispiel 4 - Pfad-Parameter.
Datei-Uploads
Ist der Endpunkt für Uploads freigeschaltet (re_upload = 1, siehe
Endpunkte), nimmt der Server
hochgeladene Dateien entgegen, legt sie in einem temporären Verzeichnis ab und
übergibt dem Skript Pfad, Originalname und Content-Type über reservierte
Parameter. Das Skript entscheidet selbst über die weitere Verarbeitung (z.B.
DMS-Ablage). Der Body wird in diesem Fall nicht als JSON geparst - oBody
ist nil. Es gilt nicht das JSON-Body-Limit (10 MB), sondern die pro
Endpunkt konfigurierte Grösse (re_upload_size, Standard 25 MB;
Überschreitung -> 413).
Beide Übertragungsarten - multipart/form-data und der resumable
Content-Range-Upload - liefern dem Skript dieselben Parameter:
| Parameter | Inhalt |
|---|---|
| _OBS_UPLOAD_PATH | Vollständiger Pfad zur temporären Datei auf dem Server |
| _OBS_UPLOAD_NAME | Originaldateiname |
| _OBS_UPLOAD_CONTENTTYPE | Content-Type der Datei (Default application/octet-stream, wenn der Client keinen angibt)
|
| _OBS_UPLOAD_SHA256 | SHA-256 der gespeicherten Datei, hex in Kleinbuchstaben. Damit lässt sich eine vom Client mitgesendete Prüfsumme vergleichen - erst dieser Vergleich macht aus ihr eine Zusicherung |
Der Dateiname ist Pflicht. Fehlt er, lehnt der Server den Upload mit 400 Bad Request ab und das Skript wird nicht ausgeführt - das Skript kann sich also darauf verlassen, dass _OBS_UPLOAD_PATH und _OBS_UPLOAD_NAME gesetzt sind, sobald es läuft.
Einfacher Upload (multipart/form-data)
Eine Datei pro Request. Name und Content-Type stammen aus der
Content-Disposition der Datei-Partie (filename=). Der
Server speichert die erste Datei-Partie und ruft das Skript auf.
Die übrigen Partien des Formulars - alles ohne filename= -
übergibt der Server als _OBS_FORM_<name>. Der Wert ist UTF-8 und
auf 1024 Zeichen begrenzt, der Name auf Buchstaben, Ziffern und Unterstrich
reduziert. Zusatzangaben lassen sich damit im selben Request mitschicken, ohne sie
an die URL zu hängen:
function Post(oParams: TStrings; oBody: TJSONObject): string;
var oRes : TJSONObject;
cPfad: string;
cName: string;
cTyp : string;
begin
oRes := TJSONObject.Create();
try
cPfad := oParams.Values['_OBS_UPLOAD_PATH'];
cName := oParams.Values['_OBS_UPLOAD_NAME'];
cTyp := oParams.Values['_OBS_UPLOAD_CONTENTTYPE'];
// ... Datei aus cPfad ins DMS / Zielverzeichnis uebernehmen ...
oRes.AddPair('status' , 'ok');
oRes.AddPair('dateiname', cName);
result := oRes.ToJSON();
finally
MyFreeAndNil(oRes);
end;
end;Resumable/Chunked Upload (Content-Range)
Für grosse Dateien überträgt der Client die Datei in Teilstücken (Chunks) mit dem
Header Content-Range: bytes START-END/TOTAL. Der Server hängt die
Chunks sequentiell an eine Session-Datei an und behandelt unvollständige
Uploads selbst - das Endpunkt-Skript läuft erst beim vollständigen Upload
(dann mit gesetztem _OBS_UPLOAD_PATH, _OBS_UPLOAD_NAME und
_OBS_UPLOAD_CONTENTTYPE).
Name und Content-Type sind im Content-Range-Protokoll nicht enthalten;
der Client liefert sie deshalb im ersten Chunk über den Header
Upload-Metadata (Format wie bei tus: kommagetrennte Paare
schlüssel base64wert, Werte Base64-kodiert):
Upload-Metadata: filename ZmlsZS5wZGY=,filetype YXBwbGljYXRpb24vcGRm
Erkannt werden die Schlüssel filename (Pflicht) und
filetype (optional). Fehlt filename im ersten Chunk,
wird der Upload mit 400 abgelehnt, ohne dass eine Datei angelegt wird.
Folge-Chunks und Statusabfragen müssen die Metadaten nicht erneut senden.
Ablauf (vom Client gesteuert, der Server antwortet jeweils):
| Request | Server-Antwort |
|---|---|
Erster Chunk Content-Range: bytes 0-1048575/5000000 + Upload-Metadata |
308 Resume Incomplete + Header Upload-Id, Upload-Offset, Range
|
weitere Chunks (jeweils Upload-Id mitsenden) |
308 mit aktualisiertem Upload-Offset
|
| letzter Chunk (Bereich erreicht TOTAL) | normale Skript-Antwort (z.B. 200/201) |
Statusabfrage Content-Range: bytes */5000000 |
aktueller Upload-Offset, ohne anzuhängen (Resume)
|
| Header | Richtung | Bedeutung |
|---|---|---|
| Content-Range | Request | bytes START-END/TOTAL; bytes */TOTAL nur als Statusabfrage
|
| Upload-Metadata | Request | tus-Metadaten im ersten Chunk: filename (Pflicht), filetype (optional), Base64-kodiert
|
| Upload-Id | Request/Response | Session-Kennung. Fehlt sie im ersten Request, erzeugt der Server eine und gibt sie zurück; alle Folge-Chunks müssen sie mitsenden. |
| Upload-Offset | Response | Anzahl der bereits gespeicherten Bytes (= Startoffset des nächsten Chunks) |
| Range | Response | Bereits gespeicherter Bereich (bytes=0-N)
|
Der Server hängt einen Chunk nur an, wenn START dem bereits gespeicherten Stand entspricht. Bei Abweichung (doppelter Chunk, Lücke) wird nicht angehängt, sondern der aktuelle Upload-Offset zurückgemeldet - der Client setzt ab dort neu auf. Eine separate Statusabfrage ist für ein Resume daher nicht zwingend nötig.
Überschreitet ein Chunk bzw. die Gesamtgrösse das Limit (re_upload_size), antwortet der Server mit 413 Payload Too Large.
Rückgabe
Die Rückgabe ist immer ein JSON-String. Wird ein leerer String zurückgegeben, antwortet der Server automatisch mit {}. Der Server setzt Content-Type auf application/json; charset=utf-8 und Status auf 200, sofern das Skript nicht selbst einen Fehler signalisiert.
Einfaches Beispiel:
function Get(oParams: TStrings; oBody: TJSONObject): string;
var oRes: TJSONObject;
begin
oRes := TJSONObject.Create();
try
oRes.AddPair('wert_string', '123');
oRes.AddPair('wert_int' , 456);
result := oRes.ToJSON();
finally
MyFreeAndNil(oRes);
end;
end;Antwort steuern: Statuscode, Header, traceId
Ein Skript kann den HTTP-Statuscode und beliebige Response-Header über reservierte Felder in der Antwort setzen. Der Server wertet sie aus und entfernt sie vor dem Senden aus dem Body.
| Antwort-Feld | Wirkung |
|---|---|
| _OBS_HTTP_STATUS (Zahl) | HTTP-Statuscode (z.B. 201, 204, 400, 409, 422). Ohne Angabe: 200. Bei 204 wird kein Body gesendet. |
| _OBS_HEADERS (Objekt) | Beliebige Response-Header, z.B. ETag, Location, Retry-After. CR/LF in Namen/Werten werden entfernt (Schutz vor Header-Injection). |
| _OBS_JWT_REVOKE (Text) | Sperrt die Sitzung des vorgelegten Tokens (session) oder alle Sitzungen des Subjects (all). Damit wird ein Endpunkt zum Logout, ohne eigene Verwaltung. Scheitert das Sperren, antwortet der Server mit 503 statt mit der Antwort des Skripts - siehe Zugänge, Abschnitt Abmelden. |
Zusätzlich trägt jede Antwort den Header X-Trace-Id (Korrelations-ID). Dieselbe ID liegt dem Skript als oParams.Values['_OBS_TRACE_ID'] vor und erscheint in jeder Server-Fehler-Logzeile in RESTSRV_PROTO - so lässt sich ein Fehler ohne Gerätezugriff im Log wiederfinden.
Beispiel: Anlegen mit 201, Location und ETag
function Post(oParams: TStrings; oBody: TJSONObject): string;
var oRes, oHdr: TJSONObject;
begin
oRes := TJSONObject.Create();
try
// ... Datensatz anlegen, neue UID + Version (ETag) ermitteln ...
oHdr := TJSONObject.Create();
oHdr.AddPair('Location', '/v1/orders/' + cNeueUid);
oHdr.AddPair('ETag', '1');
oRes.AddPair('_OBS_HTTP_STATUS', 201);
oRes.AddPair('_OBS_HEADERS', oHdr);
oRes.AddPair('uid', cNeueUid);
result := oRes.ToJSON();
finally
MyFreeAndNil(oRes);
end;
end;Beispiel: Strukturierter Fehler mit Statuscode und traceId
function Post(oParams: TStrings; oBody: TJSONObject): string;
var oRes, oErr: TJSONObject;
begin
oRes := TJSONObject.Create();
try
oErr := TJSONObject.Create();
oErr.AddPair('code', 'VALIDATION_FAILED');
oErr.AddPair('message', 'Feld "menge" fehlt');
oErr.AddPair('traceId', oParams.Values['_OBS_TRACE_ID']);
oRes.AddPair('_OBS_HTTP_STATUS', 422);
oRes.AddPair('error', oErr);
result := oRes.ToJSON();
finally
MyFreeAndNil(oRes);
end;
end;Beispiel: Optimistic Concurrency (ETag / If-Match)
Mit Statuscode, Headern und dem lesbaren Header If-Match führt das Skript je Datensatz einen Versionszähler und lehnt veraltete Schreibzugriffe mit 409 ab:
function Put(oParams: TStrings; oBody: TJSONObject): string;
var oRes, oHdr: TJSONObject;
nAktuell, nIfMatch: integer;
begin
oRes := TJSONObject.Create();
try
nAktuell := AuftragVersion(oParams.Values['_OBS_PATH_uid']);
nIfMatch := iVal(oParams.Values['if-match']);
if (nIfMatch <> nAktuell) then begin
oHdr := TJSONObject.Create();
oHdr.AddPair('ETag', xStr(nAktuell));
oRes.AddPair('_OBS_HTTP_STATUS', 409);
oRes.AddPair('_OBS_HEADERS', oHdr);
oRes.AddPair('error', TJSONObject.Create.AddPair('code', 'VERSION_CONFLICT'));
result := oRes.ToJSON();
exit;
end;
// ... speichern, Version hochzählen ...
oHdr := TJSONObject.Create();
oHdr.AddPair('ETag', xStr(nAktuell + 1));
oRes.AddPair('_OBS_HEADERS', oHdr);
result := oRes.ToJSON();
finally
MyFreeAndNil(oRes);
end;
end;Idempotenz - was das Skript beachten muss
Sendet ein Konsument bei POST/PUT/PATCH/DELETE den Header
Idempotency-Key, fängt der Server doppelte Sendungen ab. Das
Skript braucht dafür keine eigene Logik - keine Schlüssel-Tabelle, keine
Prüfung am Anfang der Methode.
Was das Skript wissen muss:
| Antwort des Skripts | Wirkung auf den Idempotenz-Speicher |
|---|---|
| 2xx | Statuscode, Body und Header werden gespeichert. Eine Wiederholung mit demselben Schlüssel bekommt genau diese Antwort zurück, das Skript läuft nicht erneut |
| 4xx | Der Schlüssel wird freigegeben. Der Konsument darf denselben Schlüssel nach Korrektur des Inhalts erneut verwenden |
| 5xx oder Exception | Der Schlüssel bleibt belegt, der Fall wird protokolliert. Eine Wiederholung wird abgelehnt, bis der Fall geklärt ist |
Daraus folgen zwei Regeln für Endpunkt-Skripte:
- Fachliche Ablehnungen als 4xx melden (422, 409, 404), nicht als 2xx mit Fehlertext im Body. Sonst wird die Ablehnung gespeichert und der Konsument bekommt sie bei jedem weiteren Versuch erneut - auch nachdem er den Fehler behoben hat.
- Die Antwort muss vollständig sein. Was zurückgegeben wird, wird eingefroren. Ein Feld, das erst der zweite Aufruf ergänzen würde, kommt beim Konsumenten nie an.
abbilden. Solche Skripte laufen unverändert weiter - die Doppelprüfung schadet
nicht, ist aber überflüssig und kann beim nächsten Überarbeiten entfallen.Der vollständige Ablauf und die Statuscodes stehen unter Endpunkte.
JWT-Authentifizierungs-Skript
Bei Zugängen mit aktiver JWT-Pflicht wird über den JWT-Endpunkt das im Zugang hinterlegte Authentifizierungs-Skript aufgerufen (F7 in der Zugänge-Liste). Das Skript muss eine Methode Authenticate bereitstellen:
function Authenticate(oParams: TStrings; oBody: TJSONObject): string;
var oRes : TJSONObject;
oVal : TJSONValue;
cUser : string;
cPass : string;
begin
oRes := TJSONObject.Create();
try
// Eingangsdaten lesen (Body oder Query-Param)
cUser := '';
cPass := '';
if (Assigned(oBody)) then begin
oVal := oBody.GetValue('username');
if (Assigned(oVal)) then begin
cUser := oVal.Value;
end;
oVal := oBody.GetValue('password');
if (Assigned(oVal)) then begin
cPass := oVal.Value;
end;
end;
// Prüfung gegen eigene Tabelle, Hash-Verfahren, LDAP, ...
if (PasswortPasst(cUser, cPass)) then begin
oRes.AddPair('status' , 1);
oRes.AddPair('_OBS_JWT_ID' , cUser);
oRes.AddPair('_OBS_JWT_SUBJECT', cUser);
oRes.AddPair('_OBS_JWT_AUDIENCE', 'mandant1');
end else begin
oRes.AddPair('status', 9);
oRes.AddPair('error' , 'Login fehlgeschlagen');
end;
result := oRes.ToJSON();
finally
MyFreeAndNil(oRes);
end;
end;Rückgabewerte:
| status | Bedeutung |
|---|---|
| 1 | Erfolgreich, der Server erzeugt aus den _OBS_JWT_*-Werten einen Token (HS256) |
| 9 | Misserfolg, der Server antwortet mit 401 und reicht den Wert von error als error.message an den Client durch (zusätzlich protokolliert) |
Liefert das Skript einen anderen Status als 1 oder 9, ist gar nicht lauffähig oder gibt kein auswertbares JSON zurück, antwortet der Server mit 403 und einer generischen Meldung - ein defektes Anmeldeskript soll dem Anwender nicht als „Passwort falsch" erscheinen.
Kann der Server die Sitzungszeile zum ausgestellten Token nicht schreiben (Tabelle fehlt, Datenbankproblem), liefert er kein Token aus und antwortet mit 503 und Retry-After. Auch das ist bewusst kein 401/403: die Zugangsdaten waren richtig, der Versuch darf wiederholt werden.
Aufbau der Token-Antwort
Bei Erfolg baut der Server die Antwort aus den Token-Feldern und allen weiteren Feldern, die das Skript zurückgibt:
{"benutzerId": "4711", "tenant": "nord", "roles": ["TECHNIKER"],
"token": "eyJ...",
"accessToken": "eyJ...",
"refreshToken":"eyJ...",
"expiresIn": 28800,
"serverTime": "2026-08-17T09:12:33+02:00"}
| Feld | Herkunft |
|---|---|
| token / accessToken | Derselbe Access-Token unter zwei Namen. accessToken erwarten die meisten Client-Bibliotheken, token bleibt für bestehende Konsumenten erhalten |
| refreshToken | Nur wenn das Skript _OBS_JWT_REFRESH_ID geliefert hat |
| expiresIn | Laufzeit des Access-Tokens in Sekunden (JWT-Exp × 60) |
| serverTime | Serverzeit ISO-8601 mit Offset |
| alle übrigen Felder | Frei vom Skript bestimmt. Übernommen wird alles ausser status und den _OBS_-Steuerfeldern |
Damit kann das Anmeldeskript alles mitliefern, was der Client direkt nach dem Login braucht (Benutzer-Id, Mandant, Rollen, Berechtigungen) - ohne dass dieser dafür einen zweiten Aufruf absetzen muss. Ein Feld, das genauso heisst wie eines der Token-Felder oben, wird verworfen; die Engine setzt diese selbst.
Custom-Claims, serverTime und Refresh
Das Authenticate-Skript kann dem Token zusätzlich beliebige Custom-Claims mitgeben - z.B. Mandant und Rollen - und optional ein Refresh-Token anstoßen:
| RÜckgabefeld | Bedeutung |
|---|---|
| _OBS_JWT_CLAIM_<name> | Beliebiger Custom-Claim, z.B. _OBS_JWT_CLAIM_tenant, _OBS_JWT_CLAIM_roles. Im Folge-Skript lesbar als oParams.Values['_OBS_JWT_CLAIM_<name>']. |
| _OBS_JWT_REFRESH_ID | (optional) jti des Refresh-Tokens. Nur wenn gesetzt, stellt der Server ein Refresh-Token aus. |
| _OBS_JWT_REFRESH_EXP | (optional) Lebensdauer des Refresh-Tokens in Minuten (Default 90 Tage = 129600). |
Jedes Token trägt automatisch den Claim token_use (access bzw. refresh) sowie die Sitzungskennungen sid und rid des Servers. Die Antwort enthält bei Erfolg serverTime (ISO-8601 mit Offset), bei ausgestelltem Refresh zusätzlich refreshToken:
{"token":"...", "refreshToken":"...", "serverTime":"2026-06-29T15:30:12+02:00"}
Refresh-Methode
Der Refresh läuft über denselben JWT-Endpunkt und dasselbe Skript. Liegt am JWT-Endpunkt ein Authorization: Bearer <token> mit einem Refresh-Token vor, ruft der Server statt Authenticate die Methode Refresh auf (ohne Bearer: Login; DELETE mit Access-Token: Abmelden, ohne Skript).
Bevor das Skript läuft, hat der Server das vorgelegte Refresh-Token verifiziert (Signatur, Ablauf, token_use=refresh) und in der Sitzung entwertet. Einmalgebrauch, Rotation und Sperrliste sind damit Sache des Servers. Das Skript liefert nur noch die aktuellen Claims - genau das ist der Sinn des Aufrufs: eine zwischenzeitlich geänderte Rolle oder ein gewechselter Mandant wirken spätestens mit der nächsten Erneuerung.
entfernt werden. Wer sie weiterführt, rotiert zweimal - einmal im Skript, einmal im Server - und riskiert, dass beide Seiten unterschiedlicher Meinung sind. Der
Server sieht die Skript-Tabelle nicht, und das Skript sieht die Sitzung nicht.Ein bereits benutztes Refresh-Token wird mit 401 abgelehnt. Erfolgt die erneute Vorlage innerhalb von 60 Sekunden, bleibt die Sitzung bestehen (paralleler Refresh einer App); danach gilt sie als Wiedervorlage und die gesamte Sitzung wird gesperrt. Details in Zugänge.
Das alte Refresh-jti steht weiterhin als oParams.Values['_OBS_JWT_ID'] bereit, falls das Skript es für eigene Protokollierung braucht.
function Refresh(oParams: TStrings; oBody: TJSONObject): string;
var oRes : TJSONObject;
cUser: string;
begin
oRes := TJSONObject.Create();
try
cUser := oParams.Values['_OBS_JWT_SUBJECT'];
// Der Server hat das vorgelegte Refresh-Token bereits geprueft und
// entwertet. Hier wird nur entschieden, ob der Benutzer die Sitzung
// fortsetzen darf - und mit welchen Rechten.
if (not BenutzerAktiv(cUser)) then begin
oRes.AddPair('status', 9);
oRes.AddPair('error' , 'Konto ist nicht mehr aktiv, bitte neu anmelden');
result := oRes.ToJSON();
exit;
end;
// Rolle und Mandant neu lesen, nicht aus dem alten Token uebernehmen -
// sonst wirkt ein Rechteentzug erst beim naechsten Login.
oRes.AddPair('status' , 1);
oRes.AddPair('_OBS_JWT_ID' , cUser);
oRes.AddPair('_OBS_JWT_SUBJECT' , cUser);
oRes.AddPair('_OBS_JWT_CLAIM_tenant', TechnikerMandant(cUser));
oRes.AddPair('_OBS_JWT_CLAIM_roles' , TechnikerRollen(cUser));
oRes.AddPair('_OBS_JWT_REFRESH_ID' , GlobalUID()); // loest das neue Refresh-Token aus
result := oRes.ToJSON();
finally
MyFreeAndNil(oRes);
end;
end;Abmelden (Logout)
Für das Abmelden braucht das Skript keine Methode. Es läuft über ein DELETE auf denselben JWT-Endpunkt (Access-Token im Authorization-Header) und wird komplett in der Engine erledigt: die Sitzung wird gesperrt, die Antwort ist 204. Eine Methode Logout gibt es nicht und wird nicht aufgerufen. Details: Zugänge, Phase 4.
Wer beim Abmelden zusätzlich fachlich etwas tun will - eine Geräteregistrierung löschen, einen Push-Token verwerfen, den Vorgang protokollieren - nimmt einen gewöhnlichen Endpunkt und setzt dort _OBS_JWT_REVOKE. Dasselbe gilt für "auf allen Geräten abmelden", weil das eine Berechtigungsentscheidung braucht:
function Post(oParams: TStrings; oBody: TJSONObject): string;
var oRes: TJSONObject;
begin
oRes := TJSONObject.Create();
try
GeraeteRegistrierungLoeschen(oParams.Values['_OBS_JWT_SUBJECT']);
// 'session' = diese Sitzung, 'all' = alle Sitzungen des Subjects
oRes.AddPair('_OBS_JWT_REVOKE' , 'session');
oRes.AddPair('_OBS_HTTP_STATUS', 204);
result := oRes.ToJSON();
finally
MyFreeAndNil(oRes);
end;
end;Gesperrt wird in beiden Wegen die ganze Sitzung, also auch die noch nicht abgelaufenen Access-Token vorheriger Erneuerungen. Ein wiederholter Aufruf ist unschädlich. Lässt sich die Sitzung nicht sperren, überschreibt der Server die Antwort des Skripts mit 503 - ein Client darf nicht glauben, er sei abgemeldet, während sein Token weiterläuft.
Fehlerbehandlung im Skript
Tritt im Skript eine Exception auf oder schlägt die Syntax-Prüfung fehl, antwortet der Server mit 500 Interner Fehler und protokolliert die Detail-Meldung in RESTSRV_PROTO (mit Skript-Fehlertext). Der Konsument sieht keine internen Details.
Server-eigene Fehler (Routing, Rate-Limit, Token, Auffangnetz) haben ein einheitliches Format mit einem error-Objekt - Aufbau siehe Übersicht.
Will das Skript einen spezifischen Fehler an den Konsumenten zurückgeben, sollte es dasselbe Format und einen passenden Statuscode verwenden:
function Post(oParams: TStrings; oBody: TJSONObject): string;
var oRes, oErr: TJSONObject;
begin
oRes := TJSONObject.Create();
try
if (Empty(oParams.Values['kundennr'])) then begin
oErr := TJSONObject.Create();
oErr.AddPair('code' , 'VALIDATION_FAILED');
oErr.AddPair('message', 'Parameter ''kundennr'' fehlt');
oErr.AddPair('traceId', oParams.Values['_OBS_TRACE_ID']);
oRes.AddPair('_OBS_HTTP_STATUS', 422);
oRes.AddPair('error', oErr);
result := oRes.ToJSON();
exit;
end;
// ...
finally
MyFreeAndNil(oRes);
end;
end;200 mit Fehlertext im Body. Nur so erkennt der Konsument den Fehlschlag ohne den Body auszuwerten - und nur so gibt der Server einen belegten
Idempotency-Key wieder frei (siehe Abschnitt Idempotenz).Ändern: qSqlInit statt qSqlRead
Zum Ändern eines vorhandenen Satzes wird ebenfalls qSqlInit
benutzt - der Satz wird über sys_uid adressiert:
q := qSqlInit(oDB, 'tickets');
q.qSet('sys_uid' , cSysUid); // Schreib-Index auf den Originalsatz
q.qSet('ti_status', '9');
if (not q.SaveData(UPDATE_RECORD)) then begin
// ... Fehler behandeln, siehe unten
end;
qSqlFree(q);qSqlRead ist dafür die falsche Wahl, aus zwei Gründen:
- Es liest den kompletten Altsatz ein - ein zusätzlicher Lesezugriff, der Zeit kostet.
- Es schreibt anschliessend nur die Änderungen. Ist der neue Wert gleich dem alten, entsteht gar kein Write, und
SaveDataliefert False - ununterscheidbar von einem echten Fehlschlag.
sys_uid legt qSqlInit einen neuen
Datensatz an, statt den vorhandenen zu ändern. Die sys_uid ist der
Damit entfällt auch das übliche Paar aus DB_LSeek und
qSqlRead: eine Abfrage auf die sys_uid liefert
zugleich die Existenzprüfung und den Schreib-Index.
cUid := '';
if (DB_SOpen(oDB, 'SELECT sys_uid FROM tickets WHERE ' + cWhere + ' LIMIT 1', q)) then begin
if (not q.EoF) then begin
cUid := q.A2UID();
end;
end;
DB_Close(q);
if (not Empty(cUid)) then begin
// ändern: qSqlInit + sys_uid
end else begin
// anlegen: qSqlInit ohne sys_uid
end;Mehrere Felder sind EIN Schreibvorgang
Ein qSqlInit-Objekt nimmt beliebig viele qSet entgegen
und schreibt sie mit einem SaveData. Fallen an derselben
Zeile mehrere Felder an, gehören sie in dasselbe Objekt:
// FALSCH: fünf Felder, fünf Schreibvorgänge - und jeder Setzer, der sich
// seine sys_uid selbst holt, bringt zusätzlich eine eigene Abfrage mit
FeldSetzen(cUid, 'ti_status' , '9');
FeldSetzen(cUid, 'ti_grund' , cGrund);
FeldSetzen(cUid, 'ti_von' , cBenutzer);
// RICHTIG: ein Schreibobjekt, ein SaveData
q := qSqlInit(oDB, 'tickets');
q.qSet('sys_uid' , cUid);
q.qSet('ti_status', '9');
q.qSet('ti_grund' , cGrund);
q.qSet('ti_von' , cBenutzer);
if (not q.SaveData(UPDATE_RECORD)) then begin
// ... Fehler behandeln
end;
qSqlFree(q);Das ist nicht nur eine Frage der Geschwindigkeit: einzeln geschriebene Felder sind nicht atomar. Bricht der dritte Schreibvorgang ab, steht die Zeile halb geändert da - Grund gesetzt, Zeitpunkt nicht.
Wird nichts gesetzt, darf auch nicht geschrieben werden: das Schreibobjekt dann
mit qSqlFree freigeben, statt ein leeres SaveData
abzusetzen.
FeldSetzen(cUid, cFeld, cWert))
ist bequem und deshalb verführerisch. Er versteckt aber je Aufruf eine eigene
Abfrage und ein eigenes SaveData. Für einen Vorgang mit mehreren
Feldern gehört ein Editier-Helfer her, der das Schreibobjekt liefert -
Keine Abfrage je Zeile
Was in einer Liste je Zeile nachgeschlagen wird, kostet N Abfragen für eine Antwort. Bei fünfzig Aufträgen sind das fünfzig Abfragen für ein einziges Feld. Der Ausweg ist immer derselbe:
- Werte aus einer 1:1-Beziehung über einen
LEFT JOINmitnehmen - bei einem eindeutigen Index auf dem Join-Feld kann er die Zeilen nicht vervielfachen. - Einzelwerte aus einer 1:n-Beziehung über eine Unterabfrage in der SELECT-Liste holen, nicht über einen Join (der Join würde die Zeilen vervielfachen).
- Eine echte Unterliste (Positionen, Dateien) nur dann nachladen, wenn ein Zählfeld in der Hauptabfrage sagt, dass es überhaupt eine gibt.
"beim ersten Ausliefern festschreiben" - vervielfachen sich mit der Zeilenzahl
und treffen den Aufrufer, der nur eine Liste angefordert hat.Schreibfehler erkennen
SaveData liefert einen Boolean - und der ist die einzige
Fehlermeldung, die es gibt. (Auch der Parameter ist ein Boolean:
NEW_RECORD und UPDATE_RECORD sind Wahrheitswerte, keine Zahlen.) Schlägt das Schreiben in der Datenbank fehl
(Duplicate entry auf einem eindeutigen Index, zu langer Wert, fehlende
Spalte), dann
- wird keine Exception ausgelöst,
- steht nichts in RESTSRV_PROTO,
- läuft das Skript weiter und antwortet mit dem Erfolg, den es selbst formuliert.
Wer den Aufruf als Anweisung schreibt, baut damit einen stillen Datenverlust ein. Weil in einem Endpunkt schnell ein Dutzend Schreibvorgänge zusammenkommen, gehört die Prüfung in eine eigene kleine Prozedur - am besten in die gemeinsame Lib:
procedure Speichern(q: TqSQL; lNeu: Boolean; const cWas: string);
var lOk: Boolean;
begin
lOk := q.SaveData(lNeu);
qSqlFree(q); // auch im Fehlerfall freigeben
if (not lOk) then begin
raise Exception.Create('Schreiben fehlgeschlagen: ' + cWas);
end;
end;
// Aufruf statt SaveData + qSqlFree:
q := qSqlInit(oDB, 'tickets');
q.qSet('ti_betreff', cBetreff);
Speichern(q, NEW_RECORD, 'Ticket');Die Exception fängt der Server: er schreibt Schreiben fehlgeschlagen: Ticket nach RESTSRV_PROTO und antwortet mit 500. Liegt ein Idempotency-Key an, wird er dabei freigegeben - die Anfrage ist also wiederholbar (siehe Abschnitt Idempotenz).
sinnvoll weiterlaufen kann: die Fachwirkung ist dann unvollständig. Wer trotzdem weitermachen will - etwa weil mehrere unabhängige Sätze geschrieben werden -, prüft den Rückgabewert und baut selbst eine Antwort. Ignorieren darf man ihn
nie.Gängige Codes, die auch der Server selbst verwendet: VALIDATION_FAILED (422), NOT_FOUND (404), FORBIDDEN_ROLE (403), VERSION_CONFLICT (409). Eigene, fachlich sprechende Codes sind erlaubt - wichtig ist, dass sie stabil bleiben, weil Konsumenten darauf ihre Reaktion aufbauen.
Gemeinsamen Code auslagern
Wird Logik in mehreren Endpunkten gebraucht - Berechtigungsprüfungen, JSON-Bausteine, Hilfsfunktionen -, muss sie nicht in jedes Skript kopiert werden. Die Skript-Engine lädt Textbausteine beim Laden aus einer beliebigen Tabelle nach:
{$L T="restsrv_endpoints" I="re_pathtemplate" V="/meinapp/lib" F="re_script"}
| Attribut | Bedeutung |
|---|---|
| T | Tabelle, aus der geladen wird |
| I | Spalte, über die gesucht wird |
| V | Wert, der in dieser Spalte stehen muss |
| F | Spalte, deren Inhalt an dieser Stelle eingesetzt wird |
Die Direktive steht üblicherweise direkt unter dem Kopfkommentar des Skripts. Der Compiler sieht danach den zusammengesetzten Text - eine Funktion aus der Lib lässt sich also aufrufen wie eine im Skript selbst geschriebene.
Bewährtes Muster: Lib als eigene Endpunkt-Zeile
Am wenigsten Verwaltung macht es, die Lib als eigene Zeile in RESTSRV_ENDPOINTS abzulegen:
- Endpunkt anlegen mit einem eigenen Pfad-Template, z.B.
/meinapp/lib. - Den Lib-Quelltext ins Skript-Feld dieser Zeile schreiben (F7).
- Keine Berechtigung in der Berechtigungs-Liste vergeben - das ist der Schutz: ohne Berechtigung beantwortet der Server einen Aufruf mit 403.
- In jedem nutzenden Endpunkt die Direktive oben einfügen.
Damit ist die Lib reiner Ablageort und von aussen nicht nutzbar. Eine Änderung wirkt auf alle einbindenden Endpunkte, ohne dass ein einziges Endpunkt-Skript angefasst werden muss.
Funktion hinzu, genügt es nicht, nur das nutzende Skript zu aktualisieren. Eine ältere Lib lädt fehlerfrei - der Compiler meldet dann ausschliesslich die neuen
Namen als Unknown name, was leicht wie ein Fehler im Skript aussieht.Fallstricke im Skript
Die folgenden Punkte unterscheiden das Skript von normalem Delphi-Code und kosten sonst eine Runde Fehlersuche:
| Stolperstein | Richtig |
|---|---|
| DB_SOpen('Y00…', oDB, …) - der AD-UID-Parameter aus dem Delphi-Code | Im Skript ohne UID: DB_SOpen(oDB, cSql, q). Gleiches gilt für qSqlInit(oDB, 'tab') und qSqlRead(oDB, 'tab', cWhere) - jede dieser Funktionen beginnt mit oDB
|
| oBody.TryGetValue<T>(…) | Generics gibt es nicht - über GetValue lesen (siehe oBody) |
| EMPTY_DATE für ein leeres Datum | EMPTY_DATE ist im Skript ein String. Für Datumsvariablen und -vergleiche MINDATETIME verwenden |
| fVal auf einen JSON-Zahlenwert | JSON liefert den Dezimalpunkt, fVal erwartet die lokale Notation: vorher StrTran(cVal, '.', ',')
|
| TStringList für Zwischenlisten | In Skripten unzuverlässig. Kleine Mengen über einen Delimiter-String führen ('|' + wert + '|') und mit Pos prüfen
|
| Default-Parameter in eigenen Funktionen | Werden nicht unterstützt - alle Parameter ausschreiben |
q.SaveData(…) als Anweisung schreiben |
SaveData liefert einen Boolean. Ein SQL-Fehler beim Schreiben wirft keine Exception und landet in keinem Protokoll - der Rückgabewert ist die einzige Meldung. Immer auswerten, siehe Schreibfehler erkennen |
| Je Feld ein eigener Setzer-Aufruf | Ein qSqlInit-Objekt, beliebig viele qSet, ein SaveData. Einzeln geschrieben kostet jedes Feld eine eigene Abfrage samt eigenem Write - und der Vorgang ist nicht mehr atomar, siehe Mehrere Felder
|
| Ein Wert je Zeile nachgeschlagen | In einer Liste sind das N Abfragen für eine Antwort. Über LEFT JOIN (1:1) oder Unterabfrage (1:n) mitnehmen, siehe Keine Abfrage je Zeile
|
Ausgabeparameter vom Typ var … : TDateTime |
Bringt die Skript-VM zum Absturz (Assertion in dwsStack.pas), und zwar beim ersten Lesen des Werts - nicht schon beim Schreiben, die Ursache liegt also nicht dort, wo der Fehler auffällt. var string, var Integer und var Double laufen. Zeitpunkte als ISO-Zeichenkette zurückgeben und erst beim Aufrufer wandeln
|
Skript-Cache
Der Server cached das kompilierte Skript pro Endpunkt (Schlüssel: sys_date des Endpunkts). Zusätzlich werden die Endpunkt-Definitionen pro Server-Profil in einem TTL-Cache (Standard 60 s) gehalten. Eine Skript-Änderung über F7 wird daher erst nach Ablauf dieses TTL (bzw. nach einer Cache-Invalidierung) wirksam - typischerweise innerhalb einer Minute, nicht zwingend sofort.
Verfügbare Bibliotheken
Im Skript können alle OBS-Standard-Bibliotheken verwendet werden. Typische Einstiegspunkte:
- Base.Tools - String-, Datum-, IIF-, Empty-Helper
- Base.DB / Base.xQuery - Datenbank-Operationen
- Base.qSqlReg - Schreibzugriffe
- System.JSON - JSON-Objekte und -Arrays
- Base.ToolsConst - Konstanten wie CRLF, SINGELQUOTE, MINDATETIME
Die wichtigsten Aufrufe mit ihren Skript-Signaturen:
| Zweck | Aufruf |
|---|---|
| Lesen | DB_SOpen(oDB, cSql, q), dann q.EoF, q.Next(), q.A2C('feld') / A2I / A2F / A2D / A2UID(), am Ende DB_Close(q)
|
| Neu anlegen | q := qSqlInit(oDB, 'tab'), q.qSet('feld', wert), if (not q.SaveData(NEW_RECORD)) then …, qSqlFree(q) – der Rückgabewert ist Pflicht, siehe Schreibfehler erkennen
|
| Ändern | q := qSqlInit(oDB, 'tab'), q.qSet('sys_uid', cUid), q.qSet(…), if (not q.SaveData(UPDATE_RECORD)) then …, qSqlFree(q) – nicht qSqlRead, siehe Ändern. Alle Felder derselben Zeile in ein Objekt, siehe Mehrere Felder
|
| Direkt ausführen | DB_SqlExec(oDB, cSql)
|
| Existenzprüfung | DB_LSeek(oDB, 'tab', cWhere)
|
| Werte quoten (Pflicht) | DB_SQLVal(wert)
|
AD-UID-Parameter als erstes Argument gehört zur Delphi-Variante und lässt sich im
Skript nicht übersetzen.Konkrete Beispiele: