OBS/Kostenpflichtige Module/RESTServer/Scripting: Unterschied zwischen den Versionen
Keine Bearbeitungszusammenfassung |
Keine Bearbeitungszusammenfassung |
||
| Zeile 15: | Zeile 15: | ||
==Methoden-Signatur== | ==Methoden-Signatur== | ||
Pro HTTP-Methode wird im Skript eine gleichnamige | Pro HTTP-Methode wird im Skript eine gleichnamige '''Prozedur''' implementiert. Der Server ruft genau die Prozedur auf, die zur Methode des eingehenden Requests passt. | ||
procedure Get (oReader: TxRestReader; oWriter: TxRestWriter); | |||
procedure Post (oReader: TxRestReader; oWriter: TxRestWriter); | |||
procedure Put (oReader: TxRestReader; oWriter: TxRestWriter); | |||
procedure Patch (oReader: TxRestReader; oWriter: TxRestWriter); | |||
Zwei Handles, kein Rückgabewert: '''oReader''' ist die Anfrage, '''oWriter''' die | |||
Antwort. Beide erzeugt der Server, beide gehören ihm - im Skript wird nichts | |||
angelegt und nichts freigegeben. | |||
{{Achtung|'''DELETE hat keine Skript-Methode.''' <code>Delete</code> ist in der | |||
Skriptsprache eine Standardprozedur (Löschen aus einer Zeichenkette); eine eigene | |||
Prozedur dieses Namens lässt sich nicht übersetzen, und der Fehler nennt nur die | |||
Zeile der Deklaration. Wer einen DELETE-Endpunkt braucht, legt die Methode unter | |||
einem anderen Verb ab oder trennt sie in ein eigenes Skript.}} | |||
Fehlt die zur Methode passende Funktion, antwortet der Server mit '''405 Method | Fehlt die zur Methode passende Funktion, antwortet der Server mit '''405 Method | ||
| Zeile 31: | Zeile 40: | ||
Allow: GET, PUT | Allow: GET, PUT | ||
Ein im Vertrag zugesagtes Verb braucht also seine | Ein im Vertrag zugesagtes Verb braucht also seine Prozedur. Fehlt sie, ist das an | ||
der Antwort sofort zu erkennen und '''kein''' Serverfehler. | der Antwort sofort zu erkennen und '''kein''' Serverfehler. | ||
== | ==Die Anfrage lesen: oReader== | ||
''oReader'' ist der einzige Zugang zur Anfrage - Query, Header, Formularfelder, | |||
Pfadwerte, Token-Claims und Körper. Alles davon sind '''Zeichenketten''' oder | |||
werden über einen typisierten Leser geholt. | |||
{| class="wikitable" | |||
! Aufruf !! Was er liefert | |||
|- | |||
| <code>oReader.Param('kundennr')</code> || Query-Parameter, POST-Parameter (form-urlencoded) oder HTTP-Header. Getrimmt, leer wenn nicht gesendet | |||
|- | |||
| <code>oReader.Path('uid')</code> || Wert eines Platzhalters aus dem Pfad-Template | |||
|- | |||
| <code>oReader.Form('bemerkung')</code> || Formularfeld einer <code>multipart/form-data</code>-Übertragung | |||
|- | |||
| <code>oReader.Claim('tenant')</code> || Custom-Claim des vorgelegten Tokens | |||
|- | |||
| <code>oReader.Subject()</code> || Subject des Tokens (''sub''-Claim) | |||
|- | |||
| <code>oReader.TraceId()</code> || Korrelations-ID des Requests | |||
|- | |||
| <code>oReader.Offset()</code> || UTC-Offset des Servers, z.B. ''+02:00'' | |||
|- | |||
| <code>oReader.HasBody()</code> || Wurde überhaupt ein JSON-Körper gesendet? | |||
|} | |||
Filterung durch den Server: | Filterung durch den Server: | ||
* Geblockte Header werden nicht durchgereicht: ''authorization'', ''cookie'', ''proxy-authorization'', ''x-forwarded-for'', ''x-real-ip'', ''apikey'', ''api_key''. | * Geblockte Header werden nicht durchgereicht: ''authorization'', ''cookie'', ''proxy-authorization'', ''x-forwarded-for'', ''x-real-ip'', ''apikey'', ''api_key''. | ||
* | * Werte, die von aussen kommen, können die reservierten Namen nicht besetzen. | ||
* Werte werden auf max. 1024 Zeichen begrenzt. | * Werte werden auf max. 1024 Zeichen begrenzt. | ||
* Null-Bytes und Steuerzeichen (ausser Tab, CR, LF) werden entfernt. | * Null-Bytes und Steuerzeichen (ausser Tab, CR, LF) werden entfernt. | ||
| Zeile 49: | Zeile 79: | ||
Zugriff im Skript: | Zugriff im Skript: | ||
if (not Empty( | cKundenNr := oReader.Param('kundennr'); | ||
if (not Empty(cKundenNr)) then begin | |||
// ... | |||
end; | end; | ||
=== | ===Der Körper: Zugriff über Pfade=== | ||
Den JSON-Körper liest ''oReader'' über '''Pfade'''. Es gibt keine Unterobjekte im | |||
Skript, keinen Cast, kein ''.Items[i]'' - und damit auch keine Lebensdauer, um die | |||
sich jemand kümmern müsste. | |||
{ | {| class="wikitable" | ||
! Aufruf !! Bedeutung | |||
|- | |||
| <code>oReader.Str('betreff', cWert)</code> || Liest den Wert. '''Rückgabe Boolean''': war das Feld da? Fehlt es, bleibt die Variable unverändert | |||
|- | |||
| <code>oReader.Int('menge', nWert)</code> || dito, Ganzzahl | |||
|- | |||
| <code>oReader.Num('preis', nWert)</code> || dito, Kommazahl. '''Der Dezimalpunkt aus JSON wird richtig gelesen''' - kein ''StrTran'' mehr | |||
|- | |||
| <code>oReader.Bool('aktiv', lWert)</code> || dito, Boolean | |||
|- | |||
| <code>oReader.NumDef('rabatt', nWert, 0)</code> || wie ''Num'', mit Vorgabewert | |||
|- | |||
| <code>oReader.Has('feld')</code> || Ist das Feld vorhanden? (auch bei ''null'') | |||
|- | |||
| <code>oReader.Count('zeiten')</code> || Anzahl Einträge einer Liste, 0 wenn keine Liste | |||
|- | |||
| <code>oReader.IsArr('zeiten')</code> / <code>IsObj('zeiten[0]')</code> || Gestalt prüfen | |||
|- | |||
| <code>oReader.Keys()</code> || Alle Feldnamen der obersten Ebene, in Kommas eingefasst | |||
|} | |||
'' | '''Verschachtelung steht im Pfad''' - Punkt für Felder, eckige Klammern für Listen: | ||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter); | |||
var cVon : string; | |||
nAnz : Integer; | |||
nI : Integer; | |||
begin | begin | ||
nAnz := oReader.Count('zeiten'); | |||
for nI := 0 to nAnz - 1 do begin | |||
if (oReader.Str('zeiten[' + IntToStr(nI) + '].von', cVon)) then begin | |||
// ... cVon verarbeiten ... | |||
end; | |||
end; | end; | ||
end; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
{{Hinweis|'''Der Rückgabewert unterscheidet „nicht gesendet" von „leer | |||
gesendet".''' <code>oReader.Str('feld', cWert)</code> liefert bei | |||
{| | <code>"feld": null</code> ''true'' mit leerem Wert und bei fehlendem Feld | ||
''false''. Genau diese Unterscheidung braucht ein PATCH-artiger Aufruf: „Feld | |||
löschen" und „Feld nicht anfassen" sehen sonst gleich aus.}} | |||
Maximale Body-Grösse: 10 MB. | Ist gar kein Körper gesendet worden, liefert jeder Leser ''false'' und | ||
''HasBody()'' ist ''false''. Maximale Body-Grösse: 10 MB. | |||
=== | ===Werte des Servers=== | ||
Bei aktiver JWT-Authentifizierung stehen die Token-Claims | Bei aktiver JWT-Authentifizierung stehen die Token-Claims über den Reader zur | ||
Verfügung. Sie tragen intern weiterhin die Namen mit Präfix '''_OBS_''' - im | |||
Skript werden sie aber über die Zugriffe der Tabelle oben gelesen und nicht mehr | |||
über den Namen: | |||
{| class="wikitable" | {| class="wikitable" | ||
! Parameter !! Inhalt | ! Parameter !! Inhalt | ||
|- | |- | ||
| _OBS_JWT_ID | | <code>oReader.Param('_OBS_JWT_ID')</code> || JWT-Id (''jti''-Claim) - typisch die User-Id. '''Muss nicht eindeutig sein''': die Sitzung führt der Server über eigene Kennungen (siehe unten) | ||
|- | |- | ||
| | | <code>oReader.Subject()</code> || Subject (''sub''-Claim) - typisch Benutzername | ||
|- | |- | ||
| _OBS_JWT_AUDIENCE | | <code>oReader.Param('_OBS_JWT_AUDIENCE')</code> || Audience (''aud''-Claim) - typisch Mandant / Rolle | ||
|- | |- | ||
| | | <code>oReader.Claim('tenant')</code> || Beliebiger Custom-Claim des Tokens, hier ''tenant''; im Folge-Skript lesbar | ||
|- | |- | ||
| | | <code>oReader.TraceId()</code> || Korrelations-ID des Requests (auch als Response-Header X-Trace-Id) | ||
|} | |} | ||
| Zeile 138: | Zeile 164: | ||
! Parameter !! Inhalt | ! Parameter !! Inhalt | ||
|- | |- | ||
| _OBS_SERVER_TIME | | <code>oReader.Param('_OBS_SERVER_TIME')</code> || 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 | | <code>oReader.Offset()</code> || Nur der Offset daraus, z.B. ''+02:00''. Er geht in die Zeitfunktionen (siehe [[#Zeitangaben auf der Leitung|Zeitangaben]]) | ||
|- | |||
| <code>oReader.Param('_OBS_JWT_EXP_SEC')</code> || Laufzeit des Access-Tokens in Sekunden (''JWT-Exp'' × 60). Damit kann ein Konfigurations-Endpunkt denselben Wert ausliefern, den der Token wirklich hat | |||
|} | |} | ||
| Zeile 146: | Zeile 174: | ||
Zusätzlich trägt jedes Token zwei '''Sitzungskennungen des Servers''', lesbar als | Zusätzlich trägt jedes Token zwei '''Sitzungskennungen des Servers''', lesbar als | ||
'' | ''oReader.Claim('sid')'' und ''oReader.Claim('rid')'': | ||
{| class="wikitable" | {| class="wikitable" | ||
| Zeile 157: | Zeile 185: | ||
Beide sind OBS-UIDs (10 Zeichen) und werden vom Server '''nach''' den Claims des | Beide sind OBS-UIDs (10 Zeichen) und werden vom Server '''nach''' den Claims des | ||
Skripts gesetzt - ein Skript kann sie also auch mit '' | Skripts gesetzt - ein Skript kann sie also auch mit einem eigenen Claim ''sid'' | ||
überschreiben. Für Fachlogik sind sie nicht gedacht; sie sind nützlich, um eine | nicht überschreiben. Für Fachlogik sind sie nicht gedacht; sie sind nützlich, um eine | ||
Sitzung im Protokoll wiederzufinden. | Sitzung im Protokoll wiederzufinden. | ||
===Pfad-Parameter=== | ===Pfad-Parameter=== | ||
Stammt der Endpunkt aus einem Pfad-Template mit Platzhaltern (siehe [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]), stehen die aus den Platzhaltern erfassten Werte | Stammt der Endpunkt aus einem Pfad-Template mit Platzhaltern (siehe [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]), stehen die aus den Platzhaltern erfassten Werte über ''oReader.Path'' bereit: | ||
{| class="wikitable" | {| class="wikitable" | ||
! Template !! Zugriff im Skript | ! Template !! Zugriff im Skript | ||
|- | |- | ||
| <code>/orders/{uid}</code> || | | <code>/orders/{uid}</code> || <code>oReader.Path('uid')</code> | ||
|- | |- | ||
| <code>/orders/{uid}/modules/{code}</code> || | | <code>/orders/{uid}/modules/{code}</code> || <code>oReader.Path('uid')</code>, <code>oReader.Path('code')</code> | ||
|} | |} | ||
Diese Werte kommen aus dem Routing und sind durch den Client nicht fälschbar. Ein vollständiges Beispiel zeigt [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4 - Pfad-Parameter]]. | |||
===Datei-Uploads=== | ===Datei-Uploads=== | ||
| Zeile 180: | Zeile 208: | ||
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]), nimmt der Server | [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]), nimmt der Server | ||
hochgeladene Dateien entgegen, legt sie in einem temporären Verzeichnis ab und | hochgeladene Dateien entgegen, legt sie in einem temporären Verzeichnis ab und | ||
übergibt dem Skript Pfad, Originalname und Content-Type über | übergibt dem Skript Pfad, Originalname und Content-Type über den Reader. Das | ||
Skript entscheidet selbst über die weitere Verarbeitung (z.B. DMS-Ablage). Der | |||
DMS-Ablage). Der Body wird in diesem Fall '''nicht''' als JSON geparst - '' | Body wird in diesem Fall '''nicht''' als JSON geparst - ''oReader.HasBody()'' ist | ||
''false''. Es gilt nicht das JSON-Body-Limit (10 MB), sondern die pro | |||
Endpunkt konfigurierte Grösse (''re_upload_size'', Standard 25 MB; | Endpunkt konfigurierte Grösse (''re_upload_size'', Standard 25 MB; | ||
Überschreitung -> 413). | Überschreitung -> 413). | ||
Beide | '''Wie die Datei übertragen wurde, spielt für das Skript keine Rolle.''' Beide | ||
<code>Content-Range</code>-Upload - liefern | Wege - <code>multipart/form-data</code> und der resumable | ||
<code>Content-Range</code>-Upload - liefern dieselben vier Werte. Das | |||
Übertragungsprotokoll samt Header, Statuscodes und Resume-Verhalten steht in | |||
[[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]]: | |||
{| class="wikitable" | {| class="wikitable" | ||
! | ! Aufruf !! Inhalt | ||
|- | |- | ||
| | | <code>oReader.UploadPath()</code> || Vollständiger Pfad zur temporären Datei auf dem Server | ||
|- | |- | ||
| | | <code>oReader.UploadName()</code> || Originaldateiname | ||
|- | |- | ||
| | | <code>oReader.UploadType()</code> || Content-Type der Datei (Default <code>application/octet-stream</code>, wenn der Client keinen angibt), kleingeschrieben | ||
|- | |- | ||
| | | <code>oReader.UploadSha()</code> || 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 | 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 | '''400 Bad Request''' ab und das Skript wird nicht ausgeführt - das Skript kann | ||
sich also darauf verlassen, dass '' | sich also darauf verlassen, dass ''UploadPath()'' und ''UploadName()'' gefüllt | ||
sind, sobald es läuft. | |||
{{Hinweis|Die temporäre Datei wird nicht automatisch verschoben. Das Skript muss sie an ihren Zielort (DMS, Verzeichnis, ...) übernehmen.}} | {{Hinweis|Die temporäre Datei wird nicht automatisch verschoben. Das Skript muss sie an ihren Zielort (DMS, Verzeichnis, ...) übernehmen.}} | ||
Zusatzangaben, die im selben Aufruf mitkommen, liest das Skript mit | |||
<code>oReader.Form('<name>')</code> - bei <code>multipart/form-data</code> sind | |||
das die Partien ohne <code>filename=</code>. Ein vollständiges Beispiel zeigt | |||
[[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6]]. | |||
==Die Antwort schreiben: oWriter== | |||
Der Writer schreibt '''sequentiell''': jeder Aufruf hängt ein Feld an, an der | |||
Stelle, an der er steht. Die äussere Klammer der Antwort setzt der Server. Wird | |||
nichts geschrieben, antwortet er mit ''{}''. Content-Type ist | |||
''application/json; charset=utf-8'', der Status 200, sofern das Skript nichts | |||
anderes setzt. | |||
Einfaches Beispiel: | |||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter); | |||
begin | begin | ||
oWriter.Str('wert_string', '123'); | |||
oWriter.Int('wert_int' , 456); | |||
end; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
{| class="wikitable" | {| class="wikitable" | ||
! | ! Aufruf !! Ergebnis | ||
|- | |||
| <code>oWriter.Str('name', cWert)</code> || Zeichenkette, immer maskiert | |||
|- | |- | ||
| | | <code>oWriter.StrN('name', cWert)</code> || wie ''Str'', aber '''null''' statt einer leeren Zeichenkette | ||
|- | |- | ||
| | | <code>oWriter.Int('anz', nWert)</code> || Ganzzahl | ||
|- | |- | ||
| | | <code>oWriter.Num('preis', nWert, 2)</code> || Kommazahl mit fester Stellenzahl. '''Immer mit Dezimalpunkt''', unabhängig von der Locale des Servers | ||
|- | |- | ||
| | | <code>oWriter.Bool('aktiv', lWert)</code> || ''true'' / ''false'' | ||
|- | |- | ||
| <code>oWriter.Null('lat')</code> || ausdrücklich ''null'' | |||
|- | |- | ||
| | | <code>oWriter.ObjBegin('kunde')</code> … <code>oWriter.ObjEnd</code> || Unterobjekt | ||
|- | |- | ||
| | | <code>oWriter.ArrBegin('dateien')</code> … <code>oWriter.ArrEnd</code> || Liste | ||
|- | |- | ||
| | | <code>oWriter.RootArr()</code> || Die '''Wurzel''' ist eine Liste statt eines Objekts. Muss vor dem ersten Feld stehen; ein ''ArrEnd'' gibt es dazu nicht | ||
|- | |- | ||
| | | <code>oWriter.Raw('meta', cJson')</code> || Fertiges JSON-Fragment einbetten. Siehe unten | ||
|} | |} | ||
'''In einer Liste hat ein Eintrag keinen Namen''' - der leere Feldname ist genau | |||
das: | |||
<syntaxhighlight lang="pascal" line> | |||
oWriter.ArrBegin('dateien'); | |||
while (not q.EoF) do begin | |||
oWriter.ObjBegin(''); | |||
oWriter.Str ('uid' , q.A2C('f_uid')); | |||
oWriter.Int ('bytes' , q.A2I('f_bytes')); | |||
oWriter.StrN('kategorie', AllTrim(q.A2C('f_kat'))); // null wenn leer | |||
oWriter.ObjEnd; | |||
q.Next(); | |||
end; | |||
oWriter.ArrEnd; | |||
</syntaxhighlight> | |||
Ein Name in einer Liste und ein fehlender Name in einem Objekt sind beides | |||
'''Strukturfehler'''. Der Server antwortet dann mit '''500''' und nennt die | |||
Stelle im Protokoll - er liefert kein halbes JSON aus. Dasselbe gilt für einen | |||
Stapel, der am Ende der Methode noch offen ist. | |||
{{Hinweis|'''Ein Helfer bekommt den Writer als Parameter''' und schreibt an der | |||
Stelle hinein, an der er aufgerufen wird - er gibt kein Fragment zurück. Damit | |||
steht die Reihenfolge der Antwort im Code und nicht im Zusammenbau: | |||
<code>procedure DateienSchreiben(oWriter: TxRestWriter; const cNr: string);</code>}} | |||
'''Raw ist der Notausgang und prüft.''' Er parst das Fragment und bettet den | |||
'''Originaltext''' ein; ungültiges JSON gibt einen Serverfehler, der das Feld | |||
nennt. Gebraucht wird er für ein gespeichertes, '''signiertes''' Dokument: | |||
Parsen und Neuserialisieren würde die signierte Feldreihenfolge zerstören. | |||
===Zeitangaben auf der Leitung=== | |||
Zeitstempel gehen als '''ISO-8601 mit Offset''' über die Leitung. Die Wandlung | |||
gehört nicht ins Skript - dort saß der Fehler „zwei Stunden zu früh" an drei | |||
Stellen gleichzeitig: | |||
{| class="wikitable" | |||
! Aufruf !! Zweck | |||
|- | |||
| <code>RestIsoFromDate(dWert, cOffset)</code> || OBS-Zeitpunkt -> ''2026-09-09T14:12:00+02:00''. Leerer String bei leerem Datum | |||
|- | |||
| <code>RestDateFromIso(cWert, cOffset)</code> || ISO-Zeichenkette -> OBS-Zeitpunkt in '''Ortszeit des Servers''' | |||
|- | |||
| <code>oReader.Offset()</code> || Der Offset, der in beide hineingeht | |||
|- | |||
| <code>RestOffsetToMinutes(cOffset)</code> || Offset in Minuten, für eigene Rechnungen | |||
|} | |||
</ | |||
==Antwort steuern: Statuscode, Header, traceId== | ==Antwort steuern: Statuscode, Header, traceId== | ||
Statuscode, Header und das Sperren einer Sitzung laufen über eigene Aufrufe des | |||
Writers, '''nicht''' über Felder im Körper. | |||
{| class="wikitable" | {| class="wikitable" | ||
! | ! Aufruf !! Wirkung | ||
|- | |||
| <code>oWriter.Status(201)</code> || HTTP-Statuscode (z.B. 201, 204, 400, 409, 422). Ohne Angabe: 200 | |||
|- | |||
| <code>oWriter.Header('ETag', '7')</code> || Beliebiger Response-Header, z.B. ETag, Location, Retry-After. CR/LF in Namen und Werten werden entfernt (Schutz vor Header-Injection) | |||
|- | |||
| <code>oWriter.NoBody()</code> || Kein Körper - für '''204''' | |||
|- | |- | ||
| | | <code>oWriter.JwtRevoke('session')</code> || 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 | ||
|- | |- | ||
| | | <code>oWriter.Error(422, 'VALIDATION_FAILED', 'Feld „menge" fehlt')</code> || Fertige Fehlerantwort in der Form des Servers, siehe [[#Fehlerbehandlung im Skript|Fehlerbehandlung]] | ||
|- | |- | ||
| | | <code>oWriter.HasError()</code> || Steht schon eine Fehlerantwort im Writer? Für einen Verteiler, der danach noch etwas anhängen will | ||
|} | |} | ||
Zusätzlich trägt jede Antwort den Header '''X-Trace-Id''' (Korrelations-ID). Dieselbe ID liegt dem Skript als '' | Zusätzlich trägt jede Antwort den Header '''X-Trace-Id''' (Korrelations-ID). Dieselbe ID liegt dem Skript als ''oReader.TraceId()'' 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=== | ||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter); | |||
var | var cNeueUid: string; | ||
begin | begin | ||
// ... Datensatz anlegen, neue UID + Version (ETag) ermitteln ... | |||
oWriter.Str('uid', cNeueUid); | |||
oWriter.Status(201); | |||
oWriter.Header('Location', '/v1/orders/' + cNeueUid); | |||
oWriter.Header('ETag' , '1'); | |||
end; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| Zeile 369: | Zeile 378: | ||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter); | |||
var | var nMenge: Integer; | ||
begin | begin | ||
if (not oReader.Int('menge', nMenge)) then begin | |||
// Statuscode, code, message, uid und traceId in EINEM Aufruf - dieselbe | |||
// Huelle, die auch die Server-eigenen Fehler tragen. | |||
oWriter.Error(422, 'VALIDATION_FAILED', 'Feld "menge" fehlt'); | |||
exit; | |||
end; | end; | ||
// ... | |||
end; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| Zeile 393: | Zeile 396: | ||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter); | |||
var | var nAktuell: Integer; | ||
nIfMatch: Integer; | |||
begin | begin | ||
nAktuell := AuftragVersion(oReader.Path('uid')); | |||
nIfMatch := iVal(oReader.Param('if-match')); | |||
if (nIfMatch <> nAktuell) then begin | |||
// Der ETag gehoert AUCH an die Ablehnung - sonst kennt der Client den | |||
// gueltigen Wert nicht und sein naechster Versuch scheitert wieder. | |||
oWriter.Header('ETag', xStr(nAktuell)); | |||
oWriter.Error(409, 'VERSION_CONFLICT', 'Der Datensatz wurde zwischenzeitlich geaendert'); | |||
exit; | |||
end; | |||
// ... speichern, Version hochzählen ... | |||
oWriter.Header('ETag', xStr(nAktuell + 1)); | |||
end; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| Zeile 447: | Zeile 440: | ||
* '''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. | * '''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. | ||
Der vollständige Ablauf und die Statuscodes stehen unter | Der vollständige Ablauf und die Statuscodes stehen unter | ||
| Zeile 459: | Zeile 449: | ||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
procedure Authenticate(oReader: TxRestReader; oWriter: TxRestWriter); | |||
var | var cUser: string; | ||
cPass: string; | |||
cPass : string; | |||
begin | begin | ||
// Eingangsdaten lesen | |||
cUser := ''; | |||
cPass := ''; | |||
oReader.Str('username', cUser); | |||
oReader.Str('password', cPass); | |||
// Prüfung gegen eigene Tabelle, Hash-Verfahren, LDAP, ... | |||
if (PasswortPasst(cUser, cPass)) then begin | |||
oWriter.Int('status' , 1); | |||
oWriter.Str('_OBS_JWT_ID' , cUser); | |||
oWriter.Str('_OBS_JWT_SUBJECT' , cUser); | |||
oWriter.Str('_OBS_JWT_AUDIENCE', 'mandant1'); | |||
end else begin | |||
oWriter.Int('status', 9); | |||
oWriter.Str('error' , 'Login fehlgeschlagen'); | |||
end; | end; | ||
end; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
{{Hinweis|'''Im Anmeldeskript bleiben die _OBS_JWT_*-Felder Felder der | |||
Antwort''' - anders als Status und Header, die zu Aufrufen des Writers geworden | |||
sind. Der Grund: die Token-Ausstellung liest sie aus dem fertigen Körper, und | |||
genau dort erwartet sie sie. Geschrieben werden sie deshalb wie jedes andere | |||
Feld, nur eben mit dem Writer.}} | |||
Rückgabewerte: | Rückgabewerte: | ||
| Zeile 561: | Zeile 540: | ||
! RÜckgabefeld !! Bedeutung | ! 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 | | _OBS_JWT_CLAIM_<name> || Beliebiger Custom-Claim, z.B. _OBS_JWT_CLAIM_tenant, _OBS_JWT_CLAIM_roles. Im Folge-Skript lesbar als <code>oReader.Claim('<name>')</code>. | ||
|- | |- | ||
| _OBS_JWT_REFRESH_ID || (optional) jti des Refresh-Tokens. Nur wenn gesetzt, stellt der Server ein Refresh-Token aus. | | _OBS_JWT_REFRESH_ID || (optional) jti des Refresh-Tokens. Nur wenn gesetzt, stellt der Server ein Refresh-Token aus. | ||
| Zeile 572: | Zeile 551: | ||
{"token":"...", "refreshToken":"...", "serverTime":"2026-06-29T15:30:12+02:00"} | {"token":"...", "refreshToken":"...", "serverTime":"2026-06-29T15:30:12+02:00"} | ||
{{Hinweis|Mandant und Rollen immer aus dem Token lesen ('' | {{Hinweis|Mandant und Rollen immer aus dem Token lesen (''oReader.Claim(…)''), nie aus Body oder Query.}} | ||
====Refresh-Methode==== | ====Refresh-Methode==== | ||
| Zeile 595: | Zeile 574: | ||
wird gesperrt'''. Details in [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]]. | wird gesperrt'''. Details in [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]]. | ||
Das alte Refresh-jti steht weiterhin als '' | Das alte Refresh-jti steht weiterhin als ''oReader.Param('_OBS_JWT_ID')'' bereit, | ||
falls das Skript es für eigene Protokollierung braucht. | falls das Skript es für eigene Protokollierung braucht. | ||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
procedure Refresh(oReader: TxRestReader; oWriter: TxRestWriter); | |||
var | var cUser: string; | ||
begin | begin | ||
cUser := oReader.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 | |||
oWriter.Int('status', 9); | |||
oWriter.Str('error' , 'Konto ist nicht mehr aktiv, bitte neu anmelden'); | |||
exit; | |||
end; | |||
// Rolle und Mandant neu lesen, nicht aus dem alten Token uebernehmen - | |||
// sonst wirkt ein Rechteentzug erst beim naechsten Login. | |||
oWriter.Int('status' , 1); | |||
oWriter.Str('_OBS_JWT_ID' , cUser); | |||
oWriter.Str('_OBS_JWT_SUBJECT' , cUser); | |||
oWriter.Str('_OBS_JWT_CLAIM_tenant', TechnikerMandant(cUser)); | |||
oWriter.Str('_OBS_JWT_CLAIM_roles' , TechnikerRollen(cUser)); | |||
oWriter.Str('_OBS_JWT_REFRESH_ID' , GlobalUID()); // loest das neue Refresh-Token aus | |||
end; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| Zeile 646: | Zeile 617: | ||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter); | |||
begin | begin | ||
GeraeteRegistrierungLoeschen(oReader.Subject()); | |||
// 'session' = diese Sitzung, 'all' = alle Sitzungen des Subjects | |||
oWriter.JwtRevoke('session'); | |||
oWriter.Status(204); | |||
oWriter.NoBody(); | |||
end; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
| Zeile 677: | Zeile 642: | ||
[[OBS/Kostenpflichtige Module/RESTServer|Übersicht]]. | [[OBS/Kostenpflichtige Module/RESTServer|Übersicht]]. | ||
Für einen spezifischen Fehler an den Konsumenten gibt es '''einen''' Aufruf. Er | |||
''' | baut dieselbe Hülle, die auch die Server-eigenen Fehler tragen - Statuscode, | ||
''code'', ''message'', ''uid'' und ''traceId'' -, sodass beide Wege nicht | |||
auseinanderlaufen können: | |||
<syntaxhighlight lang="pascal" line> | <syntaxhighlight lang="pascal" line> | ||
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter); | |||
begin | begin | ||
if (Empty(oReader.Param('kundennr'))) then begin | |||
oWriter.Error(422, 'VALIDATION_FAILED', 'Parameter "kundennr" fehlt'); | |||
exit; | |||
end; | end; | ||
// ... | |||
end; | end; | ||
</syntaxhighlight> | </syntaxhighlight> | ||
{{Hinweis|'''Error verwirft einen schon begonnenen Körper''' und setzt seinen | |||
eigenen. Ein Skript darf also getrost Felder schreiben und danach noch | |||
abbrechen - was der Client sieht, ist die Fehlerantwort. Nur ein '''offener | |||
Stapel''' (ein ''ObjBegin'' ohne ''ObjEnd'') bleibt auch dann ein Strukturfehler.}} | |||
{{Hinweis|Ein fachlicher Fehler gehört auf einen '''4xx'''-Statuscode, nicht auf | {{Hinweis|Ein fachlicher Fehler gehört auf einen '''4xx'''-Statuscode, nicht auf | ||
| Zeile 902: | Zeile 861: | ||
{{Hinweis|Auch hier greift der Endpunkt-Cache: eine Änderung an der Lib wird erst | {{Hinweis|Auch hier greift der Endpunkt-Cache: eine Änderung an der Lib wird erst | ||
nach Ablauf des TTL (bis zu 60 Sekunden) wirksam.}} | nach Ablauf des TTL (bis zu 60 Sekunden) wirksam.}} | ||
===Wohin gehört welcher Code?=== | |||
Drei Ebenen, und die Zuordnung entscheidet, wie schnell eine Änderung wirkt: | |||
{| class="wikitable" | |||
! Art des Codes !! Wohin !! Wirksam | |||
|- | |||
| Fachlogik **einer** Anwendung - Feldnamen, Zustandswerte, Rollenregeln, Schreibziele || in die '''DWS-Lib dieser Anwendung''' (eigene Endpunkt-Zeile, per <code>{$L …}</code> eingebunden) || nach Ablauf des Cache-TTL, ohne Build | |||
|- | |||
| Mechanik, die '''mehrere''' Anwendungen brauchen - und auch das OBS-Umfeld || nach '''obs_lib''' als Delphi-Unit || erst nach Build und Rollout | |||
|- | |||
| Mechanik, die '''nur''' der REST-Server braucht || in die Units des Servers || erst nach Build und Rollout | |||
|} | |||
Die Regel hat einen praktischen Grund und keinen ästhetischen: Fachlogik ändert | |||
sich in Tagen, Mechanik in Monaten. Was in der Lib liegt, ist nach einer Minute | |||
aktiv; was in einer Unit liegt, braucht einen Build. Wer Fachlogik nach unten | |||
schiebt, tauscht Geschwindigkeit gegen nichts ein. | |||
{{Hinweis|Eine Anwendung, die ihre eigene Lib hat, kopiert sie '''nicht''' von | |||
einer anderen. Zwei Kopien derselben Lib driften auseinander, und die Abweichung | |||
fällt erst auf, wenn eine der beiden Anwendungen etwas Falsches tut. Geteiltes | |||
gehört eine Ebene tiefer.}} | |||
==Fallstricke im Skript== | ==Fallstricke im Skript== | ||
| Zeile 913: | Zeile 896: | ||
| ''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'' | | ''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'' | ||
|- | |- | ||
| '' | | ''oReader.Str<T>(…)'' oder irgendein Generic || Generics gibt es in der Skriptsprache nicht. Die Leser sind je Typ eigene Methoden: ''Str'', ''Int'', ''Num'', ''Bool'' | ||
|- | |||
| Eine eigene Prozedur ''Delete'' für DELETE || ''Delete'' ist eine '''Standardprozedur''' der Sprache. Die Deklaration lässt sich nicht übersetzen, und die Meldung nennt nur die Zeile - nicht die Ursache | |||
|- | |||
| Ein Helfer, der ein JSON-Fragment als String zurückgibt || Den '''Writer als Parameter''' übergeben und an der richtigen Stelle hineinschreiben. Fragmente von Hand zusammenzusetzen ist genau der Weg, auf dem die Reihenfolge und die Klammern verloren gingen | |||
|- | |||
| ''oWriter.ArrEnd'' nach ''RootArr'' || ''RootArr'' öffnet '''keine''' Ebene, es bestimmt nur die Klammer der Wurzel. Ein ''ArrEnd'' dazu ist ein Strukturfehler | |||
|- | |||
| Zahlen selbst formatieren (''NStr'', ''StrTran'') || <code>oWriter.Num('preis', nWert, 2)</code>. Selbst formatiert stand bei Werten ab 1000 der Tausendertrenner im JSON - gültiges JSON, falscher Wert, keine Meldung | |||
|- | |- | ||
| ''EMPTY_DATE'' für ein leeres Datum || ''EMPTY_DATE'' ist im Skript ein '''String'''. Für Datumsvariablen und -vergleiche '''MINDATETIME''' verwenden | | ''EMPTY_DATE'' für ein leeres Datum || ''EMPTY_DATE'' ist im Skript ein '''String'''. Für Datumsvariablen und -vergleiche '''MINDATETIME''' verwenden | ||
| Zeile 943: | Zeile 934: | ||
* ''Base.DB'' / ''Base.xQuery'' - Datenbank-Operationen | * ''Base.DB'' / ''Base.xQuery'' - Datenbank-Operationen | ||
* ''Base.qSqlReg'' - Schreibzugriffe | * ''Base.qSqlReg'' - Schreibzugriffe | ||
* ''System.JSON'' - | * ''lib_ScriptIO'' - Writer und Reader, siehe unten. '''System.JSON braucht ein Endpunkt-Skript nicht''' - der Writer erzeugt die Antwort, der Reader liest den Körper | ||
* ''Base.ToolsConst'' - Konstanten wie ''CRLF'', ''SINGELQUOTE'', ''MINDATETIME'' | * ''Base.ToolsConst'' - Konstanten wie ''CRLF'', ''SINGELQUOTE'', ''MINDATETIME'' | ||
* ''lib_ScriptIO'' - ''TxRestWriter'', ''TxRestReader'' und die Zeitfunktionen ''RestIsoFromDate'' / ''RestDateFromIso'' / ''RestOffsetToMinutes''. Writer und Reader bekommt das Skript als Parameter; die Zeitfunktionen ruft es direkt | |||
Die wichtigsten Aufrufe mit ihren Skript-Signaturen: | Die wichtigsten Aufrufe mit ihren Skript-Signaturen: | ||
Aktuelle Version vom 9. September 2026, 13:31 Uhr
- A Preise aktualisieren
- C Personen übertragen
- E Kategorien verwalten
- G Kataloge verwalten
- I Merkliste übertragen
- K Varianten übertragen
- L Artikelvarianten übertragen
- M Referenzarten übertragen
- N Lagerbestände verwalten
- U Bestellungen einlesen
- V leere Passworte füllen
- W Update-Informationen zurücksetzen
- X Konfiguration
- Z Protokoll
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 Prozedur implementiert. Der Server ruft genau die Prozedur auf, die zur Methode des eingehenden Requests passt.
procedure Get (oReader: TxRestReader; oWriter: TxRestWriter); procedure Post (oReader: TxRestReader; oWriter: TxRestWriter); procedure Put (oReader: TxRestReader; oWriter: TxRestWriter); procedure Patch (oReader: TxRestReader; oWriter: TxRestWriter);
Zwei Handles, kein Rückgabewert: oReader ist die Anfrage, oWriter die Antwort. Beide erzeugt der Server, beide gehören ihm - im Skript wird nichts angelegt und nichts freigegeben.
Delete ist in der
Skriptsprache eine Standardprozedur (Löschen aus einer Zeichenkette); eine eigene Prozedur dieses Namens lässt sich nicht übersetzen, und der Fehler nennt nur die Zeile der Deklaration. Wer einen DELETE-Endpunkt braucht, legt die Methode unter
einem anderen Verb ab oder trennt sie in ein eigenes Skript.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 Prozedur. Fehlt sie, ist das an der Antwort sofort zu erkennen und kein Serverfehler.
Die Anfrage lesen: oReader
oReader ist der einzige Zugang zur Anfrage - Query, Header, Formularfelder, Pfadwerte, Token-Claims und Körper. Alles davon sind Zeichenketten oder werden über einen typisierten Leser geholt.
| Aufruf | Was er liefert |
|---|---|
oReader.Param('kundennr') |
Query-Parameter, POST-Parameter (form-urlencoded) oder HTTP-Header. Getrimmt, leer wenn nicht gesendet |
oReader.Path('uid') |
Wert eines Platzhalters aus dem Pfad-Template |
oReader.Form('bemerkung') |
Formularfeld einer multipart/form-data-Übertragung
|
oReader.Claim('tenant') |
Custom-Claim des vorgelegten Tokens |
oReader.Subject() |
Subject des Tokens (sub-Claim) |
oReader.TraceId() |
Korrelations-ID des Requests |
oReader.Offset() |
UTC-Offset des Servers, z.B. +02:00 |
oReader.HasBody() |
Wurde überhaupt ein JSON-Körper gesendet? |
Filterung durch den Server:
- Geblockte Header werden nicht durchgereicht: authorization, cookie, proxy-authorization, x-forwarded-for, x-real-ip, apikey, api_key.
- Werte, die von aussen kommen, können die reservierten Namen nicht besetzen.
- Werte werden auf max. 1024 Zeichen begrenzt.
- Null-Bytes und Steuerzeichen (ausser Tab, CR, LF) werden entfernt.
Zugriff im Skript:
cKundenNr := oReader.Param('kundennr');
if (not Empty(cKundenNr)) then begin
// ...
end;
Der Körper: Zugriff über Pfade
Den JSON-Körper liest oReader über Pfade. Es gibt keine Unterobjekte im Skript, keinen Cast, kein .Items[i] - und damit auch keine Lebensdauer, um die sich jemand kümmern müsste.
| Aufruf | Bedeutung |
|---|---|
oReader.Str('betreff', cWert) |
Liest den Wert. Rückgabe Boolean: war das Feld da? Fehlt es, bleibt die Variable unverändert |
oReader.Int('menge', nWert) |
dito, Ganzzahl |
oReader.Num('preis', nWert) |
dito, Kommazahl. Der Dezimalpunkt aus JSON wird richtig gelesen - kein StrTran mehr |
oReader.Bool('aktiv', lWert) |
dito, Boolean |
oReader.NumDef('rabatt', nWert, 0) |
wie Num, mit Vorgabewert |
oReader.Has('feld') |
Ist das Feld vorhanden? (auch bei null) |
oReader.Count('zeiten') |
Anzahl Einträge einer Liste, 0 wenn keine Liste |
oReader.IsArr('zeiten') / IsObj('zeiten[0]') |
Gestalt prüfen |
oReader.Keys() |
Alle Feldnamen der obersten Ebene, in Kommas eingefasst |
Verschachtelung steht im Pfad - Punkt für Felder, eckige Klammern für Listen:
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);
var cVon : string;
nAnz : Integer;
nI : Integer;
begin
nAnz := oReader.Count('zeiten');
for nI := 0 to nAnz - 1 do begin
if (oReader.Str('zeiten[' + IntToStr(nI) + '].von', cVon)) then begin
// ... cVon verarbeiten ...
end;
end;
end;gesendet". oReader.Str('feld', cWert) liefert bei
"feld": null true mit leerem Wert und bei fehlendem Feld
false. Genau diese Unterscheidung braucht ein PATCH-artiger Aufruf: „Feld
Ist gar kein Körper gesendet worden, liefert jeder Leser false und HasBody() ist false. Maximale Body-Grösse: 10 MB.
Werte des Servers
Bei aktiver JWT-Authentifizierung stehen die Token-Claims über den Reader zur Verfügung. Sie tragen intern weiterhin die Namen mit Präfix _OBS_ - im Skript werden sie aber über die Zugriffe der Tabelle oben gelesen und nicht mehr über den Namen:
| Parameter | Inhalt |
|---|---|
oReader.Param('_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) |
oReader.Subject() |
Subject (sub-Claim) - typisch Benutzername |
oReader.Param('_OBS_JWT_AUDIENCE') |
Audience (aud-Claim) - typisch Mandant / Rolle |
oReader.Claim('tenant') |
Beliebiger Custom-Claim des Tokens, hier tenant; im Folge-Skript lesbar |
oReader.TraceId() |
Korrelations-ID des Requests (auch als Response-Header X-Trace-Id) |
Unabhängig von JWT stehen ausserdem immer zur Verfügung:
| Parameter | Inhalt |
|---|---|
oReader.Param('_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 |
oReader.Offset() |
Nur der Offset daraus, z.B. +02:00. Er geht in die Zeitfunktionen (siehe Zeitangaben) |
oReader.Param('_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 oReader.Claim('sid') und oReader.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 einem eigenen 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 über oReader.Path bereit:
| Template | Zugriff im Skript |
|---|---|
/orders/{uid} |
oReader.Path('uid')
|
/orders/{uid}/modules/{code} |
oReader.Path('uid'), oReader.Path('code')
|
Diese Werte kommen aus dem Routing und sind durch den Client nicht 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 den Reader. Das
Skript entscheidet selbst über die weitere Verarbeitung (z.B. DMS-Ablage). Der
Body wird in diesem Fall nicht als JSON geparst - oReader.HasBody() ist
false. Es gilt nicht das JSON-Body-Limit (10 MB), sondern die pro
Endpunkt konfigurierte Grösse (re_upload_size, Standard 25 MB;
Überschreitung -> 413).
Wie die Datei übertragen wurde, spielt für das Skript keine Rolle. Beide
Wege - multipart/form-data und der resumable
Content-Range-Upload - liefern dieselben vier Werte. Das
Übertragungsprotokoll samt Header, Statuscodes und Resume-Verhalten steht in
Datei-Upload:
| Aufruf | Inhalt |
|---|---|
oReader.UploadPath() |
Vollständiger Pfad zur temporären Datei auf dem Server |
oReader.UploadName() |
Originaldateiname |
oReader.UploadType() |
Content-Type der Datei (Default application/octet-stream, wenn der Client keinen angibt), kleingeschrieben
|
oReader.UploadSha() |
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 UploadPath() und UploadName() gefüllt sind, sobald es läuft.
Zusatzangaben, die im selben Aufruf mitkommen, liest das Skript mit
oReader.Form('<name>') - bei multipart/form-data sind
das die Partien ohne filename=. Ein vollständiges Beispiel zeigt
Beispiel 6.
Die Antwort schreiben: oWriter
Der Writer schreibt sequentiell: jeder Aufruf hängt ein Feld an, an der Stelle, an der er steht. Die äussere Klammer der Antwort setzt der Server. Wird nichts geschrieben, antwortet er mit {}. Content-Type ist application/json; charset=utf-8, der Status 200, sofern das Skript nichts anderes setzt.
Einfaches Beispiel:
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);
begin
oWriter.Str('wert_string', '123');
oWriter.Int('wert_int' , 456);
end;| Aufruf | Ergebnis |
|---|---|
oWriter.Str('name', cWert) |
Zeichenkette, immer maskiert |
oWriter.StrN('name', cWert) |
wie Str, aber null statt einer leeren Zeichenkette |
oWriter.Int('anz', nWert) |
Ganzzahl |
oWriter.Num('preis', nWert, 2) |
Kommazahl mit fester Stellenzahl. Immer mit Dezimalpunkt, unabhängig von der Locale des Servers |
oWriter.Bool('aktiv', lWert) |
true / false |
oWriter.Null('lat') |
ausdrücklich null |
oWriter.ObjBegin('kunde') … oWriter.ObjEnd |
Unterobjekt |
oWriter.ArrBegin('dateien') … oWriter.ArrEnd |
Liste |
oWriter.RootArr() |
Die Wurzel ist eine Liste statt eines Objekts. Muss vor dem ersten Feld stehen; ein ArrEnd gibt es dazu nicht |
oWriter.Raw('meta', cJson') |
Fertiges JSON-Fragment einbetten. Siehe unten |
In einer Liste hat ein Eintrag keinen Namen - der leere Feldname ist genau das:
oWriter.ArrBegin('dateien');
while (not q.EoF) do begin
oWriter.ObjBegin('');
oWriter.Str ('uid' , q.A2C('f_uid'));
oWriter.Int ('bytes' , q.A2I('f_bytes'));
oWriter.StrN('kategorie', AllTrim(q.A2C('f_kat'))); // null wenn leer
oWriter.ObjEnd;
q.Next();
end;
oWriter.ArrEnd;Ein Name in einer Liste und ein fehlender Name in einem Objekt sind beides Strukturfehler. Der Server antwortet dann mit 500 und nennt die Stelle im Protokoll - er liefert kein halbes JSON aus. Dasselbe gilt für einen Stapel, der am Ende der Methode noch offen ist.
Stelle hinein, an der er aufgerufen wird - er gibt kein Fragment zurück. Damit steht die Reihenfolge der Antwort im Code und nicht im Zusammenbau:
procedure DateienSchreiben(oWriter: TxRestWriter; const cNr: string);Raw ist der Notausgang und prüft. Er parst das Fragment und bettet den Originaltext ein; ungültiges JSON gibt einen Serverfehler, der das Feld nennt. Gebraucht wird er für ein gespeichertes, signiertes Dokument: Parsen und Neuserialisieren würde die signierte Feldreihenfolge zerstören.
Zeitangaben auf der Leitung
Zeitstempel gehen als ISO-8601 mit Offset über die Leitung. Die Wandlung gehört nicht ins Skript - dort saß der Fehler „zwei Stunden zu früh" an drei Stellen gleichzeitig:
| Aufruf | Zweck |
|---|---|
RestIsoFromDate(dWert, cOffset) |
OBS-Zeitpunkt -> 2026-09-09T14:12:00+02:00. Leerer String bei leerem Datum |
RestDateFromIso(cWert, cOffset) |
ISO-Zeichenkette -> OBS-Zeitpunkt in Ortszeit des Servers |
oReader.Offset() |
Der Offset, der in beide hineingeht |
RestOffsetToMinutes(cOffset) |
Offset in Minuten, für eigene Rechnungen |
Antwort steuern: Statuscode, Header, traceId
Statuscode, Header und das Sperren einer Sitzung laufen über eigene Aufrufe des Writers, nicht über Felder im Körper.
| Aufruf | Wirkung |
|---|---|
oWriter.Status(201) |
HTTP-Statuscode (z.B. 201, 204, 400, 409, 422). Ohne Angabe: 200 |
oWriter.Header('ETag', '7') |
Beliebiger Response-Header, z.B. ETag, Location, Retry-After. CR/LF in Namen und Werten werden entfernt (Schutz vor Header-Injection) |
oWriter.NoBody() |
Kein Körper - für 204 |
oWriter.JwtRevoke('session') |
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 |
oWriter.Error(422, 'VALIDATION_FAILED', 'Feld „menge" fehlt') |
Fertige Fehlerantwort in der Form des Servers, siehe Fehlerbehandlung |
oWriter.HasError() |
Steht schon eine Fehlerantwort im Writer? Für einen Verteiler, der danach noch etwas anhängen will |
Zusätzlich trägt jede Antwort den Header X-Trace-Id (Korrelations-ID). Dieselbe ID liegt dem Skript als oReader.TraceId() 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
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);
var cNeueUid: string;
begin
// ... Datensatz anlegen, neue UID + Version (ETag) ermitteln ...
oWriter.Str('uid', cNeueUid);
oWriter.Status(201);
oWriter.Header('Location', '/v1/orders/' + cNeueUid);
oWriter.Header('ETag' , '1');
end;Beispiel: Strukturierter Fehler mit Statuscode und traceId
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);
var nMenge: Integer;
begin
if (not oReader.Int('menge', nMenge)) then begin
// Statuscode, code, message, uid und traceId in EINEM Aufruf - dieselbe
// Huelle, die auch die Server-eigenen Fehler tragen.
oWriter.Error(422, 'VALIDATION_FAILED', 'Feld "menge" fehlt');
exit;
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:
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);
var nAktuell: Integer;
nIfMatch: Integer;
begin
nAktuell := AuftragVersion(oReader.Path('uid'));
nIfMatch := iVal(oReader.Param('if-match'));
if (nIfMatch <> nAktuell) then begin
// Der ETag gehoert AUCH an die Ablehnung - sonst kennt der Client den
// gueltigen Wert nicht und sein naechster Versuch scheitert wieder.
oWriter.Header('ETag', xStr(nAktuell));
oWriter.Error(409, 'VERSION_CONFLICT', 'Der Datensatz wurde zwischenzeitlich geaendert');
exit;
end;
// ... speichern, Version hochzählen ...
oWriter.Header('ETag', xStr(nAktuell + 1));
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.
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:
procedure Authenticate(oReader: TxRestReader; oWriter: TxRestWriter);
var cUser: string;
cPass: string;
begin
// Eingangsdaten lesen
cUser := '';
cPass := '';
oReader.Str('username', cUser);
oReader.Str('password', cPass);
// Prüfung gegen eigene Tabelle, Hash-Verfahren, LDAP, ...
if (PasswortPasst(cUser, cPass)) then begin
oWriter.Int('status' , 1);
oWriter.Str('_OBS_JWT_ID' , cUser);
oWriter.Str('_OBS_JWT_SUBJECT' , cUser);
oWriter.Str('_OBS_JWT_AUDIENCE', 'mandant1');
end else begin
oWriter.Int('status', 9);
oWriter.Str('error' , 'Login fehlgeschlagen');
end;
end;Antwort - anders als Status und Header, die zu Aufrufen des Writers geworden sind. Der Grund: die Token-Ausstellung liest sie aus dem fertigen Körper, und genau dort erwartet sie sie. Geschrieben werden sie deshalb wie jedes andere
Feld, nur eben mit dem Writer.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 oReader.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 oReader.Param('_OBS_JWT_ID') bereit, falls das Skript es für eigene Protokollierung braucht.
procedure Refresh(oReader: TxRestReader; oWriter: TxRestWriter);
var cUser: string;
begin
cUser := oReader.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
oWriter.Int('status', 9);
oWriter.Str('error' , 'Konto ist nicht mehr aktiv, bitte neu anmelden');
exit;
end;
// Rolle und Mandant neu lesen, nicht aus dem alten Token uebernehmen -
// sonst wirkt ein Rechteentzug erst beim naechsten Login.
oWriter.Int('status' , 1);
oWriter.Str('_OBS_JWT_ID' , cUser);
oWriter.Str('_OBS_JWT_SUBJECT' , cUser);
oWriter.Str('_OBS_JWT_CLAIM_tenant', TechnikerMandant(cUser));
oWriter.Str('_OBS_JWT_CLAIM_roles' , TechnikerRollen(cUser));
oWriter.Str('_OBS_JWT_REFRESH_ID' , GlobalUID()); // loest das neue Refresh-Token aus
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:
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);
begin
GeraeteRegistrierungLoeschen(oReader.Subject());
// 'session' = diese Sitzung, 'all' = alle Sitzungen des Subjects
oWriter.JwtRevoke('session');
oWriter.Status(204);
oWriter.NoBody();
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.
Für einen spezifischen Fehler an den Konsumenten gibt es einen Aufruf. Er baut dieselbe Hülle, die auch die Server-eigenen Fehler tragen - Statuscode, code, message, uid und traceId -, sodass beide Wege nicht auseinanderlaufen können:
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);
begin
if (Empty(oReader.Param('kundennr'))) then begin
oWriter.Error(422, 'VALIDATION_FAILED', 'Parameter "kundennr" fehlt');
exit;
end;
// ...
end;eigenen. Ein Skript darf also getrost Felder schreiben und danach noch abbrechen - was der Client sieht, ist die Fehlerantwort. Nur ein offener
Stapel (ein ObjBegin ohne ObjEnd) bleibt auch dann ein Strukturfehler.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.Wohin gehört welcher Code?
Drei Ebenen, und die Zuordnung entscheidet, wie schnell eine Änderung wirkt:
| Art des Codes | Wohin | Wirksam |
|---|---|---|
| Fachlogik **einer** Anwendung - Feldnamen, Zustandswerte, Rollenregeln, Schreibziele | in die DWS-Lib dieser Anwendung (eigene Endpunkt-Zeile, per {$L …} eingebunden) |
nach Ablauf des Cache-TTL, ohne Build |
| Mechanik, die mehrere Anwendungen brauchen - und auch das OBS-Umfeld | nach obs_lib als Delphi-Unit | erst nach Build und Rollout |
| Mechanik, die nur der REST-Server braucht | in die Units des Servers | erst nach Build und Rollout |
Die Regel hat einen praktischen Grund und keinen ästhetischen: Fachlogik ändert sich in Tagen, Mechanik in Monaten. Was in der Lib liegt, ist nach einer Minute aktiv; was in einer Unit liegt, braucht einen Build. Wer Fachlogik nach unten schiebt, tauscht Geschwindigkeit gegen nichts ein.
einer anderen. Zwei Kopien derselben Lib driften auseinander, und die Abweichung fällt erst auf, wenn eine der beiden Anwendungen etwas Falsches tut. Geteiltes
gehört eine Ebene tiefer.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
|
| oReader.Str<T>(…) oder irgendein Generic | Generics gibt es in der Skriptsprache nicht. Die Leser sind je Typ eigene Methoden: Str, Int, Num, Bool |
| Eine eigene Prozedur Delete für DELETE | Delete ist eine Standardprozedur der Sprache. Die Deklaration lässt sich nicht übersetzen, und die Meldung nennt nur die Zeile - nicht die Ursache |
| Ein Helfer, der ein JSON-Fragment als String zurückgibt | Den Writer als Parameter übergeben und an der richtigen Stelle hineinschreiben. Fragmente von Hand zusammenzusetzen ist genau der Weg, auf dem die Reihenfolge und die Klammern verloren gingen |
| oWriter.ArrEnd nach RootArr | RootArr öffnet keine Ebene, es bestimmt nur die Klammer der Wurzel. Ein ArrEnd dazu ist ein Strukturfehler |
| Zahlen selbst formatieren (NStr, StrTran) | oWriter.Num('preis', nWert, 2). Selbst formatiert stand bei Werten ab 1000 der Tausendertrenner im JSON - gültiges JSON, falscher Wert, keine Meldung
|
| 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
- lib_ScriptIO - Writer und Reader, siehe unten. System.JSON braucht ein Endpunkt-Skript nicht - der Writer erzeugt die Antwort, der Reader liest den Körper
- Base.ToolsConst - Konstanten wie CRLF, SINGELQUOTE, MINDATETIME
- lib_ScriptIO - TxRestWriter, TxRestReader und die Zeitfunktionen RestIsoFromDate / RestDateFromIso / RestOffsetToMinutes. Writer und Reader bekommt das Skript als Parameter; die Zeitfunktionen ruft es direkt
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: