OBS/Kostenpflichtige Module/RESTServer/Scripting: Unterschied zwischen den Versionen
Keine Bearbeitungszusammenfassung |
Keine Bearbeitungszusammenfassung |
||
| Zeile 47: | Zeile 47: | ||
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 68: | ||
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 571: | Zeile 614: | ||
Eigene, fachlich sprechende Codes sind erlaubt - wichtig ist, dass sie stabil | Eigene, fachlich sprechende Codes sind erlaubt - wichtig ist, dass sie stabil | ||
bleiben, weil Konsumenten darauf ihre Reaktion aufbauen. | 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 | |||
|} | |||
==Skript-Cache== | ==Skript-Cache== | ||
| Zeile 581: | Zeile 693: | ||
* ''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>q.SaveData(NEW_RECORD)</code>, <code>qSqlFree(q)</code> | |||
|- | |||
| Ändern || <code>q := qSqlRead(oDB, 'tab', cWhere)</code>, <code>q.qSet(…)</code>, <code>q.SaveData(UPDATE_RECORD)</code>, <code>qSqlFree(q)</code> | |||
|- | |||
| 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: | ||
Aktuelle Version vom 17. August 2026, 09:15 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;
Nicht implementierte Methoden liefern automatisch 405-ähnliche Fehler über die generische Skript-Antwort.
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 |
| _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.
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)
|
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:
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). |
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;
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.
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). 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> vor, ruft der Server statt Authenticate die Methode Refresh auf (sonst Login). Der Server verifiziert den Refresh-Token zuvor (Signatur, Ablauf, token_use=refresh); das alte Refresh-jti steht als oParams.Values['_OBS_JWT_ID'] bereit. Das Skript prüft/rotiert seine Sperrtabelle und liefert wie Authenticate neue Claims zurück. Der Server stellt daraufhin ein rotiertes Token-Paar aus; ein bereits benutztes Refresh-Token wird mit 401 abgelehnt.
function Refresh(oParams: TStrings; oBody: TJSONObject): string;
var oRes: TJSONObject;
cAltesJti, cNeuesJti: string;
begin
oRes := TJSONObject.Create();
try
cAltesJti := oParams.Values['_OBS_JWT_ID'];
// altes Refresh-jti gegen eigene Sperrtabelle prüfen
if (not RefreshJtiGueltig(cAltesJti)) then begin
oRes.AddPair('status', 9);
oRes.AddPair('error' , 'AUTH_EXPIRED');
result := oRes.ToJSON();
exit;
end;
cNeuesJti := GlobalUID();
RefreshJtiRotieren(cAltesJti, cNeuesJti); // altes sperren, neues ablegen
oRes.AddPair('status' , 1);
oRes.AddPair('_OBS_JWT_ID' , GlobalUID());
oRes.AddPair('_OBS_JWT_SUBJECT' , oParams.Values['_OBS_JWT_SUBJECT']);
oRes.AddPair('_OBS_JWT_CLAIM_tenant', oParams.Values['_OBS_JWT_CLAIM_tenant']);
oRes.AddPair('_OBS_JWT_CLAIM_roles' , oParams.Values['_OBS_JWT_CLAIM_roles']);
oRes.AddPair('_OBS_JWT_REFRESH_ID' , cNeuesJti);
result := oRes.ToJSON();
finally
MyFreeAndNil(oRes);
end;
end;
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).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 |
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), q.SaveData(NEW_RECORD), qSqlFree(q)
|
| Ändern | q := qSqlRead(oDB, 'tab', cWhere), q.qSet(…), q.SaveData(UPDATE_RECORD), qSqlFree(q)
|
| 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: