<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="de">
	<id>https://wiki.bergau.de/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Rademacker</id>
	<title>OBS Wiki - Benutzerbeiträge [de]</title>
	<link rel="self" type="application/atom+xml" href="https://wiki.bergau.de/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Rademacker"/>
	<link rel="alternate" type="text/html" href="https://wiki.bergau.de/Spezial:Beitr%C3%A4ge/Rademacker"/>
	<updated>2026-10-03T16:50:29Z</updated>
	<subtitle>Benutzerbeiträge</subtitle>
	<generator>MediaWiki 1.43.9</generator>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Server-Profile&amp;diff=64828</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Server-Profile</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Server-Profile&amp;diff=64828"/>
		<updated>2026-09-15T10:01:10Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Server-Profile=&lt;br /&gt;
&lt;br /&gt;
Ein &#039;&#039;&#039;Server&#039;&#039;&#039; im REST-Modul ist ein eigenständiges TLS-Profil mit einer eigenen HTTP-Server-Instanz. In OBS können beliebig viele Server-Profile parallel betrieben werden, jedes mit eigenem Modus, eigenen Zertifikaten und eigenen Bindungen.&lt;br /&gt;
&lt;br /&gt;
Typische Einsatzfälle für mehrere Profile:&lt;br /&gt;
&lt;br /&gt;
* Ein &#039;&#039;&#039;Public-Profil&#039;&#039;&#039; mit Standard-TLS auf Port 443 für externe Konsumenten.&lt;br /&gt;
* Ein &#039;&#039;&#039;Internal-mTLS-Profil&#039;&#039;&#039; auf einem internen Port für Microservices, die zwingend ein Client-Zertifikat vorlegen müssen.&lt;br /&gt;
* Ein &#039;&#039;&#039;Debug-Profil&#039;&#039;&#039; mit Plain-HTTP auf 127.0.0.1, ausschliesslich für lokale Tests.&lt;br /&gt;
&lt;br /&gt;
==Aufruf==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Stammdaten -&amp;gt; Z Weitere Stammdaten -&amp;gt; REST-Server -&amp;gt; Server&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==Felder des Server-Profils==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld                !! Beschreibung&lt;br /&gt;
|-&lt;br /&gt;
| Nr                  || Eindeutige Nummer 1-99, einmalig vergeben, kann später nicht mehr geändert werden&lt;br /&gt;
|-&lt;br /&gt;
| Name                || Sprechender Name, eindeutig - erscheint in Protokoll, Statistik und Endpunkt-Zuordnung&lt;br /&gt;
|-&lt;br /&gt;
| Aktiv               || Profil wird beim Start des REST-Dienstes geladen. Inaktive Profile starten nicht&lt;br /&gt;
|-&lt;br /&gt;
| Info                || Freitext zur Dokumentation (interner Kommentar, wird nicht ausgewertet)&lt;br /&gt;
|-&lt;br /&gt;
| Kein SSL            || Plain-HTTP-Modus, ausschliesslich für Debug. TLS-Felder werden ignoriert&lt;br /&gt;
|-&lt;br /&gt;
| mTLS                || Mutual TLS: jeder Client muss ein gültiges Client-Zertifikat vorlegen&lt;br /&gt;
|-&lt;br /&gt;
| Cert                || PEM-Inhalt des Server-Zertifikats (inkl. ggf. Zwischen-CA-Kette)&lt;br /&gt;
|-&lt;br /&gt;
| Key                 || PEM-Inhalt des privaten Schlüssels&lt;br /&gt;
|-&lt;br /&gt;
| Root-Cert           || PEM-Inhalt des CA-Root-Zertifikats (Pflicht bei Standard-TLS, dort für Chain-Validierung, sowie bei mTLS, dort zusätzlich für Client-Cert-Verifikation)&lt;br /&gt;
|-&lt;br /&gt;
| Limit-Fenster       || Länge des Rate-Limit-Fensters in Sekunden. Leer/0 = Standard 60&lt;br /&gt;
|-&lt;br /&gt;
| Limit Erfolg        || Erlaubte erfolgreiche Anfragen je Schlüssel und Fenster. Leer/0 = Standard 120&lt;br /&gt;
|-&lt;br /&gt;
| Limit Fehler        || Erlaubte fehlgeschlagene Anfragen je Schlüssel und Fenster. Leer/0 = Standard 10&lt;br /&gt;
|-&lt;br /&gt;
| Limit gesamt        || Erlaubte Anfragen des gesamten Profils je Fenster. Leer/0 = Standard 600&lt;br /&gt;
|-&lt;br /&gt;
| Max. Verbindungen   || Obergrenze gleichzeitiger &#039;&#039;&#039;Verbindungen&#039;&#039;&#039; (&amp;lt;code&amp;gt;rsv_max_conn&amp;lt;/code&amp;gt;). Leer/0 = unbegrenzt. Sie deckt auch laufende Datei-Downloads ab, weil der Platz erst beim Trennen frei wird. Bei Überschreitung trennt der Server &#039;&#039;&#039;ohne HTTP-Antwort&#039;&#039;&#039; - der Wert gehört deutlich über den Normalbetrieb, als Reissleine&lt;br /&gt;
|-&lt;br /&gt;
| Max. parallel       || Obergrenze gleichzeitig &#039;&#039;&#039;verarbeiteter Anfragen&#039;&#039;&#039; (&amp;lt;code&amp;gt;rsv_max_parallel&amp;lt;/code&amp;gt;). Leer/0 = unbegrenzt. Bei Überschreitung antwortet der Server &#039;&#039;&#039;503&#039;&#039;&#039; mit &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;, der Client darf es also gleich erneut versuchen&lt;br /&gt;
|-&lt;br /&gt;
| Keine Kompression   || Antworten dieses Profils werden &#039;&#039;&#039;nicht&#039;&#039;&#039; gepackt (&amp;lt;code&amp;gt;rsv_no_gzip&amp;lt;/code&amp;gt;). Ohne Haken packt der Server JSON- und Textantworten ab 1024 Byte mit gzip, sofern der Client sie über &amp;lt;code&amp;gt;Accept-Encoding&amp;lt;/code&amp;gt; annimmt. Der Haken betrifft nur die Antwortrichtung - komprimierte &#039;&#039;&#039;Anfragen&#039;&#039;&#039; nimmt der Server weiterhin entgegen&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Inhalte der Zertifikate werden direkt in OBS als Text gespeichert. Zur Laufzeit schreibt der Server die PEM-Inhalte für den Bruchteil einer Sekunde in ein Temp-Verzeichnis, damit OpenSSL sie beim Start lesen kann; danach werden die Temp-Dateien sofort wieder gelöscht.&lt;br /&gt;
&lt;br /&gt;
==Modi==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Modus               !! Voraussetzungen                       !! Anwendung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Standard-TLS&#039;&#039;&#039;  || Cert + Key + Root-Cert hinterlegt     || Empfohlener Default für alle Server, die über das öffentliche Netz erreichbar sind&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;mTLS&#039;&#039;&#039;          || Cert + Key + Root-Cert (CA)           || Maschine-zu-Maschine-Kommunikation, in der jeder Client mit Cert validiert wird&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Plain-HTTP&#039;&#039;&#039;    || Haken &#039;&#039;Kein SSL&#039;&#039;                    || Ausschliesslich lokale Tests. Erkennt TLS-Versuche auf dem Plain-Port und trennt sie direkt&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Beide Richtungen werden erkannt: Eine &#039;&#039;&#039;unverschlüsselte Anfrage an einen&lt;br /&gt;
TLS-Port&#039;&#039;&#039; (&#039;&#039;http&#039;&#039; statt &#039;&#039;https&#039;&#039;) beantwortet der Server mit &#039;&#039;&#039;HTTP 400&#039;&#039;&#039;&lt;br /&gt;
und einem Klartext-Hinweis. Die Abweisung erfolgt &#039;&#039;&#039;vor&#039;&#039;&#039; Authentifizierung,&lt;br /&gt;
Rate-Limit und Endpunkt-Skript - ein falsch konfigurierter Client löst also&lt;br /&gt;
keine Verarbeitung aus.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Bei Standard-TLS und mTLS fährt der Server ohne Cert/Key gar nicht erst hoch. Bei Standard-TLS ohne Root-Cert wird die Anlage abgelehnt - die Chain-Validierung benötigt das CA-Root. Plain-HTTP umgeht TLS komplett und ist nicht für Produktivumgebungen geeignet.}}&lt;br /&gt;
&lt;br /&gt;
==TLS-Version, Cipher und Zertifikats-Rotation==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;TLS-Version:&#039;&#039;&#039; Der Server erzwingt &#039;&#039;&#039;TLS 1.2&#039;&#039;&#039;. Die Cipher-Liste ist auf ECDHE-Schlüsselaustausch (Forward Secrecy) mit AEAD-Verfahren (AES-GCM, ChaCha20-Poly1305) beschränkt; CBC, statisches RSA-KEX und DHE sind ausgeschlossen.&lt;br /&gt;
* &#039;&#039;&#039;Rotation:&#039;&#039;&#039; Ein Zertifikatswechsel erfolgt durch ändern der PEM-Inhalte in der DB und einen &#039;&#039;&#039;Neustart&#039;&#039;&#039; des REST-Dienstes (kein Hot-Reload). Bis zum Neustart läuft das alte Zertifikat weiter.&lt;br /&gt;
* &#039;&#039;&#039;Certificate-Pinning:&#039;&#039;&#039; Mobile Clients können den Server-Public-Key pinnen. Zertifikatswechsel daher &#039;&#039;&#039;rechtzeitig ankündigen&#039;&#039;&#039; und mit einem &#039;&#039;&#039;Backup-Pin&#039;&#039;&#039; vorbereiten - der neue Pin muss vor dem Wechsel in einer Client-Version ausgerollt sein, sonst sperrt sich die App aus.&lt;br /&gt;
==Rate-Limit je Profil==&lt;br /&gt;
&lt;br /&gt;
Jedes Server-Profil führt &#039;&#039;&#039;eigene Zähler&#039;&#039;&#039; und kann &#039;&#039;&#039;eigene Schwellen&#039;&#039;&#039; haben.&lt;br /&gt;
Ein Profil, das gerade überrannt wird, bremst damit die anderen Profile nicht mit&lt;br /&gt;
aus - das Public-Profil und das interne mTLS-Profil sind voneinander unabhängig.&lt;br /&gt;
&lt;br /&gt;
Gezählt wird pro Schlüssel, und der Schlüssel ist der &#039;&#039;&#039;API-Key&#039;&#039;&#039; des Zugangs.&lt;br /&gt;
Sendet ein Konsument keinen API-Key (Zugang mit &#039;&#039;*&#039;&#039;), wird ersatzweise auf die&lt;br /&gt;
&#039;&#039;&#039;IP-Adresse&#039;&#039;&#039; gezählt.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Das ist der wichtigste Punkt bei mobilen Anwendungen: Sitzen alle&lt;br /&gt;
Benutzer hinter einer gemeinsamen Firmen-IP und sendet die App keinen eigenen&lt;br /&gt;
API-Key, teilen sich &#039;&#039;&#039;alle Benutzer einen Zähler&#039;&#039;&#039;. Mit den Standardwerten sind&lt;br /&gt;
das 10 Fehlversuche für den ganzen Betrieb pro Minute - nach ein paar falschen&lt;br /&gt;
Passworteingaben steht die Abteilung. Für solche Profile die Schwellen bewusst&lt;br /&gt;
höher setzen oder jedem Konsumenten einen eigenen API-Key geben.}}&lt;br /&gt;
&lt;br /&gt;
Bleiben alle vier Felder leer (bzw. 0), gelten die Standardwerte. Die Werte werden&lt;br /&gt;
&#039;&#039;&#039;beim Start&#039;&#039;&#039; des REST-Dienstes gelesen - eine Änderung wird erst nach einem&lt;br /&gt;
Neustart wirksam.&lt;br /&gt;
&lt;br /&gt;
==Validierung beim Speichern==&lt;br /&gt;
&lt;br /&gt;
Beim Speichern wird geprüft:&lt;br /&gt;
&lt;br /&gt;
* Nr und Name müssen vergeben sein.&lt;br /&gt;
* Nr muss eindeutig sein.&lt;br /&gt;
* Name muss eindeutig sein.&lt;br /&gt;
* Bei nicht aktiviertem &#039;&#039;Kein SSL&#039;&#039; müssen Cert und Key vorhanden sein.&lt;br /&gt;
* Bei nicht aktiviertem &#039;&#039;mTLS&#039;&#039; (= Standard-TLS) muss Root-Cert vorhanden sein.&lt;br /&gt;
&lt;br /&gt;
==Bindungen==&lt;br /&gt;
&lt;br /&gt;
Eine Bindung ist die Kombination aus IP-Adresse und Port, auf der ein Server-Profil lauscht. Pro Server-Profil können mehrere Bindungen existieren, z.B. um die gleiche TLS-Konfiguration parallel auf 443 und 8443 anzubieten.&lt;br /&gt;
&lt;br /&gt;
===Aufruf===&lt;br /&gt;
&lt;br /&gt;
In der Server-Liste den gewünschten Server markieren und &#039;&#039;&#039;F6&#039;&#039;&#039; drücken. Es öffnet sich die zum Server gehörende Bindungs-Liste.&lt;br /&gt;
&lt;br /&gt;
===Felder===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld    !! Beschreibung&lt;br /&gt;
|-&lt;br /&gt;
| Host    || IP-Adresse oder Hostname, auf dem gelauscht wird. &#039;&#039;0.0.0.0&#039;&#039; = alle IPv4-Adressen; &#039;&#039;127.0.0.1&#039;&#039; = nur lokal; explizite IPs binden gezielt auf eine Netzwerkkarte&lt;br /&gt;
|-&lt;br /&gt;
| Port    || TCP-Port (1-65535). 443/8443 für HTTPS, frei wählbar für Debug-Profile&lt;br /&gt;
|-&lt;br /&gt;
| Aktiv   || Bindung wird beim Server-Start geladen&lt;br /&gt;
|-&lt;br /&gt;
| Standard|| Markiert die Standard-Bindung des Servers. Pro Server-Profil ist nur eine Standard-Bindung erlaubt&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
===Wirksamkeit===&lt;br /&gt;
&lt;br /&gt;
Bindungen werden ausschliesslich beim Start des REST-Dienstes geladen. Änderungen werden daher erst nach einem Neustart aktiv. Bis dahin laufen die alten Bindungen weiter.&lt;br /&gt;
&lt;br /&gt;
==Listen-Funktionen==&lt;br /&gt;
&lt;br /&gt;
In der Server-Liste:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Einfg&#039;&#039;&#039; - neues Server-Profil&lt;br /&gt;
* &#039;&#039;&#039;Return&#039;&#039;&#039; - vorhandenes Profil bearbeiten&lt;br /&gt;
* &#039;&#039;&#039;F4&#039;&#039;&#039; - Sortierung wählen&lt;br /&gt;
* &#039;&#039;&#039;F6&#039;&#039;&#039; - Bindungs-Liste des markierten Servers&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Datei-Upload&amp;diff=64827</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Datei-Upload</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Datei-Upload&amp;diff=64827"/>
		<updated>2026-09-15T10:00:58Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
= Datei-Upload =&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt nimmt Dateien nur entgegen, wenn er dafür freigeschaltet ist&lt;br /&gt;
(&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;, siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]). Dann gilt nicht&lt;br /&gt;
das JSON-Body-Limit von 10&amp;amp;nbsp;MB, sondern die pro Endpunkt konfigurierte Grösse&lt;br /&gt;
(&amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;, Standard 25&amp;amp;nbsp;MB; Überschreitung -&amp;gt; &#039;&#039;&#039;413&#039;&#039;&#039;).&lt;br /&gt;
&lt;br /&gt;
Diese Seite beschreibt die &#039;&#039;&#039;Übertragung&#039;&#039;&#039; - was ein Client senden muss und was&lt;br /&gt;
der Server antwortet. Was das &#039;&#039;&#039;Skript&#039;&#039;&#039; davon sieht, sind vier Aufrufe&lt;br /&gt;
(&amp;lt;code&amp;gt;oReader.UploadPath()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;UploadName()&amp;lt;/code&amp;gt;,&lt;br /&gt;
&amp;lt;code&amp;gt;UploadType()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;UploadSha()&amp;lt;/code&amp;gt;) und steht unter&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Scripting#Datei-Uploads|Scripting]].&lt;br /&gt;
Ein vollständiges Beispiel zeigt&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6]].&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der Server legt die Datei in einem temporären Verzeichnis ab und&lt;br /&gt;
&#039;&#039;&#039;verschiebt sie nicht selbst&#039;&#039;&#039;. Übernimmt das Skript sie nicht an ihren&lt;br /&gt;
Zielort, bleibt sie dort liegen.}}&lt;br /&gt;
&lt;br /&gt;
== Einfacher Upload (multipart/form-data) ==&lt;br /&gt;
&lt;br /&gt;
Eine Datei pro Request. Name und Content-Type stammen aus der&lt;br /&gt;
&amp;lt;code&amp;gt;Content-Disposition&amp;lt;/code&amp;gt; der Datei-Partie (&amp;lt;code&amp;gt;filename=&amp;lt;/code&amp;gt;). Der&lt;br /&gt;
Server speichert die erste Datei-Partie und ruft das Skript auf.&lt;br /&gt;
&lt;br /&gt;
Die &#039;&#039;&#039;übrigen Partien&#039;&#039;&#039; des Formulars - alles ohne &amp;lt;code&amp;gt;filename=&amp;lt;/code&amp;gt; -&lt;br /&gt;
liest das Skript über &amp;lt;code&amp;gt;oReader.Form(&#039;&amp;amp;lt;name&amp;amp;gt;&#039;)&amp;lt;/code&amp;gt;. Der Wert ist UTF-8 und&lt;br /&gt;
auf 1024 Zeichen begrenzt, der Name auf Buchstaben, Ziffern und Unterstrich&lt;br /&gt;
reduziert. Zusatzangaben lassen sich damit im selben Request mitschicken, ohne sie&lt;br /&gt;
an die URL zu hängen:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cPfad : string;&lt;br /&gt;
    cName : string;&lt;br /&gt;
    cTyp  : string;&lt;br /&gt;
    cBemerk: string;&lt;br /&gt;
begin&lt;br /&gt;
    cPfad   := oReader.UploadPath();&lt;br /&gt;
    cName   := oReader.UploadName();&lt;br /&gt;
    cTyp    := oReader.UploadType();&lt;br /&gt;
    cBemerk := oReader.Form(&#039;bemerkung&#039;);   // Formularfeld derselben Uebertragung&lt;br /&gt;
&lt;br /&gt;
    // ... Datei aus cPfad ins DMS / Zielverzeichnis uebernehmen ...&lt;br /&gt;
&lt;br /&gt;
    oWriter.Str(&#039;status&#039;   , &#039;ok&#039;);&lt;br /&gt;
    oWriter.Str(&#039;dateiname&#039;, cName);&lt;br /&gt;
    oWriter.Status(201);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Resumable/Chunked Upload (Content-Range) ==&lt;br /&gt;
&lt;br /&gt;
Für grosse Dateien überträgt der Client die Datei in Teilstücken (Chunks) mit dem&lt;br /&gt;
Header &amp;lt;code&amp;gt;Content-Range: bytes START-END/TOTAL&amp;lt;/code&amp;gt;. Der Server hängt die&lt;br /&gt;
Chunks &#039;&#039;&#039;sequentiell&#039;&#039;&#039; an eine Session-Datei an und behandelt unvollständige&lt;br /&gt;
Uploads selbst - das Endpunkt-Skript läuft &#039;&#039;&#039;erst beim vollständigen Upload&#039;&#039;&#039;,&lt;br /&gt;
und dann liefern &#039;&#039;oReader.UploadPath()&#039;&#039;, &#039;&#039;UploadName()&#039;&#039; und &#039;&#039;UploadType()&#039;&#039;&lt;br /&gt;
dasselbe wie beim einfachen Upload. Für die Teilstücke läuft es &#039;&#039;&#039;nicht&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Name und Content-Type sind im &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;-Protokoll nicht enthalten;&lt;br /&gt;
der Client liefert sie deshalb &#039;&#039;&#039;im ersten Chunk&#039;&#039;&#039; über den Header&lt;br /&gt;
&amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt; (Format wie bei tus: kommagetrennte Paare&lt;br /&gt;
&amp;lt;code&amp;gt;schlüssel base64wert&amp;lt;/code&amp;gt;, Werte Base64-kodiert):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
Upload-Metadata: filename ZmlsZS5wZGY=,filetype YXBwbGljYXRpb24vcGRm&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Erkannt werden die Schlüssel &amp;lt;code&amp;gt;filename&amp;lt;/code&amp;gt; (Pflicht) und&lt;br /&gt;
&amp;lt;code&amp;gt;filetype&amp;lt;/code&amp;gt; (optional). Fehlt &amp;lt;code&amp;gt;filename&amp;lt;/code&amp;gt; im ersten Chunk,&lt;br /&gt;
wird der Upload mit &#039;&#039;&#039;400&#039;&#039;&#039; abgelehnt, ohne dass eine Datei angelegt wird.&lt;br /&gt;
Folge-Chunks und Statusabfragen müssen die Metadaten nicht erneut senden.&lt;br /&gt;
&lt;br /&gt;
Ablauf (vom Client gesteuert, der Server antwortet jeweils):&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Request !! Server-Antwort&lt;br /&gt;
|-&lt;br /&gt;
| Erster Chunk &amp;lt;code&amp;gt;Content-Range: bytes 0-1048575/5000000&amp;lt;/code&amp;gt; + &amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt; || &#039;&#039;&#039;308&#039;&#039;&#039; Resume Incomplete + Header &amp;lt;code&amp;gt;Upload-Id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;Upload-Offset&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| weitere Chunks (jeweils &amp;lt;code&amp;gt;Upload-Id&amp;lt;/code&amp;gt; mitsenden) || &#039;&#039;&#039;308&#039;&#039;&#039; mit aktualisiertem &amp;lt;code&amp;gt;Upload-Offset&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| letzter Chunk (Bereich erreicht TOTAL) || normale Skript-Antwort (z.B. &#039;&#039;&#039;200&#039;&#039;&#039;/&#039;&#039;&#039;201&#039;&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| Statusabfrage &amp;lt;code&amp;gt;Content-Range: bytes */5000000&amp;lt;/code&amp;gt; || aktueller &amp;lt;code&amp;gt;Upload-Offset&amp;lt;/code&amp;gt;, ohne anzuhängen (Resume)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Header !! Richtung !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| Content-Range || Request || &amp;lt;code&amp;gt;bytes START-END/TOTAL&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;bytes */TOTAL&amp;lt;/code&amp;gt; nur als Statusabfrage&lt;br /&gt;
|-&lt;br /&gt;
| Upload-Metadata || Request || tus-Metadaten im ersten Chunk: &amp;lt;code&amp;gt;filename&amp;lt;/code&amp;gt; (Pflicht), &amp;lt;code&amp;gt;filetype&amp;lt;/code&amp;gt; (optional), Base64-kodiert&lt;br /&gt;
|-&lt;br /&gt;
| 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.&lt;br /&gt;
|-&lt;br /&gt;
| Upload-Offset || Response || Anzahl der bereits gespeicherten Bytes (= Startoffset des nächsten Chunks)&lt;br /&gt;
|-&lt;br /&gt;
| Range || Response || Bereits gespeicherter Bereich (&amp;lt;code&amp;gt;bytes=0-N&amp;lt;/code&amp;gt;)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Der Server hängt einen Chunk &#039;&#039;&#039;nur an, wenn START dem bereits gespeicherten Stand&lt;br /&gt;
entspricht&#039;&#039;&#039;. Bei Abweichung (doppelter Chunk, Lücke) wird nicht angehängt,&lt;br /&gt;
sondern der aktuelle &#039;&#039;Upload-Offset&#039;&#039; zurückgemeldet - der Client setzt ab dort&lt;br /&gt;
neu auf. Eine separate Statusabfrage ist für ein Resume daher nicht zwingend nötig.&lt;br /&gt;
&lt;br /&gt;
Überschreitet ein Chunk bzw. die Gesamtgrösse das Limit (&#039;&#039;re_upload_size&#039;&#039;),&lt;br /&gt;
antwortet der Server mit &#039;&#039;&#039;413 Payload Too Large&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der Upload-Status liegt prozesslokal im Speicher. Nach einem Neustart des Servers muss ein unvollständiger Upload neu begonnen werden.}}&lt;br /&gt;
&lt;br /&gt;
== Komprimierte Uploads ==&lt;br /&gt;
&lt;br /&gt;
Ein Upload-Körper mit dem Header &amp;lt;code&amp;gt;Content-Encoding&amp;lt;/code&amp;gt; wird mit &#039;&#039;&#039;415 Unsupported Media Type&#039;&#039;&#039; abgewiesen - bei beiden Verfahren, einfach wie chunked.&lt;br /&gt;
&lt;br /&gt;
Der Grund ist nicht Bequemlichkeit. Bei einem chunked Upload bliebe offen, ob &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt; die gepackten oder die rohen Bytes zählt, und die SHA256-Prüfsumme bezöge sich nach einem Entpacken auf eine andere Datei, als der Client gesendet hat. Etablierte Protokolle - tus, S3 Multipart, Google Resumable Upload, Azure Blob - behandeln Kompression aus demselben Grund als Eigenschaft der &#039;&#039;&#039;gespeicherten Datei&#039;&#039;&#039; und nicht der Übertragung.&lt;br /&gt;
&lt;br /&gt;
Wer gepackt hochladen will, packt die Datei &#039;&#039;&#039;selbst&#039;&#039;&#039; und lädt sie als &amp;lt;code&amp;gt;export.csv.gz&amp;lt;/code&amp;gt; hoch. Offsets, Prüfsumme und Wiederaufnahme stimmen dann, und das Endpunkt-Skript bekommt Name und Typ wie gewohnt über &amp;lt;code&amp;gt;_OBS_UPLOAD_NAME&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;_OBS_UPLOAD_CONTENTTYPE&amp;lt;/code&amp;gt;.&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Datei-Download&amp;diff=64826</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Datei-Download</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Datei-Download&amp;diff=64826"/>
		<updated>2026-09-15T10:00:47Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
= Datei-Download =&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt kann statt eines JSON-Körpers eine &#039;&#039;&#039;Datei&#039;&#039;&#039; ausliefern - PDF, CSV,&lt;br /&gt;
Bilder, eine APK. Das Skript entscheidet das mit einem einzigen Aufruf, alles&lt;br /&gt;
Weitere macht der Server: Content-Type, Dateiname, Bereichsanfragen und, bei&lt;br /&gt;
temporären Dateien, das Aufräumen.&lt;br /&gt;
&lt;br /&gt;
Anders als beim [[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]]&lt;br /&gt;
braucht es dafür &#039;&#039;&#039;keine Freischaltung&#039;&#039;&#039; am Endpunkt. Ein Download entsteht&lt;br /&gt;
dadurch, dass das Skript ihn erzeugt, nicht dadurch, dass ein Client ihn anfragt.&lt;br /&gt;
&lt;br /&gt;
== Die zwei Aufrufe ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Für !! Nach dem Senden !! Wiederaufsetzbar&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.SendFile(cPfad, cName, cTyp)&amp;lt;/code&amp;gt; || Dateien, die es schon gibt und weiter geben wird || bleibt liegen || &#039;&#039;&#039;ja&#039;&#039;&#039; (206/Range)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.SendTempFile(cPfad, cName, cTyp)&amp;lt;/code&amp;gt; || Ergebnisse, die je Abruf entstehen || wird &#039;&#039;&#039;gelöscht&#039;&#039;&#039; || nein&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUid : string;&lt;br /&gt;
    cPfad: string;&lt;br /&gt;
    cName: string;&lt;br /&gt;
begin&lt;br /&gt;
    cUid := oReader.Path(&#039;uid&#039;);&lt;br /&gt;
&lt;br /&gt;
    if (not BelegGehoertZuMandant(oReader.Claim(&#039;tenant&#039;), cUid)) then begin&lt;br /&gt;
        oWriter.Error(404, &#039;NOT_FOUND&#039;, &#039;Beleg nicht gefunden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    cPfad := BelegPfad(cUid);&lt;br /&gt;
    cName := &#039;Rechnung_&#039; + cUid + &#039;.pdf&#039;;&lt;br /&gt;
&lt;br /&gt;
    oWriter.SendFile(cPfad, cName, &#039;application/pdf&#039;);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Datei oder Felder - nicht beides.&#039;&#039;&#039; Schreibt ein Skript Felder&lt;br /&gt;
&#039;&#039;&#039;und&#039;&#039;&#039; ruft &amp;lt;code&amp;gt;SendFile&amp;lt;/code&amp;gt;, ist das ein Strukturfehler wie eine&lt;br /&gt;
unbalancierte Verschachtelung: der Server antwortet &#039;&#039;&#039;500&#039;&#039;&#039; und nennt die Stelle&lt;br /&gt;
im Protokoll. &amp;lt;code&amp;gt;Error()&amp;lt;/code&amp;gt; nach &amp;lt;code&amp;gt;SendFile&amp;lt;/code&amp;gt; verwirft die Datei&lt;br /&gt;
und liefert die Fehlerhülle - die Ablehnung gewinnt.}}&lt;br /&gt;
&lt;br /&gt;
== Was der Server dazu tut ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Kopfzeile !! Wert&lt;br /&gt;
|-&lt;br /&gt;
| Content-Type || der übergebene Typ; ohne Angabe &amp;lt;code&amp;gt;application/octet-stream&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Content-Disposition || &amp;lt;code&amp;gt;attachment&amp;lt;/code&amp;gt; mit dem übergebenen Namen, nach RFC&amp;amp;nbsp;5987 kodiert&lt;br /&gt;
|-&lt;br /&gt;
| Content-Length || Länge der Datei bzw. des angeforderten Bereichs&lt;br /&gt;
|-&lt;br /&gt;
| Accept-Ranges || &amp;lt;code&amp;gt;bytes&amp;lt;/code&amp;gt; bei &amp;lt;code&amp;gt;SendFile&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;none&amp;lt;/code&amp;gt; bei &amp;lt;code&amp;gt;SendTempFile&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| ETag, Last-Modified || nur bei &amp;lt;code&amp;gt;SendFile&amp;lt;/code&amp;gt;, aus Grösse und Änderungszeitpunkt&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Umlaute im Dateinamen&#039;&#039;&#039; sind unbedenklich. Header-Werte müssen ASCII sein,&lt;br /&gt;
deshalb schreibt der Server zwei Angaben: &amp;lt;code&amp;gt;filename=&amp;lt;/code&amp;gt; mit einem&lt;br /&gt;
ausgeschriebenen Rückfallnamen (&#039;&#039;Rechnung_Müller.pdf&#039;&#039; wird zu&lt;br /&gt;
&#039;&#039;Rechnung_Mueller.pdf&#039;&#039;) und &amp;lt;code&amp;gt;filename*=&amp;lt;/code&amp;gt; mit dem echten Namen&lt;br /&gt;
UTF-8-kodiert. Jeder heutige Browser nimmt den zweiten.&lt;br /&gt;
&lt;br /&gt;
Der Pfad wird &#039;&#039;&#039;nicht geprüft&#039;&#039;&#039;. Ein Skript kann über &amp;lt;code&amp;gt;Base.GFile&amp;lt;/code&amp;gt;&lt;br /&gt;
ohnehin jede Datei des Servers lesen; eine Schranke nur an dieser Stelle würde&lt;br /&gt;
Sicherheit vortäuschen, die es nicht gibt.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Den Pfad niemals ungeprüft aus einem Client-Wert bilden.&#039;&#039;&#039; Ein&lt;br /&gt;
Endpunkt &amp;lt;code&amp;gt;/public/{name}&amp;lt;/code&amp;gt;, der &amp;lt;code&amp;gt;{name}&amp;lt;/code&amp;gt; an ein&lt;br /&gt;
Basisverzeichnis hängt, liefert bei &amp;lt;code&amp;gt;..%2F..%2Fobs.ini&amp;lt;/code&amp;gt; die&lt;br /&gt;
Konfiguration aus. Entweder eine feste Zuordnung im Skript (&#039;&#039;apk&#039;&#039; -&amp;gt; fester&lt;br /&gt;
Pfad), oder eine Tabelle mit freigegebenen Dateien - nie den Rohwert.}}&lt;br /&gt;
&lt;br /&gt;
== Wiederaufnahme abgebrochener Downloads ==&lt;br /&gt;
&lt;br /&gt;
Bei &amp;lt;code&amp;gt;SendFile&amp;lt;/code&amp;gt; wertet der Server den Kopf &amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt; aus.&lt;br /&gt;
Unterstützt wird &#039;&#039;&#039;ein&#039;&#039;&#039; Bereich je Anfrage, in den drei Formen, die Clients&lt;br /&gt;
tatsächlich senden:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Range !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;bytes=0-1023&amp;lt;/code&amp;gt; || fester Bereich&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;bytes=500-&amp;lt;/code&amp;gt; || ab Offset bis zum Ende&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;bytes=-500&amp;lt;/code&amp;gt; || die letzten 500 Bytes&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Der Server antwortet dann mit &#039;&#039;&#039;206 Partial Content&#039;&#039;&#039; und&lt;br /&gt;
&amp;lt;code&amp;gt;Content-Range: bytes &amp;amp;lt;von&amp;amp;gt;-&amp;amp;lt;bis&amp;amp;gt;/&amp;amp;lt;gesamt&amp;amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Fall !! Antwort&lt;br /&gt;
|-&lt;br /&gt;
| Kein &amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt;-Kopf || &#039;&#039;&#039;200&#039;&#039;&#039; mit der ganzen Datei&lt;br /&gt;
|-&lt;br /&gt;
| Mehrere Bereiche (Komma) || &#039;&#039;&#039;200&#039;&#039;&#039; mit der ganzen Datei - die Spezifikation erlaubt das ausdrücklich&lt;br /&gt;
|-&lt;br /&gt;
| Bereich hinter dem Dateiende || &#039;&#039;&#039;416 Range Not Satisfiable&#039;&#039;&#039; mit &amp;lt;code&amp;gt;Content-Range: bytes */&amp;amp;lt;gesamt&amp;amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;If-Range&amp;lt;/code&amp;gt; stimmt nicht mehr || &#039;&#039;&#039;200&#039;&#039;&#039; mit der ganzen Datei&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Warum &amp;lt;code&amp;gt;If-Range&amp;lt;/code&amp;gt; zählt:&#039;&#039;&#039; Setzt ein Client einen Download fort und&lt;br /&gt;
die Datei hat sich zwischenzeitlich geändert, entstünde aus zwei Ständen eine&lt;br /&gt;
Datei, die erst beim Öffnen auffällt. Schickt der Client seinen ETag in&lt;br /&gt;
&amp;lt;code&amp;gt;If-Range&amp;lt;/code&amp;gt; mit und passt der nicht mehr, liefert der Server lieber alles&lt;br /&gt;
neu.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;SendTempFile kann nicht fortgesetzt werden&#039;&#039;&#039;, und das ist keine&lt;br /&gt;
Lücke: Die Datei ist nach dem Senden gelöscht, ein zweiter Abruf fände sie nicht&lt;br /&gt;
mehr vor - und eine Neuerzeugung lieferte andere Bytes, sobald sich die Daten&lt;br /&gt;
geändert haben. Der Server meldet deshalb &amp;lt;code&amp;gt;Accept-Ranges: none&amp;lt;/code&amp;gt;, damit&lt;br /&gt;
ein Client es gar nicht erst versucht.}}&lt;br /&gt;
&lt;br /&gt;
== Öffentliche Dateien ohne API-Key ==&lt;br /&gt;
&lt;br /&gt;
Für Artefakte, die für jeden bestimmt sind - eine Test-APK, ein Handbuch - kann&lt;br /&gt;
der Endpunkt ohne &amp;lt;code&amp;gt;apikey&amp;lt;/code&amp;gt;-Header erreichbar sein. Dafür genügt&lt;br /&gt;
Konfiguration, siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]]:&lt;br /&gt;
&lt;br /&gt;
# Zugang mit &amp;lt;code&amp;gt;ra_apikey = *&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;ra_jwt = 0&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;ra_cors_origin&amp;lt;/code&amp;gt; &#039;&#039;&#039;leer&#039;&#039;&#039;&lt;br /&gt;
# In &amp;lt;code&amp;gt;RESTSRV_ACCESS&amp;lt;/code&amp;gt; &#039;&#039;&#039;nur&#039;&#039;&#039; diesen einen Endpunkt freigeben&lt;br /&gt;
&lt;br /&gt;
Die zweite Zeile ist die eigentliche Grenze: Für jeden anderen Endpunkt antwortet&lt;br /&gt;
der Server &#039;&#039;&#039;403&#039;&#039;&#039;, auch wenn der Zugang existiert.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Nur für wirklich öffentliche Dateien.&#039;&#039;&#039; Ohne API-Key kennt der&lt;br /&gt;
Endpunkt weder Mandant noch Rolle - es gibt nichts, woran er eine Berechtigung&lt;br /&gt;
festmachen könnte ausser dem, was in der URL steht. Kundenbelege gehören deshalb&lt;br /&gt;
über den authentifizierten Weg, nicht hierher.}}&lt;br /&gt;
&lt;br /&gt;
Für eine Android-APK kommt hinzu: Content-Type&lt;br /&gt;
&amp;lt;code&amp;gt;application/vnd.android.package-archive&amp;lt;/code&amp;gt;, am Gerät muss &amp;quot;Unbekannte&lt;br /&gt;
Apps installieren&amp;quot; für den Browser freigegeben sein, und die Signatur sollte über&lt;br /&gt;
alle Testversionen konstant bleiben - sonst gibt es beim späteren Wechsel in den&lt;br /&gt;
Play Store auf jedem Testgerät einen Installationskonflikt.&lt;br /&gt;
&lt;br /&gt;
== Grenzen ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Was !! Stand&lt;br /&gt;
|-&lt;br /&gt;
| HEAD || wird &#039;&#039;&#039;nicht&#039;&#039;&#039; unterstützt (&#039;&#039;405&#039;&#039;). Browser brauchen es nicht; relevant nur für &amp;lt;code&amp;gt;wget --continue&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;curl -C -&amp;lt;/code&amp;gt; und Download-Manager, die meist auf GET zurückfallen.&lt;br /&gt;
|-&lt;br /&gt;
| Mehrere Bereiche je Anfrage || nicht unterstützt (ganze Datei mit 200)&lt;br /&gt;
|-&lt;br /&gt;
| Liegengebliebene Temp-Dateien || Bricht der Server mitten im Senden ab, bleibt eine &amp;lt;code&amp;gt;SendTempFile&amp;lt;/code&amp;gt;-Datei stehen. Das temporäre Verzeichnis wird von aussen geleert.&lt;br /&gt;
|-&lt;br /&gt;
| Idempotenz || Eine Dateiantwort wird &#039;&#039;&#039;nicht&#039;&#039;&#039; im Idempotenz-Store festgeschrieben - sie liesse sich nicht wiedergeben. Der &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; bleibt danach benutzbar.&lt;br /&gt;
|-&lt;br /&gt;
| Kompression || Dateiantworten werden &#039;&#039;&#039;nicht&#039;&#039;&#039; gepackt, auch wenn der Client &amp;lt;code&amp;gt;Accept-Encoding: gzip&amp;lt;/code&amp;gt; sendet. Sie sind meist bereits komprimiert (PDF, Bild, ZIP), und ein gepackter Auslauf hätte keine feste Länge mehr - &amp;lt;code&amp;gt;Content-Length&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;ETag&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;Accept-Ranges&amp;lt;/code&amp;gt; und die Wiederaufnahme hängen daran. Wer einen grossen CSV- oder XML-Export gepackt ausliefern will, packt ihn im Skript und liefert ihn als &amp;lt;code&amp;gt;.gz&amp;lt;/code&amp;gt; aus.&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Endpunkte&amp;diff=64825</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Endpunkte</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Endpunkte&amp;diff=64825"/>
		<updated>2026-09-15T10:00:38Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
= Endpunkte =&lt;br /&gt;
&lt;br /&gt;
Ein &#039;&#039;&#039;Endpunkt&#039;&#039;&#039; stellt eine Adresse bereit, über die der REST-Server konkrete&lt;br /&gt;
Funktionen anbietet. Jeder Endpunkt wird durch ein OBS-Skript oder einen WebHook&lt;br /&gt;
realisiert und ist genau einem Server-Profil zugeordnet.&lt;br /&gt;
&lt;br /&gt;
== Routing über Pfad-Templates ==&lt;br /&gt;
&lt;br /&gt;
Das Routing erfolgt über die Spalte &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_pathtemplate&amp;lt;/code&amp;gt;).&lt;br /&gt;
Ein Template beschreibt den vollständigen Pfad nach dem Host und kann&lt;br /&gt;
&#039;&#039;&#039;Platzhalter&#039;&#039;&#039; enthalten:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Statische Segmente&#039;&#039;&#039; müssen exakt übereinstimmen (Groß-/Kleinschreibung wird ignoriert).&lt;br /&gt;
* &#039;&#039;&#039;Platzhalter&#039;&#039;&#039; in geschweiften Klammern – z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;{uid}&amp;lt;/code&amp;gt; – passen auf einen beliebigen Wert und werden als [[#Pfad-Parameter im Skript|Pfad-Parameter]] erfasst.&lt;br /&gt;
&lt;br /&gt;
Beispiele für gültige Templates:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Template !! Passt auf !! Pfad-Parameter&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; || –&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/4711&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;uid = 4711&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/4711/modules/A1&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;uid = 4711&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code = A1&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/kalender/v1&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/kalender/v1&amp;lt;/code&amp;gt; || –&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Präzedenz bei mehreren Treffern ===&lt;br /&gt;
&lt;br /&gt;
Passen mehrere Templates auf denselben Pfad, &#039;&#039;&#039;gewinnt das spezifischste&#039;&#039;&#039; –&lt;br /&gt;
also das mit den meisten statischen Segmenten. So schlägt &amp;lt;code&amp;gt;/orders/summary&amp;lt;/code&amp;gt;&lt;br /&gt;
das Template &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt;, während &amp;lt;code&amp;gt;/orders/4711&amp;lt;/code&amp;gt; auf&lt;br /&gt;
&amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; matcht.&lt;br /&gt;
&lt;br /&gt;
== Hauptfelder eines Endpunkts ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld !! Spalte !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_pathtemplate&amp;lt;/code&amp;gt; || &#039;&#039;&#039;Maßgeblich fürs Routing.&#039;&#039;&#039; Vollständiger Pfad mit Platzhaltern, z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Server&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_server&amp;lt;/code&amp;gt; || Zuordnung zum Server-Profil (erforderlich)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Aktiv&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_aktiv&amp;lt;/code&amp;gt; || Statusflag; inaktive Endpunkte liefern 404&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Skript&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_script&amp;lt;/code&amp;gt; || DwScript-Quelltext des Handlers (siehe [[/OBS/Kostenpflichtige_Module/RESTServer/Scripting|Scripting]])&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;WebHook&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_webhook&amp;lt;/code&amp;gt; || optional: Verweis auf einen Einmal-Endpunkt statt Skript&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Info&#039;&#039;&#039; || – || Dokumentations-Freitext&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_upload&amp;lt;/code&amp;gt; || Erlaubt Datei-Uploads an diesem Endpunkt (&amp;lt;code&amp;gt;0&amp;lt;/code&amp;gt; = aus, &amp;lt;code&amp;gt;1&amp;lt;/code&amp;gt; = an). Ohne Freigabe werden Upload-Anfragen mit 415 abgelehnt.&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Max. Upload-Grösse&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt; || Maximale Dateigrösse in MB. Leer/0 = Standard 25&amp;amp;nbsp;MB. Überschreitung wird mit 413 abgelehnt.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pfad-Parameter im Skript ==&lt;br /&gt;
&lt;br /&gt;
Die aus den Platzhaltern erfassten Werte liest das Endpunkt-Skript über&lt;br /&gt;
&amp;lt;code&amp;gt;oReader.Path&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUid: string;&lt;br /&gt;
begin&lt;br /&gt;
    cUid := oReader.Path(&#039;uid&#039;);   // aus /orders/{uid}&lt;br /&gt;
    // ...&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Diese Werte stammen aus dem Routing und können nicht durch den Client&lt;br /&gt;
überschrieben werden.&lt;br /&gt;
&lt;br /&gt;
== Datei-Uploads ==&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt nimmt Datei-Uploads nur entgegen, wenn er dafür freigeschaltet ist&lt;br /&gt;
(&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;). Andernfalls werden Upload-Anfragen mit&lt;br /&gt;
&#039;&#039;&#039;415 Unsupported Media Type&#039;&#039;&#039; abgelehnt und das Skript wird nicht ausgeführt.&lt;br /&gt;
&lt;br /&gt;
Die maximal zulässige Dateigrösse wird pro Endpunkt über &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;&lt;br /&gt;
(in MB) festgelegt; ohne Angabe gilt der Standard von &#039;&#039;&#039;25&amp;amp;nbsp;MB&#039;&#039;&#039;. Wird das&lt;br /&gt;
Limit überschritten, antwortet der Server mit &#039;&#039;&#039;413 Payload Too Large&#039;&#039;&#039;. Für&lt;br /&gt;
Uploads gilt nicht das JSON-Body-Limit (10&amp;amp;nbsp;MB), sondern dieses Endpunkt-Limit.&lt;br /&gt;
Der Originalname (beim resumable Upload über den Header &amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt;) ist &#039;&#039;&#039;Pflicht&#039;&#039;&#039;; fehlt er, wird der Upload mit 413/400 abgelehnt. Das Skript liest Pfad, Name, Content-Type und die SHA-256-Prüfsumme der gespeicherten Datei über &amp;lt;code&amp;gt;oReader.UploadPath()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;UploadName()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;UploadType()&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;UploadSha()&amp;lt;/code&amp;gt;; bei &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; die übrigen Formularfelder über &amp;lt;code&amp;gt;oReader.Form(&#039;&amp;amp;lt;name&amp;amp;gt;&#039;)&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Das Übertragungsprotokoll&#039;&#039;&#039; - Header, Statuscodes, Resume-Verhalten - steht auf einer eigenen Seite: [[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]].&lt;br /&gt;
&lt;br /&gt;
Unterstützt werden zwei Übertragungsarten:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Einfacher Upload&#039;&#039;&#039; per &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; (eine Datei pro Request).&lt;br /&gt;
* &#039;&#039;&#039;Resumable/Chunked Upload&#039;&#039;&#039; per &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt; (grosse Dateien, fortsetzbar nach Abbruch).&lt;br /&gt;
&lt;br /&gt;
Der Server legt die hochgeladene Datei in einem temporären Verzeichnis ab und&lt;br /&gt;
übergibt dem Endpunkt-Skript den Pfad. Was mit der Datei geschieht (Ablage,&lt;br /&gt;
DMS-Verknüpfung, Weiterverarbeitung), entscheidet allein das Skript. Details und&lt;br /&gt;
Beispiele: [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
== Datei-Downloads ==&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt kann eine &#039;&#039;&#039;Datei&#039;&#039;&#039; statt eines JSON-Körpers zurückgeben -&lt;br /&gt;
PDF, CSV, eine APK. Dafür ist &#039;&#039;&#039;keine Freischaltung&#039;&#039;&#039; nötig: ein Download&lt;br /&gt;
entsteht dadurch, dass das Skript ihn mit &amp;lt;code&amp;gt;oWriter.SendFile(...)&amp;lt;/code&amp;gt;&lt;br /&gt;
bzw. &amp;lt;code&amp;gt;oWriter.SendTempFile(...)&amp;lt;/code&amp;gt; erzeugt, nicht dadurch, dass ein&lt;br /&gt;
Client ihn anfragt.&lt;br /&gt;
&lt;br /&gt;
Content-Type, Dateiname, Bereichsanfragen (&amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt;, &#039;&#039;&#039;206&#039;&#039;&#039;),&lt;br /&gt;
ETag und das Aufräumen temporärer Dateien übernimmt der Server. Einzelheiten:&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Datei-Download|Datei-Download]].&lt;br /&gt;
&lt;br /&gt;
== HTTP-Methode ==&lt;br /&gt;
&lt;br /&gt;
Welche Funktion aufgerufen wird, ergibt sich aus dem HTTP-Verb: Der Server ruft&lt;br /&gt;
die gleichnamige Skript-Methode auf (&amp;lt;code&amp;gt;Get&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Post&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Put&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Delete&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Patch&amp;lt;/code&amp;gt;).&lt;br /&gt;
Ein Template entspricht damit &#039;&#039;&#039;einem&#039;&#039;&#039; Endpunkt-Skript; unterschiedliche&lt;br /&gt;
Pfad-Formen (z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; vs. &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt;) sind&lt;br /&gt;
&#039;&#039;&#039;eigene Endpunkt-Einträge&#039;&#039;&#039; mit eigenem Skript und eigener Berechtigung.&lt;br /&gt;
&lt;br /&gt;
== URL-Struktur ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
http://[Host][:Port][Pfad-Template]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Beispiele:&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders/4711&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders/4711/modules/A1&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Versionierung ==&lt;br /&gt;
&lt;br /&gt;
Da es kein eigenes Versions-Feld mehr gibt, wird die Version als statisches&lt;br /&gt;
Segment ins Template aufgenommen, z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders/v1&amp;lt;/code&amp;gt; oder&lt;br /&gt;
&amp;lt;code&amp;gt;/v1/orders/{uid}&amp;lt;/code&amp;gt;. Änderung ohne Breaking Change:&lt;br /&gt;
&lt;br /&gt;
# Bestehenden Endpunkt unverändert lassen.&lt;br /&gt;
# Neuen Endpunkt mit gleichem Ressourcennamen, aber neuem Versions-Segment im Template anlegen.&lt;br /&gt;
# Berechtigungen für neue Konsumenten setzen.&lt;br /&gt;
# Alten Endpunkt deaktivieren, wenn die Migration abgeschlossen ist.&lt;br /&gt;
&lt;br /&gt;
== Zugriffskontrolle ==&lt;br /&gt;
&lt;br /&gt;
Endpunkte sind Server-Profilen zugeordnet; ein Zugang benötigt eine explizite&lt;br /&gt;
Berechtigung (Tabelle &amp;lt;code&amp;gt;RESTSRV_ACCESS&amp;lt;/code&amp;gt;), um einen Endpunkt nutzen zu&lt;br /&gt;
dürfen.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|&#039;&#039;&#039;Ein Endpunkt besteht aus zwei Zeilen, und beide werden von Hand&lt;br /&gt;
gepflegt:&#039;&#039;&#039; der Eintrag in &amp;lt;code&amp;gt;RESTSRV_ENDPOINTS&amp;lt;/code&amp;gt; und die Berechtigung&lt;br /&gt;
in &amp;lt;code&amp;gt;RESTSRV_ACCESS&amp;lt;/code&amp;gt;. Fehlt die zweite, antwortet der Endpunkt&lt;br /&gt;
&#039;&#039;&#039;403&#039;&#039;&#039; - und sieht dabei fertig aus: Pfad, Skript und Server-Profil stehen&lt;br /&gt;
korrekt da, der Aufruf kommt trotzdem nicht durch. Das ist die häufigste Ursache&lt;br /&gt;
für ein „der Endpunkt ist doch angelegt&amp;quot; und kostet jedes Mal eine&lt;br /&gt;
Fehlersuche, weil der Statuscode nach einem Rechteproblem des Konsumenten&lt;br /&gt;
aussieht und nicht nach einer fehlenden Konfigurationszeile.&lt;br /&gt;
&lt;br /&gt;
Beim Anlegen also &#039;&#039;&#039;immer beide Zeilen&#039;&#039;&#039;, und beim Prüfen eines&lt;br /&gt;
&amp;lt;code&amp;gt;403&amp;lt;/code&amp;gt; zuerst nachsehen, ob die zweite existiert.}} Weil jede Pfad-Form ein eigener Endpunkt-Eintrag ist, lässt sich der&lt;br /&gt;
Zugriff &#039;&#039;&#039;pro Pfad-Form&#039;&#039;&#039; granular vergeben (z.&amp;amp;nbsp;B. Lesen von&lt;br /&gt;
&amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; erlauben, aber das ändernde&lt;br /&gt;
&amp;lt;code&amp;gt;PUT /orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; nicht).&lt;br /&gt;
&lt;br /&gt;
== Caching ==&lt;br /&gt;
&lt;br /&gt;
Die Endpunkt-Definitionen werden pro Server-Profil zwischengespeichert&lt;br /&gt;
(TTL, Standard 60&amp;amp;nbsp;s). Neue oder geänderte Endpunkte/Templates werden daher&lt;br /&gt;
erst nach Ablauf des Caches (bzw. nach einer Cache-Invalidierung) wirksam – nicht&lt;br /&gt;
zwingend sofort.&lt;br /&gt;
&lt;br /&gt;
== HTTP-Fehlercodes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Code !! Ursache&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;404&#039;&#039;&#039; || Kein Template passt zum Pfad, Endpunkt inaktiv oder falsches Server-Profil&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;403&#039;&#039;&#039; || Zugang fehlt in der Berechtigungsliste&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;401&#039;&#039;&#039; || API-Key ungültig/fehlend, JWT abgelaufen oder Sitzung gesperrt (&#039;&#039;AUTH_EXPIRED&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;500&#039;&#039;&#039; || Skript- oder Syntax-Fehler (Details in &amp;lt;code&amp;gt;RESTSRV_PROTO&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;405&#039;&#039;&#039; || Das Endpunkt-Skript hat für die angefragte HTTP-Methode keine Funktion; der Header &amp;lt;code&amp;gt;Allow&amp;lt;/code&amp;gt; nennt die vorhandenen&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;206&#039;&#039;&#039; || Teilantwort eines Datei-Downloads auf eine &amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt;-Anfrage; die Antwort enthält &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;416&#039;&#039;&#039; || Der angefragte &amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt; liegt hinter dem Dateiende; die Antwort nennt in &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt; die wirkliche Grösse&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;415&#039;&#039;&#039; || Datei-Upload an einen Endpunkt, der dafür nicht freigeschaltet ist (&amp;lt;code&amp;gt;re_upload = 0&amp;lt;/code&amp;gt;); oder ein &amp;lt;code&amp;gt;Content-Encoding&amp;lt;/code&amp;gt;, das der Server nicht entpacken kann - unterstützt wird allein &amp;lt;code&amp;gt;gzip&amp;lt;/code&amp;gt; (siehe [[OBS/Kostenpflichtige Module/RESTServer|REST-Server]], Abschnitt Kompression)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;413&#039;&#039;&#039; || Hochgeladene Datei überschreitet die zulässige Maximalgrösse (&amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;); oder der Anfragekörper überschreitet 10 MB bzw. - gepackt gesendet - das 400fache der gesendeten Grösse&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;429&#039;&#039;&#039; || Rate-Limit überschritten; die Antwort enthält den Header &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;503&#039;&#039;&#039; || Eine Anfrage mit demselben &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; wird gerade verarbeitet; die Antwort enthält &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;409&#039;&#039;&#039; || Ein früherer Aufruf mit demselben &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; ist ohne Ergebnis geblieben (&amp;lt;code&amp;gt;IDEMPOTENCY_UNRESOLVED&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;422&#039;&#039;&#039; || Derselbe &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; wurde mit &#039;&#039;&#039;anderem&#039;&#039;&#039; Inhalt gesendet (&amp;lt;code&amp;gt;IDEMPOTENCY_KEY_REUSED&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;400&#039;&#039;&#039; || Die Anfrage wurde unverschlüsselt an einen TLS-Port geschickt (&amp;lt;code&amp;gt;http&amp;lt;/code&amp;gt; statt &amp;lt;code&amp;gt;https&amp;lt;/code&amp;gt;). Die Abweisung erfolgt vor Authentifizierung und Endpunkt-Skript&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Jede Fehlerantwort besteht aus einem &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt;-Objekt mit&lt;br /&gt;
maschinenlesbarem Code - Aufbau siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer|Übersicht]].&lt;br /&gt;
&lt;br /&gt;
Daneben kann ein Endpunkt-Skript den Statuscode selbst setzen (z.B. &amp;lt;code&amp;gt;201&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;204&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;409&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;422&amp;lt;/code&amp;gt;) sowie Response-Header wie &amp;lt;code&amp;gt;ETag&amp;lt;/code&amp;gt; oder &amp;lt;code&amp;gt;Location&amp;lt;/code&amp;gt; - siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
== Idempotenz ==&lt;br /&gt;
&lt;br /&gt;
Schickt ein Konsument bei &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;PUT&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;PATCH&amp;lt;/code&amp;gt;&lt;br /&gt;
oder &amp;lt;code&amp;gt;DELETE&amp;lt;/code&amp;gt; den Header &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, sorgt der Server&lt;br /&gt;
selbst dafür, dass eine wiederholte Sendung &#039;&#039;&#039;keine Zweitwirkung&#039;&#039;&#039; hat. Das gilt&lt;br /&gt;
für &#039;&#039;&#039;jeden&#039;&#039;&#039; Endpunkt - es muss weder am Endpunkt etwas eingestellt noch im&lt;br /&gt;
Skript etwas programmiert werden.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Situation !! Antwort des Servers&lt;br /&gt;
|-&lt;br /&gt;
| Erster Aufruf || Endpunkt läuft normal, das Ergebnis wird zum Schlüssel gespeichert&lt;br /&gt;
|-&lt;br /&gt;
| Wiederholung, gleicher Inhalt || &#039;&#039;&#039;Gespeicherte Antwort&#039;&#039;&#039; (gleicher Statuscode, gleicher Body, gleiche Header) plus Header &amp;lt;code&amp;gt;Idempotent-Replay: true&amp;lt;/code&amp;gt;. Das Skript läuft &#039;&#039;&#039;nicht&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| Wiederholung, anderer Inhalt || &#039;&#039;&#039;422&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_KEY_REUSED&amp;lt;/code&amp;gt; - derselbe Schlüssel für einen anderen Inhalt ist ein Client-Fehler&lt;br /&gt;
|-&lt;br /&gt;
| Erster Aufruf läuft noch || &#039;&#039;&#039;503&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_IN_PROGRESS&amp;lt;/code&amp;gt; mit &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;. Beim nächsten Versuch liegt die gespeicherte Antwort vor&lt;br /&gt;
|-&lt;br /&gt;
| Früherer Aufruf ohne Ergebnis || &#039;&#039;&#039;409&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_UNRESOLVED&amp;lt;/code&amp;gt;. Nur noch nach einem &#039;&#039;&#039;harten Abbruch&#039;&#039;&#039; des Dienstes zwischen Verarbeitung und Festschreiben; ob die Buchung stattgefunden hat, ist unbekannt. Der Aufräumlauf gibt solche Einträge nach 24 Stunden frei und protokolliert sie&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Wichtig für die Auslegung eines Endpunkts:&lt;br /&gt;
&lt;br /&gt;
* Antwortet das Skript mit &#039;&#039;&#039;2xx&#039;&#039;&#039;, wird die Antwort gespeichert und bei einer Wiederholung erneut ausgeliefert.&lt;br /&gt;
* Antwortet das Skript mit &#039;&#039;&#039;4xx&#039;&#039;&#039; (fachliche Ablehnung), wird der Schlüssel wieder &#039;&#039;&#039;freigegeben&#039;&#039;&#039; - der Konsument darf ihn nach Korrektur erneut verwenden. Sonst würde ein einziger Validierungsfehler den Schlüssel dauerhaft blockieren.&lt;br /&gt;
* Endet die Verarbeitung mit &#039;&#039;&#039;5xx&#039;&#039;&#039; oder einem Skript-Fehler, wird der Schlüssel &#039;&#039;&#039;freigegeben&#039;&#039;&#039; und der Fall protokolliert. Der Konsument darf denselben Schlüssel erneut senden.&lt;br /&gt;
* Das ist eine bewusste Abwägung (geändert 2026-08-25): Bleibt der Schlüssel nach einem Serverfehler belegt, ist der Vorgang &#039;&#039;&#039;unwiederholbar&#039;&#039;&#039; - der Client bekommt dauerhaft 503 und müsste die erfasste Arbeit verwerfen. Garantierter Datenverlust wiegt schwerer als ein möglicher Doppelsatz.&lt;br /&gt;
* &#039;&#039;&#039;Folge für die Auslegung:&#039;&#039;&#039; ein schreibender Endpunkt sollte selbst wiederholbar sein, etwa über ein fachliches Merkmal des Clients. Wo reines Anhängen stattfindet (Positionen, Dateien), bleibt sonst ein Restrisiko.&lt;br /&gt;
&lt;br /&gt;
Der Schlüssel gilt je &#039;&#039;&#039;Zugang, Methode und Pfad&#039;&#039;&#039;. Derselbe&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; an einem anderen Endpunkt ist damit ein eigener&lt;br /&gt;
Vorgang - der Konsument muss ihn nicht global eindeutig vergeben, aber pro Vorgang&lt;br /&gt;
&#039;&#039;&#039;stabil wiederverwenden&#039;&#039;&#039; (also nicht bei jedem Wiederholversuch neu erzeugen).&lt;br /&gt;
&lt;br /&gt;
Die Einträge stehen in &amp;lt;code&amp;gt;RESTSRV_IDEMPOTENCY&amp;lt;/code&amp;gt; und werden nach 30 Tagen&lt;br /&gt;
automatisch aufgeräumt. Einträge ohne Ergebnis werden &#039;&#039;&#039;nicht&#039;&#039;&#039; automatisch&lt;br /&gt;
gelöscht, sondern im Protokoll gemeldet.&lt;br /&gt;
&lt;br /&gt;
== WebHooks ==&lt;br /&gt;
&lt;br /&gt;
WebHooks sind &#039;&#039;&#039;Einmal-Endpunkte&#039;&#039;&#039; für asynchrone Callbacks. Beim ersten&lt;br /&gt;
erfolgreichen Aufruf wird die Antwort in der Tabelle &amp;lt;code&amp;gt;REMOTE_HOOK_URL&amp;lt;/code&amp;gt;&lt;br /&gt;
gespeichert und der Endpunkt anschließend automatisch gelöscht.&lt;br /&gt;
&lt;br /&gt;
Typischer Einsatz: OBS stösst einen Vorgang bei einem Fremdsystem an (Bezahldienst,&lt;br /&gt;
Versanddienstleister) und gibt diesem eine Rückruf-Adresse mit, die nur &#039;&#039;&#039;ein&lt;br /&gt;
einziges Mal&#039;&#039;&#039; gültig ist. Damit kann die Adresse nicht später erneut - oder von&lt;br /&gt;
jemand anderem - benutzt werden.&lt;br /&gt;
&lt;br /&gt;
Ablauf:&lt;br /&gt;
&lt;br /&gt;
# OBS legt einen Eintrag in &amp;lt;code&amp;gt;REMOTE_HOOK_URL&amp;lt;/code&amp;gt; an und dazu einen Endpunkt, dessen Feld &#039;&#039;&#039;WebHook&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_webhook&amp;lt;/code&amp;gt;) auf diesen Eintrag zeigt. Ein Skript wird für diesen Endpunkt nicht gepflegt.&lt;br /&gt;
# Die Adresse dieses Endpunkts wird dem Fremdsystem als Callback-URL übergeben.&lt;br /&gt;
# Das Fremdsystem ruft die Adresse auf. Der Server legt den übergebenen Inhalt in &amp;lt;code&amp;gt;REMOTE_HOOK_URL.hu_response&amp;lt;/code&amp;gt; ab, antwortet mit &amp;lt;code&amp;gt;{&amp;quot;status&amp;quot;: &amp;quot;ok&amp;quot;}&amp;lt;/code&amp;gt; und &#039;&#039;&#039;löscht den Endpunkt&#039;&#039;&#039;.&lt;br /&gt;
# Der weiterverarbeitende OBS-Prozess findet die Rückmeldung in &amp;lt;code&amp;gt;hu_response&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein zweiter Aufruf derselben Adresse läuft ins Leere: Der Endpunkt&lt;br /&gt;
existiert nicht mehr, die Antwort ist 404. Das ist gewollt - ein WebHook ist keine&lt;br /&gt;
dauerhafte Schnittstelle. Für eine dauerhaft erreichbare Rückmelde-Adresse einen&lt;br /&gt;
normalen Endpunkt mit Skript anlegen.}}&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Scripting&amp;diff=64824</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Scripting</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Scripting&amp;diff=64824"/>
		<updated>2026-09-15T10:00:28Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Anleitung für Endpunkt-Skripte=&lt;br /&gt;
&lt;br /&gt;
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.&lt;br /&gt;
&lt;br /&gt;
==Sicherheitshinweise==&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Endpunkt-Skripte verarbeiten Daten aus dem Internet. Übergabeparameter dürfen niemals ungeprüft in SQL-Anweisungen eingebaut werden. Werte immer über &#039;&#039;DB_SQLVal&#039;&#039; bzw. Parameter-Bindings absichern, Eingabewerte gegen Whitelists prüfen.}}&lt;br /&gt;
&lt;br /&gt;
* Eingaben gegen erwartete Werte prüfen (z.B. Pflichtfelder, erlaubte Typen, Wertebereiche).&lt;br /&gt;
* Nur das zurückgeben, was der Konsument wirklich braucht - keine internen IDs, keine Sys-Felder, keine Passwörter.&lt;br /&gt;
* Bei Fehlern keine internen Details an den Konsumenten zurückgeben; stattdessen schreibt der Server ohnehin Detail-Einträge in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==Methoden-Signatur==&lt;br /&gt;
&lt;br /&gt;
Pro HTTP-Methode wird im Skript eine gleichnamige &#039;&#039;&#039;Prozedur&#039;&#039;&#039; implementiert. Der Server ruft genau die Prozedur auf, die zur Methode des eingehenden Requests passt.&lt;br /&gt;
&lt;br /&gt;
 procedure Get   (oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
 procedure Post  (oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
 procedure Put   (oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
 procedure Patch (oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
&lt;br /&gt;
Zwei Handles, kein Rückgabewert: &#039;&#039;&#039;oReader&#039;&#039;&#039; ist die Anfrage, &#039;&#039;&#039;oWriter&#039;&#039;&#039; die&lt;br /&gt;
Antwort. Beide erzeugt der Server, beide gehören ihm - im Skript wird nichts&lt;br /&gt;
angelegt und nichts freigegeben.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|&#039;&#039;&#039;DELETE hat keine Skript-Methode.&#039;&#039;&#039; &amp;lt;code&amp;gt;Delete&amp;lt;/code&amp;gt; ist in der&lt;br /&gt;
Skriptsprache eine Standardprozedur (Löschen aus einer Zeichenkette); eine eigene&lt;br /&gt;
Prozedur dieses Namens lässt sich nicht übersetzen, und der Fehler nennt nur die&lt;br /&gt;
Zeile der Deklaration. Wer einen DELETE-Endpunkt braucht, legt die Methode unter&lt;br /&gt;
einem anderen Verb ab oder trennt sie in ein eigenes Skript.}}&lt;br /&gt;
&lt;br /&gt;
Fehlt die zur Methode passende Funktion, antwortet der Server mit &#039;&#039;&#039;405 Method&lt;br /&gt;
Not Allowed&#039;&#039;&#039; (&amp;lt;code&amp;gt;METHOD_NOT_ALLOWED&amp;lt;/code&amp;gt;) und nennt im Header&lt;br /&gt;
&amp;lt;code&amp;gt;Allow&amp;lt;/code&amp;gt; die Verben, die dieses Skript tatsächlich anbietet - die Liste&lt;br /&gt;
stammt aus dem kompilierten Skript und kann deshalb nicht veralten:&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 405 Method Not Allowed&lt;br /&gt;
 Allow: GET, PUT&lt;br /&gt;
&lt;br /&gt;
Ein im Vertrag zugesagtes Verb braucht also seine Prozedur. Fehlt sie, ist das an&lt;br /&gt;
der Antwort sofort zu erkennen und &#039;&#039;&#039;kein&#039;&#039;&#039; Serverfehler.&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
==Die Anfrage lesen: oReader==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;oReader&#039;&#039; ist der einzige Zugang zur Anfrage - Query, Header, Formularfelder,&lt;br /&gt;
Pfadwerte, Token-Claims und Körper. Alles davon sind &#039;&#039;&#039;Zeichenketten&#039;&#039;&#039; oder&lt;br /&gt;
werden über einen typisierten Leser geholt.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Was er liefert&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;kundennr&#039;)&amp;lt;/code&amp;gt; || Query-Parameter, POST-Parameter (form-urlencoded) oder HTTP-Header. Getrimmt, leer wenn nicht gesendet&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Path(&#039;uid&#039;)&amp;lt;/code&amp;gt; || Wert eines Platzhalters aus dem Pfad-Template&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Form(&#039;bemerkung&#039;)&amp;lt;/code&amp;gt; || Formularfeld einer &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt;-Übertragung&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Claim(&#039;tenant&#039;)&amp;lt;/code&amp;gt; || Custom-Claim des vorgelegten Tokens&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Subject()&amp;lt;/code&amp;gt; || Subject des Tokens (&#039;&#039;sub&#039;&#039;-Claim)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.TraceId()&amp;lt;/code&amp;gt; || Korrelations-ID des Requests&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Offset()&amp;lt;/code&amp;gt; || UTC-Offset des Servers, z.B. &#039;&#039;+02:00&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.HasBody()&amp;lt;/code&amp;gt; || Wurde überhaupt ein JSON-Körper gesendet?&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Filterung durch den Server:&lt;br /&gt;
&lt;br /&gt;
* Geblockte Header werden nicht durchgereicht: &#039;&#039;authorization&#039;&#039;, &#039;&#039;cookie&#039;&#039;, &#039;&#039;proxy-authorization&#039;&#039;, &#039;&#039;x-forwarded-for&#039;&#039;, &#039;&#039;x-real-ip&#039;&#039;, &#039;&#039;apikey&#039;&#039;, &#039;&#039;api_key&#039;&#039;.&lt;br /&gt;
* Werte, die von aussen kommen, können die reservierten Namen nicht besetzen.&lt;br /&gt;
* Werte werden auf max. 1024 Zeichen begrenzt.&lt;br /&gt;
* Null-Bytes und Steuerzeichen (ausser Tab, CR, LF) werden entfernt.&lt;br /&gt;
&lt;br /&gt;
Zugriff im Skript:&lt;br /&gt;
&lt;br /&gt;
 cKundenNr := oReader.Param(&#039;kundennr&#039;);&lt;br /&gt;
 if (not Empty(cKundenNr)) then begin&lt;br /&gt;
     // ...&lt;br /&gt;
 end;&lt;br /&gt;
&lt;br /&gt;
===Der Körper: Zugriff über Pfade===&lt;br /&gt;
&lt;br /&gt;
Den JSON-Körper liest &#039;&#039;oReader&#039;&#039; über &#039;&#039;&#039;Pfade&#039;&#039;&#039;. Es gibt keine Unterobjekte im&lt;br /&gt;
Skript, keinen Cast, kein &#039;&#039;.Items[i]&#039;&#039; - und damit auch keine Lebensdauer, um die&lt;br /&gt;
sich jemand kümmern müsste.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Str(&#039;betreff&#039;, cWert)&amp;lt;/code&amp;gt; || Liest den Wert. &#039;&#039;&#039;Rückgabe Boolean&#039;&#039;&#039;: war das Feld da? Fehlt es, bleibt die Variable unverändert&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Int(&#039;menge&#039;, nWert)&amp;lt;/code&amp;gt; || dito, Ganzzahl&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Num(&#039;preis&#039;, nWert)&amp;lt;/code&amp;gt; || dito, Kommazahl. &#039;&#039;&#039;Der Dezimalpunkt aus JSON wird richtig gelesen&#039;&#039;&#039; - kein &#039;&#039;StrTran&#039;&#039; mehr&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Bool(&#039;aktiv&#039;, lWert)&amp;lt;/code&amp;gt; || dito, Boolean&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.NumDef(&#039;rabatt&#039;, nWert, 0)&amp;lt;/code&amp;gt; || wie &#039;&#039;Num&#039;&#039;, mit Vorgabewert&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Has(&#039;feld&#039;)&amp;lt;/code&amp;gt; || Ist das Feld vorhanden? (auch bei &#039;&#039;null&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Count(&#039;zeiten&#039;)&amp;lt;/code&amp;gt; || Anzahl Einträge einer Liste, 0 wenn keine Liste&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.IsArr(&#039;zeiten&#039;)&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;IsObj(&#039;zeiten[0]&#039;)&amp;lt;/code&amp;gt; || Gestalt prüfen&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Keys()&amp;lt;/code&amp;gt; || Alle Feldnamen der obersten Ebene, in Kommas eingefasst&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Verschachtelung steht im Pfad&#039;&#039;&#039; - Punkt für Felder, eckige Klammern für Listen:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cVon : string;&lt;br /&gt;
    nAnz : Integer;&lt;br /&gt;
    nI   : Integer;&lt;br /&gt;
begin&lt;br /&gt;
    nAnz := oReader.Count(&#039;zeiten&#039;);&lt;br /&gt;
    for nI := 0 to nAnz - 1 do begin&lt;br /&gt;
        if (oReader.Str(&#039;zeiten[&#039; + IntToStr(nI) + &#039;].von&#039;, cVon)) then begin&lt;br /&gt;
            // ... cVon verarbeiten ...&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Der Rückgabewert unterscheidet „nicht gesendet&amp;quot; von „leer&lt;br /&gt;
gesendet&amp;quot;.&#039;&#039;&#039; &amp;lt;code&amp;gt;oReader.Str(&#039;feld&#039;, cWert)&amp;lt;/code&amp;gt; liefert bei&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;quot;feld&amp;quot;: null&amp;lt;/code&amp;gt; &#039;&#039;true&#039;&#039; mit leerem Wert und bei fehlendem Feld&lt;br /&gt;
&#039;&#039;false&#039;&#039;. Genau diese Unterscheidung braucht ein PATCH-artiger Aufruf: „Feld&lt;br /&gt;
löschen&amp;quot; und „Feld nicht anfassen&amp;quot; sehen sonst gleich aus.}}&lt;br /&gt;
&lt;br /&gt;
Ist gar kein Körper gesendet worden, liefert jeder Leser &#039;&#039;false&#039;&#039; und&lt;br /&gt;
&#039;&#039;HasBody()&#039;&#039; ist &#039;&#039;false&#039;&#039;. Maximale Body-Grösse: 10 MB - bei einem gepackt gesendeten Körper zusätzlich das 400fache der gesendeten Grösse, siehe [[OBS/Kostenpflichtige Module/RESTServer|REST-Server]], Abschnitt Kompression. Beide Überschreitungen beantwortet der Server mit &#039;&#039;&#039;413&#039;&#039;&#039;, das Skript läuft gar nicht erst an.&lt;br /&gt;
&lt;br /&gt;
===Werte des Servers===&lt;br /&gt;
&lt;br /&gt;
Bei aktiver JWT-Authentifizierung stehen die Token-Claims über den Reader zur&lt;br /&gt;
Verfügung. Sie tragen intern weiterhin die Namen mit Präfix &#039;&#039;&#039;_OBS_&#039;&#039;&#039; - im&lt;br /&gt;
Skript werden sie aber über die Zugriffe der Tabelle oben gelesen und nicht mehr&lt;br /&gt;
über den Namen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Parameter            !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;_OBS_JWT_ID&#039;)&amp;lt;/code&amp;gt; || JWT-Id (&#039;&#039;jti&#039;&#039;-Claim) - typisch die User-Id. &#039;&#039;&#039;Muss nicht eindeutig sein&#039;&#039;&#039;: die Sitzung führt der Server über eigene Kennungen (siehe unten)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Subject()&amp;lt;/code&amp;gt; || Subject (&#039;&#039;sub&#039;&#039;-Claim) - typisch Benutzername&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;_OBS_JWT_AUDIENCE&#039;)&amp;lt;/code&amp;gt; || Audience (&#039;&#039;aud&#039;&#039;-Claim) - typisch Mandant / Rolle&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Claim(&#039;tenant&#039;)&amp;lt;/code&amp;gt; || Beliebiger Custom-Claim des Tokens, hier &#039;&#039;tenant&#039;&#039;; im Folge-Skript lesbar&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.TraceId()&amp;lt;/code&amp;gt; || Korrelations-ID des Requests (auch als Response-Header X-Trace-Id)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Unabhängig von JWT stehen ausserdem immer zur Verfügung:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Parameter            !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;_OBS_SERVER_TIME&#039;)&amp;lt;/code&amp;gt; || Aktuelle Serverzeit als ISO-8601 &#039;&#039;&#039;mit UTC-Offset&#039;&#039;&#039; (z.B. &#039;&#039;2026-08-17T09:12:33+02:00&#039;&#039;). Nützlich, um Datumswerte in derselben Zeitzone auszuliefern, in der der Server arbeitet, und damit ein Client seinen Uhren-Versatz bestimmen kann&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Offset()&amp;lt;/code&amp;gt; || Nur der Offset daraus, z.B. &#039;&#039;+02:00&#039;&#039;. Er geht in die Zeitfunktionen (siehe [[#Zeitangaben auf der Leitung|Zeitangaben]])&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;_OBS_JWT_EXP_SEC&#039;)&amp;lt;/code&amp;gt; || Laufzeit des Access-Tokens in Sekunden (&#039;&#039;JWT-Exp&#039;&#039; × 60). Damit kann ein Konfigurations-Endpunkt denselben Wert ausliefern, den der Token wirklich hat&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Diese Werte werden vom Server gesetzt und können vom Skript für Berechtigungs- und Mandantenprüfungen verwendet werden.&lt;br /&gt;
&lt;br /&gt;
Zusätzlich trägt jedes Token zwei &#039;&#039;&#039;Sitzungskennungen des Servers&#039;&#039;&#039;, lesbar als&lt;br /&gt;
&#039;&#039;oReader.Claim(&#039;sid&#039;)&#039;&#039; und &#039;&#039;oReader.Claim(&#039;rid&#039;)&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Claim !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| sid || Sitzung. Bleibt über alle Erneuerungen (Refresh) hinweg gleich und ist der Schlüssel für das Abmelden&lt;br /&gt;
|-&lt;br /&gt;
| rid || Zeilenkennung des einzelnen Token-Paares in &#039;&#039;RESTSRV_TOKEN&#039;&#039;. Bei jeder Erneuerung neu&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Beide sind OBS-UIDs (10 Zeichen) und werden vom Server &#039;&#039;&#039;nach&#039;&#039;&#039; den Claims des&lt;br /&gt;
Skripts gesetzt - ein Skript kann sie also auch mit einem eigenen Claim &#039;&#039;sid&#039;&#039;&lt;br /&gt;
nicht überschreiben. Für Fachlogik sind sie nicht gedacht; sie sind nützlich, um eine&lt;br /&gt;
Sitzung im Protokoll wiederzufinden.&lt;br /&gt;
&lt;br /&gt;
===Pfad-Parameter===&lt;br /&gt;
&lt;br /&gt;
Stammt der Endpunkt aus einem Pfad-Template mit Platzhaltern (siehe [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]), stehen die aus den Platzhaltern erfassten Werte über &#039;&#039;oReader.Path&#039;&#039; bereit:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Template !! Zugriff im Skript&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;oReader.Path(&#039;uid&#039;)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;oReader.Path(&#039;uid&#039;)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;oReader.Path(&#039;code&#039;)&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
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]].&lt;br /&gt;
&lt;br /&gt;
===Datei-Uploads===&lt;br /&gt;
&lt;br /&gt;
Ist der Endpunkt für Uploads freigeschaltet (&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;, siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]), nimmt der Server&lt;br /&gt;
hochgeladene Dateien entgegen, legt sie in einem temporären Verzeichnis ab und&lt;br /&gt;
übergibt dem Skript Pfad, Originalname und Content-Type über den Reader. Das&lt;br /&gt;
Skript entscheidet selbst über die weitere Verarbeitung (z.B. DMS-Ablage). Der&lt;br /&gt;
Body wird in diesem Fall &#039;&#039;&#039;nicht&#039;&#039;&#039; als JSON geparst - &#039;&#039;oReader.HasBody()&#039;&#039; ist&lt;br /&gt;
&#039;&#039;false&#039;&#039;. Es gilt nicht das JSON-Body-Limit (10&amp;amp;nbsp;MB), sondern die pro&lt;br /&gt;
Endpunkt konfigurierte Grösse (&#039;&#039;re_upload_size&#039;&#039;, Standard 25&amp;amp;nbsp;MB;&lt;br /&gt;
Überschreitung -&amp;gt; 413).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Wie die Datei übertragen wurde, spielt für das Skript keine Rolle.&#039;&#039;&#039; Beide&lt;br /&gt;
Wege - &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; und der resumable&lt;br /&gt;
&amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;-Upload - liefern dieselben vier Werte. Das&lt;br /&gt;
Übertragungsprotokoll samt Header, Statuscodes und Resume-Verhalten steht in&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]]:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadPath()&amp;lt;/code&amp;gt; || Vollständiger Pfad zur temporären Datei auf dem Server&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadName()&amp;lt;/code&amp;gt; || Originaldateiname&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadType()&amp;lt;/code&amp;gt; || Content-Type der Datei (Default &amp;lt;code&amp;gt;application/octet-stream&amp;lt;/code&amp;gt;, wenn der Client keinen angibt), kleingeschrieben&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadSha()&amp;lt;/code&amp;gt; || SHA-256 der &#039;&#039;&#039;gespeicherten&#039;&#039;&#039; Datei, hex in Kleinbuchstaben. Damit lässt sich eine vom Client mitgesendete Prüfsumme vergleichen - erst dieser Vergleich macht aus ihr eine Zusicherung&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Der &#039;&#039;&#039;Dateiname ist Pflicht&#039;&#039;&#039;. Fehlt er, lehnt der Server den Upload mit&lt;br /&gt;
&#039;&#039;&#039;400 Bad Request&#039;&#039;&#039; ab und das Skript wird nicht ausgeführt - das Skript kann&lt;br /&gt;
sich also darauf verlassen, dass &#039;&#039;UploadPath()&#039;&#039; und &#039;&#039;UploadName()&#039;&#039; gefüllt&lt;br /&gt;
sind, sobald es läuft.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Die temporäre Datei wird nicht automatisch verschoben. Das Skript muss sie an ihren Zielort (DMS, Verzeichnis, ...) übernehmen.}}&lt;br /&gt;
&lt;br /&gt;
Zusatzangaben, die im selben Aufruf mitkommen, liest das Skript mit&lt;br /&gt;
&amp;lt;code&amp;gt;oReader.Form(&#039;&amp;amp;lt;name&amp;amp;gt;&#039;)&amp;lt;/code&amp;gt; - bei &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; sind&lt;br /&gt;
das die Partien ohne &amp;lt;code&amp;gt;filename=&amp;lt;/code&amp;gt;. Ein vollständiges Beispiel zeigt&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6]].&lt;br /&gt;
&lt;br /&gt;
==Die Antwort schreiben: oWriter==&lt;br /&gt;
&lt;br /&gt;
Der Writer schreibt &#039;&#039;&#039;sequentiell&#039;&#039;&#039;: jeder Aufruf hängt ein Feld an, an der&lt;br /&gt;
Stelle, an der er steht. Die äussere Klammer der Antwort setzt der Server. Wird&lt;br /&gt;
nichts geschrieben, antwortet er mit &#039;&#039;{}&#039;&#039;. Content-Type ist&lt;br /&gt;
&#039;&#039;application/json; charset=utf-8&#039;&#039;, der Status 200, sofern das Skript nichts&lt;br /&gt;
anderes setzt.&lt;br /&gt;
&lt;br /&gt;
Einfaches Beispiel:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
begin&lt;br /&gt;
    oWriter.Str(&#039;wert_string&#039;, &#039;123&#039;);&lt;br /&gt;
    oWriter.Int(&#039;wert_int&#039;   , 456);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Ergebnis&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Str(&#039;name&#039;, cWert)&amp;lt;/code&amp;gt; || Zeichenkette, immer maskiert&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.StrN(&#039;name&#039;, cWert)&amp;lt;/code&amp;gt; || wie &#039;&#039;Str&#039;&#039;, aber &#039;&#039;&#039;null&#039;&#039;&#039; statt einer leeren Zeichenkette&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Int(&#039;anz&#039;, nWert)&amp;lt;/code&amp;gt; || Ganzzahl&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Num(&#039;preis&#039;, nWert, 2)&amp;lt;/code&amp;gt; || Kommazahl mit fester Stellenzahl. &#039;&#039;&#039;Immer mit Dezimalpunkt&#039;&#039;&#039;, unabhängig von der Locale des Servers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Bool(&#039;aktiv&#039;, lWert)&amp;lt;/code&amp;gt; || &#039;&#039;true&#039;&#039; / &#039;&#039;false&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Null(&#039;lat&#039;)&amp;lt;/code&amp;gt; || ausdrücklich &#039;&#039;null&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.ObjBegin(&#039;kunde&#039;)&amp;lt;/code&amp;gt; … &amp;lt;code&amp;gt;oWriter.ObjEnd&amp;lt;/code&amp;gt; || Unterobjekt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.ArrBegin(&#039;dateien&#039;)&amp;lt;/code&amp;gt; … &amp;lt;code&amp;gt;oWriter.ArrEnd&amp;lt;/code&amp;gt; || Liste&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.RootArr()&amp;lt;/code&amp;gt; || Die &#039;&#039;&#039;Wurzel&#039;&#039;&#039; ist eine Liste statt eines Objekts. Muss vor dem ersten Feld stehen; ein &#039;&#039;ArrEnd&#039;&#039; gibt es dazu nicht&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Raw(&#039;meta&#039;, cJson&#039;)&amp;lt;/code&amp;gt; || Fertiges JSON-Fragment einbetten. Siehe unten&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;In einer Liste hat ein Eintrag keinen Namen&#039;&#039;&#039; - der leere Feldname ist genau&lt;br /&gt;
das:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
oWriter.ArrBegin(&#039;dateien&#039;);&lt;br /&gt;
    while (not q.EoF) do begin&lt;br /&gt;
        oWriter.ObjBegin(&#039;&#039;);&lt;br /&gt;
            oWriter.Str (&#039;uid&#039;      , q.A2C(&#039;f_uid&#039;));&lt;br /&gt;
            oWriter.Int (&#039;bytes&#039;    , q.A2I(&#039;f_bytes&#039;));&lt;br /&gt;
            oWriter.StrN(&#039;kategorie&#039;, AllTrim(q.A2C(&#039;f_kat&#039;)));   // null wenn leer&lt;br /&gt;
        oWriter.ObjEnd;&lt;br /&gt;
        q.Next();&lt;br /&gt;
    end;&lt;br /&gt;
oWriter.ArrEnd;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Ein Name in einer Liste und ein fehlender Name in einem Objekt sind beides&lt;br /&gt;
&#039;&#039;&#039;Strukturfehler&#039;&#039;&#039;. Der Server antwortet dann mit &#039;&#039;&#039;500&#039;&#039;&#039; und nennt die&lt;br /&gt;
Stelle im Protokoll - er liefert kein halbes JSON aus. Dasselbe gilt für einen&lt;br /&gt;
Stapel, der am Ende der Methode noch offen ist.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Ein Helfer bekommt den Writer als Parameter&#039;&#039;&#039; und schreibt an der&lt;br /&gt;
Stelle hinein, an der er aufgerufen wird - er gibt kein Fragment zurück. Damit&lt;br /&gt;
steht die Reihenfolge der Antwort im Code und nicht im Zusammenbau:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;procedure DateienSchreiben(oWriter: TxRestWriter; const cNr: string);&amp;lt;/code&amp;gt;}}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Raw ist der Notausgang und prüft.&#039;&#039;&#039; Er parst das Fragment und bettet den&lt;br /&gt;
&#039;&#039;&#039;Originaltext&#039;&#039;&#039; ein; ungültiges JSON gibt einen Serverfehler, der das Feld&lt;br /&gt;
nennt. Gebraucht wird er für ein gespeichertes, &#039;&#039;&#039;signiertes&#039;&#039;&#039; Dokument:&lt;br /&gt;
Parsen und Neuserialisieren würde die signierte Feldreihenfolge zerstören.&lt;br /&gt;
&lt;br /&gt;
===Zeitangaben auf der Leitung===&lt;br /&gt;
&lt;br /&gt;
Zeitstempel gehen als &#039;&#039;&#039;ISO-8601 mit Offset&#039;&#039;&#039; über die Leitung. Die Wandlung&lt;br /&gt;
gehört nicht ins Skript - dort saß der Fehler „zwei Stunden zu früh&amp;quot; an drei&lt;br /&gt;
Stellen gleichzeitig:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;RestIsoFromDate(dWert, cOffset)&amp;lt;/code&amp;gt; || OBS-Zeitpunkt -&amp;gt; &#039;&#039;2026-09-09T14:12:00+02:00&#039;&#039;. Leerer String bei leerem Datum&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;RestDateFromIso(cWert, cOffset)&amp;lt;/code&amp;gt; || ISO-Zeichenkette -&amp;gt; OBS-Zeitpunkt in &#039;&#039;&#039;Ortszeit des Servers&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Offset()&amp;lt;/code&amp;gt; || Der Offset, der in beide hineingeht&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;RestOffsetToMinutes(cOffset)&amp;lt;/code&amp;gt; || Offset in Minuten, für eigene Rechnungen&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==Antwort steuern: Statuscode, Header, traceId==&lt;br /&gt;
&lt;br /&gt;
Statuscode, Header und das Sperren einer Sitzung laufen über eigene Aufrufe des&lt;br /&gt;
Writers, &#039;&#039;&#039;nicht&#039;&#039;&#039; über Felder im Körper.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Wirkung&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Status(201)&amp;lt;/code&amp;gt; || HTTP-Statuscode (z.B. 201, 204, 400, 409, 422). Ohne Angabe: 200&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Header(&#039;ETag&#039;, &#039;7&#039;)&amp;lt;/code&amp;gt; || Beliebiger Response-Header, z.B. ETag, Location, Retry-After. CR/LF in Namen und Werten werden entfernt (Schutz vor Header-Injection)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.NoBody()&amp;lt;/code&amp;gt; || Kein Körper - für &#039;&#039;&#039;204&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.JwtRevoke(&#039;session&#039;)&amp;lt;/code&amp;gt; || Sperrt die Sitzung des vorgelegten Tokens (&#039;&#039;session&#039;&#039;) oder alle Sitzungen des Subjects (&#039;&#039;all&#039;&#039;). Damit wird ein Endpunkt zum Logout, ohne eigene Verwaltung. Scheitert das Sperren, antwortet der Server mit &#039;&#039;&#039;503&#039;&#039;&#039; statt mit der Antwort des Skripts - siehe [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]], Abschnitt Abmelden&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Feld „menge&amp;quot; fehlt&#039;)&amp;lt;/code&amp;gt; || Fertige Fehlerantwort in der Form des Servers, siehe [[#Fehlerbehandlung im Skript|Fehlerbehandlung]]&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.HasError()&amp;lt;/code&amp;gt; || Steht schon eine Fehlerantwort im Writer? Für einen Verteiler, der danach noch etwas anhängen will&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.SendFile(cPfad, cName, cTyp)&amp;lt;/code&amp;gt; || Statt eines JSON-Körpers eine &#039;&#039;&#039;Datei&#039;&#039;&#039; ausliefern. Die Datei bleibt liegen, der Download ist wiederaufsetzbar. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Datei-Download|Datei-Download]]&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.SendTempFile(cPfad, cName, cTyp)&amp;lt;/code&amp;gt; || Wie &amp;lt;code&amp;gt;SendFile&amp;lt;/code&amp;gt;, aber die Datei wird nach dem Senden &#039;&#039;&#039;gelöscht&#039;&#039;&#039; und der Download ist nicht wiederaufsetzbar - für Ergebnisse, die je Abruf entstehen (CSV-Export, Report)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Zusätzlich trägt jede Antwort den Header &#039;&#039;&#039;X-Trace-Id&#039;&#039;&#039; (Korrelations-ID). Dieselbe ID liegt dem Skript als &#039;&#039;oReader.TraceId()&#039;&#039; vor und erscheint in jeder Server-Fehler-Logzeile in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; - so lässt sich ein Fehler ohne Gerätezugriff im Log wiederfinden.&lt;br /&gt;
&lt;br /&gt;
Setzt ein Skript den Header &#039;&#039;&#039;Content-Encoding&#039;&#039;&#039; selbst - etwa weil es einen bereits gepackten Körper ausliefert -, hält sich die Engine heraus und packt die Antwort nicht noch einmal. Ohne diesen Header entscheidet der Server selbst, ob gzip zum Einsatz kommt.&lt;br /&gt;
&lt;br /&gt;
===Beispiel: Anlegen mit 201, Location und ETag===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cNeueUid: string;&lt;br /&gt;
begin&lt;br /&gt;
    // ... Datensatz anlegen, neue UID + Version (ETag) ermitteln ...&lt;br /&gt;
&lt;br /&gt;
    oWriter.Str(&#039;uid&#039;, cNeueUid);&lt;br /&gt;
    oWriter.Status(201);&lt;br /&gt;
    oWriter.Header(&#039;Location&#039;, &#039;/v1/orders/&#039; + cNeueUid);&lt;br /&gt;
    oWriter.Header(&#039;ETag&#039;    , &#039;1&#039;);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Beispiel: Strukturierter Fehler mit Statuscode und traceId===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var nMenge: Integer;&lt;br /&gt;
begin&lt;br /&gt;
    if (not oReader.Int(&#039;menge&#039;, nMenge)) then begin&lt;br /&gt;
        // Statuscode, code, message, uid und traceId in EINEM Aufruf - dieselbe&lt;br /&gt;
        // Huelle, die auch die Server-eigenen Fehler tragen.&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Feld &amp;quot;menge&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
    // ...&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Beispiel: Optimistic Concurrency (ETag / If-Match)===&lt;br /&gt;
&lt;br /&gt;
Mit Statuscode, Headern und dem lesbaren Header &#039;&#039;If-Match&#039;&#039; führt das Skript je Datensatz einen Versionszähler und lehnt veraltete Schreibzugriffe mit 409 ab:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var nAktuell: Integer;&lt;br /&gt;
    nIfMatch: Integer;&lt;br /&gt;
begin&lt;br /&gt;
    nAktuell := AuftragVersion(oReader.Path(&#039;uid&#039;));&lt;br /&gt;
    nIfMatch := iVal(oReader.Param(&#039;if-match&#039;));&lt;br /&gt;
&lt;br /&gt;
    if (nIfMatch &amp;lt;&amp;gt; nAktuell) then begin&lt;br /&gt;
        // Der ETag gehoert AUCH an die Ablehnung - sonst kennt der Client den&lt;br /&gt;
        // gueltigen Wert nicht und sein naechster Versuch scheitert wieder.&lt;br /&gt;
        oWriter.Header(&#039;ETag&#039;, xStr(nAktuell));&lt;br /&gt;
        oWriter.Error(409, &#039;VERSION_CONFLICT&#039;, &#039;Der Datensatz wurde zwischenzeitlich geaendert&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // ... speichern, Version hochzählen ...&lt;br /&gt;
    oWriter.Header(&#039;ETag&#039;, xStr(nAktuell + 1));&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Idempotenz - was das Skript beachten muss==&lt;br /&gt;
&lt;br /&gt;
Sendet ein Konsument bei &#039;&#039;POST&#039;&#039;/&#039;&#039;PUT&#039;&#039;/&#039;&#039;PATCH&#039;&#039;/&#039;&#039;DELETE&#039;&#039; den Header&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, fängt der &#039;&#039;&#039;Server&#039;&#039;&#039; doppelte Sendungen ab. Das&lt;br /&gt;
Skript braucht dafür &#039;&#039;&#039;keine eigene Logik&#039;&#039;&#039; - keine Schlüssel-Tabelle, keine&lt;br /&gt;
Prüfung am Anfang der Methode.&lt;br /&gt;
&lt;br /&gt;
Was das Skript wissen muss:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Antwort des Skripts !! Wirkung auf den Idempotenz-Speicher&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;2xx&#039;&#039;&#039; || Statuscode, Body und Header werden gespeichert. Eine Wiederholung mit demselben Schlüssel bekommt genau diese Antwort zurück, das Skript läuft nicht erneut&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;4xx&#039;&#039;&#039; || Der Schlüssel wird &#039;&#039;&#039;freigegeben&#039;&#039;&#039;. Der Konsument darf denselben Schlüssel nach Korrektur des Inhalts erneut verwenden&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;5xx&#039;&#039;&#039; oder Exception || Der Schlüssel bleibt belegt, der Fall wird protokolliert. Eine Wiederholung wird abgelehnt, bis der Fall geklärt ist&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Daraus folgen zwei Regeln für Endpunkt-Skripte:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Fachliche Ablehnungen als 4xx melden&#039;&#039;&#039; (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.&lt;br /&gt;
* &#039;&#039;&#039;Die Antwort muss vollständig sein.&#039;&#039;&#039; Was zurückgegeben wird, wird eingefroren. Ein Feld, das erst der zweite Aufruf ergänzen würde, kommt beim Konsumenten nie an.&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
Der vollständige Ablauf und die Statuscodes stehen unter&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]].&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierungs-Skript==&lt;br /&gt;
&lt;br /&gt;
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 &#039;&#039;Authenticate&#039;&#039; bereitstellen:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Authenticate(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUser: string;&lt;br /&gt;
    cPass: string;&lt;br /&gt;
begin&lt;br /&gt;
    // Eingangsdaten lesen&lt;br /&gt;
    cUser := &#039;&#039;;&lt;br /&gt;
    cPass := &#039;&#039;;&lt;br /&gt;
    oReader.Str(&#039;username&#039;, cUser);&lt;br /&gt;
    oReader.Str(&#039;password&#039;, cPass);&lt;br /&gt;
&lt;br /&gt;
    // Prüfung gegen eigene Tabelle, Hash-Verfahren, LDAP, ...&lt;br /&gt;
    if (PasswortPasst(cUser, cPass)) then begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;           , 1);&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_ID&#039;      , cUser);&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_SUBJECT&#039; , cUser);&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_AUDIENCE&#039;, &#039;mandant1&#039;);&lt;br /&gt;
    end else begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;, 9);&lt;br /&gt;
        oWriter.Str(&#039;error&#039; , &#039;Login fehlgeschlagen&#039;);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Im Anmeldeskript bleiben die _OBS_JWT_*-Felder Felder der&lt;br /&gt;
Antwort&#039;&#039;&#039; - anders als Status und Header, die zu Aufrufen des Writers geworden&lt;br /&gt;
sind. Der Grund: die Token-Ausstellung liest sie aus dem fertigen Körper, und&lt;br /&gt;
genau dort erwartet sie sie. Geschrieben werden sie deshalb wie jedes andere&lt;br /&gt;
Feld, nur eben mit dem Writer.}}&lt;br /&gt;
&lt;br /&gt;
Rückgabewerte:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! status !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| 1      || Erfolgreich, der Server erzeugt aus den &#039;&#039;_OBS_JWT_*&#039;&#039;-Werten einen Token (HS256)&lt;br /&gt;
|-&lt;br /&gt;
| 9      || Misserfolg, der Server antwortet mit &#039;&#039;&#039;401&#039;&#039;&#039; und reicht den Wert von &#039;&#039;error&#039;&#039; als &#039;&#039;error.message&#039;&#039; an den Client durch (zusätzlich protokolliert)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der Text aus &#039;&#039;error&#039;&#039; geht bei &#039;&#039;status&#039;&#039; = 9 &#039;&#039;&#039;an den Konsumenten&#039;&#039;&#039;&lt;br /&gt;
und wird typischerweise direkt unter dem Passwortfeld angezeigt. Er sollte für&lt;br /&gt;
Endanwender verständlich sein und keine internen Details verraten.}}&lt;br /&gt;
&lt;br /&gt;
Liefert das Skript einen anderen Status als 1 oder 9, ist gar nicht lauffähig oder&lt;br /&gt;
gibt kein auswertbares JSON zurück, antwortet der Server mit &#039;&#039;&#039;403&#039;&#039;&#039; und einer&lt;br /&gt;
generischen Meldung - ein defektes Anmeldeskript soll dem Anwender nicht als&lt;br /&gt;
„Passwort falsch&amp;quot; erscheinen.&lt;br /&gt;
&lt;br /&gt;
Kann der Server die &#039;&#039;&#039;Sitzungszeile&#039;&#039;&#039; zum ausgestellten Token nicht schreiben&lt;br /&gt;
(Tabelle fehlt, Datenbankproblem), liefert er &#039;&#039;&#039;kein Token&#039;&#039;&#039; aus und antwortet&lt;br /&gt;
mit &#039;&#039;&#039;503&#039;&#039;&#039; und &#039;&#039;Retry-After&#039;&#039;. Auch das ist bewusst kein 401/403: die&lt;br /&gt;
Zugangsdaten waren richtig, der Versuch darf wiederholt werden.&lt;br /&gt;
&lt;br /&gt;
===Aufbau der Token-Antwort===&lt;br /&gt;
&lt;br /&gt;
Bei Erfolg baut der Server die Antwort aus den Token-Feldern &#039;&#039;&#039;und allen weiteren&lt;br /&gt;
Feldern, die das Skript zurückgibt&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;benutzerId&amp;quot;: &amp;quot;4711&amp;quot;, &amp;quot;tenant&amp;quot;: &amp;quot;nord&amp;quot;, &amp;quot;roles&amp;quot;: [&amp;quot;TECHNIKER&amp;quot;],&lt;br /&gt;
  &amp;quot;token&amp;quot;:       &amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;accessToken&amp;quot;: &amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;refreshToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:   28800,&lt;br /&gt;
  &amp;quot;serverTime&amp;quot;:  &amp;quot;2026-08-17T09:12:33+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld !! Herkunft&lt;br /&gt;
|-&lt;br /&gt;
| token / accessToken || Derselbe Access-Token unter zwei Namen. &#039;&#039;accessToken&#039;&#039; erwarten die meisten Client-Bibliotheken, &#039;&#039;token&#039;&#039; bleibt für bestehende Konsumenten erhalten&lt;br /&gt;
|-&lt;br /&gt;
| refreshToken || Nur wenn das Skript &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; geliefert hat&lt;br /&gt;
|-&lt;br /&gt;
| expiresIn || Laufzeit des Access-Tokens in &#039;&#039;&#039;Sekunden&#039;&#039;&#039; (&#039;&#039;JWT-Exp&#039;&#039; × 60)&lt;br /&gt;
|-&lt;br /&gt;
| serverTime || Serverzeit ISO-8601 mit Offset&lt;br /&gt;
|-&lt;br /&gt;
| alle übrigen Felder || &#039;&#039;&#039;Frei vom Skript bestimmt.&#039;&#039;&#039; Übernommen wird alles ausser &#039;&#039;status&#039;&#039; und den &#039;&#039;_OBS_&#039;&#039;-Steuerfeldern&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Damit kann das Anmeldeskript alles mitliefern, was der Client direkt nach dem Login&lt;br /&gt;
braucht (Benutzer-Id, Mandant, Rollen, Berechtigungen) - ohne dass dieser dafür&lt;br /&gt;
einen zweiten Aufruf absetzen muss. Ein Feld, das genauso heisst wie eines der&lt;br /&gt;
Token-Felder oben, wird verworfen; die Engine setzt diese selbst.&lt;br /&gt;
&lt;br /&gt;
===Custom-Claims, serverTime und Refresh===&lt;br /&gt;
&lt;br /&gt;
Das Authenticate-Skript kann dem Token zusätzlich &#039;&#039;&#039;beliebige Custom-Claims&#039;&#039;&#039; mitgeben - z.B. Mandant und Rollen - und optional ein &#039;&#039;&#039;Refresh-Token&#039;&#039;&#039; anstoßen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! RÜckgabefeld !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_CLAIM_&amp;amp;lt;name&amp;amp;gt; || Beliebiger Custom-Claim, z.B. _OBS_JWT_CLAIM_tenant, _OBS_JWT_CLAIM_roles. Im Folge-Skript lesbar als &amp;lt;code&amp;gt;oReader.Claim(&#039;&amp;amp;lt;name&amp;amp;gt;&#039;)&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_REFRESH_ID || (optional) jti des Refresh-Tokens. Nur wenn gesetzt, stellt der Server ein Refresh-Token aus.&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_REFRESH_EXP || (optional) Lebensdauer des Refresh-Tokens in Minuten (Default 90 Tage = 129600).&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Jedes Token trägt automatisch den Claim &#039;&#039;token_use&#039;&#039; (&#039;&#039;access&#039;&#039; bzw. &#039;&#039;refresh&#039;&#039;) sowie die Sitzungskennungen &#039;&#039;sid&#039;&#039; und &#039;&#039;rid&#039;&#039; des Servers. Die Antwort enthält bei Erfolg &#039;&#039;serverTime&#039;&#039; (ISO-8601 mit Offset), bei ausgestelltem Refresh zusätzlich &#039;&#039;refreshToken&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;refreshToken&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;serverTime&amp;quot;:&amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Mandant und Rollen immer aus dem Token lesen (&#039;&#039;oReader.Claim(…)&#039;&#039;), nie aus Body oder Query.}}&lt;br /&gt;
&lt;br /&gt;
====Refresh-Methode====&lt;br /&gt;
&lt;br /&gt;
Der Refresh läuft über &#039;&#039;&#039;denselben JWT-Endpunkt&#039;&#039;&#039; und &#039;&#039;&#039;dasselbe Skript&#039;&#039;&#039;. Liegt am JWT-Endpunkt ein &#039;&#039;Authorization: Bearer &amp;lt;token&amp;gt;&#039;&#039; mit einem Refresh-Token vor, ruft der Server statt &#039;&#039;Authenticate&#039;&#039; die Methode &#039;&#039;&#039;Refresh&#039;&#039;&#039; auf (ohne Bearer: Login; &#039;&#039;DELETE&#039;&#039; mit Access-Token: Abmelden, ohne Skript).&lt;br /&gt;
&lt;br /&gt;
Bevor das Skript läuft, hat der Server das vorgelegte Refresh-Token verifiziert&lt;br /&gt;
(Signatur, Ablauf, &#039;&#039;token_use=refresh&#039;&#039;) und in der Sitzung &#039;&#039;&#039;entwertet&#039;&#039;&#039;.&lt;br /&gt;
Einmalgebrauch, Rotation und Sperrliste sind damit Sache des Servers. Das Skript&lt;br /&gt;
liefert nur noch die aktuellen Claims - genau das ist der Sinn des Aufrufs: eine&lt;br /&gt;
zwischenzeitlich geänderte Rolle oder ein gewechselter Mandant wirken spätestens&lt;br /&gt;
mit der nächsten Erneuerung.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Eine &#039;&#039;&#039;eigene Sperrtabelle im Skript ist nicht mehr nötig&#039;&#039;&#039; und sollte&lt;br /&gt;
entfernt werden. Wer sie weiterführt, rotiert zweimal - einmal im Skript, einmal im&lt;br /&gt;
Server - und riskiert, dass beide Seiten unterschiedlicher Meinung sind. Der&lt;br /&gt;
Server sieht die Skript-Tabelle nicht, und das Skript sieht die Sitzung nicht.}}&lt;br /&gt;
&lt;br /&gt;
Ein bereits benutztes Refresh-Token wird mit &#039;&#039;&#039;401&#039;&#039;&#039; abgelehnt. Erfolgt die&lt;br /&gt;
erneute Vorlage innerhalb von 60 Sekunden, bleibt die Sitzung bestehen (paralleler&lt;br /&gt;
Refresh einer App); danach gilt sie als Wiedervorlage und die &#039;&#039;&#039;gesamte Sitzung&lt;br /&gt;
wird gesperrt&#039;&#039;&#039;. Details in [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
&lt;br /&gt;
Das alte Refresh-jti steht weiterhin als &#039;&#039;oReader.Param(&#039;_OBS_JWT_ID&#039;)&#039;&#039; bereit,&lt;br /&gt;
falls das Skript es für eigene Protokollierung braucht.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Refresh(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUser: string;&lt;br /&gt;
begin&lt;br /&gt;
    cUser := oReader.Subject();&lt;br /&gt;
&lt;br /&gt;
    // Der Server hat das vorgelegte Refresh-Token bereits geprueft und&lt;br /&gt;
    // entwertet. Hier wird nur entschieden, ob der Benutzer die Sitzung&lt;br /&gt;
    // fortsetzen darf - und mit welchen Rechten.&lt;br /&gt;
    if (not BenutzerAktiv(cUser)) then begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;, 9);&lt;br /&gt;
        oWriter.Str(&#039;error&#039; , &#039;Konto ist nicht mehr aktiv, bitte neu anmelden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Rolle und Mandant neu lesen, nicht aus dem alten Token uebernehmen -&lt;br /&gt;
    // sonst wirkt ein Rechteentzug erst beim naechsten Login.&lt;br /&gt;
    oWriter.Int(&#039;status&#039;               , 1);&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());   // loest das neue Refresh-Token aus&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Abmelden (Logout)===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Für das Abmelden braucht das Skript keine Methode.&#039;&#039;&#039; Es läuft über ein&lt;br /&gt;
&#039;&#039;DELETE&#039;&#039; auf denselben JWT-Endpunkt (Access-Token im &#039;&#039;Authorization&#039;&#039;-Header)&lt;br /&gt;
und wird komplett in der Engine erledigt: die Sitzung wird gesperrt, die Antwort&lt;br /&gt;
ist 204. Eine Methode &#039;&#039;Logout&#039;&#039; gibt es nicht und wird nicht aufgerufen.&lt;br /&gt;
Details: [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]], Phase 4.&lt;br /&gt;
&lt;br /&gt;
Wer beim Abmelden zusätzlich fachlich etwas tun will - eine Geräteregistrierung&lt;br /&gt;
löschen, einen Push-Token verwerfen, den Vorgang protokollieren - nimmt einen&lt;br /&gt;
&#039;&#039;&#039;gewöhnlichen Endpunkt&#039;&#039;&#039; und setzt dort &#039;&#039;_OBS_JWT_REVOKE&#039;&#039;. Dasselbe gilt für&lt;br /&gt;
&amp;quot;auf allen Geräten abmelden&amp;quot;, weil das eine Berechtigungsentscheidung braucht:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
begin&lt;br /&gt;
    GeraeteRegistrierungLoeschen(oReader.Subject());&lt;br /&gt;
&lt;br /&gt;
    // &#039;session&#039; = diese Sitzung, &#039;all&#039; = alle Sitzungen des Subjects&lt;br /&gt;
    oWriter.JwtRevoke(&#039;session&#039;);&lt;br /&gt;
    oWriter.Status(204);&lt;br /&gt;
    oWriter.NoBody();&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Gesperrt wird in beiden Wegen die &#039;&#039;&#039;ganze Sitzung&#039;&#039;&#039;, also auch die noch nicht&lt;br /&gt;
abgelaufenen Access-Token vorheriger Erneuerungen. Ein wiederholter Aufruf ist&lt;br /&gt;
unschädlich. Lässt sich die Sitzung nicht sperren, überschreibt der Server die&lt;br /&gt;
Antwort des Skripts mit &#039;&#039;&#039;503&#039;&#039;&#039; - ein Client darf nicht glauben, er sei&lt;br /&gt;
abgemeldet, während sein Token weiterläuft.&lt;br /&gt;
&lt;br /&gt;
==Fehlerbehandlung im Skript==&lt;br /&gt;
&lt;br /&gt;
Tritt im Skript eine Exception auf oder schlägt die Syntax-Prüfung fehl, antwortet der Server mit 500 &#039;&#039;Interner Fehler&#039;&#039; und protokolliert die Detail-Meldung in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; (mit Skript-Fehlertext). Der Konsument sieht keine internen Details.&lt;br /&gt;
&lt;br /&gt;
Server-eigene Fehler (Routing, Rate-Limit, Token, Auffangnetz) haben ein&lt;br /&gt;
einheitliches Format mit einem &#039;&#039;error&#039;&#039;-Objekt - Aufbau siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer|Übersicht]].&lt;br /&gt;
&lt;br /&gt;
Für einen spezifischen Fehler an den Konsumenten gibt es &#039;&#039;&#039;einen&#039;&#039;&#039; Aufruf. Er&lt;br /&gt;
baut dieselbe Hülle, die auch die Server-eigenen Fehler tragen - Statuscode,&lt;br /&gt;
&#039;&#039;code&#039;&#039;, &#039;&#039;message&#039;&#039;, &#039;&#039;uid&#039;&#039; und &#039;&#039;traceId&#039;&#039; -, sodass beide Wege nicht&lt;br /&gt;
auseinanderlaufen können:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
begin&lt;br /&gt;
    if (Empty(oReader.Param(&#039;kundennr&#039;))) then begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Parameter &amp;quot;kundennr&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
    // ...&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Error verwirft einen schon begonnenen Körper&#039;&#039;&#039; und setzt seinen&lt;br /&gt;
eigenen. Ein Skript darf also getrost Felder schreiben und danach noch&lt;br /&gt;
abbrechen - was der Client sieht, ist die Fehlerantwort. Nur ein &#039;&#039;&#039;offener&lt;br /&gt;
Stapel&#039;&#039;&#039; (ein &#039;&#039;ObjBegin&#039;&#039; ohne &#039;&#039;ObjEnd&#039;&#039;) bleibt auch dann ein Strukturfehler.}}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein fachlicher Fehler gehört auf einen &#039;&#039;&#039;4xx&#039;&#039;&#039;-Statuscode, nicht auf&lt;br /&gt;
200 mit Fehlertext im Body. Nur so erkennt der Konsument den Fehlschlag ohne den&lt;br /&gt;
Body auszuwerten - und nur so gibt der Server einen belegten&lt;br /&gt;
&#039;&#039;Idempotency-Key&#039;&#039; wieder frei (siehe Abschnitt Idempotenz).}}&lt;br /&gt;
&lt;br /&gt;
===Ändern: qSqlInit statt qSqlRead===&lt;br /&gt;
&lt;br /&gt;
Zum &#039;&#039;&#039;Ändern&#039;&#039;&#039; eines vorhandenen Satzes wird ebenfalls &amp;lt;code&amp;gt;qSqlInit&amp;lt;/code&amp;gt;&lt;br /&gt;
benutzt - der Satz wird über &amp;lt;code&amp;gt;sys_uid&amp;lt;/code&amp;gt; adressiert:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
q := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
q.qSet(&#039;sys_uid&#039;  , cSysUid);      // Schreib-Index auf den Originalsatz&lt;br /&gt;
q.qSet(&#039;ti_status&#039;, &#039;9&#039;);&lt;br /&gt;
if (not q.SaveData(UPDATE_RECORD)) then begin&lt;br /&gt;
    // ... Fehler behandeln, siehe unten&lt;br /&gt;
end;&lt;br /&gt;
qSqlFree(q);&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;qSqlRead&amp;lt;/code&amp;gt; ist dafür die falsche Wahl, aus zwei Gründen:&lt;br /&gt;
&lt;br /&gt;
* Es liest den &#039;&#039;&#039;kompletten Altsatz&#039;&#039;&#039; ein - ein zusätzlicher Lesezugriff, der Zeit kostet.&lt;br /&gt;
* Es schreibt anschliessend nur die &#039;&#039;&#039;Änderungen&#039;&#039;&#039;. Ist der neue Wert gleich dem alten, entsteht gar kein Write, und &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt; liefert &#039;&#039;&#039;False&#039;&#039;&#039; - ununterscheidbar von einem echten Fehlschlag.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ohne &amp;lt;code&amp;gt;sys_uid&amp;lt;/code&amp;gt; legt &amp;lt;code&amp;gt;qSqlInit&amp;lt;/code&amp;gt; einen &#039;&#039;&#039;neuen&#039;&#039;&#039;&lt;br /&gt;
Datensatz an, statt den vorhandenen zu ändern. Die &amp;lt;code&amp;gt;sys_uid&amp;lt;/code&amp;gt; ist der&lt;br /&gt;
Schreib-Index und gehört bei jeder Änderung gesetzt.}}&lt;br /&gt;
&lt;br /&gt;
Damit entfällt auch das übliche Paar aus &amp;lt;code&amp;gt;DB_LSeek&amp;lt;/code&amp;gt; und&lt;br /&gt;
&amp;lt;code&amp;gt;qSqlRead&amp;lt;/code&amp;gt;: &#039;&#039;&#039;eine&#039;&#039;&#039; Abfrage auf die &amp;lt;code&amp;gt;sys_uid&amp;lt;/code&amp;gt; liefert&lt;br /&gt;
zugleich die Existenzprüfung und den Schreib-Index.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
cUid := &#039;&#039;;&lt;br /&gt;
if (DB_SOpen(oDB, &#039;SELECT sys_uid FROM tickets WHERE &#039; + cWhere + &#039; LIMIT 1&#039;, q)) then begin&lt;br /&gt;
    if (not q.EoF) then begin&lt;br /&gt;
        cUid := q.A2UID();&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
DB_Close(q);&lt;br /&gt;
&lt;br /&gt;
if (not Empty(cUid)) then begin&lt;br /&gt;
    // ändern: qSqlInit + sys_uid&lt;br /&gt;
end else begin&lt;br /&gt;
    // anlegen: qSqlInit ohne sys_uid&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Mehrere Felder sind EIN Schreibvorgang===&lt;br /&gt;
&lt;br /&gt;
Ein &amp;lt;code&amp;gt;qSqlInit&amp;lt;/code&amp;gt;-Objekt nimmt beliebig viele &amp;lt;code&amp;gt;qSet&amp;lt;/code&amp;gt; entgegen&lt;br /&gt;
und schreibt sie mit &#039;&#039;&#039;einem&#039;&#039;&#039; &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt;. Fallen an derselben&lt;br /&gt;
Zeile mehrere Felder an, gehören sie in dasselbe Objekt:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
// FALSCH: fünf Felder, fünf Schreibvorgänge - und jeder Setzer, der sich&lt;br /&gt;
// seine sys_uid selbst holt, bringt zusätzlich eine eigene Abfrage mit&lt;br /&gt;
FeldSetzen(cUid, &#039;ti_status&#039; , &#039;9&#039;);&lt;br /&gt;
FeldSetzen(cUid, &#039;ti_grund&#039;  , cGrund);&lt;br /&gt;
FeldSetzen(cUid, &#039;ti_von&#039;    , cBenutzer);&lt;br /&gt;
&lt;br /&gt;
// RICHTIG: ein Schreibobjekt, ein SaveData&lt;br /&gt;
q := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
q.qSet(&#039;sys_uid&#039;  , cUid);&lt;br /&gt;
q.qSet(&#039;ti_status&#039;, &#039;9&#039;);&lt;br /&gt;
q.qSet(&#039;ti_grund&#039; , cGrund);&lt;br /&gt;
q.qSet(&#039;ti_von&#039;   , cBenutzer);&lt;br /&gt;
if (not q.SaveData(UPDATE_RECORD)) then begin&lt;br /&gt;
    // ... Fehler behandeln&lt;br /&gt;
end;&lt;br /&gt;
qSqlFree(q);&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Das ist nicht nur eine Frage der Geschwindigkeit: einzeln geschriebene Felder&lt;br /&gt;
sind &#039;&#039;&#039;nicht atomar&#039;&#039;&#039;. Bricht der dritte Schreibvorgang ab, steht die Zeile&lt;br /&gt;
halb geändert da - Grund gesetzt, Zeitpunkt nicht.&lt;br /&gt;
&lt;br /&gt;
Wird nichts gesetzt, darf auch nicht geschrieben werden: das Schreibobjekt dann&lt;br /&gt;
mit &amp;lt;code&amp;gt;qSqlFree&amp;lt;/code&amp;gt; freigeben, statt ein leeres &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt;&lt;br /&gt;
abzusetzen.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein Setzer für &#039;&#039;&#039;ein&#039;&#039;&#039; Feld (&amp;lt;code&amp;gt;FeldSetzen(cUid, cFeld, cWert)&amp;lt;/code&amp;gt;)&lt;br /&gt;
ist bequem und deshalb verführerisch. Er versteckt aber je Aufruf eine eigene&lt;br /&gt;
Abfrage und ein eigenes &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt;. Für einen Vorgang mit mehreren&lt;br /&gt;
Feldern gehört ein &#039;&#039;&#039;Editier-Helfer&#039;&#039;&#039; her, der das Schreibobjekt liefert -&lt;br /&gt;
geschrieben wird einmal am Ende.}}&lt;br /&gt;
&lt;br /&gt;
===Keine Abfrage je Zeile===&lt;br /&gt;
&lt;br /&gt;
Was in einer Liste je Zeile nachgeschlagen wird, kostet &#039;&#039;&#039;N&#039;&#039;&#039; Abfragen für&lt;br /&gt;
&#039;&#039;&#039;eine&#039;&#039;&#039; Antwort. Bei fünfzig Aufträgen sind das fünfzig Abfragen für ein&lt;br /&gt;
einziges Feld. Der Ausweg ist immer derselbe:&lt;br /&gt;
&lt;br /&gt;
* Werte aus einer 1:1-Beziehung über einen &amp;lt;code&amp;gt;LEFT JOIN&amp;lt;/code&amp;gt; mitnehmen - bei einem &#039;&#039;&#039;eindeutigen&#039;&#039;&#039; Index auf dem Join-Feld kann er die Zeilen nicht vervielfachen.&lt;br /&gt;
* Einzelwerte aus einer 1:n-Beziehung über eine &#039;&#039;&#039;Unterabfrage&#039;&#039;&#039; in der SELECT-Liste holen, nicht über einen Join (der Join würde die Zeilen vervielfachen).&lt;br /&gt;
* Eine echte Unterliste (Positionen, Dateien) nur dann nachladen, wenn ein &#039;&#039;&#039;Zählfeld&#039;&#039;&#039; in der Hauptabfrage sagt, dass es überhaupt eine gibt.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein GET soll &#039;&#039;&#039;lesen&#039;&#039;&#039;. Schreibvorgänge im Lesepfad - etwas&lt;br /&gt;
&amp;quot;beim ersten Ausliefern festschreiben&amp;quot; - vervielfachen sich mit der Zeilenzahl&lt;br /&gt;
und treffen den Aufrufer, der nur eine Liste angefordert hat.}}&lt;br /&gt;
&lt;br /&gt;
===Schreibfehler erkennen===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt; liefert einen Boolean - und der ist die einzige&lt;br /&gt;
Fehlermeldung, die es gibt.&#039;&#039;&#039; (Auch der &#039;&#039;&#039;Parameter&#039;&#039;&#039; ist ein Boolean:&lt;br /&gt;
&#039;&#039;NEW_RECORD&#039;&#039; und &#039;&#039;UPDATE_RECORD&#039;&#039; sind Wahrheitswerte, keine Zahlen.) Schlägt das Schreiben in der Datenbank fehl&lt;br /&gt;
(&#039;&#039;Duplicate entry&#039;&#039; auf einem eindeutigen Index, zu langer Wert, fehlende&lt;br /&gt;
Spalte), dann&lt;br /&gt;
&lt;br /&gt;
* wird &#039;&#039;&#039;keine&#039;&#039;&#039; Exception ausgelöst,&lt;br /&gt;
* steht &#039;&#039;&#039;nichts&#039;&#039;&#039; in &#039;&#039;RESTSRV_PROTO&#039;&#039;,&lt;br /&gt;
* läuft das Skript weiter und antwortet mit dem Erfolg, den es selbst formuliert.&lt;br /&gt;
&lt;br /&gt;
Wer den Aufruf als Anweisung schreibt, baut damit einen stillen Datenverlust ein.&lt;br /&gt;
Weil in einem Endpunkt schnell ein Dutzend Schreibvorgänge zusammenkommen, gehört&lt;br /&gt;
die Prüfung in eine eigene kleine Prozedur - am besten in die gemeinsame Lib:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Speichern(q: TqSQL; lNeu: Boolean; const cWas: string);&lt;br /&gt;
var lOk: Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    lOk := q.SaveData(lNeu);&lt;br /&gt;
    qSqlFree(q);                     // auch im Fehlerfall freigeben&lt;br /&gt;
    if (not lOk) then begin&lt;br /&gt;
        raise Exception.Create(&#039;Schreiben fehlgeschlagen: &#039; + cWas);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
// Aufruf statt SaveData + qSqlFree:&lt;br /&gt;
q := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
q.qSet(&#039;ti_betreff&#039;, cBetreff);&lt;br /&gt;
Speichern(q, NEW_RECORD, &#039;Ticket&#039;);&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Die Exception fängt der Server: er schreibt &#039;&#039;Schreiben fehlgeschlagen: Ticket&#039;&#039;&lt;br /&gt;
nach &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; und antwortet mit &#039;&#039;&#039;500&#039;&#039;&#039;. Liegt ein&lt;br /&gt;
&#039;&#039;Idempotency-Key&#039;&#039; an, wird er dabei &#039;&#039;&#039;freigegeben&#039;&#039;&#039; - die Anfrage ist also&lt;br /&gt;
wiederholbar (siehe Abschnitt Idempotenz).&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein fehlgeschlagenes Schreiben ist kein Fall, in dem ein Endpunkt&lt;br /&gt;
sinnvoll weiterlaufen kann: die Fachwirkung ist dann unvollständig. Wer trotzdem&lt;br /&gt;
weitermachen will - etwa weil mehrere unabhängige Sätze geschrieben werden -,&lt;br /&gt;
prüft den Rückgabewert und baut selbst eine Antwort. Ignorieren darf man ihn&lt;br /&gt;
nie.}}&lt;br /&gt;
&lt;br /&gt;
Gängige Codes, die auch der Server selbst verwendet: &#039;&#039;VALIDATION_FAILED&#039;&#039; (422),&lt;br /&gt;
&#039;&#039;NOT_FOUND&#039;&#039; (404), &#039;&#039;FORBIDDEN_ROLE&#039;&#039; (403), &#039;&#039;VERSION_CONFLICT&#039;&#039; (409).&lt;br /&gt;
Eigene, fachlich sprechende Codes sind erlaubt - wichtig ist, dass sie stabil&lt;br /&gt;
bleiben, weil Konsumenten darauf ihre Reaktion aufbauen.&lt;br /&gt;
&lt;br /&gt;
==Gemeinsamen Code auslagern==&lt;br /&gt;
&lt;br /&gt;
Wird Logik in mehreren Endpunkten gebraucht - Berechtigungsprüfungen,&lt;br /&gt;
JSON-Bausteine, Hilfsfunktionen -, muss sie nicht in jedes Skript kopiert werden.&lt;br /&gt;
Die Skript-Engine lädt Textbausteine beim Laden aus einer beliebigen Tabelle nach:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{$L T=&amp;quot;restsrv_endpoints&amp;quot; I=&amp;quot;re_pathtemplate&amp;quot; V=&amp;quot;/meinapp/lib&amp;quot; F=&amp;quot;re_script&amp;quot;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Attribut !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;T&#039;&#039;&#039; || Tabelle, aus der geladen wird&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;I&#039;&#039;&#039; || Spalte, über die gesucht wird&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;V&#039;&#039;&#039; || Wert, der in dieser Spalte stehen muss&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;F&#039;&#039;&#039; || Spalte, deren Inhalt an dieser Stelle eingesetzt wird&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Direktive steht üblicherweise direkt unter dem Kopfkommentar des Skripts. Der&lt;br /&gt;
Compiler sieht danach den zusammengesetzten Text - eine Funktion aus der Lib lässt&lt;br /&gt;
sich also aufrufen wie eine im Skript selbst geschriebene.&lt;br /&gt;
&lt;br /&gt;
===Bewährtes Muster: Lib als eigene Endpunkt-Zeile===&lt;br /&gt;
&lt;br /&gt;
Am wenigsten Verwaltung macht es, die Lib &#039;&#039;&#039;als eigene Zeile in&lt;br /&gt;
RESTSRV_ENDPOINTS&#039;&#039;&#039; abzulegen:&lt;br /&gt;
&lt;br /&gt;
# Endpunkt anlegen mit einem eigenen Pfad-Template, z.B. &amp;lt;code&amp;gt;/meinapp/lib&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Den Lib-Quelltext ins Skript-Feld dieser Zeile schreiben (F7).&lt;br /&gt;
# &#039;&#039;&#039;Keine Berechtigung&#039;&#039;&#039; in der Berechtigungs-Liste vergeben - das ist der Schutz: ohne Berechtigung beantwortet der Server einen Aufruf mit 403.&lt;br /&gt;
# In jedem nutzenden Endpunkt die Direktive oben einfügen.&lt;br /&gt;
&lt;br /&gt;
Damit ist die Lib reiner Ablageort und von aussen nicht nutzbar. Eine Änderung&lt;br /&gt;
wirkt auf alle einbindenden Endpunkte, ohne dass ein einziges Endpunkt-Skript&lt;br /&gt;
angefasst werden muss.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Lib und Skripte gemeinsam einspielen.&#039;&#039;&#039; Kommt in der Lib eine neue&lt;br /&gt;
Funktion hinzu, genügt es nicht, nur das nutzende Skript zu aktualisieren. Eine&lt;br /&gt;
ältere Lib lädt fehlerfrei - der Compiler meldet dann ausschliesslich die neuen&lt;br /&gt;
Namen als &#039;&#039;Unknown name&#039;&#039;, was leicht wie ein Fehler im Skript aussieht.}}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Auch hier greift der Endpunkt-Cache: eine Änderung an der Lib wird erst&lt;br /&gt;
nach Ablauf des TTL (bis zu 60 Sekunden) wirksam.}}&lt;br /&gt;
&lt;br /&gt;
===Wohin gehört welcher Code?===&lt;br /&gt;
&lt;br /&gt;
Drei Ebenen, und die Zuordnung entscheidet, wie schnell eine Änderung wirkt:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Art des Codes !! Wohin !! Wirksam&lt;br /&gt;
|-&lt;br /&gt;
| Fachlogik **einer** Anwendung - Feldnamen, Zustandswerte, Rollenregeln, Schreibziele || in die &#039;&#039;&#039;DWS-Lib dieser Anwendung&#039;&#039;&#039; (eigene Endpunkt-Zeile, per &amp;lt;code&amp;gt;{$L …}&amp;lt;/code&amp;gt; eingebunden) || nach Ablauf des Cache-TTL, ohne Build&lt;br /&gt;
|-&lt;br /&gt;
| Mechanik, die &#039;&#039;&#039;mehrere&#039;&#039;&#039; Anwendungen brauchen - und auch das OBS-Umfeld || nach &#039;&#039;&#039;obs_lib&#039;&#039;&#039; als Delphi-Unit || erst nach Build und Rollout&lt;br /&gt;
|-&lt;br /&gt;
| Mechanik, die &#039;&#039;&#039;nur&#039;&#039;&#039; der REST-Server braucht || in die Units des Servers || erst nach Build und Rollout&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Regel hat einen praktischen Grund und keinen ästhetischen: Fachlogik ändert&lt;br /&gt;
sich in Tagen, Mechanik in Monaten. Was in der Lib liegt, ist nach einer Minute&lt;br /&gt;
aktiv; was in einer Unit liegt, braucht einen Build. Wer Fachlogik nach unten&lt;br /&gt;
schiebt, tauscht Geschwindigkeit gegen nichts ein.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Eine Anwendung, die ihre eigene Lib hat, kopiert sie &#039;&#039;&#039;nicht&#039;&#039;&#039; von&lt;br /&gt;
einer anderen. Zwei Kopien derselben Lib driften auseinander, und die Abweichung&lt;br /&gt;
fällt erst auf, wenn eine der beiden Anwendungen etwas Falsches tut. Geteiltes&lt;br /&gt;
gehört eine Ebene tiefer.}}&lt;br /&gt;
&lt;br /&gt;
==Fallstricke im Skript==&lt;br /&gt;
&lt;br /&gt;
Die folgenden Punkte unterscheiden das Skript von normalem Delphi-Code und kosten&lt;br /&gt;
sonst eine Runde Fehlersuche:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Stolperstein !! Richtig&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;DB_SOpen(&#039;Y00…&#039;, oDB, …)&#039;&#039; - der AD-UID-Parameter aus dem Delphi-Code || Im Skript &#039;&#039;&#039;ohne UID&#039;&#039;&#039;: &amp;lt;code&amp;gt;DB_SOpen(oDB, cSql, q)&amp;lt;/code&amp;gt;. Gleiches gilt für &amp;lt;code&amp;gt;qSqlInit(oDB, &#039;tab&#039;)&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;qSqlRead(oDB, &#039;tab&#039;, cWhere)&amp;lt;/code&amp;gt; - jede dieser Funktionen beginnt mit &#039;&#039;oDB&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;oReader.Str&amp;amp;lt;T&amp;amp;gt;(…)&#039;&#039; oder irgendein Generic || Generics gibt es in der Skriptsprache nicht. Die Leser sind je Typ eigene Methoden: &#039;&#039;Str&#039;&#039;, &#039;&#039;Int&#039;&#039;, &#039;&#039;Num&#039;&#039;, &#039;&#039;Bool&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| Eine eigene Prozedur &#039;&#039;Delete&#039;&#039; für DELETE || &#039;&#039;Delete&#039;&#039; ist eine &#039;&#039;&#039;Standardprozedur&#039;&#039;&#039; der Sprache. Die Deklaration lässt sich nicht übersetzen, und die Meldung nennt nur die Zeile - nicht die Ursache&lt;br /&gt;
|-&lt;br /&gt;
| Ein Helfer, der ein JSON-Fragment als String zurückgibt || Den &#039;&#039;&#039;Writer als Parameter&#039;&#039;&#039; übergeben und an der richtigen Stelle hineinschreiben. Fragmente von Hand zusammenzusetzen ist genau der Weg, auf dem die Reihenfolge und die Klammern verloren gingen&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;oWriter.ArrEnd&#039;&#039; nach &#039;&#039;RootArr&#039;&#039; || &#039;&#039;RootArr&#039;&#039; öffnet &#039;&#039;&#039;keine&#039;&#039;&#039; Ebene, es bestimmt nur die Klammer der Wurzel. Ein &#039;&#039;ArrEnd&#039;&#039; dazu ist ein Strukturfehler&lt;br /&gt;
|-&lt;br /&gt;
| Zahlen selbst formatieren (&#039;&#039;NStr&#039;&#039;, &#039;&#039;StrTran&#039;&#039;) || &amp;lt;code&amp;gt;oWriter.Num(&#039;preis&#039;, nWert, 2)&amp;lt;/code&amp;gt;. Selbst formatiert stand bei Werten ab 1000 der Tausendertrenner im JSON - gültiges JSON, falscher Wert, keine Meldung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;EMPTY_DATE&#039;&#039; für ein leeres Datum || &#039;&#039;EMPTY_DATE&#039;&#039; ist im Skript ein &#039;&#039;&#039;String&#039;&#039;&#039;. Für Datumsvariablen und -vergleiche &#039;&#039;&#039;MINDATETIME&#039;&#039;&#039; verwenden&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;fVal&#039;&#039; auf einen JSON-Zahlenwert || JSON liefert den Dezimalpunkt, &#039;&#039;fVal&#039;&#039; erwartet die lokale Notation: vorher &amp;lt;code&amp;gt;StrTran(cVal, &#039;.&#039;, &#039;,&#039;)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;TStringList&#039;&#039; für Zwischenlisten || In Skripten unzuverlässig. Kleine Mengen über einen Delimiter-String führen (&amp;lt;code&amp;gt;&#039;&amp;amp;#124;&#039; + wert + &#039;&amp;amp;#124;&#039;&amp;lt;/code&amp;gt;) und mit &#039;&#039;Pos&#039;&#039; prüfen&lt;br /&gt;
|-&lt;br /&gt;
| Default-Parameter in eigenen Funktionen || Werden nicht unterstützt - alle Parameter ausschreiben&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;q.SaveData(…)&amp;lt;/code&amp;gt; als Anweisung schreiben || &#039;&#039;SaveData&#039;&#039; liefert einen &#039;&#039;&#039;Boolean&#039;&#039;&#039;. Ein SQL-Fehler beim Schreiben wirft &#039;&#039;&#039;keine&#039;&#039;&#039; Exception und landet in &#039;&#039;&#039;keinem&#039;&#039;&#039; Protokoll - der Rückgabewert ist die einzige Meldung. Immer auswerten, siehe [[#Schreibfehler erkennen|Schreibfehler erkennen]]&lt;br /&gt;
|-&lt;br /&gt;
| Je Feld ein eigener Setzer-Aufruf || Ein &amp;lt;code&amp;gt;qSqlInit&amp;lt;/code&amp;gt;-Objekt, beliebig viele &amp;lt;code&amp;gt;qSet&amp;lt;/code&amp;gt;, &#039;&#039;&#039;ein&#039;&#039;&#039; &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt;. Einzeln geschrieben kostet jedes Feld eine eigene Abfrage samt eigenem Write - und der Vorgang ist nicht mehr atomar, siehe [[#Mehrere Felder sind EIN Schreibvorgang|Mehrere Felder]]&lt;br /&gt;
|-&lt;br /&gt;
| Ein Wert je Zeile nachgeschlagen || In einer Liste sind das N Abfragen für eine Antwort. Über &amp;lt;code&amp;gt;LEFT JOIN&amp;lt;/code&amp;gt; (1:1) oder Unterabfrage (1:n) mitnehmen, siehe [[#Keine Abfrage je Zeile|Keine Abfrage je Zeile]]&lt;br /&gt;
|-&lt;br /&gt;
| Ausgabeparameter vom Typ &amp;lt;code&amp;gt;var … : TDateTime&amp;lt;/code&amp;gt; || Bringt die Skript-VM zum &#039;&#039;&#039;Absturz&#039;&#039;&#039; (Assertion in &#039;&#039;dwsStack.pas&#039;&#039;), und zwar beim ersten &#039;&#039;&#039;Lesen&#039;&#039;&#039; des Werts - nicht schon beim Schreiben, die Ursache liegt also nicht dort, wo der Fehler auffällt. &amp;lt;code&amp;gt;var string&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;var Integer&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;var Double&amp;lt;/code&amp;gt; laufen. Zeitpunkte als ISO-Zeichenkette zurückgeben und erst beim Aufrufer wandeln&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==Skript-Cache==&lt;br /&gt;
&lt;br /&gt;
Der Server cached das kompilierte Skript pro Endpunkt (Schlüssel: &#039;&#039;sys_date&#039;&#039; 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.&lt;br /&gt;
&lt;br /&gt;
==Verfügbare Bibliotheken==&lt;br /&gt;
&lt;br /&gt;
Im Skript können alle OBS-Standard-Bibliotheken verwendet werden. Typische Einstiegspunkte:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;Base.Tools&#039;&#039; - String-, Datum-, IIF-, Empty-Helper&lt;br /&gt;
* &#039;&#039;Base.DB&#039;&#039; / &#039;&#039;Base.xQuery&#039;&#039; - Datenbank-Operationen&lt;br /&gt;
* &#039;&#039;Base.qSqlReg&#039;&#039; - Schreibzugriffe&lt;br /&gt;
* &#039;&#039;lib_ScriptIO&#039;&#039; - Writer und Reader, siehe unten. &#039;&#039;&#039;System.JSON braucht ein Endpunkt-Skript nicht&#039;&#039;&#039; - der Writer erzeugt die Antwort, der Reader liest den Körper&lt;br /&gt;
* &#039;&#039;Base.ToolsConst&#039;&#039; - Konstanten wie &#039;&#039;CRLF&#039;&#039;, &#039;&#039;SINGELQUOTE&#039;&#039;, &#039;&#039;MINDATETIME&#039;&#039;&lt;br /&gt;
* &#039;&#039;lib_ScriptIO&#039;&#039; - &#039;&#039;TxRestWriter&#039;&#039;, &#039;&#039;TxRestReader&#039;&#039; und die Zeitfunktionen &#039;&#039;RestIsoFromDate&#039;&#039; / &#039;&#039;RestDateFromIso&#039;&#039; / &#039;&#039;RestOffsetToMinutes&#039;&#039;. Writer und Reader bekommt das Skript als Parameter; die Zeitfunktionen ruft es direkt&lt;br /&gt;
&lt;br /&gt;
Die wichtigsten Aufrufe mit ihren Skript-Signaturen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Zweck !! Aufruf&lt;br /&gt;
|-&lt;br /&gt;
| Lesen || &amp;lt;code&amp;gt;DB_SOpen(oDB, cSql, q)&amp;lt;/code&amp;gt;, dann &amp;lt;code&amp;gt;q.EoF&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.Next()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.A2C(&#039;feld&#039;)&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2I&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2F&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2D&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2UID()&amp;lt;/code&amp;gt;, am Ende &amp;lt;code&amp;gt;DB_Close(q)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Neu anlegen || &amp;lt;code&amp;gt;q := qSqlInit(oDB, &#039;tab&#039;)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.qSet(&#039;feld&#039;, wert)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;if (not q.SaveData(NEW_RECORD)) then …&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;qSqlFree(q)&amp;lt;/code&amp;gt; – der Rückgabewert ist Pflicht, siehe [[#Schreibfehler erkennen|Schreibfehler erkennen]]&lt;br /&gt;
|-&lt;br /&gt;
| Ändern || &amp;lt;code&amp;gt;q := qSqlInit(oDB, &#039;tab&#039;)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.qSet(&#039;sys_uid&#039;, cUid)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.qSet(…)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;if (not q.SaveData(UPDATE_RECORD)) then …&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;qSqlFree(q)&amp;lt;/code&amp;gt; – &#039;&#039;&#039;nicht&#039;&#039;&#039; &amp;lt;code&amp;gt;qSqlRead&amp;lt;/code&amp;gt;, siehe [[#Ändern: qSqlInit statt qSqlRead|Ändern]]. Alle Felder derselben Zeile in &#039;&#039;&#039;ein&#039;&#039;&#039; Objekt, siehe [[#Mehrere Felder sind EIN Schreibvorgang|Mehrere Felder]]&lt;br /&gt;
|-&lt;br /&gt;
| Direkt ausführen || &amp;lt;code&amp;gt;DB_SqlExec(oDB, cSql)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Existenzprüfung || &amp;lt;code&amp;gt;DB_LSeek(oDB, &#039;tab&#039;, cWhere)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Werte quoten (Pflicht) || &amp;lt;code&amp;gt;DB_SQLVal(wert)&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Alle diese Funktionen beginnen im Skript mit &#039;&#039;oDB&#039;&#039;. Ein zusätzlicher&lt;br /&gt;
AD-UID-Parameter als erstes Argument gehört zur Delphi-Variante und lässt sich im&lt;br /&gt;
Skript nicht übersetzen.}}&lt;br /&gt;
&lt;br /&gt;
Konkrete Beispiele:&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel1|Beispiel 1: Daten-Abruf mit JWT]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel2|Beispiel 2: Zeiterfassung als komplette Webanwendung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel3|Beispiel 3: Datensatz anlegen mit JSON-Body (CRUD)]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4: Pfad-Parameter im Routing]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6: Datei-Upload und Ablage im DMS]]&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer&amp;diff=64823</id>
		<title>OBS/Kostenpflichtige Module/RESTServer</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer&amp;diff=64823"/>
		<updated>2026-09-15T10:00:17Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
=REST-Server=&lt;br /&gt;
{{Hinweis|Es handelt sich um ein kostenpflichtiges Modul. Die Schnittstelle muss über den OBS-Support aktiviert werden.}}&lt;br /&gt;
&lt;br /&gt;
==Was leistet der REST-Server?==&lt;br /&gt;
&lt;br /&gt;
Der REST-Server ist die &#039;&#039;&#039;universelle Schnittstelle&#039;&#039;&#039; Ihrer OBS-Installation nach außen. Er macht aus Ihrem OBS einen Dienst, mit dem andere Programme - Webseiten, Apps, Geräte, Kundensysteme, Cloud-Dienste - direkt sprechen können. Statt Daten manuell zu exportieren, zu mailen oder über Umwege bereitzustellen, holen sich angebundene Systeme genau die Information, die sie brauchen, in dem Moment, in dem sie sie brauchen - oder liefern neue Daten direkt in OBS ab.&lt;br /&gt;
&lt;br /&gt;
Für Anwender, die nicht selbst programmieren, bedeutet das: &#039;&#039;&#039;Was bisher nur manuell, per Datei-Import oder über Spezialschnittstellen ging, lässt sich jetzt automatisieren.&#039;&#039;&#039; Eine Webseite zeigt live aktuelle Lagerbestände. Mitarbeiter erfassen Zeiten über das Handy, ohne dass jemand Excel-Listen einpflegen muss. Ein Kunde stößt per Bestelltaste in seinem System einen Vorgang in Ihrem OBS an. Ein Lieferant meldet Wareneingänge automatisch zurück. Jeder dieser Anwendungsfälle wird einmal eingerichtet und läuft danach automatisch.&lt;br /&gt;
&lt;br /&gt;
Technisch gesehen ist der REST-Server ein in OBS integrierter, voll konfigurierbarer &#039;&#039;&#039;HTTP/HTTPS-Dienst&#039;&#039;&#039;, dessen Endpunkte über in OBS gepflegte Pascal-Skripte realisiert werden. Jeder Endpunkt bekommt eine eigene Adresse, eine eigene Logik und eine eigene Berechtigungsstruktur. Rückgaben erfolgen ausschliesslich im JSON-Format. Damit lassen sich beliebige Lese- und Schreibvorgänge auf der OBS-Datenbank realisieren, ohne dass externe Systeme direkten Datenbank-Zugriff erhalten - das ganze OBS-Regelwerk (Rechte, Validierung, Geschäftslogik) bleibt aktiv.&lt;br /&gt;
&lt;br /&gt;
Im Ergebnis ist der REST-Server kein einzelnes Feature, sondern ein &#039;&#039;&#039;Werkzeugkasten&#039;&#039;&#039;: Was an Daten oder Funktionen in OBS verfügbar ist, kann über den REST-Server auch nach außen angeboten - oder von außen entgegengenommen - werden. Die Bandbreite reicht vom einfachen Lese-Endpunkt für ein Webseiten-Widget bis hin zu kompletten B2B-Integrationen mit Mandanten- und Rollen-Trennung.&lt;br /&gt;
&lt;br /&gt;
Aufruf des Moduls in OBS:&lt;br /&gt;
&#039;&#039;&#039;Stammdaten&#039;&#039;&#039; -&amp;gt; &#039;&#039;&#039;Z Weitere Stammdaten&#039;&#039;&#039; -&amp;gt; &#039;&#039;&#039;REST-Server&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==Anwendungsbereiche==&lt;br /&gt;
&lt;br /&gt;
Die folgenden Einsatzfälle sind typische Beispiele, an denen sich der praktische Nutzen ablesen lässt. Es ist keine abschliessende Liste - alles, was sich in OBS-Skripten abbilden lässt, kann auch über einen Endpunkt bereitgestellt werden.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Dies sind Beispiele. Für die konkrete Entwicklung können, je nach Komplexität, weitere Kosten anfallen.}}&lt;br /&gt;
&lt;br /&gt;
===Webseiten und Kundenportale===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Live-Daten auf der eigenen Webseite:&#039;&#039;&#039; Lagerbestände, Verfügbarkeiten, Preise, Auftragsstatus aus OBS direkt anzeigen, ohne tägliche Datei-Exports.&lt;br /&gt;
* &#039;&#039;&#039;Kundenportale:&#039;&#039;&#039; Kunden sehen ihre offenen Posten, Auftragshistorie, Lieferscheine. Die Seite holt sich die Daten zur Anzeigezeit direkt aus OBS, jeder Kunde sieht nur seine eigenen Daten.&lt;br /&gt;
* &#039;&#039;&#039;Trackingseiten:&#039;&#039;&#039; Status einer Bestellung, eines Auftrags oder einer Reparatur für Endkunden, abrufbar über eine Sendungsnummer.&lt;br /&gt;
&lt;br /&gt;
===Mobile Anwendungen und Außendienst===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Mobile Zeiterfassung:&#039;&#039;&#039; Mitarbeiter buchen Anfang, Pause und Ende über Smartphone oder Tablet, die Daten landen direkt in OBS - ohne Zettelwirtschaft.&lt;br /&gt;
* &#039;&#039;&#039;QR-/Barcode-Scanner und Lagergeräte:&#039;&#039;&#039; Wareneingang, Inventur, Kommissionierung direkt in OBS abbilden, ohne zwischengeschaltete Software.&lt;br /&gt;
* &#039;&#039;&#039;Außendienst-Apps:&#039;&#039;&#039; Techniker sehen ihre Aufträge, dokumentieren Einsätze, erfassen Material - synchronisiert mit OBS.&lt;br /&gt;
&lt;br /&gt;
===Anbindung von Drittsystemen===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Webshops:&#039;&#039;&#039; Bestellungen werden direkt vom Shop in OBS gemeldet, Lagerbestände und Preise vom Shop bei OBS abgefragt.&lt;br /&gt;
* &#039;&#039;&#039;Buchhaltungs- und ERP-Systeme:&#039;&#039;&#039; Bidirektionaler Austausch von Belegen, Stammdaten und Buchungen.&lt;br /&gt;
* &#039;&#039;&#039;CRM- und Marketing-Tools:&#039;&#039;&#039; Personendaten, Aktivitäten oder Mailing-Empfänger werden synchron gehalten.&lt;br /&gt;
* &#039;&#039;&#039;Logistikdienstleister:&#039;&#039;&#039; Versandaufträge werden übergeben, Tracking-Informationen zurückgeliefert.&lt;br /&gt;
&lt;br /&gt;
===Automatisierte Rückmeldungen (WebHooks)===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Zahlungs-Callbacks:&#039;&#039;&#039; Bezahldienste (z.B. PayPal, Klarna, Stripe) melden den Zahlungseingang an einen WebHook-Endpunkt - OBS bucht automatisch.&lt;br /&gt;
* &#039;&#039;&#039;Versanddienste:&#039;&#039;&#039; Statusmeldungen (verschickt, zugestellt, retour) werden direkt im passenden Vorgang dokumentiert.&lt;br /&gt;
* &#039;&#039;&#039;Cloud-Workflows:&#039;&#039;&#039; Externe Automatisierungs-Plattformen (Make, Zapier, n8n, ...) stoßen OBS-Vorgänge an oder werden von OBS aus angesprochen.&lt;br /&gt;
&lt;br /&gt;
===Geschäftspartner-Kommunikation (B2B)===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Lieferanten-Schnittstelle:&#039;&#039;&#039; Bestellungen werden automatisch übergeben, Auftragsbestätigungen und Lieferavise zurückgespielt.&lt;br /&gt;
* &#039;&#039;&#039;Kunden-Schnittstelle:&#039;&#039;&#039; Grosskunden bestellen direkt aus ihrem ERP heraus, Stammdaten und Konditionen werden zentral gepflegt.&lt;br /&gt;
* &#039;&#039;&#039;Sichere Maschine-zu-Maschine-Kommunikation:&#039;&#039;&#039; Mit Mutual-TLS (mTLS) wird sichergestellt, dass nur die freigeschalteten Partnersysteme - und niemand sonst - mit OBS sprechen können.&lt;br /&gt;
&lt;br /&gt;
===Interne Microservices und Automatisierungen===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Eigene kleine Hilfs-Tools:&#039;&#039;&#039; Zeitschaltautomatik, Reporting-Generator, Sammelaktionen, die aus mehreren Quellen Daten in OBS verarbeiten.&lt;br /&gt;
* &#039;&#039;&#039;Verbindung mehrerer Standorte:&#039;&#039;&#039; Verteilte Systeme tauschen Daten über definierte Endpunkte aus, ohne offene Datenbankverbindungen.&lt;br /&gt;
* &#039;&#039;&#039;Datenbereitstellung für Dashboards und Auswertungen:&#039;&#039;&#039; BI-Tools (Power BI, Grafana, ...) lesen aufbereitete Kennzahlen direkt aus OBS.&lt;br /&gt;
&lt;br /&gt;
===Was bedeutet das wirtschaftlich?===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Manuelle Arbeit fällt weg.&#039;&#039;&#039; Daten werden nicht mehr aus OBS exportiert, per Mail geschickt und woanders eingelesen - sie fliessen direkt.&lt;br /&gt;
* &#039;&#039;&#039;Fehler werden weniger.&#039;&#039;&#039; Jede manuelle Stelle ist eine mögliche Fehlerquelle; jede Automatisierung eine Stelle, an der nichts mehr schiefgehen kann.&lt;br /&gt;
* &#039;&#039;&#039;Neue Geschäftsmodelle werden möglich.&#039;&#039;&#039; Kunden- und Lieferantenportale, Self-Service-Funktionen, App-Anbindungen lassen sich aufbauen, ohne ein zweites System pflegen zu müssen.&lt;br /&gt;
* &#039;&#039;&#039;Bestehende Software bleibt verbunden.&#039;&#039;&#039; Statt eine vorhandene Anwendung ablösen zu müssen, kann sie über den REST-Server an OBS angedockt werden - in beiden Richtungen.&lt;br /&gt;
* &#039;&#039;&#039;Skaliert mit:&#039;&#039;&#039; Was klein anfängt (ein einzelner Endpunkt für eine Webseite) kann zu einer kompletten Integrations-Plattform ausgebaut werden, ohne dass die Grundlage gewechselt werden muss.&lt;br /&gt;
&lt;br /&gt;
==Architektur im Überblick==&lt;br /&gt;
&lt;br /&gt;
Der REST-Server besteht aus mehreren aufeinander aufbauenden Bausteinen, die alle in OBS gepflegt werden:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Baustein     !! Tabelle              !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| Server       || RESTSRV_SERVER       || TLS-Profil + eigene HTTP-Server-Instanz, kann mehrfach existieren&lt;br /&gt;
|-&lt;br /&gt;
| Bindung      || RESTSRV_BINDINGS     || IP-Adresse + Port pro Server&lt;br /&gt;
|-&lt;br /&gt;
| Zugang       || RESTSRV_ACCOUNT      || API-Key, optionale Host- und CORS-Beschränkung, optionale JWT-Konfiguration&lt;br /&gt;
|-&lt;br /&gt;
| Endpunkt     || RESTSRV_ENDPOINTS    || Skript, das die Anfrage bearbeitet, einem Server fest zugeordnet&lt;br /&gt;
|-&lt;br /&gt;
| Berechtigung || RESTSRV_ACCESS       || Verbindet Zugang und Endpunkt&lt;br /&gt;
|-&lt;br /&gt;
| Idempotenz   || RESTSRV_IDEMPOTENCY  || Speicher für Idempotency-Keys und die dazu gelieferte Antwort&lt;br /&gt;
|-&lt;br /&gt;
| Sitzung      || RESTSRV_TOKEN        || Ausgestellte Token-Paare je Sitzung; Grundlage für Rotation und Widerruf&lt;br /&gt;
|-&lt;br /&gt;
| Protokoll    || RESTSRV_PROTO        || Laufende Ereignisse, Fehler, Audit-Einträge&lt;br /&gt;
|-&lt;br /&gt;
| Statistik    || RESTSRV_STATS        || Aufrufzähler, Antwortzeiten und HTTP-Status pro Endpunkt&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Eine Anfrage durchläuft folgende Stationen:&lt;br /&gt;
&lt;br /&gt;
# Rate-Limit-Prüfung (pro API-Key bzw. IP und global, Schwellen je Server-Profil)&lt;br /&gt;
# Authentifizierung über den API-Key (Header &#039;&#039;apikey&#039;&#039;)&lt;br /&gt;
# CORS-Prüfung (sofern &#039;&#039;Origin&#039;&#039;-Header gesetzt)&lt;br /&gt;
# Optional: JWT-Prüfung - Signatur, Ablauf und Sitzung (sofern für den Zugang aktiviert)&lt;br /&gt;
# Routing: Auflösung des Pfads über die Pfad-Templates der Endpunkte&lt;br /&gt;
# Prüfung der Berechtigung (Zugang -&amp;gt; Endpunkt)&lt;br /&gt;
# Idempotenz-Prüfung (nur bei POST/PUT/PATCH/DELETE &#039;&#039;&#039;mit&#039;&#039;&#039; &#039;&#039;Idempotency-Key&#039;&#039;-Header)&lt;br /&gt;
# Ausführung des Endpunkt-Skripts (oder WebHook-Antwort)&lt;br /&gt;
# Festschreiben des Ergebnisses im Idempotenz-Speicher (sofern in Schritt 7 reserviert)&lt;br /&gt;
# JSON-Antwort, Statistik-Eintrag, Protokoll-Eintrag&lt;br /&gt;
&lt;br /&gt;
==Adressierung==&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt wird über ein &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; angesprochen, das den Pfad nach dem Host beschreibt und Platzhalter enthalten kann:&lt;br /&gt;
&lt;br /&gt;
 http://[Hostadresse][:Port][Pfad-Template]&lt;br /&gt;
&lt;br /&gt;
Beispiele:&lt;br /&gt;
&lt;br /&gt;
 https://api.meinserver.de/kalender/v1&lt;br /&gt;
 https://api.meinserver.de/orders/{uid}&lt;br /&gt;
 https://api.meinserver.de/orders/{uid}/modules/{code}&lt;br /&gt;
&lt;br /&gt;
Statische Segmente müssen exakt passen; Platzhalter in geschweiften Klammern (z.B. {uid}) matchen einen beliebigen Wert und stehen im Skript als Pfad-Parameter zur Verfügung. Eine Versionskennung wird als statisches Segment ins Template aufgenommen (z.B. /kalender/v1), um eine Funktionalität zu verändern, ohne bestehende Konsumenten zu brechen. Details: [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]].&lt;br /&gt;
&lt;br /&gt;
==Unterstützte HTTP-Methoden==&lt;br /&gt;
&lt;br /&gt;
Der Server akzeptiert die Methoden &#039;&#039;&#039;GET&#039;&#039;&#039;, &#039;&#039;&#039;POST&#039;&#039;&#039;, &#039;&#039;&#039;PUT&#039;&#039;&#039;, &#039;&#039;&#039;DELETE&#039;&#039;&#039; und &#039;&#039;&#039;PATCH&#039;&#039;&#039; sowie &#039;&#039;&#039;OPTIONS&#039;&#039;&#039; (CORS-Preflight, ohne Skript-Aufruf). Jede Methode wird im Endpunkt-Skript als gleichnamige Funktion implementiert. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
==Kompression==&lt;br /&gt;
&lt;br /&gt;
Der Server packt Antworten mit &#039;&#039;&#039;gzip&#039;&#039;&#039;, wenn der Client sie annimmt. Ausgehandelt wird über den Anfrage-Header &amp;lt;code&amp;gt;Accept-Encoding&amp;lt;/code&amp;gt;; die Antwort trägt dann &amp;lt;code&amp;gt;Content-Encoding: gzip&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;Vary: Accept-Encoding&amp;lt;/code&amp;gt; - bei einer CORS-Anfrage steht dort &amp;lt;code&amp;gt;Vary: Origin, Accept-Encoding&amp;lt;/code&amp;gt;, also &#039;&#039;&#039;eine&#039;&#039;&#039; Zeile mit beiden Bedingungen. Browser, &amp;lt;code&amp;gt;curl --compressed&amp;lt;/code&amp;gt; und die gängigen HTTP-Bibliotheken entpacken selbstständig - für den Konsumenten ändert sich nichts ausser der übertragenen Datenmenge.&lt;br /&gt;
&lt;br /&gt;
Gepackt wird nur, wo es etwas bringt:&lt;br /&gt;
&lt;br /&gt;
* Nur JSON, XML, Text, CSV und SVG. Bereits komprimierte Typen - PDF, Bild, ZIP, Video - werden durch gzip &#039;&#039;&#039;grösser&#039;&#039;&#039;, nicht kleiner.&lt;br /&gt;
* Erst ab &#039;&#039;&#039;1024 Byte&#039;&#039;&#039;. Darunter kostet der gzip-Rahmen mehr, als der Inhalt einspart.&lt;br /&gt;
* &#039;&#039;&#039;Nicht&#039;&#039;&#039; bei Dateiantworten, nicht bei Teilauslieferungen (206) und nicht bei Antworten ohne Körper (204, 304).&lt;br /&gt;
* Nicht, wenn das Endpunkt-Skript den Header &amp;lt;code&amp;gt;Content-Encoding&amp;lt;/code&amp;gt; selbst gesetzt hat - dann gehört die Antwort dem Skript.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;deflate&#039;&#039;&#039; wird in &#039;&#039;&#039;keiner&#039;&#039;&#039; Richtung unterstützt. Der Wert ist historisch uneindeutig - die einen senden RFC-1950-gerahmt, die anderen roh -, und wer ihn nennt, beherrscht ohnehin gzip. Eine Anfrage mit &amp;lt;code&amp;gt;Content-Encoding: deflate&amp;lt;/code&amp;gt; wird mit 415 abgewiesen.&lt;br /&gt;
&lt;br /&gt;
Abschalten lässt sich das Packen je Server-Profil über den Haken &#039;&#039;Keine Kompression&#039;&#039; (siehe [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]]). Der Haken betrifft ausschliesslich die Antwortrichtung.&lt;br /&gt;
&lt;br /&gt;
===Komprimierte Anfragen===&lt;br /&gt;
&lt;br /&gt;
In der Gegenrichtung nimmt der Server &#039;&#039;&#039;gzip&#039;&#039;&#039; entgegen: Trägt eine Anfrage den Header &amp;lt;code&amp;gt;Content-Encoding: gzip&amp;lt;/code&amp;gt;, wird der Körper entpackt, bevor ihn jemand liest. Das Endpunkt-Skript merkt davon nichts.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Anfrage                                              !! Antwort&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;Content-Encoding&amp;lt;/code&amp;gt; fehlt oder &amp;lt;code&amp;gt;identity&amp;lt;/code&amp;gt; || normale Verarbeitung; ab 10 MB Körpergrösse &#039;&#039;&#039;413&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;gzip&amp;lt;/code&amp;gt;                                    || Körper wird entpackt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;deflate&amp;lt;/code&amp;gt;, anderer Wert (z.B. &amp;lt;code&amp;gt;br&amp;lt;/code&amp;gt;) oder mehrere Werte || &#039;&#039;&#039;415&#039;&#039;&#039; &amp;lt;code&amp;gt;UNSUPPORTED_MEDIA_TYPE&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Gepackter Körper bei einem Datei-Upload              || &#039;&#039;&#039;415&#039;&#039;&#039; - siehe [[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]]&lt;br /&gt;
|-&lt;br /&gt;
| Entpackter Körper über &#039;&#039;&#039;10 MB&#039;&#039;&#039;                    || &#039;&#039;&#039;413&#039;&#039;&#039; &amp;lt;code&amp;gt;PAYLOAD_TOO_LARGE&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Entpackter Körper über dem &#039;&#039;&#039;400fachen&#039;&#039;&#039; der gesendeten Grösse || &#039;&#039;&#039;413&#039;&#039;&#039; &amp;lt;code&amp;gt;PAYLOAD_TOO_LARGE&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Für einen gepackten Anfragekörper gelten &#039;&#039;&#039;zwei&#039;&#039;&#039; Grenzen. Beide zählen den &#039;&#039;&#039;entpackten&#039;&#039;&#039; Inhalt, und beide greifen bereits &#039;&#039;&#039;während&#039;&#039;&#039; des Entpackens - ein Körper, der sie reisst, belegt den Speicher gar nicht erst:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;10 MB&#039;&#039;&#039; absolut. Dieselbe Grenze, die auch für einen ungepackten Körper gilt.&lt;br /&gt;
* &#039;&#039;&#039;das 400fache&#039;&#039;&#039; der gesendeten Grösse. Damit wird eine &#039;&#039;Dekompressionsbombe&#039;&#039; - wenige Kilobyte, die sich auf Hunderte Megabyte aufblähen - schon nach wenigen Prozent abgebrochen.&lt;br /&gt;
&lt;br /&gt;
Die zweite Grenze trifft normale Nutzlasten nicht. Gemessen erreichen echte JSON-Körper Verhältnisse von 24:1 bis 40:1, und selbst eine Liste aus zehntausenden identischen Zeilen kommt nur auf rund 340:1 - mehr gibt das Verfahren für wiederholte Datensätze nicht her. Über 400:1 kommt nur, wer sehr lange Läufe desselben Zeichens sendet.&lt;br /&gt;
&lt;br /&gt;
Für Datei-Uploads gilt keine der beiden Grenzen - dort zählt allein die je Endpunkt eingestellte Maximalgrösse.}}&lt;br /&gt;
&lt;br /&gt;
==Datei-Uploads==&lt;br /&gt;
&lt;br /&gt;
Endpunkte, die dafür freigeschaltet sind (&amp;lt;code&amp;gt;re_upload&amp;lt;/code&amp;gt;), nehmen Dateien&lt;br /&gt;
per &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; oder als fortsetzbaren &#039;&#039;&#039;Resumable-Upload&#039;&#039;&#039;&lt;br /&gt;
(&amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;) entgegen. Die maximale Grösse wird pro Endpunkt&lt;br /&gt;
festgelegt (&amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;, Standard 25&amp;amp;nbsp;MB); Überschreitung&lt;br /&gt;
ergibt 413, ein Upload an einen nicht freigeschalteten Endpunkt 415. Der Server&lt;br /&gt;
legt die Datei temporär ab und übergibt dem Skript den Pfad; die weitere&lt;br /&gt;
Verarbeitung (z.B. DMS-Ablage) übernimmt das Skript. Details:&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
==Doppelte Sendungen abfangen (Idempotenz)==&lt;br /&gt;
&lt;br /&gt;
Ein Client, der nach einem Timeout dieselbe Anfrage erneut schickt, darf keine&lt;br /&gt;
Zweitbuchung auslösen. Der Server erledigt das selbst: Sendet ein Aufruf mit&lt;br /&gt;
&#039;&#039;&#039;POST&#039;&#039;&#039;, &#039;&#039;&#039;PUT&#039;&#039;&#039;, &#039;&#039;&#039;PATCH&#039;&#039;&#039; oder &#039;&#039;&#039;DELETE&#039;&#039;&#039; den Header&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, merkt sich der Server Schlüssel und Ergebnis in&lt;br /&gt;
&#039;&#039;&#039;RESTSRV_IDEMPOTENCY&#039;&#039;&#039;. Eine Wiederholung mit demselben Schlüssel und demselben&lt;br /&gt;
Inhalt bekommt die &#039;&#039;&#039;gespeicherte Antwort&#039;&#039;&#039; zurück - das Endpunkt-Skript läuft&lt;br /&gt;
gar nicht erst an.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Ohne den Header ändert sich nichts.&#039;&#039;&#039; Bestehende Endpunkte verhalten sich unverändert.&lt;br /&gt;
* &#039;&#039;&#039;Kein Schalter am Endpunkt.&#039;&#039;&#039; Die Mechanik greift automatisch, sobald der Header anliegt.&lt;br /&gt;
* &#039;&#039;&#039;Das Skript muss nichts tun.&#039;&#039;&#039; Eine eigene Idempotenz-Logik im Skript ist nicht mehr nötig.&lt;br /&gt;
&lt;br /&gt;
Details und die Statuscodes: [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
==Sitzungen und Abmelden==&lt;br /&gt;
&lt;br /&gt;
Ein JWT trägt seine Gültigkeit in sich: der Server prüft Signatur und Ablauf und&lt;br /&gt;
braucht dafür keine Datenbank. Genau deshalb liesse sich ein ausgegebener Token&lt;br /&gt;
von sich aus auch nicht mehr zurückziehen - ein verlorenes Gerät wäre bis zum&lt;br /&gt;
Ablauf des Tokens weiter nutzbar.&lt;br /&gt;
&lt;br /&gt;
Der Server führt deshalb zu jedem ausgestellten Token-Paar eine Zeile in&lt;br /&gt;
&#039;&#039;&#039;RESTSRV_TOKEN&#039;&#039;&#039; und prüft jeden Zugriff dagegen. Daraus ergeben sich drei&lt;br /&gt;
Eigenschaften:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Abmelden wirkt sofort.&#039;&#039;&#039; Ein &#039;&#039;DELETE&#039;&#039; auf den JWT-Endpunkt sperrt die Sitzung; alle Token dieser Sitzung sind damit unmittelbar ungültig, auch die noch nicht abgelaufenen. Anmelden, Erneuern und Abmelden laufen also über einen einzigen Endpunkt, und für das Abmelden ist kein Skript nötig.&lt;br /&gt;
* &#039;&#039;&#039;Ein Refresh-Token gilt genau einmal.&#039;&#039;&#039; Beim Erneuern wird es entwertet. Wird es später erneut vorgelegt, gilt das als Diebstahl oder Fehlfunktion: die betroffene Sitzung wird komplett gesperrt und der Vorfall protokolliert.&lt;br /&gt;
* &#039;&#039;&#039;Alle Geräte abmelden.&#039;&#039;&#039; Ein gewöhnlicher Endpunkt kann über das Antwortfeld &#039;&#039;_OBS_JWT_REVOKE&#039;&#039; auch alle Sitzungen eines Benutzers sperren - der Support-Fall „Gerät verloren&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Mit der Einführung dieser Prüfung verlieren &#039;&#039;&#039;alle vorher&lt;br /&gt;
ausgestellten Token&#039;&#039;&#039; ihre Gültigkeit, weil sie zu keiner Sitzung gehören.&lt;br /&gt;
Angemeldete Konsumenten müssen sich nach dem Update einmalig neu anmelden.}}&lt;br /&gt;
&lt;br /&gt;
Der Speicher pflegt sich selbst: Zeilen werden gelöscht, sobald sowohl das&lt;br /&gt;
Access- als auch das Refresh-Token abgelaufen sind. Details und die Skript-Seite:&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
&lt;br /&gt;
==Fehlerformat==&lt;br /&gt;
&lt;br /&gt;
Jede Fehlerantwort des Servers hat dieselbe Form: ein einziges &#039;&#039;error&#039;&#039;-Objekt&lt;br /&gt;
mit maschinenlesbarem Code, Support-Kennung, Meldung und Korrelations-ID:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;error&amp;quot;: {&lt;br /&gt;
    &amp;quot;code&amp;quot;:    &amp;quot;AUTH_EXPIRED&amp;quot;,&lt;br /&gt;
    &amp;quot;uid&amp;quot;:     &amp;quot;Y00E2RTPDY&amp;quot;,&lt;br /&gt;
    &amp;quot;message&amp;quot;: &amp;quot;Ungültiges oder abgelaufenes Token&amp;quot;,&lt;br /&gt;
    &amp;quot;traceId&amp;quot;: &amp;quot;20260817T091233123-00001A&amp;quot;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;code&#039;&#039;&#039; - sprechender Bezeichner (z.B. &#039;&#039;AUTH_EXPIRED&#039;&#039;, &#039;&#039;NOT_FOUND&#039;&#039;, &#039;&#039;RATE_LIMITED&#039;&#039;, &#039;&#039;INTERNAL_ERROR&#039;&#039;), an dem ein Client seine Reaktion festmachen kann, ohne Texte auszuwerten.&lt;br /&gt;
* &#039;&#039;&#039;uid&#039;&#039;&#039; - OBS-Kennung der Codestelle. Für die Support-Recherche gedacht, nicht zur Auswertung im Client.&lt;br /&gt;
* &#039;&#039;&#039;message&#039;&#039;&#039; - Text für den Client. Interne Fehlerdetails stehen nie darin, sondern ausschliesslich im Protokoll &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039;.&lt;br /&gt;
* &#039;&#039;&#039;traceId&#039;&#039;&#039; - Korrelations-ID. Steht zusätzlich im Header &#039;&#039;X-Trace-Id&#039;&#039; und in jeder zugehörigen Logzeile.&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt-Skript kann eigene Codes setzen; siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
{{Achtung|&#039;&#039;Fehlercode&#039;&#039;, &#039;&#039;Nachricht&#039;&#039; und &#039;&#039;traceId&#039;&#039; stehen &#039;&#039;&#039;nur&#039;&#039;&#039; im&lt;br /&gt;
&#039;&#039;error&#039;&#039;-Objekt, nicht flach daneben - der Wert von&lt;br /&gt;
&#039;&#039;Fehlercode&#039;&#039; heisst jetzt &#039;&#039;error.uid&#039;&#039;, &#039;&#039;Nachricht&#039;&#039; entspricht&lt;br /&gt;
&#039;&#039;error.message&#039;&#039;. Clients, die die flachen Felder auswerten, müssen angepasst&lt;br /&gt;
werden.}}&lt;br /&gt;
&lt;br /&gt;
==Sicherheitsmerkmale==&lt;br /&gt;
&lt;br /&gt;
Der REST-Server enthält eine Reihe von Schutzmechanismen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Rate-Limit:&#039;&#039;&#039; Standardmässig max. 10 fehlgeschlagene und 120 erfolgreiche Anfragen pro API-Key (Account) bzw. IP und 60 Sekunden, max. 600 Anfragen global pro 60 Sekunden. Beim Überschreiten wird HTTP 429 mit &#039;&#039;Retry-After&#039;&#039;-Header zurückgegeben. Die vier Schwellen lassen sich &#039;&#039;&#039;pro Server-Profil&#039;&#039;&#039; hinterlegen; die Zähler laufen ebenfalls getrennt je Profil, ein überlastetes Profil bremst das andere also nicht mit aus. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]].&lt;br /&gt;
* &#039;&#039;&#039;Body-Limit:&#039;&#039;&#039; max. 10 MB Request-Body (JSON), max. 1024 Zeichen pro Header- oder Query-Parameter. Datei-Uploads laufen über einen separaten Pfad und sind pro Endpunkt auf &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt; MB begrenzt (Standard 25&amp;amp;nbsp;MB).&lt;br /&gt;
* &#039;&#039;&#039;Sensible Felder&#039;&#039;&#039; (&#039;&#039;password&#039;&#039;, &#039;&#039;token&#039;&#039;, &#039;&#039;secret&#039;&#039;, &#039;&#039;apikey&#039;&#039;, &#039;&#039;authorization&#039;&#039;, &#039;&#039;cookie&#039;&#039;, ...) werden in Protokoll-Ausgaben automatisch maskiert.&lt;br /&gt;
* &#039;&#039;&#039;Widerrufbare Token:&#039;&#039;&#039; Jedes ausgestellte Token-Paar gehört zu einer Sitzung in &#039;&#039;&#039;RESTSRV_TOKEN&#039;&#039;&#039; und wird bei &#039;&#039;&#039;jedem&#039;&#039;&#039; Zugriff dagegen geprüft. Eine Abmeldung wirkt damit sofort und nicht erst mit dem Ablauf des Tokens; ein Refresh-Token gilt genau einmal. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
* &#039;&#039;&#039;Reserviertes Präfix:&#039;&#039;&#039; Parameter-Namen mit Präfix &#039;&#039;_OBS_&#039;&#039; können von aussen nicht gesetzt werden, sie sind für den Server reserviert (z.B. JWT-Claims).&lt;br /&gt;
* &#039;&#039;&#039;Geblockte Header:&#039;&#039;&#039; &#039;&#039;authorization&#039;&#039;, &#039;&#039;cookie&#039;&#039;, &#039;&#039;proxy-authorization&#039;&#039;, &#039;&#039;x-forwarded-for&#039;&#039;, &#039;&#039;x-real-ip&#039;&#039;, &#039;&#039;apikey&#039;&#039;, &#039;&#039;api_key&#039;&#039; werden nicht an das Skript durchgereicht.&lt;br /&gt;
* &#039;&#039;&#039;TLS:&#039;&#039;&#039; Bei Profilen mit aktivem TLS fährt der Server ohne Zertifikat und Key nicht hoch. Plain-HTTP ist nur für ein dediziertes Debug-Profil möglich.&lt;br /&gt;
* &#039;&#039;&#039;mTLS&#039;&#039;&#039; (Mutual TLS): erzwingt, dass jeder Client ein gültiges Client-Zertifikat vorlegt. Optional kann pro Zugang ein erwarteter Subject-DN hinterlegt werden.&lt;br /&gt;
* &#039;&#039;&#039;DNS-Cache:&#039;&#039;&#039; Host-zu-IP-Auflösungen für die Zugangs-Host-Prüfung werden 5 Minuten gecached.&lt;br /&gt;
&lt;br /&gt;
==Protokoll==&lt;br /&gt;
&lt;br /&gt;
Die Tabelle &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; enthält alle relevanten Ereignisse: erfolgreiche und abgelehnte Anfragen, TLS-/JWT-Fehler, Bindungs-Ereignisse, Skript-Fehler. Einträge sind nach Datum, Uhrzeit und Host (IP oder Account-Name) sortiert. Das Protokoll ist die erste Anlaufstelle bei Störungen. Jede Antwort trägt zudem eine Korrelations-ID (Response-Header &#039;&#039;X-Trace-Id&#039;&#039;), die in den Protokoll-Einträgen wiederzufinden ist.&lt;br /&gt;
&lt;br /&gt;
==Statistik==&lt;br /&gt;
&lt;br /&gt;
Die Tabelle &#039;&#039;&#039;RESTSRV_STATS&#039;&#039;&#039; enthält eine Statistik aller bearbeiteten Anfragen mit Zugang, Endpunkt, Methode, HTTP-Status und Laufzeit in Millisekunden. Aufruf der Statistik:&lt;br /&gt;
&lt;br /&gt;
* Aus der Endpunkt-Liste: F8 zeigt die Statistik des markierten Endpunkts.&lt;br /&gt;
* Aus der Zugänge-Liste: F8 zeigt die Statistik des markierten Zugangs.&lt;br /&gt;
&lt;br /&gt;
==Konsole==&lt;br /&gt;
&lt;br /&gt;
Wird der REST-Server nicht als Dienst, sondern interaktiv gestartet, erscheint eine farbige Konsole mit Live-Log. Die wichtigsten Befehle:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Befehl       !! Wirkung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;show &amp;lt;n&amp;gt;&#039;&#039; || Zeigt die Details zum Debug-Eintrag &#039;&#039;#n&#039;&#039; (z.B. Request-Header, Response-Body), JSON wird farbig formatiert&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;exit&#039;&#039; / &#039;&#039;quit&#039;&#039; || Beendet den Server geordnet&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Im Service-Modus (Windows-Dienst) ist die Konsole nicht sichtbar. Alle Einträge landen dort ausschliesslich in der Tabelle RESTSRV_PROTO.&lt;br /&gt;
&lt;br /&gt;
==Weiterführende Seiten==&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Einrichtung|Einrichtung]] - Schritt-für-Schritt-Anleitung&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]] - TLS, mTLS und Bindungen&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]] - API-Keys, JWT, CORS, Host-Beschränkung&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]] - Verwaltung, Berechtigungen, WebHooks&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]] - Aufbau der Endpunkt-Skripte&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]] - Übertragungsprotokoll für Dateien (Multipart, resumable)&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Datei-Download|Datei-Download]] - Dateien ausliefern, Range/Resume, öffentliche Artefakte&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel1|Beispiel 1: Daten-Abruf mit JWT]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel2|Beispiel 2: Zeiterfassung als komplette Webanwendung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel3|Beispiel 3: Datensatz anlegen mit JSON-Body (CRUD)]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4: Pfad-Parameter im Routing]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6: Datei-Upload und Ablage im DMS]]&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=Vorlage:Kostenpflichtige_Module&amp;diff=64822</id>
		<title>Vorlage:Kostenpflichtige Module</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=Vorlage:Kostenpflichtige_Module&amp;diff=64822"/>
		<updated>2026-09-14T08:08:31Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;div style=&amp;quot;float:right; clear:both; margin-left:2px; padding:0px; background:#FFFFFF; width:19em&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div style=&amp;quot;border:none;margin-top:0pt;padding:0pt;&amp;quot; id=&amp;quot;navigation&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div style=&amp;quot;position:relative;top:-20pt;color:#000000;background:#ffffff; border: 3pt double #cfcfcf; padding: 5pt; margin: 0pt;margin-bottom:-20pt; z-index:1;border-top-right-radius:7pt;-moz-border-radius-topright:7pt;-webkit-border-top-right-radius:7pt;border-bottom-left-radius:7pt;-moz-border-radius-bottomleft:7pt;-webkit-border-bottom-left-radius:7pt;box-shadow: 3px 3px 4px #c0c0c0;-webkit-box-shadow: 3px 3px 4px #c0c0c0;-moz-box-shadow: 3px 3px 4px #c0c0c0;&amp;quot;&amp;gt;[[Hauptseite|Wiki]] » [[Kostenpflichtige Module|Kostenpflichtige Module]]&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Textbox-Blau|1=Kostenpflichtige Module|2=&lt;br /&gt;
&amp;lt;small&amp;gt;&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Internet-Shop&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Stammdaten/Schnittstellen/Internet-Shop| OBS Schnittstelle Internet Shop]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Der modified ECommerce Shop&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop-xtcModified/FAQs|FAQs]]&lt;br /&gt;
*[[OBS/Internet-Shop/modified-eCommerce|Überblick zum modified ECommerce Shop]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=Shop-Menü|inhalt=&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/A Preise aktualisieren|A Preise aktualisieren]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/C Personen übertragen|C Personen übertragen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/E Kategorien verwalten|E Kategorien verwalten]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/G Kataloge verwalten|G Kataloge verwalten]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop-xtcModified/I Merkliste übertragen|I Merkliste übertragen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/K Varianten übertragen|K Varianten übertragen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/L Artikelvarianten übertragen|L Artikelvarianten übertragen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/M Referenzarten übertragen|M Referenzarten übertragen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/N Lagerbestände verwalten|N Lagerbestände verwalten]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop#Bestellungen_einlesen|U Bestellungen einlesen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/V leere Passworte füllen|V leere Passworte füllen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/W Update-Informationen zurücksetzen|W Update-Informationen zurücksetzen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/Konfiguration/modified_eCommerce|X Konfiguration]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop#Protokoll|Z Protokoll]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=Automatische Vorgänge|inhalt=&lt;br /&gt;
* [[OBS/Internet-Shop/modified-eCommerce/Scheduler/Bestellungen einlesen|Bestellungen einlesen]]&lt;br /&gt;
* [[OBS/Internet-Shop/modified-eCommerce/Scheduler/Verfügbarkeitsdateien erstellen|Verfügbarkeitsdateien erstellen]]&lt;br /&gt;
* [[OBS/Internet-Shop/modified-eCommerce/Scheduler/Verfügbarkeitsdateien hochladen|Verfügbarkeitsdateien hochladen]]&lt;br /&gt;
*[[OBS/Internet-Shop/modified-eCommerce/Scheduler/Änderungsautomatik|geänderte Artikel übertragen]]&lt;br /&gt;
*[[OBS/Internet-Shop/modified-eCommerce/Scheduler/Preise aktualisieren|Preise aktualisieren]]&lt;br /&gt;
*[[OBS/Internet-Shop/modified-eCommerce/Scheduler/Anlage Shopkategorien|Anlage der Shopkategorien auf Grundlage der OBS-Warengruppen]]&lt;br /&gt;
}}&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop-xtcModified/besondere Funktionen|Besondere Anpassungen im xtcModified Shop]]&amp;lt;br&amp;gt;&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[Kostenpflichtige_Module/Internet-Shop/modified_eCommerce_2|modified eCommerce 2.0]]|inhalt=&lt;br /&gt;
*[[OBS/Internet-Shop/modified_eCommerce_2.0|Funktionen und FAQ]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[OBS/Internet-Shop/ShopV4|Der Büroring-ShopV4]]|inhalt=&lt;br /&gt;
*[[OBS/Internet-Shop/ShopV4/FAQs|FAQs]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=Einrichtung|inhalt=&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/1.1_Einstellungen_im_Shop|Einstellungen im Shop]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/1.2_Einstellungen_in_OBS|Einstellungen in OBS]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Erste_Schritte|Erste Schritte]]&lt;br /&gt;
}}&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Shop-Menu|Shop-Menü]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Shop-Lieferanten|Shop-Lieferanten]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Import_von_Bestellungen|Import von Bestellungen]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Personen_/_Kundengruppen|Personen / Kundengruppe]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=Eigene Artikel|inhalt=&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/6.1_Artikel_übertragen|Artikel übertragen]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/6.2_Shop-Warengruppen|Shop-Warengruppen]]&lt;br /&gt;
}}&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Preislisten|Preislisten]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Katalogvorlagen|Katalogvorlagen]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Bestellvorlagen|Bestellvorlagen]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Sortiment|Sortiment]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Kostenstellen|Kostenstellen]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Personen-Einstellungen|Personen-Einstellungen]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[Kostenpflichtige_Module/Internet-Shop/brShop24|brShop24]]|inhalt=&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24|Übersicht]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Einrichtung|Einrichtung]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24#Bekannte_Probleme|Bekannte Probleme]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24#H.C3.A4ufig_gestellte_Fragen_.28FAQ.29|Häufig gestellte Fragen (FAQ)]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[Kostenpflichtige_Module/Internet-Shop/brShop24|Einstellungen und Automatiken]]|inhalt=&lt;br /&gt;
* [[OBS/Stammdaten/Schnittstellen/Internet-Shop/Konfiguration|Konfiguration]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24#Zuordnungen|Zuordnungen/Datenverknüpfung OBS und Shop]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/Einrichtung/Automatiken|Automatiken einrichten]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[Kostenpflichtige_Module/Internet-Shop/brShop24#Erkl.C3.A4rungen.2FFunktionen|Funktionen]]|inhalt=&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Kundengruppen_verwalten|Kundengruppen verwalten]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Personen_übertragen|Personen übertragen]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Preise_übertragen|Preise übertragen]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Eigene_Artikel_übertragen|Eigene Artikel übertragen]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Shoppinglisten_übertragen|Shoppinglisten übertragen]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Jobinformationen|Jobinformationen]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Migration|Migration]]&lt;br /&gt;
* [[Kostenpflichtige Module/Internet-Shop/brShop24/PDF-Druck Artikellink|PDF-Druck Artikellink]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Definition_Sortiment|Definition Sortiment (individuelle Konfiguration)]]&lt;br /&gt;
}}&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
* [[Kostenpflichtige Module/Internet-Shop/soCONNECT|so.CONNECT (Soennecken)]]&lt;br /&gt;
&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Preislisten&#039;&#039;&#039;|inhalt=&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[OBS/Kostenpflichtige_Module/Preislisten|Preislistenverwaltung]]|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Preislisten/Preislisten-Artikel|Preislisten-Artikel]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Preislisten/Preislistengruppen|Preislistengruppen]]&lt;br /&gt;
}}&lt;br /&gt;
* [[OBS/Preislisten/FAQ_Preislisten|FAQ Preislisten]]&lt;br /&gt;
* [[OBS/Häufig gestellte Fragen/Vorgehensweise für Preislisten ohne Server|Vorgehen für Preislisten ohne Server]]&lt;br /&gt;
* [[OBS/Häufig gestellte Fragen/Vorgehensweise mit Server/Client Installation für Preislisten|Vorgehen für Preislisten mit Server]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere Funktionen|F10 Weitere Funktionen]]|inhalt=&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere_Funktionen/Export_Import#A_Preisliste_nach_Excel|A Preisliste nach Excel]]&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere_Funktionen/Export_Import#B1_Excel_in_Preisliste|B1 Excel in Preisliste]]&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere_Funktionen/Export_Import#B2_freie_Excel_in_Preisliste|B2 freie Excel in Preisliste]]&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere_Funktionen/Export_Import#B3_Lieferanten-Preise_aus_CSV_in_Preisliste|B3 Lieferanten-Preise aus CSV in Preisliste]]&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/AutoPflege|C Automatische Vorgänge Preislisten]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Preislisten/Formeln|D Formelverwaltung]]&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere_Funktionen/Angebot_Preisliste#F.C3.BCllen_einer_Preisliste_aus_Angebot|E1 Angebot nach Preisliste]]&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere_Funktionen/Angebot_Preisliste#Angebot_aus_Preisliste_generieren|E2 Preisliste nach Angebot]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[OBS/Stammdaten/Artikel/Preislistenverwaltung/AutoPflege|Automatiken Kommandos]]|inhalt=&lt;br /&gt;
* [[OBS/Preislisten/Preislisten per Macro füllen|Preislisten per Macro füllen]]&lt;br /&gt;
* [[OBS/Preislisten/Preislisten per Macro kopieren|Preislisten per Macro kopieren]]&lt;br /&gt;
}}&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;ZUGFeRD&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/ZUGFeRD|ZUGFeRD]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/ZUGFeRD/Konfiguration|ZUGFeRD Konfiguration]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Factoring&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Was ist Factoring?|Was ist Factoring?]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Factoring in OBS| Factoring in OBS]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Erstellen und Verwenden von Factoring Rechnungen|Erstellen und Verwenden von Factoring Rechnungen]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Auswertung und Übermittlung von Factoring Rechnungen|Auswertung und Übermittlung von Factoring Rechnungen]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Factoring Fibu|Factoring Fibu]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;UPS&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/kostenpflichtige Module/UPS|UPS Modul]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;DHL&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/kostenpflichtige Module/DHL|DHL Schnittstelle]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;IMS Professional&#039;&#039;&#039;|inhalt=&lt;br /&gt;
;&lt;br /&gt;
* [[OBS/kostenpflichtige Module/IMSPro|IMS-Professional]]&lt;br /&gt;
&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;TAPI&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/TAPI|TAPI]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/TAPI Voraussetzungen|Voraussetzungen]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/TAPI Konfiguration|Konfiguration]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/TAPI Externe Telefonanbindungen|Externe Telefonanbindungen]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;SMS&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/SMS|SMS]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[OBS/Kostenpflichtige_Module/Fleet_Management|&#039;&#039;&#039;Fleet Management&#039;&#039;&#039;]]|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Fleet Management/FMAudit|FMAudit]]&lt;br /&gt;
* [[FRMFMZENTRALE_MELDUNGEN|FMAudit Pro]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Fleet Management/UTAX E-mail|UTAX E-Mail]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Fleet_Management/Brother_XML|Brother XML]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Fleet_Management/KonicaMinolta_E-Mail|Konica Minolta E-Mail]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Fleet_Management/docuFORM|docuFORM XML]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Fleet_Management/KyoceraFleetServices|KFS XML]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Fleet_Management/SimpleClicksXML|SimpleClicks XML]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Fleet_Management/Brother_PPPB_xlsx|Brother PPPB Xlsx]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Mehrlager-Verwaltung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Mehrlager-Verwaltung|Mehrlager-Verwaltung]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Mehrsprachen Modul&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Mehrsprachen Modul|Mehrsprachen Modul]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Multilanguage Modul&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Multilanguage Modul|Multilanguage Modul]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Einfache Produktionsnachverfolgung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Einfache Produktionsnachverfolgung/Was ist|Was ist die Einfache Produktionsnachverfolgung]]&lt;br /&gt;
* [[OBS/Einfache Produktionsnachverfolgung/FRMEDITPRODMENGE|Scannen der Auftragspositionen]]&lt;br /&gt;
* [[OBS/Einfache Produktionsnachverfolgung/Sammellieferschein|Sammellieferschein]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;DMS - Dokumenten Management System&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS|DMS]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Konfiguration|Konfiguration]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/Scannen von Dokumenten|Einscannen von Dokumenten im Stapelbetrieb]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=DMS Dokumente|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente|Liste]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/Versionierung|Dokumentenversionierung]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=Tasten und Schaltflächen|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente/F2 Filter|F2 Filter]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente/F5 Markieren|F5 Mark]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Verknüpfung|F6 Verknü.]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente/F7 Suche|F7 Suche]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente/F8 Info|F8 Info]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente/F9 Gruppe|F9 Gruppe]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=F10 Weit.|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente/F10 Weitere Funktionen|F10 Weit.]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Stammdaten|DMS Stammdaten]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=E DMS Definitionen|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Definitionen|Liste]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Definitionen Eingabemaske|Eingabemaske]]&lt;br /&gt;
}}&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/QR Etiketten|M QR Etiketten drucken]]&lt;br /&gt;
}}&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Viewer|Dokument]]&lt;br /&gt;
}}&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Eingabemaske|Eingabemaske]]&lt;br /&gt;
}}&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;QR Zeiterfassung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/QR Zeiterfassung|QR Zeiterfassung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/QR Einrichtung Checkliste|QR Einrichtung Checkliste]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;EVA Marketing Tool&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/EVA]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Technikersteuerung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Technikersteuerung/Ziel der Technikersteuerung|Ziel der Technikersteuerung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Technikersteuerung/Konfiguration|Konfiguration]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Technikersteuerung/Terminvorschläge erstellen|Terminvorschläge für Techniker erstellen]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Technikersteuerung/Liste|Liste]]&lt;br /&gt;
** [[OBS/Kostenpflichtige Module/Technikersteuerung/Definition Terminvorschlagslisten|Definition Terminvorschlagslisten/T-Def]]&lt;br /&gt;
** [[OBS/Kostenpflichtige Module/Technikersteuerung/Terminvorschläge bearbeiten|Terminvorschläge für Techniker bearbeiten]]&lt;br /&gt;
** [[OBS/Kostenpflichtige Module/Technikersteuerung/Terminvorschlagsliste aktualisieren|Terminvorschlagsliste akt./T-Liste]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Termin-Projekte&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Termin-Projekte|Termin-Projekte]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Edifact-Schnittstelle&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Edifact|Edifact Schnittstelle]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Backup Überwachung Email&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Backup Überwachung Email|Backup Überwachung Email]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Anlagenbuchhaltung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung|Anlagenbuchhaltung]]&lt;br /&gt;
** [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung/Auswertungen|Auswertungsdrucke]]&lt;br /&gt;
** [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung/Anlagengüter|Anlagegüterübersicht]]&lt;br /&gt;
** [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung/Abschreibungsarten|Abschreibungsarten]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung/Anlagekonten|Anlagekonten]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung/Bewegung|Anlagebewegungen]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung/Anlagenstapel|Anlagenstapel]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;OBS Geo Daten&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/OBS Geo Daten|OBS Geo Daten]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;DeliSprint / DPD&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Delisprint|DeliSprint / DPD]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Filialen&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Filialen|Filialen]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Auto-Waagen&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Auto-Waagen|Auto-Waagen]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Auto-Waagen/Installation|Installation]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Auto-Waagen/Liste|Liste]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Auto-Waagen/Eingabemaske|Eingabemaske]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Auto-Waagen/Waage.INI|Waage.INI]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Cashback&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Cashback|Cashback]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Moebelschnittstelle&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/Moebelschnittstelle|Moebelschnittstelle]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Tourenplanung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[OBS/Kostenpflichtige Module/Tourenplanung|Tourenplanung]]|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/Tourenplanung/TourVG|Vorgänge]]&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/Tourenplanung/TourTouren|Touren]]&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/Tourenplanung/TourTourPos|Positionen]]&lt;br /&gt;
}}&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/Tourenplanung/Stammdaten|Stammdaten]]&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/Tourenplanung/Einrichtung|Einrichtung]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Dokumenten Manager&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/Dokumenten_Manager|Dokumenten Manager]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;DocuWare-Schnittstelle&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/DocuWare|DocuWare-Schnittstelle]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;OFML-Kalkulation&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/OFML-Kalkulation|OFML-Kalkulation]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Pascom&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/Pascom|Pascom]]&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/Pascom_Einrichtung|Einrichtung]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Versicherungsschaden&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/Versicherungsschaden|Versicherungsschaden]]&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/Energienachweise|Energienachweise]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Gutschriftsanzeigen&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Gutschriftsanzeigen|Gutschriftsanzeigen]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;OCPP Ladestationen&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/OCPP_Ladestationen|OCPP Wallboxen Abrechnung (Ladesäulen)]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/SteVe|SteVe]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Kameraverwaltung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Kameraverwaltung|Kameraverwaltung]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;DataInOut&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DataInOut|DataInOut]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;REST-Schnittstelle&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer|REST-Server]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Einrichtung|Einrichtung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Skripting]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Datei-Download|Datei-Download]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Beispiele&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/RESTServer/Beispiel1|PHP-Abruf von Steuerungsdaten]]&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/RESTServer/Beispiel2|Zeiterfassung]]&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/RESTServer/Beispiel3|Externes Ticketsystem]]&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/RESTServer/Beispiel4|Pfad-Parameter]]&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/RESTServer/Beispiel5|Schreibzugriff mit Statuscodes, ETag und Idempotenz]]&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/RESTServer/Beispiel6|Datei-Upload und Ablage im DMS]]&lt;br /&gt;
}}&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Sammelverträge&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Sammelvertrag|Sammelvertrag]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Sammelvertrag/Container|Container-Stammdaten]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Craftboxx&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Craftboxx|Craftboxx]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/CraftboxxAPI|Craftboxx API Dokumentation]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;OpenMasterData / IDS&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/OpenMasterData|OpenMasterData]]&lt;br /&gt;
* [[BS/Kostenpflichtige Module/IDSConnect|IDSConnect]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Sammelpositionen&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Sammelpositionen|Sammelpositionen]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;KI-Mining&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining|KI-Mining]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=KI-Mining Apps|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Auftragsmonitor|Auftragsmonitor]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Lageroptimierung|Lageroptimierung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Kundenklassifizierung|Kundenklassifizierung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Cross-Selling|Cross-Selling]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Vertriebs-ToDos|Vertriebs-ToDos]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Vertriebs-ToDos pro|Vertriebs-ToDos pro]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/MPS-ToDos pro|MPS-ToDos pro]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Vertriebs-Wirkungsmonitor|Vertriebs-Wirkungsmonitor]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Führungs-Dashboard|Führungs-Dashboard]]&lt;br /&gt;
}}&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/small&amp;gt;&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;noinclude&amp;gt;&lt;br /&gt;
----&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;noinclude&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Folgender Code muss in die jeweils gewünschte Seite hinzugefügt werden:&lt;br /&gt;
 &amp;lt;nowiki&amp;gt;{{Kostenpflichtige Module}}&amp;lt;/nowiki&amp;gt;&lt;br /&gt;
[[Kategorie:Templates/Navigationen]]&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&lt;br /&gt;
[[Kategorie:OBS/Module]]&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Datei-Download&amp;diff=64821</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Datei-Download</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Datei-Download&amp;diff=64821"/>
		<updated>2026-09-14T08:07:57Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: Die Seite wurde neu angelegt: „{{Kostenpflichtige Module}}  = Datei-Download =  Ein Endpunkt kann statt eines JSON-Körpers eine &amp;#039;&amp;#039;&amp;#039;Datei&amp;#039;&amp;#039;&amp;#039; ausliefern - PDF, CSV, Bilder, eine APK. Das Skript entscheidet das mit einem einzigen Aufruf, alles Weitere macht der Server: Content-Type, Dateiname, Bereichsanfragen und, bei temporären Dateien, das Aufräumen.  Anders als beim Datei-Upload braucht es dafür &amp;#039;&amp;#039;&amp;#039;keine Freischaltung&amp;#039;&amp;#039;&amp;#039; am E…“&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
= Datei-Download =&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt kann statt eines JSON-Körpers eine &#039;&#039;&#039;Datei&#039;&#039;&#039; ausliefern - PDF, CSV,&lt;br /&gt;
Bilder, eine APK. Das Skript entscheidet das mit einem einzigen Aufruf, alles&lt;br /&gt;
Weitere macht der Server: Content-Type, Dateiname, Bereichsanfragen und, bei&lt;br /&gt;
temporären Dateien, das Aufräumen.&lt;br /&gt;
&lt;br /&gt;
Anders als beim [[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]]&lt;br /&gt;
braucht es dafür &#039;&#039;&#039;keine Freischaltung&#039;&#039;&#039; am Endpunkt. Ein Download entsteht&lt;br /&gt;
dadurch, dass das Skript ihn erzeugt, nicht dadurch, dass ein Client ihn anfragt.&lt;br /&gt;
&lt;br /&gt;
== Die zwei Aufrufe ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Für !! Nach dem Senden !! Wiederaufsetzbar&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.SendFile(cPfad, cName, cTyp)&amp;lt;/code&amp;gt; || Dateien, die es schon gibt und weiter geben wird || bleibt liegen || &#039;&#039;&#039;ja&#039;&#039;&#039; (206/Range)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.SendTempFile(cPfad, cName, cTyp)&amp;lt;/code&amp;gt; || Ergebnisse, die je Abruf entstehen || wird &#039;&#039;&#039;gelöscht&#039;&#039;&#039; || nein&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUid : string;&lt;br /&gt;
    cPfad: string;&lt;br /&gt;
    cName: string;&lt;br /&gt;
begin&lt;br /&gt;
    cUid := oReader.Path(&#039;uid&#039;);&lt;br /&gt;
&lt;br /&gt;
    if (not BelegGehoertZuMandant(oReader.Claim(&#039;tenant&#039;), cUid)) then begin&lt;br /&gt;
        oWriter.Error(404, &#039;NOT_FOUND&#039;, &#039;Beleg nicht gefunden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    cPfad := BelegPfad(cUid);&lt;br /&gt;
    cName := &#039;Rechnung_&#039; + cUid + &#039;.pdf&#039;;&lt;br /&gt;
&lt;br /&gt;
    oWriter.SendFile(cPfad, cName, &#039;application/pdf&#039;);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Datei oder Felder - nicht beides.&#039;&#039;&#039; Schreibt ein Skript Felder&lt;br /&gt;
&#039;&#039;&#039;und&#039;&#039;&#039; ruft &amp;lt;code&amp;gt;SendFile&amp;lt;/code&amp;gt;, ist das ein Strukturfehler wie eine&lt;br /&gt;
unbalancierte Verschachtelung: der Server antwortet &#039;&#039;&#039;500&#039;&#039;&#039; und nennt die Stelle&lt;br /&gt;
im Protokoll. &amp;lt;code&amp;gt;Error()&amp;lt;/code&amp;gt; nach &amp;lt;code&amp;gt;SendFile&amp;lt;/code&amp;gt; verwirft die Datei&lt;br /&gt;
und liefert die Fehlerhülle - die Ablehnung gewinnt.}}&lt;br /&gt;
&lt;br /&gt;
== Was der Server dazu tut ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Kopfzeile !! Wert&lt;br /&gt;
|-&lt;br /&gt;
| Content-Type || der übergebene Typ; ohne Angabe &amp;lt;code&amp;gt;application/octet-stream&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Content-Disposition || &amp;lt;code&amp;gt;attachment&amp;lt;/code&amp;gt; mit dem übergebenen Namen, nach RFC&amp;amp;nbsp;5987 kodiert&lt;br /&gt;
|-&lt;br /&gt;
| Content-Length || Länge der Datei bzw. des angeforderten Bereichs&lt;br /&gt;
|-&lt;br /&gt;
| Accept-Ranges || &amp;lt;code&amp;gt;bytes&amp;lt;/code&amp;gt; bei &amp;lt;code&amp;gt;SendFile&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;none&amp;lt;/code&amp;gt; bei &amp;lt;code&amp;gt;SendTempFile&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| ETag, Last-Modified || nur bei &amp;lt;code&amp;gt;SendFile&amp;lt;/code&amp;gt;, aus Grösse und Änderungszeitpunkt&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Umlaute im Dateinamen&#039;&#039;&#039; sind unbedenklich. Header-Werte müssen ASCII sein,&lt;br /&gt;
deshalb schreibt der Server zwei Angaben: &amp;lt;code&amp;gt;filename=&amp;lt;/code&amp;gt; mit einem&lt;br /&gt;
ausgeschriebenen Rückfallnamen (&#039;&#039;Rechnung_Müller.pdf&#039;&#039; wird zu&lt;br /&gt;
&#039;&#039;Rechnung_Mueller.pdf&#039;&#039;) und &amp;lt;code&amp;gt;filename*=&amp;lt;/code&amp;gt; mit dem echten Namen&lt;br /&gt;
UTF-8-kodiert. Jeder heutige Browser nimmt den zweiten.&lt;br /&gt;
&lt;br /&gt;
Der Pfad wird &#039;&#039;&#039;nicht geprüft&#039;&#039;&#039;. Ein Skript kann über &amp;lt;code&amp;gt;Base.GFile&amp;lt;/code&amp;gt;&lt;br /&gt;
ohnehin jede Datei des Servers lesen; eine Schranke nur an dieser Stelle würde&lt;br /&gt;
Sicherheit vortäuschen, die es nicht gibt.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Den Pfad niemals ungeprüft aus einem Client-Wert bilden.&#039;&#039;&#039; Ein&lt;br /&gt;
Endpunkt &amp;lt;code&amp;gt;/public/{name}&amp;lt;/code&amp;gt;, der &amp;lt;code&amp;gt;{name}&amp;lt;/code&amp;gt; an ein&lt;br /&gt;
Basisverzeichnis hängt, liefert bei &amp;lt;code&amp;gt;..%2F..%2Fobs.ini&amp;lt;/code&amp;gt; die&lt;br /&gt;
Konfiguration aus. Entweder eine feste Zuordnung im Skript (&#039;&#039;apk&#039;&#039; -&amp;gt; fester&lt;br /&gt;
Pfad), oder eine Tabelle mit freigegebenen Dateien - nie den Rohwert.}}&lt;br /&gt;
&lt;br /&gt;
== Wiederaufnahme abgebrochener Downloads ==&lt;br /&gt;
&lt;br /&gt;
Bei &amp;lt;code&amp;gt;SendFile&amp;lt;/code&amp;gt; wertet der Server den Kopf &amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt; aus.&lt;br /&gt;
Unterstützt wird &#039;&#039;&#039;ein&#039;&#039;&#039; Bereich je Anfrage, in den drei Formen, die Clients&lt;br /&gt;
tatsächlich senden:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Range !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;bytes=0-1023&amp;lt;/code&amp;gt; || fester Bereich&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;bytes=500-&amp;lt;/code&amp;gt; || ab Offset bis zum Ende&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;bytes=-500&amp;lt;/code&amp;gt; || die letzten 500 Bytes&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Der Server antwortet dann mit &#039;&#039;&#039;206 Partial Content&#039;&#039;&#039; und&lt;br /&gt;
&amp;lt;code&amp;gt;Content-Range: bytes &amp;amp;lt;von&amp;amp;gt;-&amp;amp;lt;bis&amp;amp;gt;/&amp;amp;lt;gesamt&amp;amp;gt;&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Fall !! Antwort&lt;br /&gt;
|-&lt;br /&gt;
| Kein &amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt;-Kopf || &#039;&#039;&#039;200&#039;&#039;&#039; mit der ganzen Datei&lt;br /&gt;
|-&lt;br /&gt;
| Mehrere Bereiche (Komma) || &#039;&#039;&#039;200&#039;&#039;&#039; mit der ganzen Datei - die Spezifikation erlaubt das ausdrücklich&lt;br /&gt;
|-&lt;br /&gt;
| Bereich hinter dem Dateiende || &#039;&#039;&#039;416 Range Not Satisfiable&#039;&#039;&#039; mit &amp;lt;code&amp;gt;Content-Range: bytes */&amp;amp;lt;gesamt&amp;amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;If-Range&amp;lt;/code&amp;gt; stimmt nicht mehr || &#039;&#039;&#039;200&#039;&#039;&#039; mit der ganzen Datei&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Warum &amp;lt;code&amp;gt;If-Range&amp;lt;/code&amp;gt; zählt:&#039;&#039;&#039; Setzt ein Client einen Download fort und&lt;br /&gt;
die Datei hat sich zwischenzeitlich geändert, entstünde aus zwei Ständen eine&lt;br /&gt;
Datei, die erst beim Öffnen auffällt. Schickt der Client seinen ETag in&lt;br /&gt;
&amp;lt;code&amp;gt;If-Range&amp;lt;/code&amp;gt; mit und passt der nicht mehr, liefert der Server lieber alles&lt;br /&gt;
neu.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;SendTempFile kann nicht fortgesetzt werden&#039;&#039;&#039;, und das ist keine&lt;br /&gt;
Lücke: Die Datei ist nach dem Senden gelöscht, ein zweiter Abruf fände sie nicht&lt;br /&gt;
mehr vor - und eine Neuerzeugung lieferte andere Bytes, sobald sich die Daten&lt;br /&gt;
geändert haben. Der Server meldet deshalb &amp;lt;code&amp;gt;Accept-Ranges: none&amp;lt;/code&amp;gt;, damit&lt;br /&gt;
ein Client es gar nicht erst versucht.}}&lt;br /&gt;
&lt;br /&gt;
== Öffentliche Dateien ohne API-Key ==&lt;br /&gt;
&lt;br /&gt;
Für Artefakte, die für jeden bestimmt sind - eine Test-APK, ein Handbuch - kann&lt;br /&gt;
der Endpunkt ohne &amp;lt;code&amp;gt;apikey&amp;lt;/code&amp;gt;-Header erreichbar sein. Dafür genügt&lt;br /&gt;
Konfiguration, siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]]:&lt;br /&gt;
&lt;br /&gt;
# Zugang mit &amp;lt;code&amp;gt;ra_apikey = *&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;ra_jwt = 0&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;ra_cors_origin&amp;lt;/code&amp;gt; &#039;&#039;&#039;leer&#039;&#039;&#039;&lt;br /&gt;
# In &amp;lt;code&amp;gt;RESTSRV_ACCESS&amp;lt;/code&amp;gt; &#039;&#039;&#039;nur&#039;&#039;&#039; diesen einen Endpunkt freigeben&lt;br /&gt;
&lt;br /&gt;
Die zweite Zeile ist die eigentliche Grenze: Für jeden anderen Endpunkt antwortet&lt;br /&gt;
der Server &#039;&#039;&#039;403&#039;&#039;&#039;, auch wenn der Zugang existiert.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Nur für wirklich öffentliche Dateien.&#039;&#039;&#039; Ohne API-Key kennt der&lt;br /&gt;
Endpunkt weder Mandant noch Rolle - es gibt nichts, woran er eine Berechtigung&lt;br /&gt;
festmachen könnte ausser dem, was in der URL steht. Kundenbelege gehören deshalb&lt;br /&gt;
über den authentifizierten Weg, nicht hierher.}}&lt;br /&gt;
&lt;br /&gt;
Für eine Android-APK kommt hinzu: Content-Type&lt;br /&gt;
&amp;lt;code&amp;gt;application/vnd.android.package-archive&amp;lt;/code&amp;gt;, am Gerät muss &amp;quot;Unbekannte&lt;br /&gt;
Apps installieren&amp;quot; für den Browser freigegeben sein, und die Signatur sollte über&lt;br /&gt;
alle Testversionen konstant bleiben - sonst gibt es beim späteren Wechsel in den&lt;br /&gt;
Play Store auf jedem Testgerät einen Installationskonflikt.&lt;br /&gt;
&lt;br /&gt;
== Grenzen ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Was !! Stand&lt;br /&gt;
|-&lt;br /&gt;
| HEAD || wird &#039;&#039;&#039;nicht&#039;&#039;&#039; unterstützt (&#039;&#039;405&#039;&#039;). Browser brauchen es nicht; relevant nur für &amp;lt;code&amp;gt;wget --continue&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;curl -C -&amp;lt;/code&amp;gt; und Download-Manager, die meist auf GET zurückfallen.&lt;br /&gt;
|-&lt;br /&gt;
| Mehrere Bereiche je Anfrage || nicht unterstützt (ganze Datei mit 200)&lt;br /&gt;
|-&lt;br /&gt;
| Liegengebliebene Temp-Dateien || Bricht der Server mitten im Senden ab, bleibt eine &amp;lt;code&amp;gt;SendTempFile&amp;lt;/code&amp;gt;-Datei stehen. Das temporäre Verzeichnis wird von aussen geleert.&lt;br /&gt;
|-&lt;br /&gt;
| Idempotenz || Eine Dateiantwort wird &#039;&#039;&#039;nicht&#039;&#039;&#039; im Idempotenz-Store festgeschrieben - sie liesse sich nicht wiedergeben. Der &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; bleibt danach benutzbar.&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Scripting&amp;diff=64820</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Scripting</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Scripting&amp;diff=64820"/>
		<updated>2026-09-14T08:07:39Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Anleitung für Endpunkt-Skripte=&lt;br /&gt;
&lt;br /&gt;
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.&lt;br /&gt;
&lt;br /&gt;
==Sicherheitshinweise==&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Endpunkt-Skripte verarbeiten Daten aus dem Internet. Übergabeparameter dürfen niemals ungeprüft in SQL-Anweisungen eingebaut werden. Werte immer über &#039;&#039;DB_SQLVal&#039;&#039; bzw. Parameter-Bindings absichern, Eingabewerte gegen Whitelists prüfen.}}&lt;br /&gt;
&lt;br /&gt;
* Eingaben gegen erwartete Werte prüfen (z.B. Pflichtfelder, erlaubte Typen, Wertebereiche).&lt;br /&gt;
* Nur das zurückgeben, was der Konsument wirklich braucht - keine internen IDs, keine Sys-Felder, keine Passwörter.&lt;br /&gt;
* Bei Fehlern keine internen Details an den Konsumenten zurückgeben; stattdessen schreibt der Server ohnehin Detail-Einträge in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==Methoden-Signatur==&lt;br /&gt;
&lt;br /&gt;
Pro HTTP-Methode wird im Skript eine gleichnamige &#039;&#039;&#039;Prozedur&#039;&#039;&#039; implementiert. Der Server ruft genau die Prozedur auf, die zur Methode des eingehenden Requests passt.&lt;br /&gt;
&lt;br /&gt;
 procedure Get   (oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
 procedure Post  (oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
 procedure Put   (oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
 procedure Patch (oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
&lt;br /&gt;
Zwei Handles, kein Rückgabewert: &#039;&#039;&#039;oReader&#039;&#039;&#039; ist die Anfrage, &#039;&#039;&#039;oWriter&#039;&#039;&#039; die&lt;br /&gt;
Antwort. Beide erzeugt der Server, beide gehören ihm - im Skript wird nichts&lt;br /&gt;
angelegt und nichts freigegeben.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|&#039;&#039;&#039;DELETE hat keine Skript-Methode.&#039;&#039;&#039; &amp;lt;code&amp;gt;Delete&amp;lt;/code&amp;gt; ist in der&lt;br /&gt;
Skriptsprache eine Standardprozedur (Löschen aus einer Zeichenkette); eine eigene&lt;br /&gt;
Prozedur dieses Namens lässt sich nicht übersetzen, und der Fehler nennt nur die&lt;br /&gt;
Zeile der Deklaration. Wer einen DELETE-Endpunkt braucht, legt die Methode unter&lt;br /&gt;
einem anderen Verb ab oder trennt sie in ein eigenes Skript.}}&lt;br /&gt;
&lt;br /&gt;
Fehlt die zur Methode passende Funktion, antwortet der Server mit &#039;&#039;&#039;405 Method&lt;br /&gt;
Not Allowed&#039;&#039;&#039; (&amp;lt;code&amp;gt;METHOD_NOT_ALLOWED&amp;lt;/code&amp;gt;) und nennt im Header&lt;br /&gt;
&amp;lt;code&amp;gt;Allow&amp;lt;/code&amp;gt; die Verben, die dieses Skript tatsächlich anbietet - die Liste&lt;br /&gt;
stammt aus dem kompilierten Skript und kann deshalb nicht veralten:&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 405 Method Not Allowed&lt;br /&gt;
 Allow: GET, PUT&lt;br /&gt;
&lt;br /&gt;
Ein im Vertrag zugesagtes Verb braucht also seine Prozedur. Fehlt sie, ist das an&lt;br /&gt;
der Antwort sofort zu erkennen und &#039;&#039;&#039;kein&#039;&#039;&#039; Serverfehler.&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
==Die Anfrage lesen: oReader==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;oReader&#039;&#039; ist der einzige Zugang zur Anfrage - Query, Header, Formularfelder,&lt;br /&gt;
Pfadwerte, Token-Claims und Körper. Alles davon sind &#039;&#039;&#039;Zeichenketten&#039;&#039;&#039; oder&lt;br /&gt;
werden über einen typisierten Leser geholt.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Was er liefert&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;kundennr&#039;)&amp;lt;/code&amp;gt; || Query-Parameter, POST-Parameter (form-urlencoded) oder HTTP-Header. Getrimmt, leer wenn nicht gesendet&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Path(&#039;uid&#039;)&amp;lt;/code&amp;gt; || Wert eines Platzhalters aus dem Pfad-Template&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Form(&#039;bemerkung&#039;)&amp;lt;/code&amp;gt; || Formularfeld einer &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt;-Übertragung&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Claim(&#039;tenant&#039;)&amp;lt;/code&amp;gt; || Custom-Claim des vorgelegten Tokens&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Subject()&amp;lt;/code&amp;gt; || Subject des Tokens (&#039;&#039;sub&#039;&#039;-Claim)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.TraceId()&amp;lt;/code&amp;gt; || Korrelations-ID des Requests&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Offset()&amp;lt;/code&amp;gt; || UTC-Offset des Servers, z.B. &#039;&#039;+02:00&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.HasBody()&amp;lt;/code&amp;gt; || Wurde überhaupt ein JSON-Körper gesendet?&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Filterung durch den Server:&lt;br /&gt;
&lt;br /&gt;
* Geblockte Header werden nicht durchgereicht: &#039;&#039;authorization&#039;&#039;, &#039;&#039;cookie&#039;&#039;, &#039;&#039;proxy-authorization&#039;&#039;, &#039;&#039;x-forwarded-for&#039;&#039;, &#039;&#039;x-real-ip&#039;&#039;, &#039;&#039;apikey&#039;&#039;, &#039;&#039;api_key&#039;&#039;.&lt;br /&gt;
* Werte, die von aussen kommen, können die reservierten Namen nicht besetzen.&lt;br /&gt;
* Werte werden auf max. 1024 Zeichen begrenzt.&lt;br /&gt;
* Null-Bytes und Steuerzeichen (ausser Tab, CR, LF) werden entfernt.&lt;br /&gt;
&lt;br /&gt;
Zugriff im Skript:&lt;br /&gt;
&lt;br /&gt;
 cKundenNr := oReader.Param(&#039;kundennr&#039;);&lt;br /&gt;
 if (not Empty(cKundenNr)) then begin&lt;br /&gt;
     // ...&lt;br /&gt;
 end;&lt;br /&gt;
&lt;br /&gt;
===Der Körper: Zugriff über Pfade===&lt;br /&gt;
&lt;br /&gt;
Den JSON-Körper liest &#039;&#039;oReader&#039;&#039; über &#039;&#039;&#039;Pfade&#039;&#039;&#039;. Es gibt keine Unterobjekte im&lt;br /&gt;
Skript, keinen Cast, kein &#039;&#039;.Items[i]&#039;&#039; - und damit auch keine Lebensdauer, um die&lt;br /&gt;
sich jemand kümmern müsste.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Str(&#039;betreff&#039;, cWert)&amp;lt;/code&amp;gt; || Liest den Wert. &#039;&#039;&#039;Rückgabe Boolean&#039;&#039;&#039;: war das Feld da? Fehlt es, bleibt die Variable unverändert&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Int(&#039;menge&#039;, nWert)&amp;lt;/code&amp;gt; || dito, Ganzzahl&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Num(&#039;preis&#039;, nWert)&amp;lt;/code&amp;gt; || dito, Kommazahl. &#039;&#039;&#039;Der Dezimalpunkt aus JSON wird richtig gelesen&#039;&#039;&#039; - kein &#039;&#039;StrTran&#039;&#039; mehr&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Bool(&#039;aktiv&#039;, lWert)&amp;lt;/code&amp;gt; || dito, Boolean&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.NumDef(&#039;rabatt&#039;, nWert, 0)&amp;lt;/code&amp;gt; || wie &#039;&#039;Num&#039;&#039;, mit Vorgabewert&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Has(&#039;feld&#039;)&amp;lt;/code&amp;gt; || Ist das Feld vorhanden? (auch bei &#039;&#039;null&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Count(&#039;zeiten&#039;)&amp;lt;/code&amp;gt; || Anzahl Einträge einer Liste, 0 wenn keine Liste&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.IsArr(&#039;zeiten&#039;)&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;IsObj(&#039;zeiten[0]&#039;)&amp;lt;/code&amp;gt; || Gestalt prüfen&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Keys()&amp;lt;/code&amp;gt; || Alle Feldnamen der obersten Ebene, in Kommas eingefasst&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Verschachtelung steht im Pfad&#039;&#039;&#039; - Punkt für Felder, eckige Klammern für Listen:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cVon : string;&lt;br /&gt;
    nAnz : Integer;&lt;br /&gt;
    nI   : Integer;&lt;br /&gt;
begin&lt;br /&gt;
    nAnz := oReader.Count(&#039;zeiten&#039;);&lt;br /&gt;
    for nI := 0 to nAnz - 1 do begin&lt;br /&gt;
        if (oReader.Str(&#039;zeiten[&#039; + IntToStr(nI) + &#039;].von&#039;, cVon)) then begin&lt;br /&gt;
            // ... cVon verarbeiten ...&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Der Rückgabewert unterscheidet „nicht gesendet&amp;quot; von „leer&lt;br /&gt;
gesendet&amp;quot;.&#039;&#039;&#039; &amp;lt;code&amp;gt;oReader.Str(&#039;feld&#039;, cWert)&amp;lt;/code&amp;gt; liefert bei&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;quot;feld&amp;quot;: null&amp;lt;/code&amp;gt; &#039;&#039;true&#039;&#039; mit leerem Wert und bei fehlendem Feld&lt;br /&gt;
&#039;&#039;false&#039;&#039;. Genau diese Unterscheidung braucht ein PATCH-artiger Aufruf: „Feld&lt;br /&gt;
löschen&amp;quot; und „Feld nicht anfassen&amp;quot; sehen sonst gleich aus.}}&lt;br /&gt;
&lt;br /&gt;
Ist gar kein Körper gesendet worden, liefert jeder Leser &#039;&#039;false&#039;&#039; und&lt;br /&gt;
&#039;&#039;HasBody()&#039;&#039; ist &#039;&#039;false&#039;&#039;. Maximale Body-Grösse: 10 MB.&lt;br /&gt;
&lt;br /&gt;
===Werte des Servers===&lt;br /&gt;
&lt;br /&gt;
Bei aktiver JWT-Authentifizierung stehen die Token-Claims über den Reader zur&lt;br /&gt;
Verfügung. Sie tragen intern weiterhin die Namen mit Präfix &#039;&#039;&#039;_OBS_&#039;&#039;&#039; - im&lt;br /&gt;
Skript werden sie aber über die Zugriffe der Tabelle oben gelesen und nicht mehr&lt;br /&gt;
über den Namen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Parameter            !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;_OBS_JWT_ID&#039;)&amp;lt;/code&amp;gt; || JWT-Id (&#039;&#039;jti&#039;&#039;-Claim) - typisch die User-Id. &#039;&#039;&#039;Muss nicht eindeutig sein&#039;&#039;&#039;: die Sitzung führt der Server über eigene Kennungen (siehe unten)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Subject()&amp;lt;/code&amp;gt; || Subject (&#039;&#039;sub&#039;&#039;-Claim) - typisch Benutzername&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;_OBS_JWT_AUDIENCE&#039;)&amp;lt;/code&amp;gt; || Audience (&#039;&#039;aud&#039;&#039;-Claim) - typisch Mandant / Rolle&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Claim(&#039;tenant&#039;)&amp;lt;/code&amp;gt; || Beliebiger Custom-Claim des Tokens, hier &#039;&#039;tenant&#039;&#039;; im Folge-Skript lesbar&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.TraceId()&amp;lt;/code&amp;gt; || Korrelations-ID des Requests (auch als Response-Header X-Trace-Id)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Unabhängig von JWT stehen ausserdem immer zur Verfügung:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Parameter            !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;_OBS_SERVER_TIME&#039;)&amp;lt;/code&amp;gt; || Aktuelle Serverzeit als ISO-8601 &#039;&#039;&#039;mit UTC-Offset&#039;&#039;&#039; (z.B. &#039;&#039;2026-08-17T09:12:33+02:00&#039;&#039;). Nützlich, um Datumswerte in derselben Zeitzone auszuliefern, in der der Server arbeitet, und damit ein Client seinen Uhren-Versatz bestimmen kann&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Offset()&amp;lt;/code&amp;gt; || Nur der Offset daraus, z.B. &#039;&#039;+02:00&#039;&#039;. Er geht in die Zeitfunktionen (siehe [[#Zeitangaben auf der Leitung|Zeitangaben]])&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;_OBS_JWT_EXP_SEC&#039;)&amp;lt;/code&amp;gt; || Laufzeit des Access-Tokens in Sekunden (&#039;&#039;JWT-Exp&#039;&#039; × 60). Damit kann ein Konfigurations-Endpunkt denselben Wert ausliefern, den der Token wirklich hat&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Diese Werte werden vom Server gesetzt und können vom Skript für Berechtigungs- und Mandantenprüfungen verwendet werden.&lt;br /&gt;
&lt;br /&gt;
Zusätzlich trägt jedes Token zwei &#039;&#039;&#039;Sitzungskennungen des Servers&#039;&#039;&#039;, lesbar als&lt;br /&gt;
&#039;&#039;oReader.Claim(&#039;sid&#039;)&#039;&#039; und &#039;&#039;oReader.Claim(&#039;rid&#039;)&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Claim !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| sid || Sitzung. Bleibt über alle Erneuerungen (Refresh) hinweg gleich und ist der Schlüssel für das Abmelden&lt;br /&gt;
|-&lt;br /&gt;
| rid || Zeilenkennung des einzelnen Token-Paares in &#039;&#039;RESTSRV_TOKEN&#039;&#039;. Bei jeder Erneuerung neu&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Beide sind OBS-UIDs (10 Zeichen) und werden vom Server &#039;&#039;&#039;nach&#039;&#039;&#039; den Claims des&lt;br /&gt;
Skripts gesetzt - ein Skript kann sie also auch mit einem eigenen Claim &#039;&#039;sid&#039;&#039;&lt;br /&gt;
nicht überschreiben. Für Fachlogik sind sie nicht gedacht; sie sind nützlich, um eine&lt;br /&gt;
Sitzung im Protokoll wiederzufinden.&lt;br /&gt;
&lt;br /&gt;
===Pfad-Parameter===&lt;br /&gt;
&lt;br /&gt;
Stammt der Endpunkt aus einem Pfad-Template mit Platzhaltern (siehe [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]), stehen die aus den Platzhaltern erfassten Werte über &#039;&#039;oReader.Path&#039;&#039; bereit:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Template !! Zugriff im Skript&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;oReader.Path(&#039;uid&#039;)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;oReader.Path(&#039;uid&#039;)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;oReader.Path(&#039;code&#039;)&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
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]].&lt;br /&gt;
&lt;br /&gt;
===Datei-Uploads===&lt;br /&gt;
&lt;br /&gt;
Ist der Endpunkt für Uploads freigeschaltet (&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;, siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]), nimmt der Server&lt;br /&gt;
hochgeladene Dateien entgegen, legt sie in einem temporären Verzeichnis ab und&lt;br /&gt;
übergibt dem Skript Pfad, Originalname und Content-Type über den Reader. Das&lt;br /&gt;
Skript entscheidet selbst über die weitere Verarbeitung (z.B. DMS-Ablage). Der&lt;br /&gt;
Body wird in diesem Fall &#039;&#039;&#039;nicht&#039;&#039;&#039; als JSON geparst - &#039;&#039;oReader.HasBody()&#039;&#039; ist&lt;br /&gt;
&#039;&#039;false&#039;&#039;. Es gilt nicht das JSON-Body-Limit (10&amp;amp;nbsp;MB), sondern die pro&lt;br /&gt;
Endpunkt konfigurierte Grösse (&#039;&#039;re_upload_size&#039;&#039;, Standard 25&amp;amp;nbsp;MB;&lt;br /&gt;
Überschreitung -&amp;gt; 413).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Wie die Datei übertragen wurde, spielt für das Skript keine Rolle.&#039;&#039;&#039; Beide&lt;br /&gt;
Wege - &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; und der resumable&lt;br /&gt;
&amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;-Upload - liefern dieselben vier Werte. Das&lt;br /&gt;
Übertragungsprotokoll samt Header, Statuscodes und Resume-Verhalten steht in&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]]:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadPath()&amp;lt;/code&amp;gt; || Vollständiger Pfad zur temporären Datei auf dem Server&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadName()&amp;lt;/code&amp;gt; || Originaldateiname&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadType()&amp;lt;/code&amp;gt; || Content-Type der Datei (Default &amp;lt;code&amp;gt;application/octet-stream&amp;lt;/code&amp;gt;, wenn der Client keinen angibt), kleingeschrieben&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadSha()&amp;lt;/code&amp;gt; || SHA-256 der &#039;&#039;&#039;gespeicherten&#039;&#039;&#039; Datei, hex in Kleinbuchstaben. Damit lässt sich eine vom Client mitgesendete Prüfsumme vergleichen - erst dieser Vergleich macht aus ihr eine Zusicherung&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Der &#039;&#039;&#039;Dateiname ist Pflicht&#039;&#039;&#039;. Fehlt er, lehnt der Server den Upload mit&lt;br /&gt;
&#039;&#039;&#039;400 Bad Request&#039;&#039;&#039; ab und das Skript wird nicht ausgeführt - das Skript kann&lt;br /&gt;
sich also darauf verlassen, dass &#039;&#039;UploadPath()&#039;&#039; und &#039;&#039;UploadName()&#039;&#039; gefüllt&lt;br /&gt;
sind, sobald es läuft.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Die temporäre Datei wird nicht automatisch verschoben. Das Skript muss sie an ihren Zielort (DMS, Verzeichnis, ...) übernehmen.}}&lt;br /&gt;
&lt;br /&gt;
Zusatzangaben, die im selben Aufruf mitkommen, liest das Skript mit&lt;br /&gt;
&amp;lt;code&amp;gt;oReader.Form(&#039;&amp;amp;lt;name&amp;amp;gt;&#039;)&amp;lt;/code&amp;gt; - bei &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; sind&lt;br /&gt;
das die Partien ohne &amp;lt;code&amp;gt;filename=&amp;lt;/code&amp;gt;. Ein vollständiges Beispiel zeigt&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6]].&lt;br /&gt;
&lt;br /&gt;
==Die Antwort schreiben: oWriter==&lt;br /&gt;
&lt;br /&gt;
Der Writer schreibt &#039;&#039;&#039;sequentiell&#039;&#039;&#039;: jeder Aufruf hängt ein Feld an, an der&lt;br /&gt;
Stelle, an der er steht. Die äussere Klammer der Antwort setzt der Server. Wird&lt;br /&gt;
nichts geschrieben, antwortet er mit &#039;&#039;{}&#039;&#039;. Content-Type ist&lt;br /&gt;
&#039;&#039;application/json; charset=utf-8&#039;&#039;, der Status 200, sofern das Skript nichts&lt;br /&gt;
anderes setzt.&lt;br /&gt;
&lt;br /&gt;
Einfaches Beispiel:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
begin&lt;br /&gt;
    oWriter.Str(&#039;wert_string&#039;, &#039;123&#039;);&lt;br /&gt;
    oWriter.Int(&#039;wert_int&#039;   , 456);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Ergebnis&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Str(&#039;name&#039;, cWert)&amp;lt;/code&amp;gt; || Zeichenkette, immer maskiert&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.StrN(&#039;name&#039;, cWert)&amp;lt;/code&amp;gt; || wie &#039;&#039;Str&#039;&#039;, aber &#039;&#039;&#039;null&#039;&#039;&#039; statt einer leeren Zeichenkette&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Int(&#039;anz&#039;, nWert)&amp;lt;/code&amp;gt; || Ganzzahl&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Num(&#039;preis&#039;, nWert, 2)&amp;lt;/code&amp;gt; || Kommazahl mit fester Stellenzahl. &#039;&#039;&#039;Immer mit Dezimalpunkt&#039;&#039;&#039;, unabhängig von der Locale des Servers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Bool(&#039;aktiv&#039;, lWert)&amp;lt;/code&amp;gt; || &#039;&#039;true&#039;&#039; / &#039;&#039;false&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Null(&#039;lat&#039;)&amp;lt;/code&amp;gt; || ausdrücklich &#039;&#039;null&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.ObjBegin(&#039;kunde&#039;)&amp;lt;/code&amp;gt; … &amp;lt;code&amp;gt;oWriter.ObjEnd&amp;lt;/code&amp;gt; || Unterobjekt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.ArrBegin(&#039;dateien&#039;)&amp;lt;/code&amp;gt; … &amp;lt;code&amp;gt;oWriter.ArrEnd&amp;lt;/code&amp;gt; || Liste&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.RootArr()&amp;lt;/code&amp;gt; || Die &#039;&#039;&#039;Wurzel&#039;&#039;&#039; ist eine Liste statt eines Objekts. Muss vor dem ersten Feld stehen; ein &#039;&#039;ArrEnd&#039;&#039; gibt es dazu nicht&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Raw(&#039;meta&#039;, cJson&#039;)&amp;lt;/code&amp;gt; || Fertiges JSON-Fragment einbetten. Siehe unten&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;In einer Liste hat ein Eintrag keinen Namen&#039;&#039;&#039; - der leere Feldname ist genau&lt;br /&gt;
das:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
oWriter.ArrBegin(&#039;dateien&#039;);&lt;br /&gt;
    while (not q.EoF) do begin&lt;br /&gt;
        oWriter.ObjBegin(&#039;&#039;);&lt;br /&gt;
            oWriter.Str (&#039;uid&#039;      , q.A2C(&#039;f_uid&#039;));&lt;br /&gt;
            oWriter.Int (&#039;bytes&#039;    , q.A2I(&#039;f_bytes&#039;));&lt;br /&gt;
            oWriter.StrN(&#039;kategorie&#039;, AllTrim(q.A2C(&#039;f_kat&#039;)));   // null wenn leer&lt;br /&gt;
        oWriter.ObjEnd;&lt;br /&gt;
        q.Next();&lt;br /&gt;
    end;&lt;br /&gt;
oWriter.ArrEnd;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Ein Name in einer Liste und ein fehlender Name in einem Objekt sind beides&lt;br /&gt;
&#039;&#039;&#039;Strukturfehler&#039;&#039;&#039;. Der Server antwortet dann mit &#039;&#039;&#039;500&#039;&#039;&#039; und nennt die&lt;br /&gt;
Stelle im Protokoll - er liefert kein halbes JSON aus. Dasselbe gilt für einen&lt;br /&gt;
Stapel, der am Ende der Methode noch offen ist.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Ein Helfer bekommt den Writer als Parameter&#039;&#039;&#039; und schreibt an der&lt;br /&gt;
Stelle hinein, an der er aufgerufen wird - er gibt kein Fragment zurück. Damit&lt;br /&gt;
steht die Reihenfolge der Antwort im Code und nicht im Zusammenbau:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;procedure DateienSchreiben(oWriter: TxRestWriter; const cNr: string);&amp;lt;/code&amp;gt;}}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Raw ist der Notausgang und prüft.&#039;&#039;&#039; Er parst das Fragment und bettet den&lt;br /&gt;
&#039;&#039;&#039;Originaltext&#039;&#039;&#039; ein; ungültiges JSON gibt einen Serverfehler, der das Feld&lt;br /&gt;
nennt. Gebraucht wird er für ein gespeichertes, &#039;&#039;&#039;signiertes&#039;&#039;&#039; Dokument:&lt;br /&gt;
Parsen und Neuserialisieren würde die signierte Feldreihenfolge zerstören.&lt;br /&gt;
&lt;br /&gt;
===Zeitangaben auf der Leitung===&lt;br /&gt;
&lt;br /&gt;
Zeitstempel gehen als &#039;&#039;&#039;ISO-8601 mit Offset&#039;&#039;&#039; über die Leitung. Die Wandlung&lt;br /&gt;
gehört nicht ins Skript - dort saß der Fehler „zwei Stunden zu früh&amp;quot; an drei&lt;br /&gt;
Stellen gleichzeitig:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;RestIsoFromDate(dWert, cOffset)&amp;lt;/code&amp;gt; || OBS-Zeitpunkt -&amp;gt; &#039;&#039;2026-09-09T14:12:00+02:00&#039;&#039;. Leerer String bei leerem Datum&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;RestDateFromIso(cWert, cOffset)&amp;lt;/code&amp;gt; || ISO-Zeichenkette -&amp;gt; OBS-Zeitpunkt in &#039;&#039;&#039;Ortszeit des Servers&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Offset()&amp;lt;/code&amp;gt; || Der Offset, der in beide hineingeht&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;RestOffsetToMinutes(cOffset)&amp;lt;/code&amp;gt; || Offset in Minuten, für eigene Rechnungen&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==Antwort steuern: Statuscode, Header, traceId==&lt;br /&gt;
&lt;br /&gt;
Statuscode, Header und das Sperren einer Sitzung laufen über eigene Aufrufe des&lt;br /&gt;
Writers, &#039;&#039;&#039;nicht&#039;&#039;&#039; über Felder im Körper.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Wirkung&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Status(201)&amp;lt;/code&amp;gt; || HTTP-Statuscode (z.B. 201, 204, 400, 409, 422). Ohne Angabe: 200&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Header(&#039;ETag&#039;, &#039;7&#039;)&amp;lt;/code&amp;gt; || Beliebiger Response-Header, z.B. ETag, Location, Retry-After. CR/LF in Namen und Werten werden entfernt (Schutz vor Header-Injection)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.NoBody()&amp;lt;/code&amp;gt; || Kein Körper - für &#039;&#039;&#039;204&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.JwtRevoke(&#039;session&#039;)&amp;lt;/code&amp;gt; || Sperrt die Sitzung des vorgelegten Tokens (&#039;&#039;session&#039;&#039;) oder alle Sitzungen des Subjects (&#039;&#039;all&#039;&#039;). Damit wird ein Endpunkt zum Logout, ohne eigene Verwaltung. Scheitert das Sperren, antwortet der Server mit &#039;&#039;&#039;503&#039;&#039;&#039; statt mit der Antwort des Skripts - siehe [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]], Abschnitt Abmelden&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Feld „menge&amp;quot; fehlt&#039;)&amp;lt;/code&amp;gt; || Fertige Fehlerantwort in der Form des Servers, siehe [[#Fehlerbehandlung im Skript|Fehlerbehandlung]]&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.HasError()&amp;lt;/code&amp;gt; || Steht schon eine Fehlerantwort im Writer? Für einen Verteiler, der danach noch etwas anhängen will&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.SendFile(cPfad, cName, cTyp)&amp;lt;/code&amp;gt; || Statt eines JSON-Körpers eine &#039;&#039;&#039;Datei&#039;&#039;&#039; ausliefern. Die Datei bleibt liegen, der Download ist wiederaufsetzbar. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Datei-Download|Datei-Download]]&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.SendTempFile(cPfad, cName, cTyp)&amp;lt;/code&amp;gt; || Wie &amp;lt;code&amp;gt;SendFile&amp;lt;/code&amp;gt;, aber die Datei wird nach dem Senden &#039;&#039;&#039;gelöscht&#039;&#039;&#039; und der Download ist nicht wiederaufsetzbar - für Ergebnisse, die je Abruf entstehen (CSV-Export, Report)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Zusätzlich trägt jede Antwort den Header &#039;&#039;&#039;X-Trace-Id&#039;&#039;&#039; (Korrelations-ID). Dieselbe ID liegt dem Skript als &#039;&#039;oReader.TraceId()&#039;&#039; vor und erscheint in jeder Server-Fehler-Logzeile in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; - so lässt sich ein Fehler ohne Gerätezugriff im Log wiederfinden.&lt;br /&gt;
&lt;br /&gt;
===Beispiel: Anlegen mit 201, Location und ETag===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cNeueUid: string;&lt;br /&gt;
begin&lt;br /&gt;
    // ... Datensatz anlegen, neue UID + Version (ETag) ermitteln ...&lt;br /&gt;
&lt;br /&gt;
    oWriter.Str(&#039;uid&#039;, cNeueUid);&lt;br /&gt;
    oWriter.Status(201);&lt;br /&gt;
    oWriter.Header(&#039;Location&#039;, &#039;/v1/orders/&#039; + cNeueUid);&lt;br /&gt;
    oWriter.Header(&#039;ETag&#039;    , &#039;1&#039;);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Beispiel: Strukturierter Fehler mit Statuscode und traceId===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var nMenge: Integer;&lt;br /&gt;
begin&lt;br /&gt;
    if (not oReader.Int(&#039;menge&#039;, nMenge)) then begin&lt;br /&gt;
        // Statuscode, code, message, uid und traceId in EINEM Aufruf - dieselbe&lt;br /&gt;
        // Huelle, die auch die Server-eigenen Fehler tragen.&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Feld &amp;quot;menge&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
    // ...&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Beispiel: Optimistic Concurrency (ETag / If-Match)===&lt;br /&gt;
&lt;br /&gt;
Mit Statuscode, Headern und dem lesbaren Header &#039;&#039;If-Match&#039;&#039; führt das Skript je Datensatz einen Versionszähler und lehnt veraltete Schreibzugriffe mit 409 ab:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var nAktuell: Integer;&lt;br /&gt;
    nIfMatch: Integer;&lt;br /&gt;
begin&lt;br /&gt;
    nAktuell := AuftragVersion(oReader.Path(&#039;uid&#039;));&lt;br /&gt;
    nIfMatch := iVal(oReader.Param(&#039;if-match&#039;));&lt;br /&gt;
&lt;br /&gt;
    if (nIfMatch &amp;lt;&amp;gt; nAktuell) then begin&lt;br /&gt;
        // Der ETag gehoert AUCH an die Ablehnung - sonst kennt der Client den&lt;br /&gt;
        // gueltigen Wert nicht und sein naechster Versuch scheitert wieder.&lt;br /&gt;
        oWriter.Header(&#039;ETag&#039;, xStr(nAktuell));&lt;br /&gt;
        oWriter.Error(409, &#039;VERSION_CONFLICT&#039;, &#039;Der Datensatz wurde zwischenzeitlich geaendert&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // ... speichern, Version hochzählen ...&lt;br /&gt;
    oWriter.Header(&#039;ETag&#039;, xStr(nAktuell + 1));&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Idempotenz - was das Skript beachten muss==&lt;br /&gt;
&lt;br /&gt;
Sendet ein Konsument bei &#039;&#039;POST&#039;&#039;/&#039;&#039;PUT&#039;&#039;/&#039;&#039;PATCH&#039;&#039;/&#039;&#039;DELETE&#039;&#039; den Header&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, fängt der &#039;&#039;&#039;Server&#039;&#039;&#039; doppelte Sendungen ab. Das&lt;br /&gt;
Skript braucht dafür &#039;&#039;&#039;keine eigene Logik&#039;&#039;&#039; - keine Schlüssel-Tabelle, keine&lt;br /&gt;
Prüfung am Anfang der Methode.&lt;br /&gt;
&lt;br /&gt;
Was das Skript wissen muss:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Antwort des Skripts !! Wirkung auf den Idempotenz-Speicher&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;2xx&#039;&#039;&#039; || Statuscode, Body und Header werden gespeichert. Eine Wiederholung mit demselben Schlüssel bekommt genau diese Antwort zurück, das Skript läuft nicht erneut&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;4xx&#039;&#039;&#039; || Der Schlüssel wird &#039;&#039;&#039;freigegeben&#039;&#039;&#039;. Der Konsument darf denselben Schlüssel nach Korrektur des Inhalts erneut verwenden&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;5xx&#039;&#039;&#039; oder Exception || Der Schlüssel bleibt belegt, der Fall wird protokolliert. Eine Wiederholung wird abgelehnt, bis der Fall geklärt ist&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Daraus folgen zwei Regeln für Endpunkt-Skripte:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Fachliche Ablehnungen als 4xx melden&#039;&#039;&#039; (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.&lt;br /&gt;
* &#039;&#039;&#039;Die Antwort muss vollständig sein.&#039;&#039;&#039; Was zurückgegeben wird, wird eingefroren. Ein Feld, das erst der zweite Aufruf ergänzen würde, kommt beim Konsumenten nie an.&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
Der vollständige Ablauf und die Statuscodes stehen unter&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]].&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierungs-Skript==&lt;br /&gt;
&lt;br /&gt;
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 &#039;&#039;Authenticate&#039;&#039; bereitstellen:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Authenticate(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUser: string;&lt;br /&gt;
    cPass: string;&lt;br /&gt;
begin&lt;br /&gt;
    // Eingangsdaten lesen&lt;br /&gt;
    cUser := &#039;&#039;;&lt;br /&gt;
    cPass := &#039;&#039;;&lt;br /&gt;
    oReader.Str(&#039;username&#039;, cUser);&lt;br /&gt;
    oReader.Str(&#039;password&#039;, cPass);&lt;br /&gt;
&lt;br /&gt;
    // Prüfung gegen eigene Tabelle, Hash-Verfahren, LDAP, ...&lt;br /&gt;
    if (PasswortPasst(cUser, cPass)) then begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;           , 1);&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_ID&#039;      , cUser);&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_SUBJECT&#039; , cUser);&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_AUDIENCE&#039;, &#039;mandant1&#039;);&lt;br /&gt;
    end else begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;, 9);&lt;br /&gt;
        oWriter.Str(&#039;error&#039; , &#039;Login fehlgeschlagen&#039;);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Im Anmeldeskript bleiben die _OBS_JWT_*-Felder Felder der&lt;br /&gt;
Antwort&#039;&#039;&#039; - anders als Status und Header, die zu Aufrufen des Writers geworden&lt;br /&gt;
sind. Der Grund: die Token-Ausstellung liest sie aus dem fertigen Körper, und&lt;br /&gt;
genau dort erwartet sie sie. Geschrieben werden sie deshalb wie jedes andere&lt;br /&gt;
Feld, nur eben mit dem Writer.}}&lt;br /&gt;
&lt;br /&gt;
Rückgabewerte:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! status !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| 1      || Erfolgreich, der Server erzeugt aus den &#039;&#039;_OBS_JWT_*&#039;&#039;-Werten einen Token (HS256)&lt;br /&gt;
|-&lt;br /&gt;
| 9      || Misserfolg, der Server antwortet mit &#039;&#039;&#039;401&#039;&#039;&#039; und reicht den Wert von &#039;&#039;error&#039;&#039; als &#039;&#039;error.message&#039;&#039; an den Client durch (zusätzlich protokolliert)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der Text aus &#039;&#039;error&#039;&#039; geht bei &#039;&#039;status&#039;&#039; = 9 &#039;&#039;&#039;an den Konsumenten&#039;&#039;&#039;&lt;br /&gt;
und wird typischerweise direkt unter dem Passwortfeld angezeigt. Er sollte für&lt;br /&gt;
Endanwender verständlich sein und keine internen Details verraten.}}&lt;br /&gt;
&lt;br /&gt;
Liefert das Skript einen anderen Status als 1 oder 9, ist gar nicht lauffähig oder&lt;br /&gt;
gibt kein auswertbares JSON zurück, antwortet der Server mit &#039;&#039;&#039;403&#039;&#039;&#039; und einer&lt;br /&gt;
generischen Meldung - ein defektes Anmeldeskript soll dem Anwender nicht als&lt;br /&gt;
„Passwort falsch&amp;quot; erscheinen.&lt;br /&gt;
&lt;br /&gt;
Kann der Server die &#039;&#039;&#039;Sitzungszeile&#039;&#039;&#039; zum ausgestellten Token nicht schreiben&lt;br /&gt;
(Tabelle fehlt, Datenbankproblem), liefert er &#039;&#039;&#039;kein Token&#039;&#039;&#039; aus und antwortet&lt;br /&gt;
mit &#039;&#039;&#039;503&#039;&#039;&#039; und &#039;&#039;Retry-After&#039;&#039;. Auch das ist bewusst kein 401/403: die&lt;br /&gt;
Zugangsdaten waren richtig, der Versuch darf wiederholt werden.&lt;br /&gt;
&lt;br /&gt;
===Aufbau der Token-Antwort===&lt;br /&gt;
&lt;br /&gt;
Bei Erfolg baut der Server die Antwort aus den Token-Feldern &#039;&#039;&#039;und allen weiteren&lt;br /&gt;
Feldern, die das Skript zurückgibt&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;benutzerId&amp;quot;: &amp;quot;4711&amp;quot;, &amp;quot;tenant&amp;quot;: &amp;quot;nord&amp;quot;, &amp;quot;roles&amp;quot;: [&amp;quot;TECHNIKER&amp;quot;],&lt;br /&gt;
  &amp;quot;token&amp;quot;:       &amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;accessToken&amp;quot;: &amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;refreshToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:   28800,&lt;br /&gt;
  &amp;quot;serverTime&amp;quot;:  &amp;quot;2026-08-17T09:12:33+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld !! Herkunft&lt;br /&gt;
|-&lt;br /&gt;
| token / accessToken || Derselbe Access-Token unter zwei Namen. &#039;&#039;accessToken&#039;&#039; erwarten die meisten Client-Bibliotheken, &#039;&#039;token&#039;&#039; bleibt für bestehende Konsumenten erhalten&lt;br /&gt;
|-&lt;br /&gt;
| refreshToken || Nur wenn das Skript &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; geliefert hat&lt;br /&gt;
|-&lt;br /&gt;
| expiresIn || Laufzeit des Access-Tokens in &#039;&#039;&#039;Sekunden&#039;&#039;&#039; (&#039;&#039;JWT-Exp&#039;&#039; × 60)&lt;br /&gt;
|-&lt;br /&gt;
| serverTime || Serverzeit ISO-8601 mit Offset&lt;br /&gt;
|-&lt;br /&gt;
| alle übrigen Felder || &#039;&#039;&#039;Frei vom Skript bestimmt.&#039;&#039;&#039; Übernommen wird alles ausser &#039;&#039;status&#039;&#039; und den &#039;&#039;_OBS_&#039;&#039;-Steuerfeldern&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Damit kann das Anmeldeskript alles mitliefern, was der Client direkt nach dem Login&lt;br /&gt;
braucht (Benutzer-Id, Mandant, Rollen, Berechtigungen) - ohne dass dieser dafür&lt;br /&gt;
einen zweiten Aufruf absetzen muss. Ein Feld, das genauso heisst wie eines der&lt;br /&gt;
Token-Felder oben, wird verworfen; die Engine setzt diese selbst.&lt;br /&gt;
&lt;br /&gt;
===Custom-Claims, serverTime und Refresh===&lt;br /&gt;
&lt;br /&gt;
Das Authenticate-Skript kann dem Token zusätzlich &#039;&#039;&#039;beliebige Custom-Claims&#039;&#039;&#039; mitgeben - z.B. Mandant und Rollen - und optional ein &#039;&#039;&#039;Refresh-Token&#039;&#039;&#039; anstoßen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! RÜckgabefeld !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_CLAIM_&amp;amp;lt;name&amp;amp;gt; || Beliebiger Custom-Claim, z.B. _OBS_JWT_CLAIM_tenant, _OBS_JWT_CLAIM_roles. Im Folge-Skript lesbar als &amp;lt;code&amp;gt;oReader.Claim(&#039;&amp;amp;lt;name&amp;amp;gt;&#039;)&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_REFRESH_ID || (optional) jti des Refresh-Tokens. Nur wenn gesetzt, stellt der Server ein Refresh-Token aus.&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_REFRESH_EXP || (optional) Lebensdauer des Refresh-Tokens in Minuten (Default 90 Tage = 129600).&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Jedes Token trägt automatisch den Claim &#039;&#039;token_use&#039;&#039; (&#039;&#039;access&#039;&#039; bzw. &#039;&#039;refresh&#039;&#039;) sowie die Sitzungskennungen &#039;&#039;sid&#039;&#039; und &#039;&#039;rid&#039;&#039; des Servers. Die Antwort enthält bei Erfolg &#039;&#039;serverTime&#039;&#039; (ISO-8601 mit Offset), bei ausgestelltem Refresh zusätzlich &#039;&#039;refreshToken&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;refreshToken&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;serverTime&amp;quot;:&amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Mandant und Rollen immer aus dem Token lesen (&#039;&#039;oReader.Claim(…)&#039;&#039;), nie aus Body oder Query.}}&lt;br /&gt;
&lt;br /&gt;
====Refresh-Methode====&lt;br /&gt;
&lt;br /&gt;
Der Refresh läuft über &#039;&#039;&#039;denselben JWT-Endpunkt&#039;&#039;&#039; und &#039;&#039;&#039;dasselbe Skript&#039;&#039;&#039;. Liegt am JWT-Endpunkt ein &#039;&#039;Authorization: Bearer &amp;lt;token&amp;gt;&#039;&#039; mit einem Refresh-Token vor, ruft der Server statt &#039;&#039;Authenticate&#039;&#039; die Methode &#039;&#039;&#039;Refresh&#039;&#039;&#039; auf (ohne Bearer: Login; &#039;&#039;DELETE&#039;&#039; mit Access-Token: Abmelden, ohne Skript).&lt;br /&gt;
&lt;br /&gt;
Bevor das Skript läuft, hat der Server das vorgelegte Refresh-Token verifiziert&lt;br /&gt;
(Signatur, Ablauf, &#039;&#039;token_use=refresh&#039;&#039;) und in der Sitzung &#039;&#039;&#039;entwertet&#039;&#039;&#039;.&lt;br /&gt;
Einmalgebrauch, Rotation und Sperrliste sind damit Sache des Servers. Das Skript&lt;br /&gt;
liefert nur noch die aktuellen Claims - genau das ist der Sinn des Aufrufs: eine&lt;br /&gt;
zwischenzeitlich geänderte Rolle oder ein gewechselter Mandant wirken spätestens&lt;br /&gt;
mit der nächsten Erneuerung.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Eine &#039;&#039;&#039;eigene Sperrtabelle im Skript ist nicht mehr nötig&#039;&#039;&#039; und sollte&lt;br /&gt;
entfernt werden. Wer sie weiterführt, rotiert zweimal - einmal im Skript, einmal im&lt;br /&gt;
Server - und riskiert, dass beide Seiten unterschiedlicher Meinung sind. Der&lt;br /&gt;
Server sieht die Skript-Tabelle nicht, und das Skript sieht die Sitzung nicht.}}&lt;br /&gt;
&lt;br /&gt;
Ein bereits benutztes Refresh-Token wird mit &#039;&#039;&#039;401&#039;&#039;&#039; abgelehnt. Erfolgt die&lt;br /&gt;
erneute Vorlage innerhalb von 60 Sekunden, bleibt die Sitzung bestehen (paralleler&lt;br /&gt;
Refresh einer App); danach gilt sie als Wiedervorlage und die &#039;&#039;&#039;gesamte Sitzung&lt;br /&gt;
wird gesperrt&#039;&#039;&#039;. Details in [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
&lt;br /&gt;
Das alte Refresh-jti steht weiterhin als &#039;&#039;oReader.Param(&#039;_OBS_JWT_ID&#039;)&#039;&#039; bereit,&lt;br /&gt;
falls das Skript es für eigene Protokollierung braucht.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Refresh(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUser: string;&lt;br /&gt;
begin&lt;br /&gt;
    cUser := oReader.Subject();&lt;br /&gt;
&lt;br /&gt;
    // Der Server hat das vorgelegte Refresh-Token bereits geprueft und&lt;br /&gt;
    // entwertet. Hier wird nur entschieden, ob der Benutzer die Sitzung&lt;br /&gt;
    // fortsetzen darf - und mit welchen Rechten.&lt;br /&gt;
    if (not BenutzerAktiv(cUser)) then begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;, 9);&lt;br /&gt;
        oWriter.Str(&#039;error&#039; , &#039;Konto ist nicht mehr aktiv, bitte neu anmelden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Rolle und Mandant neu lesen, nicht aus dem alten Token uebernehmen -&lt;br /&gt;
    // sonst wirkt ein Rechteentzug erst beim naechsten Login.&lt;br /&gt;
    oWriter.Int(&#039;status&#039;               , 1);&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());   // loest das neue Refresh-Token aus&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Abmelden (Logout)===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Für das Abmelden braucht das Skript keine Methode.&#039;&#039;&#039; Es läuft über ein&lt;br /&gt;
&#039;&#039;DELETE&#039;&#039; auf denselben JWT-Endpunkt (Access-Token im &#039;&#039;Authorization&#039;&#039;-Header)&lt;br /&gt;
und wird komplett in der Engine erledigt: die Sitzung wird gesperrt, die Antwort&lt;br /&gt;
ist 204. Eine Methode &#039;&#039;Logout&#039;&#039; gibt es nicht und wird nicht aufgerufen.&lt;br /&gt;
Details: [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]], Phase 4.&lt;br /&gt;
&lt;br /&gt;
Wer beim Abmelden zusätzlich fachlich etwas tun will - eine Geräteregistrierung&lt;br /&gt;
löschen, einen Push-Token verwerfen, den Vorgang protokollieren - nimmt einen&lt;br /&gt;
&#039;&#039;&#039;gewöhnlichen Endpunkt&#039;&#039;&#039; und setzt dort &#039;&#039;_OBS_JWT_REVOKE&#039;&#039;. Dasselbe gilt für&lt;br /&gt;
&amp;quot;auf allen Geräten abmelden&amp;quot;, weil das eine Berechtigungsentscheidung braucht:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
begin&lt;br /&gt;
    GeraeteRegistrierungLoeschen(oReader.Subject());&lt;br /&gt;
&lt;br /&gt;
    // &#039;session&#039; = diese Sitzung, &#039;all&#039; = alle Sitzungen des Subjects&lt;br /&gt;
    oWriter.JwtRevoke(&#039;session&#039;);&lt;br /&gt;
    oWriter.Status(204);&lt;br /&gt;
    oWriter.NoBody();&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Gesperrt wird in beiden Wegen die &#039;&#039;&#039;ganze Sitzung&#039;&#039;&#039;, also auch die noch nicht&lt;br /&gt;
abgelaufenen Access-Token vorheriger Erneuerungen. Ein wiederholter Aufruf ist&lt;br /&gt;
unschädlich. Lässt sich die Sitzung nicht sperren, überschreibt der Server die&lt;br /&gt;
Antwort des Skripts mit &#039;&#039;&#039;503&#039;&#039;&#039; - ein Client darf nicht glauben, er sei&lt;br /&gt;
abgemeldet, während sein Token weiterläuft.&lt;br /&gt;
&lt;br /&gt;
==Fehlerbehandlung im Skript==&lt;br /&gt;
&lt;br /&gt;
Tritt im Skript eine Exception auf oder schlägt die Syntax-Prüfung fehl, antwortet der Server mit 500 &#039;&#039;Interner Fehler&#039;&#039; und protokolliert die Detail-Meldung in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; (mit Skript-Fehlertext). Der Konsument sieht keine internen Details.&lt;br /&gt;
&lt;br /&gt;
Server-eigene Fehler (Routing, Rate-Limit, Token, Auffangnetz) haben ein&lt;br /&gt;
einheitliches Format mit einem &#039;&#039;error&#039;&#039;-Objekt - Aufbau siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer|Übersicht]].&lt;br /&gt;
&lt;br /&gt;
Für einen spezifischen Fehler an den Konsumenten gibt es &#039;&#039;&#039;einen&#039;&#039;&#039; Aufruf. Er&lt;br /&gt;
baut dieselbe Hülle, die auch die Server-eigenen Fehler tragen - Statuscode,&lt;br /&gt;
&#039;&#039;code&#039;&#039;, &#039;&#039;message&#039;&#039;, &#039;&#039;uid&#039;&#039; und &#039;&#039;traceId&#039;&#039; -, sodass beide Wege nicht&lt;br /&gt;
auseinanderlaufen können:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
begin&lt;br /&gt;
    if (Empty(oReader.Param(&#039;kundennr&#039;))) then begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Parameter &amp;quot;kundennr&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
    // ...&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Error verwirft einen schon begonnenen Körper&#039;&#039;&#039; und setzt seinen&lt;br /&gt;
eigenen. Ein Skript darf also getrost Felder schreiben und danach noch&lt;br /&gt;
abbrechen - was der Client sieht, ist die Fehlerantwort. Nur ein &#039;&#039;&#039;offener&lt;br /&gt;
Stapel&#039;&#039;&#039; (ein &#039;&#039;ObjBegin&#039;&#039; ohne &#039;&#039;ObjEnd&#039;&#039;) bleibt auch dann ein Strukturfehler.}}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein fachlicher Fehler gehört auf einen &#039;&#039;&#039;4xx&#039;&#039;&#039;-Statuscode, nicht auf&lt;br /&gt;
200 mit Fehlertext im Body. Nur so erkennt der Konsument den Fehlschlag ohne den&lt;br /&gt;
Body auszuwerten - und nur so gibt der Server einen belegten&lt;br /&gt;
&#039;&#039;Idempotency-Key&#039;&#039; wieder frei (siehe Abschnitt Idempotenz).}}&lt;br /&gt;
&lt;br /&gt;
===Ändern: qSqlInit statt qSqlRead===&lt;br /&gt;
&lt;br /&gt;
Zum &#039;&#039;&#039;Ändern&#039;&#039;&#039; eines vorhandenen Satzes wird ebenfalls &amp;lt;code&amp;gt;qSqlInit&amp;lt;/code&amp;gt;&lt;br /&gt;
benutzt - der Satz wird über &amp;lt;code&amp;gt;sys_uid&amp;lt;/code&amp;gt; adressiert:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
q := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
q.qSet(&#039;sys_uid&#039;  , cSysUid);      // Schreib-Index auf den Originalsatz&lt;br /&gt;
q.qSet(&#039;ti_status&#039;, &#039;9&#039;);&lt;br /&gt;
if (not q.SaveData(UPDATE_RECORD)) then begin&lt;br /&gt;
    // ... Fehler behandeln, siehe unten&lt;br /&gt;
end;&lt;br /&gt;
qSqlFree(q);&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;qSqlRead&amp;lt;/code&amp;gt; ist dafür die falsche Wahl, aus zwei Gründen:&lt;br /&gt;
&lt;br /&gt;
* Es liest den &#039;&#039;&#039;kompletten Altsatz&#039;&#039;&#039; ein - ein zusätzlicher Lesezugriff, der Zeit kostet.&lt;br /&gt;
* Es schreibt anschliessend nur die &#039;&#039;&#039;Änderungen&#039;&#039;&#039;. Ist der neue Wert gleich dem alten, entsteht gar kein Write, und &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt; liefert &#039;&#039;&#039;False&#039;&#039;&#039; - ununterscheidbar von einem echten Fehlschlag.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ohne &amp;lt;code&amp;gt;sys_uid&amp;lt;/code&amp;gt; legt &amp;lt;code&amp;gt;qSqlInit&amp;lt;/code&amp;gt; einen &#039;&#039;&#039;neuen&#039;&#039;&#039;&lt;br /&gt;
Datensatz an, statt den vorhandenen zu ändern. Die &amp;lt;code&amp;gt;sys_uid&amp;lt;/code&amp;gt; ist der&lt;br /&gt;
Schreib-Index und gehört bei jeder Änderung gesetzt.}}&lt;br /&gt;
&lt;br /&gt;
Damit entfällt auch das übliche Paar aus &amp;lt;code&amp;gt;DB_LSeek&amp;lt;/code&amp;gt; und&lt;br /&gt;
&amp;lt;code&amp;gt;qSqlRead&amp;lt;/code&amp;gt;: &#039;&#039;&#039;eine&#039;&#039;&#039; Abfrage auf die &amp;lt;code&amp;gt;sys_uid&amp;lt;/code&amp;gt; liefert&lt;br /&gt;
zugleich die Existenzprüfung und den Schreib-Index.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
cUid := &#039;&#039;;&lt;br /&gt;
if (DB_SOpen(oDB, &#039;SELECT sys_uid FROM tickets WHERE &#039; + cWhere + &#039; LIMIT 1&#039;, q)) then begin&lt;br /&gt;
    if (not q.EoF) then begin&lt;br /&gt;
        cUid := q.A2UID();&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
DB_Close(q);&lt;br /&gt;
&lt;br /&gt;
if (not Empty(cUid)) then begin&lt;br /&gt;
    // ändern: qSqlInit + sys_uid&lt;br /&gt;
end else begin&lt;br /&gt;
    // anlegen: qSqlInit ohne sys_uid&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Mehrere Felder sind EIN Schreibvorgang===&lt;br /&gt;
&lt;br /&gt;
Ein &amp;lt;code&amp;gt;qSqlInit&amp;lt;/code&amp;gt;-Objekt nimmt beliebig viele &amp;lt;code&amp;gt;qSet&amp;lt;/code&amp;gt; entgegen&lt;br /&gt;
und schreibt sie mit &#039;&#039;&#039;einem&#039;&#039;&#039; &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt;. Fallen an derselben&lt;br /&gt;
Zeile mehrere Felder an, gehören sie in dasselbe Objekt:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
// FALSCH: fünf Felder, fünf Schreibvorgänge - und jeder Setzer, der sich&lt;br /&gt;
// seine sys_uid selbst holt, bringt zusätzlich eine eigene Abfrage mit&lt;br /&gt;
FeldSetzen(cUid, &#039;ti_status&#039; , &#039;9&#039;);&lt;br /&gt;
FeldSetzen(cUid, &#039;ti_grund&#039;  , cGrund);&lt;br /&gt;
FeldSetzen(cUid, &#039;ti_von&#039;    , cBenutzer);&lt;br /&gt;
&lt;br /&gt;
// RICHTIG: ein Schreibobjekt, ein SaveData&lt;br /&gt;
q := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
q.qSet(&#039;sys_uid&#039;  , cUid);&lt;br /&gt;
q.qSet(&#039;ti_status&#039;, &#039;9&#039;);&lt;br /&gt;
q.qSet(&#039;ti_grund&#039; , cGrund);&lt;br /&gt;
q.qSet(&#039;ti_von&#039;   , cBenutzer);&lt;br /&gt;
if (not q.SaveData(UPDATE_RECORD)) then begin&lt;br /&gt;
    // ... Fehler behandeln&lt;br /&gt;
end;&lt;br /&gt;
qSqlFree(q);&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Das ist nicht nur eine Frage der Geschwindigkeit: einzeln geschriebene Felder&lt;br /&gt;
sind &#039;&#039;&#039;nicht atomar&#039;&#039;&#039;. Bricht der dritte Schreibvorgang ab, steht die Zeile&lt;br /&gt;
halb geändert da - Grund gesetzt, Zeitpunkt nicht.&lt;br /&gt;
&lt;br /&gt;
Wird nichts gesetzt, darf auch nicht geschrieben werden: das Schreibobjekt dann&lt;br /&gt;
mit &amp;lt;code&amp;gt;qSqlFree&amp;lt;/code&amp;gt; freigeben, statt ein leeres &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt;&lt;br /&gt;
abzusetzen.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein Setzer für &#039;&#039;&#039;ein&#039;&#039;&#039; Feld (&amp;lt;code&amp;gt;FeldSetzen(cUid, cFeld, cWert)&amp;lt;/code&amp;gt;)&lt;br /&gt;
ist bequem und deshalb verführerisch. Er versteckt aber je Aufruf eine eigene&lt;br /&gt;
Abfrage und ein eigenes &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt;. Für einen Vorgang mit mehreren&lt;br /&gt;
Feldern gehört ein &#039;&#039;&#039;Editier-Helfer&#039;&#039;&#039; her, der das Schreibobjekt liefert -&lt;br /&gt;
geschrieben wird einmal am Ende.}}&lt;br /&gt;
&lt;br /&gt;
===Keine Abfrage je Zeile===&lt;br /&gt;
&lt;br /&gt;
Was in einer Liste je Zeile nachgeschlagen wird, kostet &#039;&#039;&#039;N&#039;&#039;&#039; Abfragen für&lt;br /&gt;
&#039;&#039;&#039;eine&#039;&#039;&#039; Antwort. Bei fünfzig Aufträgen sind das fünfzig Abfragen für ein&lt;br /&gt;
einziges Feld. Der Ausweg ist immer derselbe:&lt;br /&gt;
&lt;br /&gt;
* Werte aus einer 1:1-Beziehung über einen &amp;lt;code&amp;gt;LEFT JOIN&amp;lt;/code&amp;gt; mitnehmen - bei einem &#039;&#039;&#039;eindeutigen&#039;&#039;&#039; Index auf dem Join-Feld kann er die Zeilen nicht vervielfachen.&lt;br /&gt;
* Einzelwerte aus einer 1:n-Beziehung über eine &#039;&#039;&#039;Unterabfrage&#039;&#039;&#039; in der SELECT-Liste holen, nicht über einen Join (der Join würde die Zeilen vervielfachen).&lt;br /&gt;
* Eine echte Unterliste (Positionen, Dateien) nur dann nachladen, wenn ein &#039;&#039;&#039;Zählfeld&#039;&#039;&#039; in der Hauptabfrage sagt, dass es überhaupt eine gibt.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein GET soll &#039;&#039;&#039;lesen&#039;&#039;&#039;. Schreibvorgänge im Lesepfad - etwas&lt;br /&gt;
&amp;quot;beim ersten Ausliefern festschreiben&amp;quot; - vervielfachen sich mit der Zeilenzahl&lt;br /&gt;
und treffen den Aufrufer, der nur eine Liste angefordert hat.}}&lt;br /&gt;
&lt;br /&gt;
===Schreibfehler erkennen===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt; liefert einen Boolean - und der ist die einzige&lt;br /&gt;
Fehlermeldung, die es gibt.&#039;&#039;&#039; (Auch der &#039;&#039;&#039;Parameter&#039;&#039;&#039; ist ein Boolean:&lt;br /&gt;
&#039;&#039;NEW_RECORD&#039;&#039; und &#039;&#039;UPDATE_RECORD&#039;&#039; sind Wahrheitswerte, keine Zahlen.) Schlägt das Schreiben in der Datenbank fehl&lt;br /&gt;
(&#039;&#039;Duplicate entry&#039;&#039; auf einem eindeutigen Index, zu langer Wert, fehlende&lt;br /&gt;
Spalte), dann&lt;br /&gt;
&lt;br /&gt;
* wird &#039;&#039;&#039;keine&#039;&#039;&#039; Exception ausgelöst,&lt;br /&gt;
* steht &#039;&#039;&#039;nichts&#039;&#039;&#039; in &#039;&#039;RESTSRV_PROTO&#039;&#039;,&lt;br /&gt;
* läuft das Skript weiter und antwortet mit dem Erfolg, den es selbst formuliert.&lt;br /&gt;
&lt;br /&gt;
Wer den Aufruf als Anweisung schreibt, baut damit einen stillen Datenverlust ein.&lt;br /&gt;
Weil in einem Endpunkt schnell ein Dutzend Schreibvorgänge zusammenkommen, gehört&lt;br /&gt;
die Prüfung in eine eigene kleine Prozedur - am besten in die gemeinsame Lib:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Speichern(q: TqSQL; lNeu: Boolean; const cWas: string);&lt;br /&gt;
var lOk: Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    lOk := q.SaveData(lNeu);&lt;br /&gt;
    qSqlFree(q);                     // auch im Fehlerfall freigeben&lt;br /&gt;
    if (not lOk) then begin&lt;br /&gt;
        raise Exception.Create(&#039;Schreiben fehlgeschlagen: &#039; + cWas);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
// Aufruf statt SaveData + qSqlFree:&lt;br /&gt;
q := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
q.qSet(&#039;ti_betreff&#039;, cBetreff);&lt;br /&gt;
Speichern(q, NEW_RECORD, &#039;Ticket&#039;);&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Die Exception fängt der Server: er schreibt &#039;&#039;Schreiben fehlgeschlagen: Ticket&#039;&#039;&lt;br /&gt;
nach &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; und antwortet mit &#039;&#039;&#039;500&#039;&#039;&#039;. Liegt ein&lt;br /&gt;
&#039;&#039;Idempotency-Key&#039;&#039; an, wird er dabei &#039;&#039;&#039;freigegeben&#039;&#039;&#039; - die Anfrage ist also&lt;br /&gt;
wiederholbar (siehe Abschnitt Idempotenz).&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein fehlgeschlagenes Schreiben ist kein Fall, in dem ein Endpunkt&lt;br /&gt;
sinnvoll weiterlaufen kann: die Fachwirkung ist dann unvollständig. Wer trotzdem&lt;br /&gt;
weitermachen will - etwa weil mehrere unabhängige Sätze geschrieben werden -,&lt;br /&gt;
prüft den Rückgabewert und baut selbst eine Antwort. Ignorieren darf man ihn&lt;br /&gt;
nie.}}&lt;br /&gt;
&lt;br /&gt;
Gängige Codes, die auch der Server selbst verwendet: &#039;&#039;VALIDATION_FAILED&#039;&#039; (422),&lt;br /&gt;
&#039;&#039;NOT_FOUND&#039;&#039; (404), &#039;&#039;FORBIDDEN_ROLE&#039;&#039; (403), &#039;&#039;VERSION_CONFLICT&#039;&#039; (409).&lt;br /&gt;
Eigene, fachlich sprechende Codes sind erlaubt - wichtig ist, dass sie stabil&lt;br /&gt;
bleiben, weil Konsumenten darauf ihre Reaktion aufbauen.&lt;br /&gt;
&lt;br /&gt;
==Gemeinsamen Code auslagern==&lt;br /&gt;
&lt;br /&gt;
Wird Logik in mehreren Endpunkten gebraucht - Berechtigungsprüfungen,&lt;br /&gt;
JSON-Bausteine, Hilfsfunktionen -, muss sie nicht in jedes Skript kopiert werden.&lt;br /&gt;
Die Skript-Engine lädt Textbausteine beim Laden aus einer beliebigen Tabelle nach:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{$L T=&amp;quot;restsrv_endpoints&amp;quot; I=&amp;quot;re_pathtemplate&amp;quot; V=&amp;quot;/meinapp/lib&amp;quot; F=&amp;quot;re_script&amp;quot;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Attribut !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;T&#039;&#039;&#039; || Tabelle, aus der geladen wird&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;I&#039;&#039;&#039; || Spalte, über die gesucht wird&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;V&#039;&#039;&#039; || Wert, der in dieser Spalte stehen muss&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;F&#039;&#039;&#039; || Spalte, deren Inhalt an dieser Stelle eingesetzt wird&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Direktive steht üblicherweise direkt unter dem Kopfkommentar des Skripts. Der&lt;br /&gt;
Compiler sieht danach den zusammengesetzten Text - eine Funktion aus der Lib lässt&lt;br /&gt;
sich also aufrufen wie eine im Skript selbst geschriebene.&lt;br /&gt;
&lt;br /&gt;
===Bewährtes Muster: Lib als eigene Endpunkt-Zeile===&lt;br /&gt;
&lt;br /&gt;
Am wenigsten Verwaltung macht es, die Lib &#039;&#039;&#039;als eigene Zeile in&lt;br /&gt;
RESTSRV_ENDPOINTS&#039;&#039;&#039; abzulegen:&lt;br /&gt;
&lt;br /&gt;
# Endpunkt anlegen mit einem eigenen Pfad-Template, z.B. &amp;lt;code&amp;gt;/meinapp/lib&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Den Lib-Quelltext ins Skript-Feld dieser Zeile schreiben (F7).&lt;br /&gt;
# &#039;&#039;&#039;Keine Berechtigung&#039;&#039;&#039; in der Berechtigungs-Liste vergeben - das ist der Schutz: ohne Berechtigung beantwortet der Server einen Aufruf mit 403.&lt;br /&gt;
# In jedem nutzenden Endpunkt die Direktive oben einfügen.&lt;br /&gt;
&lt;br /&gt;
Damit ist die Lib reiner Ablageort und von aussen nicht nutzbar. Eine Änderung&lt;br /&gt;
wirkt auf alle einbindenden Endpunkte, ohne dass ein einziges Endpunkt-Skript&lt;br /&gt;
angefasst werden muss.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Lib und Skripte gemeinsam einspielen.&#039;&#039;&#039; Kommt in der Lib eine neue&lt;br /&gt;
Funktion hinzu, genügt es nicht, nur das nutzende Skript zu aktualisieren. Eine&lt;br /&gt;
ältere Lib lädt fehlerfrei - der Compiler meldet dann ausschliesslich die neuen&lt;br /&gt;
Namen als &#039;&#039;Unknown name&#039;&#039;, was leicht wie ein Fehler im Skript aussieht.}}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Auch hier greift der Endpunkt-Cache: eine Änderung an der Lib wird erst&lt;br /&gt;
nach Ablauf des TTL (bis zu 60 Sekunden) wirksam.}}&lt;br /&gt;
&lt;br /&gt;
===Wohin gehört welcher Code?===&lt;br /&gt;
&lt;br /&gt;
Drei Ebenen, und die Zuordnung entscheidet, wie schnell eine Änderung wirkt:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Art des Codes !! Wohin !! Wirksam&lt;br /&gt;
|-&lt;br /&gt;
| Fachlogik **einer** Anwendung - Feldnamen, Zustandswerte, Rollenregeln, Schreibziele || in die &#039;&#039;&#039;DWS-Lib dieser Anwendung&#039;&#039;&#039; (eigene Endpunkt-Zeile, per &amp;lt;code&amp;gt;{$L …}&amp;lt;/code&amp;gt; eingebunden) || nach Ablauf des Cache-TTL, ohne Build&lt;br /&gt;
|-&lt;br /&gt;
| Mechanik, die &#039;&#039;&#039;mehrere&#039;&#039;&#039; Anwendungen brauchen - und auch das OBS-Umfeld || nach &#039;&#039;&#039;obs_lib&#039;&#039;&#039; als Delphi-Unit || erst nach Build und Rollout&lt;br /&gt;
|-&lt;br /&gt;
| Mechanik, die &#039;&#039;&#039;nur&#039;&#039;&#039; der REST-Server braucht || in die Units des Servers || erst nach Build und Rollout&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Regel hat einen praktischen Grund und keinen ästhetischen: Fachlogik ändert&lt;br /&gt;
sich in Tagen, Mechanik in Monaten. Was in der Lib liegt, ist nach einer Minute&lt;br /&gt;
aktiv; was in einer Unit liegt, braucht einen Build. Wer Fachlogik nach unten&lt;br /&gt;
schiebt, tauscht Geschwindigkeit gegen nichts ein.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Eine Anwendung, die ihre eigene Lib hat, kopiert sie &#039;&#039;&#039;nicht&#039;&#039;&#039; von&lt;br /&gt;
einer anderen. Zwei Kopien derselben Lib driften auseinander, und die Abweichung&lt;br /&gt;
fällt erst auf, wenn eine der beiden Anwendungen etwas Falsches tut. Geteiltes&lt;br /&gt;
gehört eine Ebene tiefer.}}&lt;br /&gt;
&lt;br /&gt;
==Fallstricke im Skript==&lt;br /&gt;
&lt;br /&gt;
Die folgenden Punkte unterscheiden das Skript von normalem Delphi-Code und kosten&lt;br /&gt;
sonst eine Runde Fehlersuche:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Stolperstein !! Richtig&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;DB_SOpen(&#039;Y00…&#039;, oDB, …)&#039;&#039; - der AD-UID-Parameter aus dem Delphi-Code || Im Skript &#039;&#039;&#039;ohne UID&#039;&#039;&#039;: &amp;lt;code&amp;gt;DB_SOpen(oDB, cSql, q)&amp;lt;/code&amp;gt;. Gleiches gilt für &amp;lt;code&amp;gt;qSqlInit(oDB, &#039;tab&#039;)&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;qSqlRead(oDB, &#039;tab&#039;, cWhere)&amp;lt;/code&amp;gt; - jede dieser Funktionen beginnt mit &#039;&#039;oDB&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;oReader.Str&amp;amp;lt;T&amp;amp;gt;(…)&#039;&#039; oder irgendein Generic || Generics gibt es in der Skriptsprache nicht. Die Leser sind je Typ eigene Methoden: &#039;&#039;Str&#039;&#039;, &#039;&#039;Int&#039;&#039;, &#039;&#039;Num&#039;&#039;, &#039;&#039;Bool&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| Eine eigene Prozedur &#039;&#039;Delete&#039;&#039; für DELETE || &#039;&#039;Delete&#039;&#039; ist eine &#039;&#039;&#039;Standardprozedur&#039;&#039;&#039; der Sprache. Die Deklaration lässt sich nicht übersetzen, und die Meldung nennt nur die Zeile - nicht die Ursache&lt;br /&gt;
|-&lt;br /&gt;
| Ein Helfer, der ein JSON-Fragment als String zurückgibt || Den &#039;&#039;&#039;Writer als Parameter&#039;&#039;&#039; übergeben und an der richtigen Stelle hineinschreiben. Fragmente von Hand zusammenzusetzen ist genau der Weg, auf dem die Reihenfolge und die Klammern verloren gingen&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;oWriter.ArrEnd&#039;&#039; nach &#039;&#039;RootArr&#039;&#039; || &#039;&#039;RootArr&#039;&#039; öffnet &#039;&#039;&#039;keine&#039;&#039;&#039; Ebene, es bestimmt nur die Klammer der Wurzel. Ein &#039;&#039;ArrEnd&#039;&#039; dazu ist ein Strukturfehler&lt;br /&gt;
|-&lt;br /&gt;
| Zahlen selbst formatieren (&#039;&#039;NStr&#039;&#039;, &#039;&#039;StrTran&#039;&#039;) || &amp;lt;code&amp;gt;oWriter.Num(&#039;preis&#039;, nWert, 2)&amp;lt;/code&amp;gt;. Selbst formatiert stand bei Werten ab 1000 der Tausendertrenner im JSON - gültiges JSON, falscher Wert, keine Meldung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;EMPTY_DATE&#039;&#039; für ein leeres Datum || &#039;&#039;EMPTY_DATE&#039;&#039; ist im Skript ein &#039;&#039;&#039;String&#039;&#039;&#039;. Für Datumsvariablen und -vergleiche &#039;&#039;&#039;MINDATETIME&#039;&#039;&#039; verwenden&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;fVal&#039;&#039; auf einen JSON-Zahlenwert || JSON liefert den Dezimalpunkt, &#039;&#039;fVal&#039;&#039; erwartet die lokale Notation: vorher &amp;lt;code&amp;gt;StrTran(cVal, &#039;.&#039;, &#039;,&#039;)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;TStringList&#039;&#039; für Zwischenlisten || In Skripten unzuverlässig. Kleine Mengen über einen Delimiter-String führen (&amp;lt;code&amp;gt;&#039;&amp;amp;#124;&#039; + wert + &#039;&amp;amp;#124;&#039;&amp;lt;/code&amp;gt;) und mit &#039;&#039;Pos&#039;&#039; prüfen&lt;br /&gt;
|-&lt;br /&gt;
| Default-Parameter in eigenen Funktionen || Werden nicht unterstützt - alle Parameter ausschreiben&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;q.SaveData(…)&amp;lt;/code&amp;gt; als Anweisung schreiben || &#039;&#039;SaveData&#039;&#039; liefert einen &#039;&#039;&#039;Boolean&#039;&#039;&#039;. Ein SQL-Fehler beim Schreiben wirft &#039;&#039;&#039;keine&#039;&#039;&#039; Exception und landet in &#039;&#039;&#039;keinem&#039;&#039;&#039; Protokoll - der Rückgabewert ist die einzige Meldung. Immer auswerten, siehe [[#Schreibfehler erkennen|Schreibfehler erkennen]]&lt;br /&gt;
|-&lt;br /&gt;
| Je Feld ein eigener Setzer-Aufruf || Ein &amp;lt;code&amp;gt;qSqlInit&amp;lt;/code&amp;gt;-Objekt, beliebig viele &amp;lt;code&amp;gt;qSet&amp;lt;/code&amp;gt;, &#039;&#039;&#039;ein&#039;&#039;&#039; &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt;. Einzeln geschrieben kostet jedes Feld eine eigene Abfrage samt eigenem Write - und der Vorgang ist nicht mehr atomar, siehe [[#Mehrere Felder sind EIN Schreibvorgang|Mehrere Felder]]&lt;br /&gt;
|-&lt;br /&gt;
| Ein Wert je Zeile nachgeschlagen || In einer Liste sind das N Abfragen für eine Antwort. Über &amp;lt;code&amp;gt;LEFT JOIN&amp;lt;/code&amp;gt; (1:1) oder Unterabfrage (1:n) mitnehmen, siehe [[#Keine Abfrage je Zeile|Keine Abfrage je Zeile]]&lt;br /&gt;
|-&lt;br /&gt;
| Ausgabeparameter vom Typ &amp;lt;code&amp;gt;var … : TDateTime&amp;lt;/code&amp;gt; || Bringt die Skript-VM zum &#039;&#039;&#039;Absturz&#039;&#039;&#039; (Assertion in &#039;&#039;dwsStack.pas&#039;&#039;), und zwar beim ersten &#039;&#039;&#039;Lesen&#039;&#039;&#039; des Werts - nicht schon beim Schreiben, die Ursache liegt also nicht dort, wo der Fehler auffällt. &amp;lt;code&amp;gt;var string&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;var Integer&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;var Double&amp;lt;/code&amp;gt; laufen. Zeitpunkte als ISO-Zeichenkette zurückgeben und erst beim Aufrufer wandeln&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==Skript-Cache==&lt;br /&gt;
&lt;br /&gt;
Der Server cached das kompilierte Skript pro Endpunkt (Schlüssel: &#039;&#039;sys_date&#039;&#039; 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.&lt;br /&gt;
&lt;br /&gt;
==Verfügbare Bibliotheken==&lt;br /&gt;
&lt;br /&gt;
Im Skript können alle OBS-Standard-Bibliotheken verwendet werden. Typische Einstiegspunkte:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;Base.Tools&#039;&#039; - String-, Datum-, IIF-, Empty-Helper&lt;br /&gt;
* &#039;&#039;Base.DB&#039;&#039; / &#039;&#039;Base.xQuery&#039;&#039; - Datenbank-Operationen&lt;br /&gt;
* &#039;&#039;Base.qSqlReg&#039;&#039; - Schreibzugriffe&lt;br /&gt;
* &#039;&#039;lib_ScriptIO&#039;&#039; - Writer und Reader, siehe unten. &#039;&#039;&#039;System.JSON braucht ein Endpunkt-Skript nicht&#039;&#039;&#039; - der Writer erzeugt die Antwort, der Reader liest den Körper&lt;br /&gt;
* &#039;&#039;Base.ToolsConst&#039;&#039; - Konstanten wie &#039;&#039;CRLF&#039;&#039;, &#039;&#039;SINGELQUOTE&#039;&#039;, &#039;&#039;MINDATETIME&#039;&#039;&lt;br /&gt;
* &#039;&#039;lib_ScriptIO&#039;&#039; - &#039;&#039;TxRestWriter&#039;&#039;, &#039;&#039;TxRestReader&#039;&#039; und die Zeitfunktionen &#039;&#039;RestIsoFromDate&#039;&#039; / &#039;&#039;RestDateFromIso&#039;&#039; / &#039;&#039;RestOffsetToMinutes&#039;&#039;. Writer und Reader bekommt das Skript als Parameter; die Zeitfunktionen ruft es direkt&lt;br /&gt;
&lt;br /&gt;
Die wichtigsten Aufrufe mit ihren Skript-Signaturen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Zweck !! Aufruf&lt;br /&gt;
|-&lt;br /&gt;
| Lesen || &amp;lt;code&amp;gt;DB_SOpen(oDB, cSql, q)&amp;lt;/code&amp;gt;, dann &amp;lt;code&amp;gt;q.EoF&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.Next()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.A2C(&#039;feld&#039;)&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2I&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2F&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2D&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2UID()&amp;lt;/code&amp;gt;, am Ende &amp;lt;code&amp;gt;DB_Close(q)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Neu anlegen || &amp;lt;code&amp;gt;q := qSqlInit(oDB, &#039;tab&#039;)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.qSet(&#039;feld&#039;, wert)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;if (not q.SaveData(NEW_RECORD)) then …&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;qSqlFree(q)&amp;lt;/code&amp;gt; – der Rückgabewert ist Pflicht, siehe [[#Schreibfehler erkennen|Schreibfehler erkennen]]&lt;br /&gt;
|-&lt;br /&gt;
| Ändern || &amp;lt;code&amp;gt;q := qSqlInit(oDB, &#039;tab&#039;)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.qSet(&#039;sys_uid&#039;, cUid)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.qSet(…)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;if (not q.SaveData(UPDATE_RECORD)) then …&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;qSqlFree(q)&amp;lt;/code&amp;gt; – &#039;&#039;&#039;nicht&#039;&#039;&#039; &amp;lt;code&amp;gt;qSqlRead&amp;lt;/code&amp;gt;, siehe [[#Ändern: qSqlInit statt qSqlRead|Ändern]]. Alle Felder derselben Zeile in &#039;&#039;&#039;ein&#039;&#039;&#039; Objekt, siehe [[#Mehrere Felder sind EIN Schreibvorgang|Mehrere Felder]]&lt;br /&gt;
|-&lt;br /&gt;
| Direkt ausführen || &amp;lt;code&amp;gt;DB_SqlExec(oDB, cSql)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Existenzprüfung || &amp;lt;code&amp;gt;DB_LSeek(oDB, &#039;tab&#039;, cWhere)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Werte quoten (Pflicht) || &amp;lt;code&amp;gt;DB_SQLVal(wert)&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Alle diese Funktionen beginnen im Skript mit &#039;&#039;oDB&#039;&#039;. Ein zusätzlicher&lt;br /&gt;
AD-UID-Parameter als erstes Argument gehört zur Delphi-Variante und lässt sich im&lt;br /&gt;
Skript nicht übersetzen.}}&lt;br /&gt;
&lt;br /&gt;
Konkrete Beispiele:&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel1|Beispiel 1: Daten-Abruf mit JWT]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel2|Beispiel 2: Zeiterfassung als komplette Webanwendung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel3|Beispiel 3: Datensatz anlegen mit JSON-Body (CRUD)]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4: Pfad-Parameter im Routing]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6: Datei-Upload und Ablage im DMS]]&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Endpunkte&amp;diff=64819</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Endpunkte</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Endpunkte&amp;diff=64819"/>
		<updated>2026-09-14T08:07:29Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
= Endpunkte =&lt;br /&gt;
&lt;br /&gt;
Ein &#039;&#039;&#039;Endpunkt&#039;&#039;&#039; stellt eine Adresse bereit, über die der REST-Server konkrete&lt;br /&gt;
Funktionen anbietet. Jeder Endpunkt wird durch ein OBS-Skript oder einen WebHook&lt;br /&gt;
realisiert und ist genau einem Server-Profil zugeordnet.&lt;br /&gt;
&lt;br /&gt;
== Routing über Pfad-Templates ==&lt;br /&gt;
&lt;br /&gt;
Das Routing erfolgt über die Spalte &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_pathtemplate&amp;lt;/code&amp;gt;).&lt;br /&gt;
Ein Template beschreibt den vollständigen Pfad nach dem Host und kann&lt;br /&gt;
&#039;&#039;&#039;Platzhalter&#039;&#039;&#039; enthalten:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Statische Segmente&#039;&#039;&#039; müssen exakt übereinstimmen (Groß-/Kleinschreibung wird ignoriert).&lt;br /&gt;
* &#039;&#039;&#039;Platzhalter&#039;&#039;&#039; in geschweiften Klammern – z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;{uid}&amp;lt;/code&amp;gt; – passen auf einen beliebigen Wert und werden als [[#Pfad-Parameter im Skript|Pfad-Parameter]] erfasst.&lt;br /&gt;
&lt;br /&gt;
Beispiele für gültige Templates:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Template !! Passt auf !! Pfad-Parameter&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; || –&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/4711&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;uid = 4711&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/4711/modules/A1&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;uid = 4711&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code = A1&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/kalender/v1&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/kalender/v1&amp;lt;/code&amp;gt; || –&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Präzedenz bei mehreren Treffern ===&lt;br /&gt;
&lt;br /&gt;
Passen mehrere Templates auf denselben Pfad, &#039;&#039;&#039;gewinnt das spezifischste&#039;&#039;&#039; –&lt;br /&gt;
also das mit den meisten statischen Segmenten. So schlägt &amp;lt;code&amp;gt;/orders/summary&amp;lt;/code&amp;gt;&lt;br /&gt;
das Template &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt;, während &amp;lt;code&amp;gt;/orders/4711&amp;lt;/code&amp;gt; auf&lt;br /&gt;
&amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; matcht.&lt;br /&gt;
&lt;br /&gt;
== Hauptfelder eines Endpunkts ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld !! Spalte !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_pathtemplate&amp;lt;/code&amp;gt; || &#039;&#039;&#039;Maßgeblich fürs Routing.&#039;&#039;&#039; Vollständiger Pfad mit Platzhaltern, z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Server&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_server&amp;lt;/code&amp;gt; || Zuordnung zum Server-Profil (erforderlich)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Aktiv&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_aktiv&amp;lt;/code&amp;gt; || Statusflag; inaktive Endpunkte liefern 404&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Skript&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_script&amp;lt;/code&amp;gt; || DwScript-Quelltext des Handlers (siehe [[/OBS/Kostenpflichtige_Module/RESTServer/Scripting|Scripting]])&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;WebHook&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_webhook&amp;lt;/code&amp;gt; || optional: Verweis auf einen Einmal-Endpunkt statt Skript&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Info&#039;&#039;&#039; || – || Dokumentations-Freitext&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_upload&amp;lt;/code&amp;gt; || Erlaubt Datei-Uploads an diesem Endpunkt (&amp;lt;code&amp;gt;0&amp;lt;/code&amp;gt; = aus, &amp;lt;code&amp;gt;1&amp;lt;/code&amp;gt; = an). Ohne Freigabe werden Upload-Anfragen mit 415 abgelehnt.&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Max. Upload-Grösse&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt; || Maximale Dateigrösse in MB. Leer/0 = Standard 25&amp;amp;nbsp;MB. Überschreitung wird mit 413 abgelehnt.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pfad-Parameter im Skript ==&lt;br /&gt;
&lt;br /&gt;
Die aus den Platzhaltern erfassten Werte liest das Endpunkt-Skript über&lt;br /&gt;
&amp;lt;code&amp;gt;oReader.Path&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUid: string;&lt;br /&gt;
begin&lt;br /&gt;
    cUid := oReader.Path(&#039;uid&#039;);   // aus /orders/{uid}&lt;br /&gt;
    // ...&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Diese Werte stammen aus dem Routing und können nicht durch den Client&lt;br /&gt;
überschrieben werden.&lt;br /&gt;
&lt;br /&gt;
== Datei-Uploads ==&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt nimmt Datei-Uploads nur entgegen, wenn er dafür freigeschaltet ist&lt;br /&gt;
(&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;). Andernfalls werden Upload-Anfragen mit&lt;br /&gt;
&#039;&#039;&#039;415 Unsupported Media Type&#039;&#039;&#039; abgelehnt und das Skript wird nicht ausgeführt.&lt;br /&gt;
&lt;br /&gt;
Die maximal zulässige Dateigrösse wird pro Endpunkt über &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;&lt;br /&gt;
(in MB) festgelegt; ohne Angabe gilt der Standard von &#039;&#039;&#039;25&amp;amp;nbsp;MB&#039;&#039;&#039;. Wird das&lt;br /&gt;
Limit überschritten, antwortet der Server mit &#039;&#039;&#039;413 Payload Too Large&#039;&#039;&#039;. Für&lt;br /&gt;
Uploads gilt nicht das JSON-Body-Limit (10&amp;amp;nbsp;MB), sondern dieses Endpunkt-Limit.&lt;br /&gt;
Der Originalname (beim resumable Upload über den Header &amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt;) ist &#039;&#039;&#039;Pflicht&#039;&#039;&#039;; fehlt er, wird der Upload mit 413/400 abgelehnt. Das Skript liest Pfad, Name, Content-Type und die SHA-256-Prüfsumme der gespeicherten Datei über &amp;lt;code&amp;gt;oReader.UploadPath()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;UploadName()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;UploadType()&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;UploadSha()&amp;lt;/code&amp;gt;; bei &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; die übrigen Formularfelder über &amp;lt;code&amp;gt;oReader.Form(&#039;&amp;amp;lt;name&amp;amp;gt;&#039;)&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Das Übertragungsprotokoll&#039;&#039;&#039; - Header, Statuscodes, Resume-Verhalten - steht auf einer eigenen Seite: [[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]].&lt;br /&gt;
&lt;br /&gt;
Unterstützt werden zwei Übertragungsarten:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Einfacher Upload&#039;&#039;&#039; per &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; (eine Datei pro Request).&lt;br /&gt;
* &#039;&#039;&#039;Resumable/Chunked Upload&#039;&#039;&#039; per &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt; (grosse Dateien, fortsetzbar nach Abbruch).&lt;br /&gt;
&lt;br /&gt;
Der Server legt die hochgeladene Datei in einem temporären Verzeichnis ab und&lt;br /&gt;
übergibt dem Endpunkt-Skript den Pfad. Was mit der Datei geschieht (Ablage,&lt;br /&gt;
DMS-Verknüpfung, Weiterverarbeitung), entscheidet allein das Skript. Details und&lt;br /&gt;
Beispiele: [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
== Datei-Downloads ==&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt kann eine &#039;&#039;&#039;Datei&#039;&#039;&#039; statt eines JSON-Körpers zurückgeben -&lt;br /&gt;
PDF, CSV, eine APK. Dafür ist &#039;&#039;&#039;keine Freischaltung&#039;&#039;&#039; nötig: ein Download&lt;br /&gt;
entsteht dadurch, dass das Skript ihn mit &amp;lt;code&amp;gt;oWriter.SendFile(...)&amp;lt;/code&amp;gt;&lt;br /&gt;
bzw. &amp;lt;code&amp;gt;oWriter.SendTempFile(...)&amp;lt;/code&amp;gt; erzeugt, nicht dadurch, dass ein&lt;br /&gt;
Client ihn anfragt.&lt;br /&gt;
&lt;br /&gt;
Content-Type, Dateiname, Bereichsanfragen (&amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt;, &#039;&#039;&#039;206&#039;&#039;&#039;),&lt;br /&gt;
ETag und das Aufräumen temporärer Dateien übernimmt der Server. Einzelheiten:&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Datei-Download|Datei-Download]].&lt;br /&gt;
&lt;br /&gt;
== HTTP-Methode ==&lt;br /&gt;
&lt;br /&gt;
Welche Funktion aufgerufen wird, ergibt sich aus dem HTTP-Verb: Der Server ruft&lt;br /&gt;
die gleichnamige Skript-Methode auf (&amp;lt;code&amp;gt;Get&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Post&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Put&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Delete&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Patch&amp;lt;/code&amp;gt;).&lt;br /&gt;
Ein Template entspricht damit &#039;&#039;&#039;einem&#039;&#039;&#039; Endpunkt-Skript; unterschiedliche&lt;br /&gt;
Pfad-Formen (z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; vs. &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt;) sind&lt;br /&gt;
&#039;&#039;&#039;eigene Endpunkt-Einträge&#039;&#039;&#039; mit eigenem Skript und eigener Berechtigung.&lt;br /&gt;
&lt;br /&gt;
== URL-Struktur ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
http://[Host][:Port][Pfad-Template]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Beispiele:&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders/4711&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders/4711/modules/A1&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Versionierung ==&lt;br /&gt;
&lt;br /&gt;
Da es kein eigenes Versions-Feld mehr gibt, wird die Version als statisches&lt;br /&gt;
Segment ins Template aufgenommen, z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders/v1&amp;lt;/code&amp;gt; oder&lt;br /&gt;
&amp;lt;code&amp;gt;/v1/orders/{uid}&amp;lt;/code&amp;gt;. Änderung ohne Breaking Change:&lt;br /&gt;
&lt;br /&gt;
# Bestehenden Endpunkt unverändert lassen.&lt;br /&gt;
# Neuen Endpunkt mit gleichem Ressourcennamen, aber neuem Versions-Segment im Template anlegen.&lt;br /&gt;
# Berechtigungen für neue Konsumenten setzen.&lt;br /&gt;
# Alten Endpunkt deaktivieren, wenn die Migration abgeschlossen ist.&lt;br /&gt;
&lt;br /&gt;
== Zugriffskontrolle ==&lt;br /&gt;
&lt;br /&gt;
Endpunkte sind Server-Profilen zugeordnet; ein Zugang benötigt eine explizite&lt;br /&gt;
Berechtigung (Tabelle &amp;lt;code&amp;gt;RESTSRV_ACCESS&amp;lt;/code&amp;gt;), um einen Endpunkt nutzen zu&lt;br /&gt;
dürfen.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|&#039;&#039;&#039;Ein Endpunkt besteht aus zwei Zeilen, und beide werden von Hand&lt;br /&gt;
gepflegt:&#039;&#039;&#039; der Eintrag in &amp;lt;code&amp;gt;RESTSRV_ENDPOINTS&amp;lt;/code&amp;gt; und die Berechtigung&lt;br /&gt;
in &amp;lt;code&amp;gt;RESTSRV_ACCESS&amp;lt;/code&amp;gt;. Fehlt die zweite, antwortet der Endpunkt&lt;br /&gt;
&#039;&#039;&#039;403&#039;&#039;&#039; - und sieht dabei fertig aus: Pfad, Skript und Server-Profil stehen&lt;br /&gt;
korrekt da, der Aufruf kommt trotzdem nicht durch. Das ist die häufigste Ursache&lt;br /&gt;
für ein „der Endpunkt ist doch angelegt&amp;quot; und kostet jedes Mal eine&lt;br /&gt;
Fehlersuche, weil der Statuscode nach einem Rechteproblem des Konsumenten&lt;br /&gt;
aussieht und nicht nach einer fehlenden Konfigurationszeile.&lt;br /&gt;
&lt;br /&gt;
Beim Anlegen also &#039;&#039;&#039;immer beide Zeilen&#039;&#039;&#039;, und beim Prüfen eines&lt;br /&gt;
&amp;lt;code&amp;gt;403&amp;lt;/code&amp;gt; zuerst nachsehen, ob die zweite existiert.}} Weil jede Pfad-Form ein eigener Endpunkt-Eintrag ist, lässt sich der&lt;br /&gt;
Zugriff &#039;&#039;&#039;pro Pfad-Form&#039;&#039;&#039; granular vergeben (z.&amp;amp;nbsp;B. Lesen von&lt;br /&gt;
&amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; erlauben, aber das ändernde&lt;br /&gt;
&amp;lt;code&amp;gt;PUT /orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; nicht).&lt;br /&gt;
&lt;br /&gt;
== Caching ==&lt;br /&gt;
&lt;br /&gt;
Die Endpunkt-Definitionen werden pro Server-Profil zwischengespeichert&lt;br /&gt;
(TTL, Standard 60&amp;amp;nbsp;s). Neue oder geänderte Endpunkte/Templates werden daher&lt;br /&gt;
erst nach Ablauf des Caches (bzw. nach einer Cache-Invalidierung) wirksam – nicht&lt;br /&gt;
zwingend sofort.&lt;br /&gt;
&lt;br /&gt;
== HTTP-Fehlercodes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Code !! Ursache&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;404&#039;&#039;&#039; || Kein Template passt zum Pfad, Endpunkt inaktiv oder falsches Server-Profil&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;403&#039;&#039;&#039; || Zugang fehlt in der Berechtigungsliste&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;401&#039;&#039;&#039; || API-Key ungültig/fehlend, JWT abgelaufen oder Sitzung gesperrt (&#039;&#039;AUTH_EXPIRED&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;500&#039;&#039;&#039; || Skript- oder Syntax-Fehler (Details in &amp;lt;code&amp;gt;RESTSRV_PROTO&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;405&#039;&#039;&#039; || Das Endpunkt-Skript hat für die angefragte HTTP-Methode keine Funktion; der Header &amp;lt;code&amp;gt;Allow&amp;lt;/code&amp;gt; nennt die vorhandenen&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;206&#039;&#039;&#039; || Teilantwort eines Datei-Downloads auf eine &amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt;-Anfrage; die Antwort enthält &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;416&#039;&#039;&#039; || Der angefragte &amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt; liegt hinter dem Dateiende; die Antwort nennt in &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt; die wirkliche Grösse&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;415&#039;&#039;&#039; || Datei-Upload an einen Endpunkt, der dafür nicht freigeschaltet ist (&amp;lt;code&amp;gt;re_upload = 0&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;413&#039;&#039;&#039; || Hochgeladene Datei überschreitet die zulässige Maximalgrösse (&amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;429&#039;&#039;&#039; || Rate-Limit überschritten; die Antwort enthält den Header &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;503&#039;&#039;&#039; || Eine Anfrage mit demselben &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; wird gerade verarbeitet; die Antwort enthält &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;409&#039;&#039;&#039; || Ein früherer Aufruf mit demselben &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; ist ohne Ergebnis geblieben (&amp;lt;code&amp;gt;IDEMPOTENCY_UNRESOLVED&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;422&#039;&#039;&#039; || Derselbe &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; wurde mit &#039;&#039;&#039;anderem&#039;&#039;&#039; Inhalt gesendet (&amp;lt;code&amp;gt;IDEMPOTENCY_KEY_REUSED&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;400&#039;&#039;&#039; || Die Anfrage wurde unverschlüsselt an einen TLS-Port geschickt (&amp;lt;code&amp;gt;http&amp;lt;/code&amp;gt; statt &amp;lt;code&amp;gt;https&amp;lt;/code&amp;gt;). Die Abweisung erfolgt vor Authentifizierung und Endpunkt-Skript&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Jede Fehlerantwort besteht aus einem &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt;-Objekt mit&lt;br /&gt;
maschinenlesbarem Code - Aufbau siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer|Übersicht]].&lt;br /&gt;
&lt;br /&gt;
Daneben kann ein Endpunkt-Skript den Statuscode selbst setzen (z.B. &amp;lt;code&amp;gt;201&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;204&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;409&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;422&amp;lt;/code&amp;gt;) sowie Response-Header wie &amp;lt;code&amp;gt;ETag&amp;lt;/code&amp;gt; oder &amp;lt;code&amp;gt;Location&amp;lt;/code&amp;gt; - siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
== Idempotenz ==&lt;br /&gt;
&lt;br /&gt;
Schickt ein Konsument bei &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;PUT&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;PATCH&amp;lt;/code&amp;gt;&lt;br /&gt;
oder &amp;lt;code&amp;gt;DELETE&amp;lt;/code&amp;gt; den Header &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, sorgt der Server&lt;br /&gt;
selbst dafür, dass eine wiederholte Sendung &#039;&#039;&#039;keine Zweitwirkung&#039;&#039;&#039; hat. Das gilt&lt;br /&gt;
für &#039;&#039;&#039;jeden&#039;&#039;&#039; Endpunkt - es muss weder am Endpunkt etwas eingestellt noch im&lt;br /&gt;
Skript etwas programmiert werden.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Situation !! Antwort des Servers&lt;br /&gt;
|-&lt;br /&gt;
| Erster Aufruf || Endpunkt läuft normal, das Ergebnis wird zum Schlüssel gespeichert&lt;br /&gt;
|-&lt;br /&gt;
| Wiederholung, gleicher Inhalt || &#039;&#039;&#039;Gespeicherte Antwort&#039;&#039;&#039; (gleicher Statuscode, gleicher Body, gleiche Header) plus Header &amp;lt;code&amp;gt;Idempotent-Replay: true&amp;lt;/code&amp;gt;. Das Skript läuft &#039;&#039;&#039;nicht&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| Wiederholung, anderer Inhalt || &#039;&#039;&#039;422&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_KEY_REUSED&amp;lt;/code&amp;gt; - derselbe Schlüssel für einen anderen Inhalt ist ein Client-Fehler&lt;br /&gt;
|-&lt;br /&gt;
| Erster Aufruf läuft noch || &#039;&#039;&#039;503&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_IN_PROGRESS&amp;lt;/code&amp;gt; mit &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;. Beim nächsten Versuch liegt die gespeicherte Antwort vor&lt;br /&gt;
|-&lt;br /&gt;
| Früherer Aufruf ohne Ergebnis || &#039;&#039;&#039;409&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_UNRESOLVED&amp;lt;/code&amp;gt;. Nur noch nach einem &#039;&#039;&#039;harten Abbruch&#039;&#039;&#039; des Dienstes zwischen Verarbeitung und Festschreiben; ob die Buchung stattgefunden hat, ist unbekannt. Der Aufräumlauf gibt solche Einträge nach 24 Stunden frei und protokolliert sie&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Wichtig für die Auslegung eines Endpunkts:&lt;br /&gt;
&lt;br /&gt;
* Antwortet das Skript mit &#039;&#039;&#039;2xx&#039;&#039;&#039;, wird die Antwort gespeichert und bei einer Wiederholung erneut ausgeliefert.&lt;br /&gt;
* Antwortet das Skript mit &#039;&#039;&#039;4xx&#039;&#039;&#039; (fachliche Ablehnung), wird der Schlüssel wieder &#039;&#039;&#039;freigegeben&#039;&#039;&#039; - der Konsument darf ihn nach Korrektur erneut verwenden. Sonst würde ein einziger Validierungsfehler den Schlüssel dauerhaft blockieren.&lt;br /&gt;
* Endet die Verarbeitung mit &#039;&#039;&#039;5xx&#039;&#039;&#039; oder einem Skript-Fehler, wird der Schlüssel &#039;&#039;&#039;freigegeben&#039;&#039;&#039; und der Fall protokolliert. Der Konsument darf denselben Schlüssel erneut senden.&lt;br /&gt;
* Das ist eine bewusste Abwägung (geändert 2026-08-25): Bleibt der Schlüssel nach einem Serverfehler belegt, ist der Vorgang &#039;&#039;&#039;unwiederholbar&#039;&#039;&#039; - der Client bekommt dauerhaft 503 und müsste die erfasste Arbeit verwerfen. Garantierter Datenverlust wiegt schwerer als ein möglicher Doppelsatz.&lt;br /&gt;
* &#039;&#039;&#039;Folge für die Auslegung:&#039;&#039;&#039; ein schreibender Endpunkt sollte selbst wiederholbar sein, etwa über ein fachliches Merkmal des Clients. Wo reines Anhängen stattfindet (Positionen, Dateien), bleibt sonst ein Restrisiko.&lt;br /&gt;
&lt;br /&gt;
Der Schlüssel gilt je &#039;&#039;&#039;Zugang, Methode und Pfad&#039;&#039;&#039;. Derselbe&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; an einem anderen Endpunkt ist damit ein eigener&lt;br /&gt;
Vorgang - der Konsument muss ihn nicht global eindeutig vergeben, aber pro Vorgang&lt;br /&gt;
&#039;&#039;&#039;stabil wiederverwenden&#039;&#039;&#039; (also nicht bei jedem Wiederholversuch neu erzeugen).&lt;br /&gt;
&lt;br /&gt;
Die Einträge stehen in &amp;lt;code&amp;gt;RESTSRV_IDEMPOTENCY&amp;lt;/code&amp;gt; und werden nach 30 Tagen&lt;br /&gt;
automatisch aufgeräumt. Einträge ohne Ergebnis werden &#039;&#039;&#039;nicht&#039;&#039;&#039; automatisch&lt;br /&gt;
gelöscht, sondern im Protokoll gemeldet.&lt;br /&gt;
&lt;br /&gt;
== WebHooks ==&lt;br /&gt;
&lt;br /&gt;
WebHooks sind &#039;&#039;&#039;Einmal-Endpunkte&#039;&#039;&#039; für asynchrone Callbacks. Beim ersten&lt;br /&gt;
erfolgreichen Aufruf wird die Antwort in der Tabelle &amp;lt;code&amp;gt;REMOTE_HOOK_URL&amp;lt;/code&amp;gt;&lt;br /&gt;
gespeichert und der Endpunkt anschließend automatisch gelöscht.&lt;br /&gt;
&lt;br /&gt;
Typischer Einsatz: OBS stösst einen Vorgang bei einem Fremdsystem an (Bezahldienst,&lt;br /&gt;
Versanddienstleister) und gibt diesem eine Rückruf-Adresse mit, die nur &#039;&#039;&#039;ein&lt;br /&gt;
einziges Mal&#039;&#039;&#039; gültig ist. Damit kann die Adresse nicht später erneut - oder von&lt;br /&gt;
jemand anderem - benutzt werden.&lt;br /&gt;
&lt;br /&gt;
Ablauf:&lt;br /&gt;
&lt;br /&gt;
# OBS legt einen Eintrag in &amp;lt;code&amp;gt;REMOTE_HOOK_URL&amp;lt;/code&amp;gt; an und dazu einen Endpunkt, dessen Feld &#039;&#039;&#039;WebHook&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_webhook&amp;lt;/code&amp;gt;) auf diesen Eintrag zeigt. Ein Skript wird für diesen Endpunkt nicht gepflegt.&lt;br /&gt;
# Die Adresse dieses Endpunkts wird dem Fremdsystem als Callback-URL übergeben.&lt;br /&gt;
# Das Fremdsystem ruft die Adresse auf. Der Server legt den übergebenen Inhalt in &amp;lt;code&amp;gt;REMOTE_HOOK_URL.hu_response&amp;lt;/code&amp;gt; ab, antwortet mit &amp;lt;code&amp;gt;{&amp;quot;status&amp;quot;: &amp;quot;ok&amp;quot;}&amp;lt;/code&amp;gt; und &#039;&#039;&#039;löscht den Endpunkt&#039;&#039;&#039;.&lt;br /&gt;
# Der weiterverarbeitende OBS-Prozess findet die Rückmeldung in &amp;lt;code&amp;gt;hu_response&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein zweiter Aufruf derselben Adresse läuft ins Leere: Der Endpunkt&lt;br /&gt;
existiert nicht mehr, die Antwort ist 404. Das ist gewollt - ein WebHook ist keine&lt;br /&gt;
dauerhafte Schnittstelle. Für eine dauerhaft erreichbare Rückmelde-Adresse einen&lt;br /&gt;
normalen Endpunkt mit Skript anlegen.}}&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Zugaenge&amp;diff=64818</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Zugaenge</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Zugaenge&amp;diff=64818"/>
		<updated>2026-09-14T08:07:19Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Zugänge=&lt;br /&gt;
&lt;br /&gt;
Ein &#039;&#039;&#039;Zugang&#039;&#039;&#039; (Account) ist der Kanal, über den ein externer Konsument den REST-Server anspricht. Er bündelt API-Key, Host-Beschränkung, CORS-Konfiguration, optionale JWT-Authentifizierung und optionale mTLS-Subject-Prüfung.&lt;br /&gt;
&lt;br /&gt;
==Aufruf==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Stammdaten -&amp;gt; Z Weitere Stammdaten -&amp;gt; REST-Server -&amp;gt; Zugänge&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==Felder==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld          !! Beschreibung&lt;br /&gt;
|-&lt;br /&gt;
| Name          || Sprechender Name, erscheint im Protokoll und der Statistik&lt;br /&gt;
|-&lt;br /&gt;
| Aktiv         || Inaktive Zugänge werden bei der Authentifizierung mit 403 abgewiesen&lt;br /&gt;
|-&lt;br /&gt;
| API-Key       || Eindeutiger Schlüssel, den der Konsument im HTTP-Header &#039;&#039;apikey&#039;&#039; senden muss. Per Schlüsselsymbol generierbar&lt;br /&gt;
|-&lt;br /&gt;
| Host          || Optionaler Hostname oder IP-Adresse des Konsumenten. Stimmt die anfragende IP nicht überein, wird mit 403 abgelehnt&lt;br /&gt;
|-&lt;br /&gt;
| CORS-Origins  || Liste erlaubter Origins für Browser-Aufrufe, mehrere durch &#039;&#039;;&#039;&#039; getrennt. &#039;&#039;&#039;Leer heisst nicht „keine Einschränkung&amp;quot;, sondern „kein Browser-Zugriff&amp;quot;&#039;&#039;&#039; - siehe unten&lt;br /&gt;
|-&lt;br /&gt;
| mTLS-Subject  || Erwartete RDN-Komponente(n) im Client-Zertifikat-Subject, Komma-/Semikolon-getrennt (alle müssen vorkommen; nur sinnvoll bei mTLS-Servern)&lt;br /&gt;
|-&lt;br /&gt;
| JWT           || Schalter, ob für diesen Zugang ein JWT-Token nötig ist&lt;br /&gt;
|-&lt;br /&gt;
| JWT-Endpunkt  || Pfad, über den &#039;&#039;&#039;alle Token-Vorgänge&#039;&#039;&#039; laufen: Anmelden, Erneuern und Abmelden (z.B. &#039;&#039;oauth&#039;&#039; oder &#039;&#039;meineapp/dev/auth&#039;&#039;). Verglichen wird der &#039;&#039;&#039;vollständige Pfad&#039;&#039;&#039;, mehrstufige Angaben mit &#039;&#039;/&#039;&#039; sind also erlaubt&lt;br /&gt;
|-&lt;br /&gt;
| JWT-Key       || Geheimer Schlüssel zur Signierung/Verifikation, per Schlüsselsymbol generierbar&lt;br /&gt;
|-&lt;br /&gt;
| JWT-Exp       || Gültigkeitsdauer eines ausgestellten Access-Tokens in &#039;&#039;&#039;Minuten&#039;&#039;&#039;. Derselbe Wert wird dem Konsumenten in der Token-Antwort als &#039;&#039;expiresIn&#039;&#039; in &#039;&#039;&#039;Sekunden&#039;&#039;&#039; mitgeteilt und steht dem Endpunkt-Skript als &#039;&#039;_OBS_JWT_EXP_SEC&#039;&#039; zur Verfügung&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==API-Key==&lt;br /&gt;
&lt;br /&gt;
Der API-Key ist die Basis-Authentifizierung. Der Client übergibt ihn im HTTP-Header:&lt;br /&gt;
&lt;br /&gt;
 apikey: abcd-efgh-ijkl-mnop-qrstuvwxyz12&lt;br /&gt;
&lt;br /&gt;
Wird ein Aufruf ohne &#039;&#039;apikey&#039;&#039;-Header gestellt, sucht der Server intern nach einem Zugang mit API-Key &#039;&#039;&#039;*&#039;&#039;&#039; - damit lassen sich (mit Vorsicht!) auch öffentliche Endpunkte ohne Authentifizierung realisieren.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein Zugang mit API-Key &#039;&#039;*&#039;&#039; sollte nur für bewusst freigegebene Endpunkte verwendet werden und immer in Kombination mit einer Host-Beschränkung oder CORS-Beschränkung betrieben werden.}}&lt;br /&gt;
&lt;br /&gt;
{{Achtung|&#039;&#039;&#039;Nur für wirklich öffentliche Dateien.&#039;&#039;&#039; Ohne API-Key kennt der&lt;br /&gt;
Endpunkt weder Mandant noch Rolle - es gibt nichts, woran er eine Berechtigung&lt;br /&gt;
festmachen könnte ausser dem, was in der URL steht. Ein Wildcard-Zugang eignet&lt;br /&gt;
sich deshalb für Artefakte, die für jeden bestimmt sind (Test-APK, Handbuch),&lt;br /&gt;
&#039;&#039;&#039;nicht&#039;&#039;&#039; für Kundenbelege. Siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Datei-Download|Datei-Download]].}}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Bei einem Wildcard-Zugang sollte &#039;&#039;CORS-Origin&#039;&#039; &#039;&#039;&#039;leer&#039;&#039;&#039; bleiben.&lt;br /&gt;
Ein &#039;&#039;*&#039;&#039; dort wirkt auf die Preflight-Antwort &#039;&#039;&#039;aller&#039;&#039;&#039; Zugänge (siehe unten);&lt;br /&gt;
ein Download über die Adresszeile des Browsers sendet ohnehin kein &#039;&#039;Origin&#039;&#039;,&lt;br /&gt;
CORS greift dort also gar nicht.}}&lt;br /&gt;
&lt;br /&gt;
==Host-Beschränkung==&lt;br /&gt;
&lt;br /&gt;
Ist im Feld &#039;&#039;&#039;Host&#039;&#039;&#039; ein Wert hinterlegt, akzeptiert der Server Anfragen unter diesem Zugang nur dann, wenn die Remote-IP exakt diesem Host entspricht oder durch DNS-Auflösung auf diese IP zeigt. DNS-Auflösungen werden 5 Minuten gecached.&lt;br /&gt;
&lt;br /&gt;
==CORS==&lt;br /&gt;
&lt;br /&gt;
Soll ein Endpunkt aus einer Browser-Anwendung (Single-Page-App, JavaScript) heraus aufgerufen werden, muss das Origin der Anwendung in der CORS-Liste des Zugangs eingetragen sein.&lt;br /&gt;
&lt;br /&gt;
Format der Liste:&lt;br /&gt;
&lt;br /&gt;
 https://app.kunde.de;https://test.kunde.de;https://localhost:8080&lt;br /&gt;
&lt;br /&gt;
Ablauf:&lt;br /&gt;
&lt;br /&gt;
# Browser sendet einen Preflight-Request (&#039;&#039;OPTIONS&#039;&#039;). Der REST-Server prüft das Origin gegen die globale Origin-Liste &#039;&#039;&#039;aller&#039;&#039;&#039; Accounts (Cache, TTL 60s) und antwortet mit 204 + CORS-Header, falls erlaubt.&lt;br /&gt;
# Der eigentliche Request wird gegen die Origin-Liste &#039;&#039;&#039;des aktuell verwendeten Zugangs&#039;&#039;&#039; geprüft.&lt;br /&gt;
# Stimmt das Origin nicht, wird mit 403 &#039;&#039;Origin nicht erlaubt für Account ...&#039;&#039; abgelehnt.&lt;br /&gt;
&lt;br /&gt;
Der Preflight kann nicht zugangsbezogen prüfen: der Browser schickt dabei keinen&lt;br /&gt;
&#039;&#039;apikey&#039;&#039; mit, der Zugang ist zu diesem Zeitpunkt unbekannt. &#039;&#039;&#039;Die Durchsetzung&lt;br /&gt;
liegt deshalb auf Schritt 2&#039;&#039;&#039;, nicht auf dem Preflight. Praktische Folge: trägt&lt;br /&gt;
&#039;&#039;&#039;ein&#039;&#039;&#039; Zugang auf dem Server ein &#039;&#039;*&#039;&#039; in seiner Liste, antwortet jeder&lt;br /&gt;
Preflight mit &#039;&#039;Access-Control-Allow-Origin: *&#039;&#039; - auch für Zugänge mit engerer&lt;br /&gt;
Liste. Deren eigentliche Anfrage wird trotzdem gegen die eigene Liste geprüft.&lt;br /&gt;
&lt;br /&gt;
Erlaubte Methoden: &#039;&#039;GET, POST, PUT, DELETE, PATCH&#039;&#039; (systemseitig festgelegt).&lt;br /&gt;
&lt;br /&gt;
Bei den &#039;&#039;&#039;Anfrage-Headern&#039;&#039;&#039; spiegelt der Server im Preflight zurück, was der&lt;br /&gt;
Browser in &#039;&#039;Access-Control-Request-Headers&#039;&#039; angefragt hat. Damit sind eigene&lt;br /&gt;
Header eines Projekts - &#039;&#039;Idempotency-Key&#039;&#039;, &#039;&#039;If-Match&#039;&#039;, später&lt;br /&gt;
&#039;&#039;Upload-Offset&#039;&#039;/&#039;&#039;Upload-Length&#039;&#039; - ohne Änderung am Server nutzbar. Fragt der&lt;br /&gt;
Preflight keine Header an, antwortet der Server mit der Liste&lt;br /&gt;
&#039;&#039;Content-Type, apikey, Authorization, Accept&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Die Header-Liste ist &#039;&#039;&#039;keine Schutzgrenze&#039;&#039;&#039; - das ist das Origin. Sie&lt;br /&gt;
sagt dem Browser nur, welche Header der Server versteht. Dazu kommt, dass die&lt;br /&gt;
Zugangsdaten dieser Schnittstelle in Headern stehen (&#039;&#039;apikey&#039;&#039;,&lt;br /&gt;
&#039;&#039;Authorization&#039;&#039;) und nicht in Cookies: es gibt also keine automatisch&lt;br /&gt;
mitgeschickte Berechtigung, die eine fremde Seite ausnutzen könnte.}}&lt;br /&gt;
&lt;br /&gt;
===Was „leer&amp;quot; bedeutet===&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Ein &#039;&#039;&#039;leeres&#039;&#039;&#039; Feld CORS-Origins heisst &#039;&#039;&#039;nicht&#039;&#039;&#039; „keine&lt;br /&gt;
Einschränkung&amp;quot;. Es heisst: &#039;&#039;&#039;jeder Aufruf mit &#039;&#039;Origin&#039;&#039;-Header wird mit 403&lt;br /&gt;
abgelehnt&#039;&#039;&#039;. Für Clients &#039;&#039;&#039;ohne&#039;&#039;&#039; &#039;&#039;Origin&#039;&#039;-Header - mobile Apps, Server,&lt;br /&gt;
curl - ändert das Feld gar nichts, sie werden nie gegen CORS geprüft.}}&lt;br /&gt;
&lt;br /&gt;
Daraus ergeben sich drei sinnvolle Einstellungen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld !! Wirkung&lt;br /&gt;
|-&lt;br /&gt;
| leer || Kein Browser-Zugriff. Richtig für Zugänge, die nur von Apps oder Servern benutzt werden&lt;br /&gt;
|-&lt;br /&gt;
| konkrete Origins || Browser-Zugriff nur von diesen Seiten. Der Regelfall für Web-Anwendungen&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;*&#039;&#039; || Browser-Zugriff von überall. Wirkt zusätzlich auf die Preflight-Antwort aller anderen Zugänge - bewusst und sparsam verwenden&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Für eine Browser-Anwendung ist dieses Feld besonders wichtig, weil ihr API-Key&lt;br /&gt;
zwangsläufig im JavaScript liegt und damit öffentlich ist. Die Origin-Liste&lt;br /&gt;
verhindert dann, dass jemand den Key nimmt und von seiner eigenen Seite benutzt.&lt;br /&gt;
Sie ist allerdings kein Ersatz für eine Zugriffskontrolle: wer den Key hat,&lt;br /&gt;
umgeht CORS mit einem einzigen Aufruf ausserhalb des Browsers.&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierung==&lt;br /&gt;
&lt;br /&gt;
Wird ein Zugang mit aktiver JWT-Pflicht aufgerufen, sind die folgenden Phasen zu durchlaufen (Phase 3 nur bei genutztem Refresh).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Anmelden, Erneuern und Abmelden laufen über denselben Endpunkt&#039;&#039;&#039; - den im&lt;br /&gt;
Feld JWT-Endpunkt hinterlegten Pfad. Ein eigener Endpunkt fürs Abmelden ist&lt;br /&gt;
nicht nötig und wäre auch umständlicher: der JWT-Endpunkt greift &#039;&#039;&#039;vor&#039;&#039;&#039; der&lt;br /&gt;
Adressauflösung und braucht deshalb weder eine Zeile in &#039;&#039;RESTSRV_ENDPOINTS&#039;&#039;&lt;br /&gt;
noch eine Berechtigung in &#039;&#039;RESTSRV_ACCESS&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Unterschieden wird nach Verb und Token-Art:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Ablauf&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;POST&#039;&#039; ohne &#039;&#039;Authorization&#039;&#039; || Anmelden (Skript-Methode &#039;&#039;Authenticate&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;POST&#039;&#039; mit Bearer, Refresh-Token || Erneuern (Skript-Methode &#039;&#039;Refresh&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;DELETE&#039;&#039; mit Bearer, Access-Token || Abmelden (kein Skript nötig)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;POST&#039;&#039; mit Bearer, Access-Token || &#039;&#039;&#039;401&#039;&#039;&#039; - unverändert&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Dass das Abmelden am Verb hängt und nicht allein an der Token-Art, ist&lt;br /&gt;
Absicht. Würde ein vorgelegtes Access-Token beim &#039;&#039;POST&#039;&#039; als Abmeldung gedeutet,&lt;br /&gt;
würde ein Client, der seine beiden Token verwechselt, den Anwender still&lt;br /&gt;
abmelden - statt das klare 401 zu bekommen, an dem der Fehler auffällt.}}&lt;br /&gt;
&lt;br /&gt;
===Phase 1: Token-Bezug===&lt;br /&gt;
&lt;br /&gt;
Der Konsument ruft den JWT-Endpunkt des Zugangs auf, z.B.:&lt;br /&gt;
&lt;br /&gt;
 POST https://api.meinserver.de/oauth/&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
 Body:   {&amp;quot;username&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;password&amp;quot;:&amp;quot;...&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Der Server führt das im Zugang hinterlegte &#039;&#039;&#039;Authentifizierungs-Skript&#039;&#039;&#039; aus (F7 in der Zugangs-Liste). Das Skript muss eine Prozedur &#039;&#039;Authenticate&#039;&#039; bereitstellen, die über den Writer schreibt:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;status&#039;&#039; = &#039;&#039;1&#039;&#039; bei Erfolg, &#039;&#039;9&#039;&#039; bei Misserfolg&lt;br /&gt;
* &#039;&#039;error&#039;&#039; = Fehlertext (bei status=9)&lt;br /&gt;
* &#039;&#039;_OBS_JWT_ID&#039;&#039;, &#039;&#039;_OBS_JWT_SUBJECT&#039;&#039;, &#039;&#039;_OBS_JWT_AUDIENCE&#039;&#039; für die JWT-Claims (optional)&lt;br /&gt;
* &#039;&#039;_OBS_JWT_CLAIM_&amp;amp;lt;name&amp;amp;gt;&#039;&#039; für beliebige Custom-Claims (z.B. Mandant &#039;&#039;tenant&#039;&#039;, Rollen &#039;&#039;roles&#039;&#039;); im Folge-Skript lesbar&lt;br /&gt;
* &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; (optional) löst die Ausstellung eines Refresh-Tokens aus; &#039;&#039;_OBS_JWT_REFRESH_EXP&#039;&#039; setzt dessen Lebensdauer in Minuten (Default 90 Tage)&lt;br /&gt;
&lt;br /&gt;
Die Signatur lautet &#039;&#039;procedure Authenticate(oReader: TxRestReader; oWriter: TxRestWriter);&#039;&#039; - Details unter [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]]. Bei Erfolg signiert der Server einen Token (HS256, Issuer &#039;&#039;OBS REST-Server&#039;&#039;, Lebenszeit gemäß JWT-Exp) und antwortet:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:        &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;accessToken&amp;quot;:  &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:    28800,&lt;br /&gt;
  &amp;quot;serverTime&amp;quot;:   &amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Zum Aufbau der Antwort:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;token&#039;&#039; und &#039;&#039;accessToken&#039;&#039; enthalten &#039;&#039;&#039;denselben&#039;&#039;&#039; Token. &#039;&#039;accessToken&#039;&#039; ist der Name, den die meisten Client-Bibliotheken erwarten; &#039;&#039;token&#039;&#039; bleibt für bestehende Konsumenten erhalten.&lt;br /&gt;
* &#039;&#039;expiresIn&#039;&#039; ist die Gültigkeit in &#039;&#039;&#039;Sekunden&#039;&#039;&#039; (JWT-Exp × 60).&lt;br /&gt;
* &#039;&#039;&#039;Alle weiteren Felder, die das Authenticate-Skript zurückgibt, werden mit ausgeliefert&#039;&#039;&#039; - mit Ausnahme von &#039;&#039;status&#039;&#039; und den &#039;&#039;_OBS_&#039;&#039;-Steuerfeldern. So kann das Skript z.B. eine Benutzer-Id, den Mandanten oder Rollen direkt in die Anmelde-Antwort legen, ohne dass der Client dafür einen zweiten Aufruf braucht.&lt;br /&gt;
&lt;br /&gt;
Beispiel mit Zusatzfeldern aus dem Skript:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;benutzerId&amp;quot;: &amp;quot;4711&amp;quot;, &amp;quot;tenant&amp;quot;: &amp;quot;nord&amp;quot;, &amp;quot;roles&amp;quot;: [&amp;quot;TECHNIKER&amp;quot;],&lt;br /&gt;
  &amp;quot;token&amp;quot;: &amp;quot;eyJ...&amp;quot;, &amp;quot;accessToken&amp;quot;: &amp;quot;eyJ...&amp;quot;, &amp;quot;refreshToken&amp;quot;: &amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;: 28800, &amp;quot;serverTime&amp;quot;: &amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Mit der Ausstellung legt der Server zu dem Token-Paar eine &#039;&#039;&#039;Sitzungszeile&#039;&#039;&#039; in&lt;br /&gt;
&#039;&#039;RESTSRV_TOKEN&#039;&#039; an. Lässt sich diese Zeile nicht schreiben (Tabelle fehlt,&lt;br /&gt;
Datenbankproblem), wird &#039;&#039;&#039;kein Token ausgeliefert&#039;&#039;&#039;: der Server antwortet mit&lt;br /&gt;
&#039;&#039;&#039;503&#039;&#039;&#039; und &#039;&#039;Retry-After&#039;&#039;. Das ist bewusst von der abgelehnten Anmeldung&lt;br /&gt;
unterschieden - die Zugangsdaten waren in Ordnung, der Versuch darf wiederholt&lt;br /&gt;
werden.&lt;br /&gt;
&lt;br /&gt;
Wird die Anmeldung abgelehnt (&#039;&#039;status&#039;&#039; = 9), antwortet der Server mit&lt;br /&gt;
&#039;&#039;&#039;401 Unauthorized&#039;&#039;&#039; und reicht den Text aus dem Feld &#039;&#039;error&#039;&#039; als&lt;br /&gt;
&#039;&#039;error.message&#039;&#039; an den Client durch - der Konsument kann ihn also direkt&lt;br /&gt;
anzeigen. Die Formulierung liegt damit beim Skript.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der durchgereichte Text ist für den Endanwender gedacht und sollte&lt;br /&gt;
keine internen Details verraten. „Benutzer oder Passwort ist falsch&amp;quot; ist eine gute&lt;br /&gt;
Meldung, „Kein Datensatz in BENUTZ mit b_name=meier&amp;quot; nicht.}}&lt;br /&gt;
&lt;br /&gt;
===Phase 2: Token-Verwendung===&lt;br /&gt;
&lt;br /&gt;
Bei allen folgenden Aufrufen sendet der Client den Token im &#039;&#039;Authorization&#039;&#039;-Header:&lt;br /&gt;
&lt;br /&gt;
 GET https://api.meinserver.de/kalender/v1&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
         Authorization: Bearer eyJhbGciOi...&lt;br /&gt;
&lt;br /&gt;
Der Server verifiziert Signatur, Issuer, Ausstellzeitpunkt und Ablauf (Toleranz: 20 Sekunden Clock-Skew) und prüft anschliessend die &#039;&#039;&#039;Sitzung&#039;&#039;&#039; in &#039;&#039;RESTSRV_TOKEN&#039;&#039;. Im Skript stehen die Claims als Parameter &#039;&#039;_OBS_JWT_ID&#039;&#039;, &#039;&#039;_OBS_JWT_SUBJECT&#039;&#039; und &#039;&#039;_OBS_JWT_AUDIENCE&#039;&#039; zur Verfügung.&lt;br /&gt;
&lt;br /&gt;
Die Sitzungsprüfung weist ein Token ab, das zu keiner Zeile gehört oder dessen&lt;br /&gt;
Sitzung gesperrt wurde - beides mit &#039;&#039;&#039;401&#039;&#039;&#039; und dem Code &#039;&#039;AUTH_EXPIRED&#039;&#039;. Der&lt;br /&gt;
Grund steht jeweils im Protokoll &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; unter &#039;&#039;TokenStore: ...&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein Token, das &#039;&#039;&#039;vor&#039;&#039;&#039; der Einführung dieser Prüfung ausgestellt&lt;br /&gt;
wurde, gehört zu keiner Sitzung und wird abgewiesen. Nach dem Update müssen sich&lt;br /&gt;
angemeldete Konsumenten also einmalig neu anmelden.}}&lt;br /&gt;
&lt;br /&gt;
===Phase 3: Token erneuern (Refresh)===&lt;br /&gt;
&lt;br /&gt;
Soll die App lange ohne Neuanmeldung arbeiten, stellt das Authenticate-Skript zusätzlich ein Refresh-Token aus (Feld &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039;). Zum Erneuern ruft der Client &#039;&#039;&#039;denselben JWT-Endpunkt&#039;&#039;&#039; auf, aber mit dem Refresh-Token im &#039;&#039;Authorization&#039;&#039;-Header:&lt;br /&gt;
&lt;br /&gt;
 POST https://api.meinserver.de/oauth/&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
         Authorization: Bearer eyJhbGciOi... (Refresh-Token)&lt;br /&gt;
&lt;br /&gt;
Der Server erkennt am Bearer-Token den Refresh-Fall, verifiziert das Token (Signatur, Ablauf, &#039;&#039;token_use=refresh&#039;&#039;), &#039;&#039;&#039;entwertet es in der Sitzung&#039;&#039;&#039; und ruft erst dann im selben Skript die Methode &#039;&#039;&#039;Refresh&#039;&#039;&#039; auf. Das Skript liefert nur noch die aktuellen Claims zurück - Rotation, Einmalgebrauch und Sperrliste erledigt der Server. Die Antwort ist ein rotiertes Paar &#039;&#039;{&amp;quot;token&amp;quot;,&amp;quot;accessToken&amp;quot;,&amp;quot;refreshToken&amp;quot;,&amp;quot;expiresIn&amp;quot;,&amp;quot;serverTime&amp;quot;}&#039;&#039; in &#039;&#039;&#039;derselben Sitzung&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Eine &#039;&#039;&#039;eigene Sperrtabelle im Skript ist nicht mehr nötig&#039;&#039;&#039; und sollte&lt;br /&gt;
entfernt werden. Wer sie weiterführt, rotiert zweimal: einmal im Skript, einmal im&lt;br /&gt;
Server. Der Server sieht die Skript-Tabelle nicht, und das Skript sieht die&lt;br /&gt;
Sitzung nicht.}}&lt;br /&gt;
&lt;br /&gt;
Verhalten bei einem bereits benutzten Refresh-Token:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Lage !! Antwort !! Wirkung auf die Sitzung&lt;br /&gt;
|-&lt;br /&gt;
| Erneute Vorlage &#039;&#039;&#039;innerhalb von 60 Sekunden&#039;&#039;&#039; || 401 &#039;&#039;AUTH_EXPIRED&#039;&#039; || Keine. Das ist der Normalfall einer App, deren parallele Anfragen gleichzeitig in den Refresh laufen - eine gewinnt, die anderen sollen die vom Gewinner gespeicherten Token benutzen&lt;br /&gt;
|-&lt;br /&gt;
| Erneute Vorlage &#039;&#039;&#039;später&#039;&#039;&#039; || 401 &#039;&#039;AUTH_EXPIRED&#039;&#039; || Die &#039;&#039;&#039;gesamte Sitzung wird gesperrt&#039;&#039;&#039;. Das Token wurde kopiert oder wiederholt, ohne dass ein Gewinner es noch brauchen könnte; der Vorfall wird mit IP protokolliert&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Details und ein vollständiges Beispiel siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
===Phase 4: Abmelden (Logout)===&lt;br /&gt;
&lt;br /&gt;
Abgemeldet wird mit einem &#039;&#039;&#039;DELETE&#039;&#039;&#039; auf denselben JWT-Endpunkt, das&lt;br /&gt;
Access-Token im &#039;&#039;Authorization&#039;&#039;-Header:&lt;br /&gt;
&lt;br /&gt;
 DELETE https://api.meinserver.de/oauth/&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
         Authorization: Bearer eyJhbGciOi... (Access-Token)&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 204 No Content&lt;br /&gt;
&lt;br /&gt;
Der Server sperrt die &#039;&#039;&#039;ganze Sitzung&#039;&#039;&#039; - also auch die noch nicht&lt;br /&gt;
abgelaufenen Access-Token aller vorherigen Erneuerungen. &#039;&#039;&#039;Ein Skript ist dafür&lt;br /&gt;
nicht nötig&#039;&#039;&#039;: es gibt keine Methode &#039;&#039;Logout&#039;&#039;, der Vorgang läuft komplett in&lt;br /&gt;
der Engine. Damit funktioniert das Abmelden für jeden Zugang mit aktiver&lt;br /&gt;
JWT-Pflicht, ohne dass am Authentifizierungs-Skript etwas geändert wird.&lt;br /&gt;
&lt;br /&gt;
* Ein wiederholter Aufruf ist unschädlich und bleibt &#039;&#039;&#039;204&#039;&#039;&#039; - auch wenn die Sitzung längst gesperrt ist.&lt;br /&gt;
* Ein abgelaufenes oder ungültiges Token ergibt &#039;&#039;&#039;401&#039;&#039;&#039; (es wird schon bei der Prüfung abgewiesen).&lt;br /&gt;
* Ein Refresh-Token statt des Access-Tokens ergibt &#039;&#039;&#039;401&#039;&#039;&#039;.&lt;br /&gt;
* Lässt sich die Sitzung nicht sperren, antwortet der Server &#039;&#039;&#039;503&#039;&#039;&#039; mit &#039;&#039;Retry-After&#039;&#039; - ein Client darf nicht glauben, er sei abgemeldet, während sein Token weiterläuft.&lt;br /&gt;
&lt;br /&gt;
====Alle Geräte abmelden====&lt;br /&gt;
&lt;br /&gt;
Der Fall „Gerät verloren&amp;quot; ist bewusst &#039;&#039;&#039;nicht&#039;&#039;&#039; am JWT-Endpunkt untergebracht:&lt;br /&gt;
er braucht eine fachliche Entscheidung darüber, wer das darf. Dafür gibt es das&lt;br /&gt;
&#039;&#039;oWriter.JwtRevoke&#039;&#039;, das jeder gewöhnliche Endpunkt aufrufen kann:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
begin&lt;br /&gt;
    // &#039;session&#039; = nur diese Sitzung, &#039;all&#039; = alle Sitzungen des Subjects&lt;br /&gt;
    oWriter.JwtRevoke(&#039;all&#039;);&lt;br /&gt;
    oWriter.Status(204);&lt;br /&gt;
    oWriter.NoBody();&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;all&#039;&#039; setzt voraus, dass das Authenticate-Skript &#039;&#039;_OBS_JWT_SUBJECT&#039;&#039;&lt;br /&gt;
gesetzt hat. Ohne Subject lässt sich die Menge der Sitzungen nicht bestimmen, und&lt;br /&gt;
der Aufruf antwortet mit 503.}}&lt;br /&gt;
&lt;br /&gt;
==Woher ein Anmelder seine Identität bekommt==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Diese Frage entscheidet das Skript des Zugangs, nicht der Server.&#039;&#039;&#039; Das&lt;br /&gt;
Authentifizierungs-Skript liegt je Zugang in &amp;lt;code&amp;gt;ra_jwt_script&amp;lt;/code&amp;gt; - und&lt;br /&gt;
damit gilt für jede Anwendung ihre eigene Regel.&lt;br /&gt;
&lt;br /&gt;
Das ist keine Kleinigkeit, sondern der Punkt, an dem sich zwei Anwendungen&lt;br /&gt;
unterscheiden. Ein Beispiel aus dem Betrieb: Ein Zugang für eine&lt;br /&gt;
Techniker-Anwendung lehnt jeden Benutzer ohne Mitarbeiterzuordnung&lt;br /&gt;
(&amp;lt;code&amp;gt;BENUTZ.b_mitarbeiter&amp;lt;/code&amp;gt;) ab - &#039;&#039;&#039;bewusst&#039;&#039;&#039;, denn ein Techniker ohne&lt;br /&gt;
Zuordnung bekäme eine leere Auftragsliste und würde den Fehler bei sich suchen.&lt;br /&gt;
Für einen Kundenzugang wäre genau dieselbe Regel falsch: sie träfe &#039;&#039;&#039;jeden&#039;&#039;&#039;&lt;br /&gt;
Anmelder.&lt;br /&gt;
&lt;br /&gt;
Wer einen zweiten Zugang für eine andere Anwendung anlegt, prüft deshalb als&lt;br /&gt;
Erstes: Woraus besteht die Identität dieser Anmelder, und welche Ablehnung ist&lt;br /&gt;
für sie richtig? Ein kopiertes Skript bringt die Regel der anderen Anwendung&lt;br /&gt;
mit, und sie fällt erst auf, wenn sich der erste echte Anmelder nicht anmelden&lt;br /&gt;
kann.&lt;br /&gt;
&lt;br /&gt;
==Sitzungen (RESTSRV_TOKEN)==&lt;br /&gt;
&lt;br /&gt;
Je ausgestelltem Token-Paar entsteht eine Zeile. Sie enthält die Sitzung, die&lt;br /&gt;
Zeilenkennung, das Subject, die beiden Laufzeiten, IP und Trace-ID der Ausstellung&lt;br /&gt;
sowie die Kennzeichen „verbraucht&amp;quot; und „gesperrt&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Rotation und Widerruf sind getrennt.&#039;&#039;&#039; Ein Refresh entwertet nur das Refresh-Token; der zugehörige Access-Token bleibt bis zu seinem Ablauf gültig. Sonst bekämen parallel laufende Anfragen ein 401, obwohl niemand abgemeldet ist.&lt;br /&gt;
* &#039;&#039;&#039;Die Sitzung überlebt die Rotation.&#039;&#039;&#039; Alle Paare einer Anmeldung tragen dieselbe Sitzungskennung - deshalb kann ein Logout sie gemeinsam sperren.&lt;br /&gt;
* &#039;&#039;&#039;Aufräumen läuft automatisch&#039;&#039;&#039; beim Serverstart und danach höchstens stündlich. Gelöscht wird nur, was nichts mehr entscheiden kann: Zeilen, deren Access- &#039;&#039;&#039;und&#039;&#039;&#039; Refresh-Laufzeit vorbei sind. Eine verbrauchte Zeile bleibt bis dahin stehen, weil sonst die Erkennung eines später vorgelegten gestohlenen Refresh-Tokens verloren ginge.&lt;br /&gt;
* &#039;&#039;&#039;Kein Eingriff nötig.&#039;&#039;&#039; Die Tabelle wird nicht gepflegt; sie ist Diagnose-Material. Alle Abweisungen stehen als &#039;&#039;TokenStore: ...&#039;&#039; in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==mTLS-Subject-Prüfung==&lt;br /&gt;
&lt;br /&gt;
Wird im Feld &#039;&#039;&#039;mTLS-Subject&#039;&#039;&#039; ein Wert hinterlegt, prüft der Server zusätzlich zur Standard-mTLS-Validierung, ob der Subject-DN des (CA-validierten) Client-Zertifikats die hinterlegten &#039;&#039;&#039;RDN-Komponenten&#039;&#039;&#039; enthält. Verglichen wird jede Komponente &#039;&#039;&#039;vollständig&#039;&#039;&#039; (kein Substring-Match) und case-insensitiv; mehrere Komponenten werden durch Komma oder Semikolon getrennt und müssen &#039;&#039;&#039;alle&#039;&#039;&#039; im Subject vorkommen.&lt;br /&gt;
&lt;br /&gt;
Beispiele für Subject-Strings im Cert (OneLine-DN):&lt;br /&gt;
&lt;br /&gt;
 /C=DE/O=Kunde GmbH/CN=client-prod.kunde.de&lt;br /&gt;
 /C=DE/O=Kunde GmbH/CN=client-test.kunde.de&lt;br /&gt;
&lt;br /&gt;
Mit &#039;&#039;&#039;CN=client-prod.kunde.de&#039;&#039;&#039; im Feld wird genau das prod-Cert akzeptiert, das test-Cert mit 401 abgewiesen. Da jede RDN-Komponente vollständig verglichen wird, matcht z.B. &#039;&#039;&#039;CN=client1&#039;&#039;&#039; nicht fälschlich auch &#039;&#039;&#039;CN=client10&#039;&#039;&#039;. Mehrere Bedingungen lassen sich mit Komma/Semikolon kombinieren, z.B. &#039;&#039;O=Kunde GmbH;CN=client-prod.kunde.de&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==Listen-Funktionen==&lt;br /&gt;
&lt;br /&gt;
In der Zugänge-Liste (allein):&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Einfg&#039;&#039;&#039; - neuer Zugang&lt;br /&gt;
* &#039;&#039;&#039;Return&#039;&#039;&#039; - Zugang bearbeiten&lt;br /&gt;
* &#039;&#039;&#039;F4&#039;&#039;&#039; - Sortierung&lt;br /&gt;
* &#039;&#039;&#039;F7&#039;&#039;&#039; - JWT-Authentifizierungs-Skript bearbeiten&lt;br /&gt;
* &#039;&#039;&#039;F8&#039;&#039;&#039; - Statistik für den Zugang&lt;br /&gt;
&lt;br /&gt;
Aus der Endpunkt-Liste via F6 geöffnet (Auswahl für Berechtigung):&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;F2&#039;&#039;&#039; - markierte Zugänge dem Endpunkt zuordnen&lt;br /&gt;
* &#039;&#039;&#039;F5&#039;&#039;&#039; - Zugang in der Liste markieren / Markierung aufheben&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Server-Profile&amp;diff=64817</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Server-Profile</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Server-Profile&amp;diff=64817"/>
		<updated>2026-09-14T08:07:07Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Server-Profile=&lt;br /&gt;
&lt;br /&gt;
Ein &#039;&#039;&#039;Server&#039;&#039;&#039; im REST-Modul ist ein eigenständiges TLS-Profil mit einer eigenen HTTP-Server-Instanz. In OBS können beliebig viele Server-Profile parallel betrieben werden, jedes mit eigenem Modus, eigenen Zertifikaten und eigenen Bindungen.&lt;br /&gt;
&lt;br /&gt;
Typische Einsatzfälle für mehrere Profile:&lt;br /&gt;
&lt;br /&gt;
* Ein &#039;&#039;&#039;Public-Profil&#039;&#039;&#039; mit Standard-TLS auf Port 443 für externe Konsumenten.&lt;br /&gt;
* Ein &#039;&#039;&#039;Internal-mTLS-Profil&#039;&#039;&#039; auf einem internen Port für Microservices, die zwingend ein Client-Zertifikat vorlegen müssen.&lt;br /&gt;
* Ein &#039;&#039;&#039;Debug-Profil&#039;&#039;&#039; mit Plain-HTTP auf 127.0.0.1, ausschliesslich für lokale Tests.&lt;br /&gt;
&lt;br /&gt;
==Aufruf==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Stammdaten -&amp;gt; Z Weitere Stammdaten -&amp;gt; REST-Server -&amp;gt; Server&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==Felder des Server-Profils==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld                !! Beschreibung&lt;br /&gt;
|-&lt;br /&gt;
| Nr                  || Eindeutige Nummer 1-99, einmalig vergeben, kann später nicht mehr geändert werden&lt;br /&gt;
|-&lt;br /&gt;
| Name                || Sprechender Name, eindeutig - erscheint in Protokoll, Statistik und Endpunkt-Zuordnung&lt;br /&gt;
|-&lt;br /&gt;
| Aktiv               || Profil wird beim Start des REST-Dienstes geladen. Inaktive Profile starten nicht&lt;br /&gt;
|-&lt;br /&gt;
| Info                || Freitext zur Dokumentation (interner Kommentar, wird nicht ausgewertet)&lt;br /&gt;
|-&lt;br /&gt;
| Kein SSL            || Plain-HTTP-Modus, ausschliesslich für Debug. TLS-Felder werden ignoriert&lt;br /&gt;
|-&lt;br /&gt;
| mTLS                || Mutual TLS: jeder Client muss ein gültiges Client-Zertifikat vorlegen&lt;br /&gt;
|-&lt;br /&gt;
| Cert                || PEM-Inhalt des Server-Zertifikats (inkl. ggf. Zwischen-CA-Kette)&lt;br /&gt;
|-&lt;br /&gt;
| Key                 || PEM-Inhalt des privaten Schlüssels&lt;br /&gt;
|-&lt;br /&gt;
| Root-Cert           || PEM-Inhalt des CA-Root-Zertifikats (Pflicht bei Standard-TLS, dort für Chain-Validierung, sowie bei mTLS, dort zusätzlich für Client-Cert-Verifikation)&lt;br /&gt;
|-&lt;br /&gt;
| Limit-Fenster       || Länge des Rate-Limit-Fensters in Sekunden. Leer/0 = Standard 60&lt;br /&gt;
|-&lt;br /&gt;
| Limit Erfolg        || Erlaubte erfolgreiche Anfragen je Schlüssel und Fenster. Leer/0 = Standard 120&lt;br /&gt;
|-&lt;br /&gt;
| Limit Fehler        || Erlaubte fehlgeschlagene Anfragen je Schlüssel und Fenster. Leer/0 = Standard 10&lt;br /&gt;
|-&lt;br /&gt;
| Limit gesamt        || Erlaubte Anfragen des gesamten Profils je Fenster. Leer/0 = Standard 600&lt;br /&gt;
|-&lt;br /&gt;
| Max. Verbindungen   || Obergrenze gleichzeitiger &#039;&#039;&#039;Verbindungen&#039;&#039;&#039; (&amp;lt;code&amp;gt;rsv_max_conn&amp;lt;/code&amp;gt;). Leer/0 = unbegrenzt. Sie deckt auch laufende Datei-Downloads ab, weil der Platz erst beim Trennen frei wird. Bei Überschreitung trennt der Server &#039;&#039;&#039;ohne HTTP-Antwort&#039;&#039;&#039; - der Wert gehört deutlich über den Normalbetrieb, als Reissleine&lt;br /&gt;
|-&lt;br /&gt;
| Max. parallel       || Obergrenze gleichzeitig &#039;&#039;&#039;verarbeiteter Anfragen&#039;&#039;&#039; (&amp;lt;code&amp;gt;rsv_max_parallel&amp;lt;/code&amp;gt;). Leer/0 = unbegrenzt. Bei Überschreitung antwortet der Server &#039;&#039;&#039;503&#039;&#039;&#039; mit &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;, der Client darf es also gleich erneut versuchen&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Inhalte der Zertifikate werden direkt in OBS als Text gespeichert. Zur Laufzeit schreibt der Server die PEM-Inhalte für den Bruchteil einer Sekunde in ein Temp-Verzeichnis, damit OpenSSL sie beim Start lesen kann; danach werden die Temp-Dateien sofort wieder gelöscht.&lt;br /&gt;
&lt;br /&gt;
==Modi==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Modus               !! Voraussetzungen                       !! Anwendung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Standard-TLS&#039;&#039;&#039;  || Cert + Key + Root-Cert hinterlegt     || Empfohlener Default für alle Server, die über das öffentliche Netz erreichbar sind&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;mTLS&#039;&#039;&#039;          || Cert + Key + Root-Cert (CA)           || Maschine-zu-Maschine-Kommunikation, in der jeder Client mit Cert validiert wird&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Plain-HTTP&#039;&#039;&#039;    || Haken &#039;&#039;Kein SSL&#039;&#039;                    || Ausschliesslich lokale Tests. Erkennt TLS-Versuche auf dem Plain-Port und trennt sie direkt&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Beide Richtungen werden erkannt: Eine &#039;&#039;&#039;unverschlüsselte Anfrage an einen&lt;br /&gt;
TLS-Port&#039;&#039;&#039; (&#039;&#039;http&#039;&#039; statt &#039;&#039;https&#039;&#039;) beantwortet der Server mit &#039;&#039;&#039;HTTP 400&#039;&#039;&#039;&lt;br /&gt;
und einem Klartext-Hinweis. Die Abweisung erfolgt &#039;&#039;&#039;vor&#039;&#039;&#039; Authentifizierung,&lt;br /&gt;
Rate-Limit und Endpunkt-Skript - ein falsch konfigurierter Client löst also&lt;br /&gt;
keine Verarbeitung aus.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Bei Standard-TLS und mTLS fährt der Server ohne Cert/Key gar nicht erst hoch. Bei Standard-TLS ohne Root-Cert wird die Anlage abgelehnt - die Chain-Validierung benötigt das CA-Root. Plain-HTTP umgeht TLS komplett und ist nicht für Produktivumgebungen geeignet.}}&lt;br /&gt;
&lt;br /&gt;
==TLS-Version, Cipher und Zertifikats-Rotation==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;TLS-Version:&#039;&#039;&#039; Der Server erzwingt &#039;&#039;&#039;TLS 1.2&#039;&#039;&#039;. Die Cipher-Liste ist auf ECDHE-Schlüsselaustausch (Forward Secrecy) mit AEAD-Verfahren (AES-GCM, ChaCha20-Poly1305) beschränkt; CBC, statisches RSA-KEX und DHE sind ausgeschlossen.&lt;br /&gt;
* &#039;&#039;&#039;Rotation:&#039;&#039;&#039; Ein Zertifikatswechsel erfolgt durch ändern der PEM-Inhalte in der DB und einen &#039;&#039;&#039;Neustart&#039;&#039;&#039; des REST-Dienstes (kein Hot-Reload). Bis zum Neustart läuft das alte Zertifikat weiter.&lt;br /&gt;
* &#039;&#039;&#039;Certificate-Pinning:&#039;&#039;&#039; Mobile Clients können den Server-Public-Key pinnen. Zertifikatswechsel daher &#039;&#039;&#039;rechtzeitig ankündigen&#039;&#039;&#039; und mit einem &#039;&#039;&#039;Backup-Pin&#039;&#039;&#039; vorbereiten - der neue Pin muss vor dem Wechsel in einer Client-Version ausgerollt sein, sonst sperrt sich die App aus.&lt;br /&gt;
==Rate-Limit je Profil==&lt;br /&gt;
&lt;br /&gt;
Jedes Server-Profil führt &#039;&#039;&#039;eigene Zähler&#039;&#039;&#039; und kann &#039;&#039;&#039;eigene Schwellen&#039;&#039;&#039; haben.&lt;br /&gt;
Ein Profil, das gerade überrannt wird, bremst damit die anderen Profile nicht mit&lt;br /&gt;
aus - das Public-Profil und das interne mTLS-Profil sind voneinander unabhängig.&lt;br /&gt;
&lt;br /&gt;
Gezählt wird pro Schlüssel, und der Schlüssel ist der &#039;&#039;&#039;API-Key&#039;&#039;&#039; des Zugangs.&lt;br /&gt;
Sendet ein Konsument keinen API-Key (Zugang mit &#039;&#039;*&#039;&#039;), wird ersatzweise auf die&lt;br /&gt;
&#039;&#039;&#039;IP-Adresse&#039;&#039;&#039; gezählt.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Das ist der wichtigste Punkt bei mobilen Anwendungen: Sitzen alle&lt;br /&gt;
Benutzer hinter einer gemeinsamen Firmen-IP und sendet die App keinen eigenen&lt;br /&gt;
API-Key, teilen sich &#039;&#039;&#039;alle Benutzer einen Zähler&#039;&#039;&#039;. Mit den Standardwerten sind&lt;br /&gt;
das 10 Fehlversuche für den ganzen Betrieb pro Minute - nach ein paar falschen&lt;br /&gt;
Passworteingaben steht die Abteilung. Für solche Profile die Schwellen bewusst&lt;br /&gt;
höher setzen oder jedem Konsumenten einen eigenen API-Key geben.}}&lt;br /&gt;
&lt;br /&gt;
Bleiben alle vier Felder leer (bzw. 0), gelten die Standardwerte. Die Werte werden&lt;br /&gt;
&#039;&#039;&#039;beim Start&#039;&#039;&#039; des REST-Dienstes gelesen - eine Änderung wird erst nach einem&lt;br /&gt;
Neustart wirksam.&lt;br /&gt;
&lt;br /&gt;
==Validierung beim Speichern==&lt;br /&gt;
&lt;br /&gt;
Beim Speichern wird geprüft:&lt;br /&gt;
&lt;br /&gt;
* Nr und Name müssen vergeben sein.&lt;br /&gt;
* Nr muss eindeutig sein.&lt;br /&gt;
* Name muss eindeutig sein.&lt;br /&gt;
* Bei nicht aktiviertem &#039;&#039;Kein SSL&#039;&#039; müssen Cert und Key vorhanden sein.&lt;br /&gt;
* Bei nicht aktiviertem &#039;&#039;mTLS&#039;&#039; (= Standard-TLS) muss Root-Cert vorhanden sein.&lt;br /&gt;
&lt;br /&gt;
==Bindungen==&lt;br /&gt;
&lt;br /&gt;
Eine Bindung ist die Kombination aus IP-Adresse und Port, auf der ein Server-Profil lauscht. Pro Server-Profil können mehrere Bindungen existieren, z.B. um die gleiche TLS-Konfiguration parallel auf 443 und 8443 anzubieten.&lt;br /&gt;
&lt;br /&gt;
===Aufruf===&lt;br /&gt;
&lt;br /&gt;
In der Server-Liste den gewünschten Server markieren und &#039;&#039;&#039;F6&#039;&#039;&#039; drücken. Es öffnet sich die zum Server gehörende Bindungs-Liste.&lt;br /&gt;
&lt;br /&gt;
===Felder===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld    !! Beschreibung&lt;br /&gt;
|-&lt;br /&gt;
| Host    || IP-Adresse oder Hostname, auf dem gelauscht wird. &#039;&#039;0.0.0.0&#039;&#039; = alle IPv4-Adressen; &#039;&#039;127.0.0.1&#039;&#039; = nur lokal; explizite IPs binden gezielt auf eine Netzwerkkarte&lt;br /&gt;
|-&lt;br /&gt;
| Port    || TCP-Port (1-65535). 443/8443 für HTTPS, frei wählbar für Debug-Profile&lt;br /&gt;
|-&lt;br /&gt;
| Aktiv   || Bindung wird beim Server-Start geladen&lt;br /&gt;
|-&lt;br /&gt;
| Standard|| Markiert die Standard-Bindung des Servers. Pro Server-Profil ist nur eine Standard-Bindung erlaubt&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
===Wirksamkeit===&lt;br /&gt;
&lt;br /&gt;
Bindungen werden ausschliesslich beim Start des REST-Dienstes geladen. Änderungen werden daher erst nach einem Neustart aktiv. Bis dahin laufen die alten Bindungen weiter.&lt;br /&gt;
&lt;br /&gt;
==Listen-Funktionen==&lt;br /&gt;
&lt;br /&gt;
In der Server-Liste:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Einfg&#039;&#039;&#039; - neues Server-Profil&lt;br /&gt;
* &#039;&#039;&#039;Return&#039;&#039;&#039; - vorhandenes Profil bearbeiten&lt;br /&gt;
* &#039;&#039;&#039;F4&#039;&#039;&#039; - Sortierung wählen&lt;br /&gt;
* &#039;&#039;&#039;F6&#039;&#039;&#039; - Bindungs-Liste des markierten Servers&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer&amp;diff=64816</id>
		<title>OBS/Kostenpflichtige Module/RESTServer</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer&amp;diff=64816"/>
		<updated>2026-09-14T08:06:51Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
=REST-Server=&lt;br /&gt;
{{Hinweis|Es handelt sich um ein kostenpflichtiges Modul. Die Schnittstelle muss über den OBS-Support aktiviert werden.}}&lt;br /&gt;
&lt;br /&gt;
==Was leistet der REST-Server?==&lt;br /&gt;
&lt;br /&gt;
Der REST-Server ist die &#039;&#039;&#039;universelle Schnittstelle&#039;&#039;&#039; Ihrer OBS-Installation nach außen. Er macht aus Ihrem OBS einen Dienst, mit dem andere Programme - Webseiten, Apps, Geräte, Kundensysteme, Cloud-Dienste - direkt sprechen können. Statt Daten manuell zu exportieren, zu mailen oder über Umwege bereitzustellen, holen sich angebundene Systeme genau die Information, die sie brauchen, in dem Moment, in dem sie sie brauchen - oder liefern neue Daten direkt in OBS ab.&lt;br /&gt;
&lt;br /&gt;
Für Anwender, die nicht selbst programmieren, bedeutet das: &#039;&#039;&#039;Was bisher nur manuell, per Datei-Import oder über Spezialschnittstellen ging, lässt sich jetzt automatisieren.&#039;&#039;&#039; Eine Webseite zeigt live aktuelle Lagerbestände. Mitarbeiter erfassen Zeiten über das Handy, ohne dass jemand Excel-Listen einpflegen muss. Ein Kunde stößt per Bestelltaste in seinem System einen Vorgang in Ihrem OBS an. Ein Lieferant meldet Wareneingänge automatisch zurück. Jeder dieser Anwendungsfälle wird einmal eingerichtet und läuft danach automatisch.&lt;br /&gt;
&lt;br /&gt;
Technisch gesehen ist der REST-Server ein in OBS integrierter, voll konfigurierbarer &#039;&#039;&#039;HTTP/HTTPS-Dienst&#039;&#039;&#039;, dessen Endpunkte über in OBS gepflegte Pascal-Skripte realisiert werden. Jeder Endpunkt bekommt eine eigene Adresse, eine eigene Logik und eine eigene Berechtigungsstruktur. Rückgaben erfolgen ausschliesslich im JSON-Format. Damit lassen sich beliebige Lese- und Schreibvorgänge auf der OBS-Datenbank realisieren, ohne dass externe Systeme direkten Datenbank-Zugriff erhalten - das ganze OBS-Regelwerk (Rechte, Validierung, Geschäftslogik) bleibt aktiv.&lt;br /&gt;
&lt;br /&gt;
Im Ergebnis ist der REST-Server kein einzelnes Feature, sondern ein &#039;&#039;&#039;Werkzeugkasten&#039;&#039;&#039;: Was an Daten oder Funktionen in OBS verfügbar ist, kann über den REST-Server auch nach außen angeboten - oder von außen entgegengenommen - werden. Die Bandbreite reicht vom einfachen Lese-Endpunkt für ein Webseiten-Widget bis hin zu kompletten B2B-Integrationen mit Mandanten- und Rollen-Trennung.&lt;br /&gt;
&lt;br /&gt;
Aufruf des Moduls in OBS:&lt;br /&gt;
&#039;&#039;&#039;Stammdaten&#039;&#039;&#039; -&amp;gt; &#039;&#039;&#039;Z Weitere Stammdaten&#039;&#039;&#039; -&amp;gt; &#039;&#039;&#039;REST-Server&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==Anwendungsbereiche==&lt;br /&gt;
&lt;br /&gt;
Die folgenden Einsatzfälle sind typische Beispiele, an denen sich der praktische Nutzen ablesen lässt. Es ist keine abschliessende Liste - alles, was sich in OBS-Skripten abbilden lässt, kann auch über einen Endpunkt bereitgestellt werden.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Dies sind Beispiele. Für die konkrete Entwicklung können, je nach Komplexität, weitere Kosten anfallen.}}&lt;br /&gt;
&lt;br /&gt;
===Webseiten und Kundenportale===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Live-Daten auf der eigenen Webseite:&#039;&#039;&#039; Lagerbestände, Verfügbarkeiten, Preise, Auftragsstatus aus OBS direkt anzeigen, ohne tägliche Datei-Exports.&lt;br /&gt;
* &#039;&#039;&#039;Kundenportale:&#039;&#039;&#039; Kunden sehen ihre offenen Posten, Auftragshistorie, Lieferscheine. Die Seite holt sich die Daten zur Anzeigezeit direkt aus OBS, jeder Kunde sieht nur seine eigenen Daten.&lt;br /&gt;
* &#039;&#039;&#039;Trackingseiten:&#039;&#039;&#039; Status einer Bestellung, eines Auftrags oder einer Reparatur für Endkunden, abrufbar über eine Sendungsnummer.&lt;br /&gt;
&lt;br /&gt;
===Mobile Anwendungen und Außendienst===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Mobile Zeiterfassung:&#039;&#039;&#039; Mitarbeiter buchen Anfang, Pause und Ende über Smartphone oder Tablet, die Daten landen direkt in OBS - ohne Zettelwirtschaft.&lt;br /&gt;
* &#039;&#039;&#039;QR-/Barcode-Scanner und Lagergeräte:&#039;&#039;&#039; Wareneingang, Inventur, Kommissionierung direkt in OBS abbilden, ohne zwischengeschaltete Software.&lt;br /&gt;
* &#039;&#039;&#039;Außendienst-Apps:&#039;&#039;&#039; Techniker sehen ihre Aufträge, dokumentieren Einsätze, erfassen Material - synchronisiert mit OBS.&lt;br /&gt;
&lt;br /&gt;
===Anbindung von Drittsystemen===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Webshops:&#039;&#039;&#039; Bestellungen werden direkt vom Shop in OBS gemeldet, Lagerbestände und Preise vom Shop bei OBS abgefragt.&lt;br /&gt;
* &#039;&#039;&#039;Buchhaltungs- und ERP-Systeme:&#039;&#039;&#039; Bidirektionaler Austausch von Belegen, Stammdaten und Buchungen.&lt;br /&gt;
* &#039;&#039;&#039;CRM- und Marketing-Tools:&#039;&#039;&#039; Personendaten, Aktivitäten oder Mailing-Empfänger werden synchron gehalten.&lt;br /&gt;
* &#039;&#039;&#039;Logistikdienstleister:&#039;&#039;&#039; Versandaufträge werden übergeben, Tracking-Informationen zurückgeliefert.&lt;br /&gt;
&lt;br /&gt;
===Automatisierte Rückmeldungen (WebHooks)===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Zahlungs-Callbacks:&#039;&#039;&#039; Bezahldienste (z.B. PayPal, Klarna, Stripe) melden den Zahlungseingang an einen WebHook-Endpunkt - OBS bucht automatisch.&lt;br /&gt;
* &#039;&#039;&#039;Versanddienste:&#039;&#039;&#039; Statusmeldungen (verschickt, zugestellt, retour) werden direkt im passenden Vorgang dokumentiert.&lt;br /&gt;
* &#039;&#039;&#039;Cloud-Workflows:&#039;&#039;&#039; Externe Automatisierungs-Plattformen (Make, Zapier, n8n, ...) stoßen OBS-Vorgänge an oder werden von OBS aus angesprochen.&lt;br /&gt;
&lt;br /&gt;
===Geschäftspartner-Kommunikation (B2B)===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Lieferanten-Schnittstelle:&#039;&#039;&#039; Bestellungen werden automatisch übergeben, Auftragsbestätigungen und Lieferavise zurückgespielt.&lt;br /&gt;
* &#039;&#039;&#039;Kunden-Schnittstelle:&#039;&#039;&#039; Grosskunden bestellen direkt aus ihrem ERP heraus, Stammdaten und Konditionen werden zentral gepflegt.&lt;br /&gt;
* &#039;&#039;&#039;Sichere Maschine-zu-Maschine-Kommunikation:&#039;&#039;&#039; Mit Mutual-TLS (mTLS) wird sichergestellt, dass nur die freigeschalteten Partnersysteme - und niemand sonst - mit OBS sprechen können.&lt;br /&gt;
&lt;br /&gt;
===Interne Microservices und Automatisierungen===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Eigene kleine Hilfs-Tools:&#039;&#039;&#039; Zeitschaltautomatik, Reporting-Generator, Sammelaktionen, die aus mehreren Quellen Daten in OBS verarbeiten.&lt;br /&gt;
* &#039;&#039;&#039;Verbindung mehrerer Standorte:&#039;&#039;&#039; Verteilte Systeme tauschen Daten über definierte Endpunkte aus, ohne offene Datenbankverbindungen.&lt;br /&gt;
* &#039;&#039;&#039;Datenbereitstellung für Dashboards und Auswertungen:&#039;&#039;&#039; BI-Tools (Power BI, Grafana, ...) lesen aufbereitete Kennzahlen direkt aus OBS.&lt;br /&gt;
&lt;br /&gt;
===Was bedeutet das wirtschaftlich?===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Manuelle Arbeit fällt weg.&#039;&#039;&#039; Daten werden nicht mehr aus OBS exportiert, per Mail geschickt und woanders eingelesen - sie fliessen direkt.&lt;br /&gt;
* &#039;&#039;&#039;Fehler werden weniger.&#039;&#039;&#039; Jede manuelle Stelle ist eine mögliche Fehlerquelle; jede Automatisierung eine Stelle, an der nichts mehr schiefgehen kann.&lt;br /&gt;
* &#039;&#039;&#039;Neue Geschäftsmodelle werden möglich.&#039;&#039;&#039; Kunden- und Lieferantenportale, Self-Service-Funktionen, App-Anbindungen lassen sich aufbauen, ohne ein zweites System pflegen zu müssen.&lt;br /&gt;
* &#039;&#039;&#039;Bestehende Software bleibt verbunden.&#039;&#039;&#039; Statt eine vorhandene Anwendung ablösen zu müssen, kann sie über den REST-Server an OBS angedockt werden - in beiden Richtungen.&lt;br /&gt;
* &#039;&#039;&#039;Skaliert mit:&#039;&#039;&#039; Was klein anfängt (ein einzelner Endpunkt für eine Webseite) kann zu einer kompletten Integrations-Plattform ausgebaut werden, ohne dass die Grundlage gewechselt werden muss.&lt;br /&gt;
&lt;br /&gt;
==Architektur im Überblick==&lt;br /&gt;
&lt;br /&gt;
Der REST-Server besteht aus mehreren aufeinander aufbauenden Bausteinen, die alle in OBS gepflegt werden:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Baustein     !! Tabelle              !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| Server       || RESTSRV_SERVER       || TLS-Profil + eigene HTTP-Server-Instanz, kann mehrfach existieren&lt;br /&gt;
|-&lt;br /&gt;
| Bindung      || RESTSRV_BINDINGS     || IP-Adresse + Port pro Server&lt;br /&gt;
|-&lt;br /&gt;
| Zugang       || RESTSRV_ACCOUNT      || API-Key, optionale Host- und CORS-Beschränkung, optionale JWT-Konfiguration&lt;br /&gt;
|-&lt;br /&gt;
| Endpunkt     || RESTSRV_ENDPOINTS    || Skript, das die Anfrage bearbeitet, einem Server fest zugeordnet&lt;br /&gt;
|-&lt;br /&gt;
| Berechtigung || RESTSRV_ACCESS       || Verbindet Zugang und Endpunkt&lt;br /&gt;
|-&lt;br /&gt;
| Idempotenz   || RESTSRV_IDEMPOTENCY  || Speicher für Idempotency-Keys und die dazu gelieferte Antwort&lt;br /&gt;
|-&lt;br /&gt;
| Sitzung      || RESTSRV_TOKEN        || Ausgestellte Token-Paare je Sitzung; Grundlage für Rotation und Widerruf&lt;br /&gt;
|-&lt;br /&gt;
| Protokoll    || RESTSRV_PROTO        || Laufende Ereignisse, Fehler, Audit-Einträge&lt;br /&gt;
|-&lt;br /&gt;
| Statistik    || RESTSRV_STATS        || Aufrufzähler, Antwortzeiten und HTTP-Status pro Endpunkt&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Eine Anfrage durchläuft folgende Stationen:&lt;br /&gt;
&lt;br /&gt;
# Rate-Limit-Prüfung (pro API-Key bzw. IP und global, Schwellen je Server-Profil)&lt;br /&gt;
# Authentifizierung über den API-Key (Header &#039;&#039;apikey&#039;&#039;)&lt;br /&gt;
# CORS-Prüfung (sofern &#039;&#039;Origin&#039;&#039;-Header gesetzt)&lt;br /&gt;
# Optional: JWT-Prüfung - Signatur, Ablauf und Sitzung (sofern für den Zugang aktiviert)&lt;br /&gt;
# Routing: Auflösung des Pfads über die Pfad-Templates der Endpunkte&lt;br /&gt;
# Prüfung der Berechtigung (Zugang -&amp;gt; Endpunkt)&lt;br /&gt;
# Idempotenz-Prüfung (nur bei POST/PUT/PATCH/DELETE &#039;&#039;&#039;mit&#039;&#039;&#039; &#039;&#039;Idempotency-Key&#039;&#039;-Header)&lt;br /&gt;
# Ausführung des Endpunkt-Skripts (oder WebHook-Antwort)&lt;br /&gt;
# Festschreiben des Ergebnisses im Idempotenz-Speicher (sofern in Schritt 7 reserviert)&lt;br /&gt;
# JSON-Antwort, Statistik-Eintrag, Protokoll-Eintrag&lt;br /&gt;
&lt;br /&gt;
==Adressierung==&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt wird über ein &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; angesprochen, das den Pfad nach dem Host beschreibt und Platzhalter enthalten kann:&lt;br /&gt;
&lt;br /&gt;
 http://[Hostadresse][:Port][Pfad-Template]&lt;br /&gt;
&lt;br /&gt;
Beispiele:&lt;br /&gt;
&lt;br /&gt;
 https://api.meinserver.de/kalender/v1&lt;br /&gt;
 https://api.meinserver.de/orders/{uid}&lt;br /&gt;
 https://api.meinserver.de/orders/{uid}/modules/{code}&lt;br /&gt;
&lt;br /&gt;
Statische Segmente müssen exakt passen; Platzhalter in geschweiften Klammern (z.B. {uid}) matchen einen beliebigen Wert und stehen im Skript als Pfad-Parameter zur Verfügung. Eine Versionskennung wird als statisches Segment ins Template aufgenommen (z.B. /kalender/v1), um eine Funktionalität zu verändern, ohne bestehende Konsumenten zu brechen. Details: [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]].&lt;br /&gt;
&lt;br /&gt;
==Unterstützte HTTP-Methoden==&lt;br /&gt;
&lt;br /&gt;
Der Server akzeptiert die Methoden &#039;&#039;&#039;GET&#039;&#039;&#039;, &#039;&#039;&#039;POST&#039;&#039;&#039;, &#039;&#039;&#039;PUT&#039;&#039;&#039;, &#039;&#039;&#039;DELETE&#039;&#039;&#039; und &#039;&#039;&#039;PATCH&#039;&#039;&#039; sowie &#039;&#039;&#039;OPTIONS&#039;&#039;&#039; (CORS-Preflight, ohne Skript-Aufruf). Jede Methode wird im Endpunkt-Skript als gleichnamige Funktion implementiert. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
==Datei-Uploads==&lt;br /&gt;
&lt;br /&gt;
Endpunkte, die dafür freigeschaltet sind (&amp;lt;code&amp;gt;re_upload&amp;lt;/code&amp;gt;), nehmen Dateien&lt;br /&gt;
per &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; oder als fortsetzbaren &#039;&#039;&#039;Resumable-Upload&#039;&#039;&#039;&lt;br /&gt;
(&amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;) entgegen. Die maximale Grösse wird pro Endpunkt&lt;br /&gt;
festgelegt (&amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;, Standard 25&amp;amp;nbsp;MB); Überschreitung&lt;br /&gt;
ergibt 413, ein Upload an einen nicht freigeschalteten Endpunkt 415. Der Server&lt;br /&gt;
legt die Datei temporär ab und übergibt dem Skript den Pfad; die weitere&lt;br /&gt;
Verarbeitung (z.B. DMS-Ablage) übernimmt das Skript. Details:&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
==Doppelte Sendungen abfangen (Idempotenz)==&lt;br /&gt;
&lt;br /&gt;
Ein Client, der nach einem Timeout dieselbe Anfrage erneut schickt, darf keine&lt;br /&gt;
Zweitbuchung auslösen. Der Server erledigt das selbst: Sendet ein Aufruf mit&lt;br /&gt;
&#039;&#039;&#039;POST&#039;&#039;&#039;, &#039;&#039;&#039;PUT&#039;&#039;&#039;, &#039;&#039;&#039;PATCH&#039;&#039;&#039; oder &#039;&#039;&#039;DELETE&#039;&#039;&#039; den Header&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, merkt sich der Server Schlüssel und Ergebnis in&lt;br /&gt;
&#039;&#039;&#039;RESTSRV_IDEMPOTENCY&#039;&#039;&#039;. Eine Wiederholung mit demselben Schlüssel und demselben&lt;br /&gt;
Inhalt bekommt die &#039;&#039;&#039;gespeicherte Antwort&#039;&#039;&#039; zurück - das Endpunkt-Skript läuft&lt;br /&gt;
gar nicht erst an.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Ohne den Header ändert sich nichts.&#039;&#039;&#039; Bestehende Endpunkte verhalten sich unverändert.&lt;br /&gt;
* &#039;&#039;&#039;Kein Schalter am Endpunkt.&#039;&#039;&#039; Die Mechanik greift automatisch, sobald der Header anliegt.&lt;br /&gt;
* &#039;&#039;&#039;Das Skript muss nichts tun.&#039;&#039;&#039; Eine eigene Idempotenz-Logik im Skript ist nicht mehr nötig.&lt;br /&gt;
&lt;br /&gt;
Details und die Statuscodes: [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
==Sitzungen und Abmelden==&lt;br /&gt;
&lt;br /&gt;
Ein JWT trägt seine Gültigkeit in sich: der Server prüft Signatur und Ablauf und&lt;br /&gt;
braucht dafür keine Datenbank. Genau deshalb liesse sich ein ausgegebener Token&lt;br /&gt;
von sich aus auch nicht mehr zurückziehen - ein verlorenes Gerät wäre bis zum&lt;br /&gt;
Ablauf des Tokens weiter nutzbar.&lt;br /&gt;
&lt;br /&gt;
Der Server führt deshalb zu jedem ausgestellten Token-Paar eine Zeile in&lt;br /&gt;
&#039;&#039;&#039;RESTSRV_TOKEN&#039;&#039;&#039; und prüft jeden Zugriff dagegen. Daraus ergeben sich drei&lt;br /&gt;
Eigenschaften:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Abmelden wirkt sofort.&#039;&#039;&#039; Ein &#039;&#039;DELETE&#039;&#039; auf den JWT-Endpunkt sperrt die Sitzung; alle Token dieser Sitzung sind damit unmittelbar ungültig, auch die noch nicht abgelaufenen. Anmelden, Erneuern und Abmelden laufen also über einen einzigen Endpunkt, und für das Abmelden ist kein Skript nötig.&lt;br /&gt;
* &#039;&#039;&#039;Ein Refresh-Token gilt genau einmal.&#039;&#039;&#039; Beim Erneuern wird es entwertet. Wird es später erneut vorgelegt, gilt das als Diebstahl oder Fehlfunktion: die betroffene Sitzung wird komplett gesperrt und der Vorfall protokolliert.&lt;br /&gt;
* &#039;&#039;&#039;Alle Geräte abmelden.&#039;&#039;&#039; Ein gewöhnlicher Endpunkt kann über das Antwortfeld &#039;&#039;_OBS_JWT_REVOKE&#039;&#039; auch alle Sitzungen eines Benutzers sperren - der Support-Fall „Gerät verloren&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Mit der Einführung dieser Prüfung verlieren &#039;&#039;&#039;alle vorher&lt;br /&gt;
ausgestellten Token&#039;&#039;&#039; ihre Gültigkeit, weil sie zu keiner Sitzung gehören.&lt;br /&gt;
Angemeldete Konsumenten müssen sich nach dem Update einmalig neu anmelden.}}&lt;br /&gt;
&lt;br /&gt;
Der Speicher pflegt sich selbst: Zeilen werden gelöscht, sobald sowohl das&lt;br /&gt;
Access- als auch das Refresh-Token abgelaufen sind. Details und die Skript-Seite:&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
&lt;br /&gt;
==Fehlerformat==&lt;br /&gt;
&lt;br /&gt;
Jede Fehlerantwort des Servers hat dieselbe Form: ein einziges &#039;&#039;error&#039;&#039;-Objekt&lt;br /&gt;
mit maschinenlesbarem Code, Support-Kennung, Meldung und Korrelations-ID:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;error&amp;quot;: {&lt;br /&gt;
    &amp;quot;code&amp;quot;:    &amp;quot;AUTH_EXPIRED&amp;quot;,&lt;br /&gt;
    &amp;quot;uid&amp;quot;:     &amp;quot;Y00E2RTPDY&amp;quot;,&lt;br /&gt;
    &amp;quot;message&amp;quot;: &amp;quot;Ungültiges oder abgelaufenes Token&amp;quot;,&lt;br /&gt;
    &amp;quot;traceId&amp;quot;: &amp;quot;20260817T091233123-00001A&amp;quot;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;code&#039;&#039;&#039; - sprechender Bezeichner (z.B. &#039;&#039;AUTH_EXPIRED&#039;&#039;, &#039;&#039;NOT_FOUND&#039;&#039;, &#039;&#039;RATE_LIMITED&#039;&#039;, &#039;&#039;INTERNAL_ERROR&#039;&#039;), an dem ein Client seine Reaktion festmachen kann, ohne Texte auszuwerten.&lt;br /&gt;
* &#039;&#039;&#039;uid&#039;&#039;&#039; - OBS-Kennung der Codestelle. Für die Support-Recherche gedacht, nicht zur Auswertung im Client.&lt;br /&gt;
* &#039;&#039;&#039;message&#039;&#039;&#039; - Text für den Client. Interne Fehlerdetails stehen nie darin, sondern ausschliesslich im Protokoll &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039;.&lt;br /&gt;
* &#039;&#039;&#039;traceId&#039;&#039;&#039; - Korrelations-ID. Steht zusätzlich im Header &#039;&#039;X-Trace-Id&#039;&#039; und in jeder zugehörigen Logzeile.&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt-Skript kann eigene Codes setzen; siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
{{Achtung|&#039;&#039;Fehlercode&#039;&#039;, &#039;&#039;Nachricht&#039;&#039; und &#039;&#039;traceId&#039;&#039; stehen &#039;&#039;&#039;nur&#039;&#039;&#039; im&lt;br /&gt;
&#039;&#039;error&#039;&#039;-Objekt, nicht flach daneben - der Wert von&lt;br /&gt;
&#039;&#039;Fehlercode&#039;&#039; heisst jetzt &#039;&#039;error.uid&#039;&#039;, &#039;&#039;Nachricht&#039;&#039; entspricht&lt;br /&gt;
&#039;&#039;error.message&#039;&#039;. Clients, die die flachen Felder auswerten, müssen angepasst&lt;br /&gt;
werden.}}&lt;br /&gt;
&lt;br /&gt;
==Sicherheitsmerkmale==&lt;br /&gt;
&lt;br /&gt;
Der REST-Server enthält eine Reihe von Schutzmechanismen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Rate-Limit:&#039;&#039;&#039; Standardmässig max. 10 fehlgeschlagene und 120 erfolgreiche Anfragen pro API-Key (Account) bzw. IP und 60 Sekunden, max. 600 Anfragen global pro 60 Sekunden. Beim Überschreiten wird HTTP 429 mit &#039;&#039;Retry-After&#039;&#039;-Header zurückgegeben. Die vier Schwellen lassen sich &#039;&#039;&#039;pro Server-Profil&#039;&#039;&#039; hinterlegen; die Zähler laufen ebenfalls getrennt je Profil, ein überlastetes Profil bremst das andere also nicht mit aus. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]].&lt;br /&gt;
* &#039;&#039;&#039;Body-Limit:&#039;&#039;&#039; max. 10 MB Request-Body (JSON), max. 1024 Zeichen pro Header- oder Query-Parameter. Datei-Uploads laufen über einen separaten Pfad und sind pro Endpunkt auf &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt; MB begrenzt (Standard 25&amp;amp;nbsp;MB).&lt;br /&gt;
* &#039;&#039;&#039;Sensible Felder&#039;&#039;&#039; (&#039;&#039;password&#039;&#039;, &#039;&#039;token&#039;&#039;, &#039;&#039;secret&#039;&#039;, &#039;&#039;apikey&#039;&#039;, &#039;&#039;authorization&#039;&#039;, &#039;&#039;cookie&#039;&#039;, ...) werden in Protokoll-Ausgaben automatisch maskiert.&lt;br /&gt;
* &#039;&#039;&#039;Widerrufbare Token:&#039;&#039;&#039; Jedes ausgestellte Token-Paar gehört zu einer Sitzung in &#039;&#039;&#039;RESTSRV_TOKEN&#039;&#039;&#039; und wird bei &#039;&#039;&#039;jedem&#039;&#039;&#039; Zugriff dagegen geprüft. Eine Abmeldung wirkt damit sofort und nicht erst mit dem Ablauf des Tokens; ein Refresh-Token gilt genau einmal. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
* &#039;&#039;&#039;Reserviertes Präfix:&#039;&#039;&#039; Parameter-Namen mit Präfix &#039;&#039;_OBS_&#039;&#039; können von aussen nicht gesetzt werden, sie sind für den Server reserviert (z.B. JWT-Claims).&lt;br /&gt;
* &#039;&#039;&#039;Geblockte Header:&#039;&#039;&#039; &#039;&#039;authorization&#039;&#039;, &#039;&#039;cookie&#039;&#039;, &#039;&#039;proxy-authorization&#039;&#039;, &#039;&#039;x-forwarded-for&#039;&#039;, &#039;&#039;x-real-ip&#039;&#039;, &#039;&#039;apikey&#039;&#039;, &#039;&#039;api_key&#039;&#039; werden nicht an das Skript durchgereicht.&lt;br /&gt;
* &#039;&#039;&#039;TLS:&#039;&#039;&#039; Bei Profilen mit aktivem TLS fährt der Server ohne Zertifikat und Key nicht hoch. Plain-HTTP ist nur für ein dediziertes Debug-Profil möglich.&lt;br /&gt;
* &#039;&#039;&#039;mTLS&#039;&#039;&#039; (Mutual TLS): erzwingt, dass jeder Client ein gültiges Client-Zertifikat vorlegt. Optional kann pro Zugang ein erwarteter Subject-DN hinterlegt werden.&lt;br /&gt;
* &#039;&#039;&#039;DNS-Cache:&#039;&#039;&#039; Host-zu-IP-Auflösungen für die Zugangs-Host-Prüfung werden 5 Minuten gecached.&lt;br /&gt;
&lt;br /&gt;
==Protokoll==&lt;br /&gt;
&lt;br /&gt;
Die Tabelle &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; enthält alle relevanten Ereignisse: erfolgreiche und abgelehnte Anfragen, TLS-/JWT-Fehler, Bindungs-Ereignisse, Skript-Fehler. Einträge sind nach Datum, Uhrzeit und Host (IP oder Account-Name) sortiert. Das Protokoll ist die erste Anlaufstelle bei Störungen. Jede Antwort trägt zudem eine Korrelations-ID (Response-Header &#039;&#039;X-Trace-Id&#039;&#039;), die in den Protokoll-Einträgen wiederzufinden ist.&lt;br /&gt;
&lt;br /&gt;
==Statistik==&lt;br /&gt;
&lt;br /&gt;
Die Tabelle &#039;&#039;&#039;RESTSRV_STATS&#039;&#039;&#039; enthält eine Statistik aller bearbeiteten Anfragen mit Zugang, Endpunkt, Methode, HTTP-Status und Laufzeit in Millisekunden. Aufruf der Statistik:&lt;br /&gt;
&lt;br /&gt;
* Aus der Endpunkt-Liste: F8 zeigt die Statistik des markierten Endpunkts.&lt;br /&gt;
* Aus der Zugänge-Liste: F8 zeigt die Statistik des markierten Zugangs.&lt;br /&gt;
&lt;br /&gt;
==Konsole==&lt;br /&gt;
&lt;br /&gt;
Wird der REST-Server nicht als Dienst, sondern interaktiv gestartet, erscheint eine farbige Konsole mit Live-Log. Die wichtigsten Befehle:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Befehl       !! Wirkung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;show &amp;lt;n&amp;gt;&#039;&#039; || Zeigt die Details zum Debug-Eintrag &#039;&#039;#n&#039;&#039; (z.B. Request-Header, Response-Body), JSON wird farbig formatiert&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;exit&#039;&#039; / &#039;&#039;quit&#039;&#039; || Beendet den Server geordnet&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Im Service-Modus (Windows-Dienst) ist die Konsole nicht sichtbar. Alle Einträge landen dort ausschliesslich in der Tabelle RESTSRV_PROTO.&lt;br /&gt;
&lt;br /&gt;
==Weiterführende Seiten==&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Einrichtung|Einrichtung]] - Schritt-für-Schritt-Anleitung&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]] - TLS, mTLS und Bindungen&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]] - API-Keys, JWT, CORS, Host-Beschränkung&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]] - Verwaltung, Berechtigungen, WebHooks&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]] - Aufbau der Endpunkt-Skripte&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]] - Übertragungsprotokoll für Dateien (Multipart, resumable)&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Datei-Download|Datei-Download]] - Dateien ausliefern, Range/Resume, öffentliche Artefakte&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel1|Beispiel 1: Daten-Abruf mit JWT]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel2|Beispiel 2: Zeiterfassung als komplette Webanwendung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel3|Beispiel 3: Datensatz anlegen mit JSON-Body (CRUD)]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4: Pfad-Parameter im Routing]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6: Datei-Upload und Ablage im DMS]]&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=Vorlage:Kostenpflichtige_Module&amp;diff=64811</id>
		<title>Vorlage:Kostenpflichtige Module</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=Vorlage:Kostenpflichtige_Module&amp;diff=64811"/>
		<updated>2026-09-09T12:33:27Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&amp;lt;div style=&amp;quot;float:right; clear:both; margin-left:2px; padding:0px; background:#FFFFFF; width:19em&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;div style=&amp;quot;border:none;margin-top:0pt;padding:0pt;&amp;quot; id=&amp;quot;navigation&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;div style=&amp;quot;position:relative;top:-20pt;color:#000000;background:#ffffff; border: 3pt double #cfcfcf; padding: 5pt; margin: 0pt;margin-bottom:-20pt; z-index:1;border-top-right-radius:7pt;-moz-border-radius-topright:7pt;-webkit-border-top-right-radius:7pt;border-bottom-left-radius:7pt;-moz-border-radius-bottomleft:7pt;-webkit-border-bottom-left-radius:7pt;box-shadow: 3px 3px 4px #c0c0c0;-webkit-box-shadow: 3px 3px 4px #c0c0c0;-moz-box-shadow: 3px 3px 4px #c0c0c0;&amp;quot;&amp;gt;[[Hauptseite|Wiki]] » [[Kostenpflichtige Module|Kostenpflichtige Module]]&amp;lt;/div&amp;gt;&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Textbox-Blau|1=Kostenpflichtige Module|2=&lt;br /&gt;
&amp;lt;small&amp;gt;&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Internet-Shop&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Stammdaten/Schnittstellen/Internet-Shop| OBS Schnittstelle Internet Shop]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Der modified ECommerce Shop&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop-xtcModified/FAQs|FAQs]]&lt;br /&gt;
*[[OBS/Internet-Shop/modified-eCommerce|Überblick zum modified ECommerce Shop]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=Shop-Menü|inhalt=&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/A Preise aktualisieren|A Preise aktualisieren]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/C Personen übertragen|C Personen übertragen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/E Kategorien verwalten|E Kategorien verwalten]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/G Kataloge verwalten|G Kataloge verwalten]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop-xtcModified/I Merkliste übertragen|I Merkliste übertragen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/K Varianten übertragen|K Varianten übertragen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/L Artikelvarianten übertragen|L Artikelvarianten übertragen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/M Referenzarten übertragen|M Referenzarten übertragen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/N Lagerbestände verwalten|N Lagerbestände verwalten]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop#Bestellungen_einlesen|U Bestellungen einlesen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/V leere Passworte füllen|V leere Passworte füllen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/modified eCommerce/W Update-Informationen zurücksetzen|W Update-Informationen zurücksetzen]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop/Konfiguration/modified_eCommerce|X Konfiguration]]&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop#Protokoll|Z Protokoll]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=Automatische Vorgänge|inhalt=&lt;br /&gt;
* [[OBS/Internet-Shop/modified-eCommerce/Scheduler/Bestellungen einlesen|Bestellungen einlesen]]&lt;br /&gt;
* [[OBS/Internet-Shop/modified-eCommerce/Scheduler/Verfügbarkeitsdateien erstellen|Verfügbarkeitsdateien erstellen]]&lt;br /&gt;
* [[OBS/Internet-Shop/modified-eCommerce/Scheduler/Verfügbarkeitsdateien hochladen|Verfügbarkeitsdateien hochladen]]&lt;br /&gt;
*[[OBS/Internet-Shop/modified-eCommerce/Scheduler/Änderungsautomatik|geänderte Artikel übertragen]]&lt;br /&gt;
*[[OBS/Internet-Shop/modified-eCommerce/Scheduler/Preise aktualisieren|Preise aktualisieren]]&lt;br /&gt;
*[[OBS/Internet-Shop/modified-eCommerce/Scheduler/Anlage Shopkategorien|Anlage der Shopkategorien auf Grundlage der OBS-Warengruppen]]&lt;br /&gt;
}}&lt;br /&gt;
*[[OBS/Stammdaten/Schnittstellen/Internet-Shop-xtcModified/besondere Funktionen|Besondere Anpassungen im xtcModified Shop]]&amp;lt;br&amp;gt;&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[Kostenpflichtige_Module/Internet-Shop/modified_eCommerce_2|modified eCommerce 2.0]]|inhalt=&lt;br /&gt;
*[[OBS/Internet-Shop/modified_eCommerce_2.0|Funktionen und FAQ]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[OBS/Internet-Shop/ShopV4|Der Büroring-ShopV4]]|inhalt=&lt;br /&gt;
*[[OBS/Internet-Shop/ShopV4/FAQs|FAQs]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=Einrichtung|inhalt=&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/1.1_Einstellungen_im_Shop|Einstellungen im Shop]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/1.2_Einstellungen_in_OBS|Einstellungen in OBS]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Erste_Schritte|Erste Schritte]]&lt;br /&gt;
}}&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Shop-Menu|Shop-Menü]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Shop-Lieferanten|Shop-Lieferanten]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Import_von_Bestellungen|Import von Bestellungen]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Personen_/_Kundengruppen|Personen / Kundengruppe]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=Eigene Artikel|inhalt=&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/6.1_Artikel_übertragen|Artikel übertragen]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/6.2_Shop-Warengruppen|Shop-Warengruppen]]&lt;br /&gt;
}}&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Preislisten|Preislisten]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Katalogvorlagen|Katalogvorlagen]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Bestellvorlagen|Bestellvorlagen]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Sortiment|Sortiment]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Kostenstellen|Kostenstellen]]&lt;br /&gt;
* [[OBS/Internet-Shop/ShopV4/Personen-Einstellungen|Personen-Einstellungen]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[Kostenpflichtige_Module/Internet-Shop/brShop24|brShop24]]|inhalt=&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24|Übersicht]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Einrichtung|Einrichtung]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24#Bekannte_Probleme|Bekannte Probleme]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24#H.C3.A4ufig_gestellte_Fragen_.28FAQ.29|Häufig gestellte Fragen (FAQ)]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[Kostenpflichtige_Module/Internet-Shop/brShop24|Einstellungen und Automatiken]]|inhalt=&lt;br /&gt;
* [[OBS/Stammdaten/Schnittstellen/Internet-Shop/Konfiguration|Konfiguration]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24#Zuordnungen|Zuordnungen/Datenverknüpfung OBS und Shop]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/Einrichtung/Automatiken|Automatiken einrichten]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[Kostenpflichtige_Module/Internet-Shop/brShop24#Erkl.C3.A4rungen.2FFunktionen|Funktionen]]|inhalt=&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Kundengruppen_verwalten|Kundengruppen verwalten]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Personen_übertragen|Personen übertragen]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Preise_übertragen|Preise übertragen]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Eigene_Artikel_übertragen|Eigene Artikel übertragen]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Shoppinglisten_übertragen|Shoppinglisten übertragen]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Jobinformationen|Jobinformationen]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Migration|Migration]]&lt;br /&gt;
* [[Kostenpflichtige Module/Internet-Shop/brShop24/PDF-Druck Artikellink|PDF-Druck Artikellink]]&lt;br /&gt;
* [[Kostenpflichtige_Module/Internet-Shop/brShop24/Definition_Sortiment|Definition Sortiment (individuelle Konfiguration)]]&lt;br /&gt;
}}&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
* [[Kostenpflichtige Module/Internet-Shop/soCONNECT|so.CONNECT (Soennecken)]]&lt;br /&gt;
&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Preislisten&#039;&#039;&#039;|inhalt=&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[OBS/Kostenpflichtige_Module/Preislisten|Preislistenverwaltung]]|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Preislisten/Preislisten-Artikel|Preislisten-Artikel]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Preislisten/Preislistengruppen|Preislistengruppen]]&lt;br /&gt;
}}&lt;br /&gt;
* [[OBS/Preislisten/FAQ_Preislisten|FAQ Preislisten]]&lt;br /&gt;
* [[OBS/Häufig gestellte Fragen/Vorgehensweise für Preislisten ohne Server|Vorgehen für Preislisten ohne Server]]&lt;br /&gt;
* [[OBS/Häufig gestellte Fragen/Vorgehensweise mit Server/Client Installation für Preislisten|Vorgehen für Preislisten mit Server]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere Funktionen|F10 Weitere Funktionen]]|inhalt=&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere_Funktionen/Export_Import#A_Preisliste_nach_Excel|A Preisliste nach Excel]]&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere_Funktionen/Export_Import#B1_Excel_in_Preisliste|B1 Excel in Preisliste]]&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere_Funktionen/Export_Import#B2_freie_Excel_in_Preisliste|B2 freie Excel in Preisliste]]&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere_Funktionen/Export_Import#B3_Lieferanten-Preise_aus_CSV_in_Preisliste|B3 Lieferanten-Preise aus CSV in Preisliste]]&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/AutoPflege|C Automatische Vorgänge Preislisten]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Preislisten/Formeln|D Formelverwaltung]]&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere_Funktionen/Angebot_Preisliste#F.C3.BCllen_einer_Preisliste_aus_Angebot|E1 Angebot nach Preisliste]]&lt;br /&gt;
* [[OBS/Stammdaten/Artikel/Preislistenverwaltung/Weitere_Funktionen/Angebot_Preisliste#Angebot_aus_Preisliste_generieren|E2 Preisliste nach Angebot]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[OBS/Stammdaten/Artikel/Preislistenverwaltung/AutoPflege|Automatiken Kommandos]]|inhalt=&lt;br /&gt;
* [[OBS/Preislisten/Preislisten per Macro füllen|Preislisten per Macro füllen]]&lt;br /&gt;
* [[OBS/Preislisten/Preislisten per Macro kopieren|Preislisten per Macro kopieren]]&lt;br /&gt;
}}&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;ZUGFeRD&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/ZUGFeRD|ZUGFeRD]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/ZUGFeRD/Konfiguration|ZUGFeRD Konfiguration]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Factoring&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Was ist Factoring?|Was ist Factoring?]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Factoring in OBS| Factoring in OBS]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Erstellen und Verwenden von Factoring Rechnungen|Erstellen und Verwenden von Factoring Rechnungen]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Auswertung und Übermittlung von Factoring Rechnungen|Auswertung und Übermittlung von Factoring Rechnungen]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Factoring Fibu|Factoring Fibu]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;UPS&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/kostenpflichtige Module/UPS|UPS Modul]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;DHL&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/kostenpflichtige Module/DHL|DHL Schnittstelle]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;IMS Professional&#039;&#039;&#039;|inhalt=&lt;br /&gt;
;&lt;br /&gt;
* [[OBS/kostenpflichtige Module/IMSPro|IMS-Professional]]&lt;br /&gt;
&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;TAPI&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/TAPI|TAPI]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/TAPI Voraussetzungen|Voraussetzungen]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/TAPI Konfiguration|Konfiguration]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/TAPI Externe Telefonanbindungen|Externe Telefonanbindungen]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;SMS&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/SMS|SMS]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[OBS/Kostenpflichtige_Module/Fleet_Management|&#039;&#039;&#039;Fleet Management&#039;&#039;&#039;]]|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Fleet Management/FMAudit|FMAudit]]&lt;br /&gt;
* [[FRMFMZENTRALE_MELDUNGEN|FMAudit Pro]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Fleet Management/UTAX E-mail|UTAX E-Mail]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Fleet_Management/Brother_XML|Brother XML]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Fleet_Management/KonicaMinolta_E-Mail|Konica Minolta E-Mail]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Fleet_Management/docuFORM|docuFORM XML]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Fleet_Management/KyoceraFleetServices|KFS XML]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Fleet_Management/SimpleClicksXML|SimpleClicks XML]]&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Fleet_Management/Brother_PPPB_xlsx|Brother PPPB Xlsx]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Mehrlager-Verwaltung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Mehrlager-Verwaltung|Mehrlager-Verwaltung]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Mehrsprachen Modul&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Mehrsprachen Modul|Mehrsprachen Modul]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Multilanguage Modul&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Multilanguage Modul|Multilanguage Modul]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Einfache Produktionsnachverfolgung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Einfache Produktionsnachverfolgung/Was ist|Was ist die Einfache Produktionsnachverfolgung]]&lt;br /&gt;
* [[OBS/Einfache Produktionsnachverfolgung/FRMEDITPRODMENGE|Scannen der Auftragspositionen]]&lt;br /&gt;
* [[OBS/Einfache Produktionsnachverfolgung/Sammellieferschein|Sammellieferschein]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;DMS - Dokumenten Management System&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS|DMS]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Konfiguration|Konfiguration]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/Scannen von Dokumenten|Einscannen von Dokumenten im Stapelbetrieb]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=DMS Dokumente|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente|Liste]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/Versionierung|Dokumentenversionierung]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=Tasten und Schaltflächen|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente/F2 Filter|F2 Filter]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente/F5 Markieren|F5 Mark]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Verknüpfung|F6 Verknü.]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente/F7 Suche|F7 Suche]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente/F8 Info|F8 Info]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente/F9 Gruppe|F9 Gruppe]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=F10 Weit.|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Dokumente/F10 Weitere Funktionen|F10 Weit.]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Stammdaten|DMS Stammdaten]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=E DMS Definitionen|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Definitionen|Liste]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Definitionen Eingabemaske|Eingabemaske]]&lt;br /&gt;
}}&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/QR Etiketten|M QR Etiketten drucken]]&lt;br /&gt;
}}&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Viewer|Dokument]]&lt;br /&gt;
}}&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DMS/DMS Eingabemaske|Eingabemaske]]&lt;br /&gt;
}}&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;QR Zeiterfassung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/QR Zeiterfassung|QR Zeiterfassung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/QR Einrichtung Checkliste|QR Einrichtung Checkliste]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;EVA Marketing Tool&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/EVA]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Technikersteuerung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Technikersteuerung/Ziel der Technikersteuerung|Ziel der Technikersteuerung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Technikersteuerung/Konfiguration|Konfiguration]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Technikersteuerung/Terminvorschläge erstellen|Terminvorschläge für Techniker erstellen]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Technikersteuerung/Liste|Liste]]&lt;br /&gt;
** [[OBS/Kostenpflichtige Module/Technikersteuerung/Definition Terminvorschlagslisten|Definition Terminvorschlagslisten/T-Def]]&lt;br /&gt;
** [[OBS/Kostenpflichtige Module/Technikersteuerung/Terminvorschläge bearbeiten|Terminvorschläge für Techniker bearbeiten]]&lt;br /&gt;
** [[OBS/Kostenpflichtige Module/Technikersteuerung/Terminvorschlagsliste aktualisieren|Terminvorschlagsliste akt./T-Liste]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Termin-Projekte&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Termin-Projekte|Termin-Projekte]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Edifact-Schnittstelle&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Edifact|Edifact Schnittstelle]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Backup Überwachung Email&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Backup Überwachung Email|Backup Überwachung Email]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Anlagenbuchhaltung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung|Anlagenbuchhaltung]]&lt;br /&gt;
** [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung/Auswertungen|Auswertungsdrucke]]&lt;br /&gt;
** [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung/Anlagengüter|Anlagegüterübersicht]]&lt;br /&gt;
** [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung/Abschreibungsarten|Abschreibungsarten]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung/Anlagekonten|Anlagekonten]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung/Bewegung|Anlagebewegungen]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Anlagenbuchhaltung/Anlagenstapel|Anlagenstapel]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;OBS Geo Daten&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/OBS Geo Daten|OBS Geo Daten]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;DeliSprint / DPD&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige_Module/Delisprint|DeliSprint / DPD]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Filialen&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Filialen|Filialen]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Auto-Waagen&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Auto-Waagen|Auto-Waagen]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Auto-Waagen/Installation|Installation]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Auto-Waagen/Liste|Liste]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Auto-Waagen/Eingabemaske|Eingabemaske]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Auto-Waagen/Waage.INI|Waage.INI]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Cashback&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Cashback|Cashback]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Moebelschnittstelle&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/Moebelschnittstelle|Moebelschnittstelle]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Tourenplanung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
{{Vorlage:Klapptext|kopf=[[OBS/Kostenpflichtige Module/Tourenplanung|Tourenplanung]]|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/Tourenplanung/TourVG|Vorgänge]]&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/Tourenplanung/TourTouren|Touren]]&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/Tourenplanung/TourTourPos|Positionen]]&lt;br /&gt;
}}&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/Tourenplanung/Stammdaten|Stammdaten]]&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/Tourenplanung/Einrichtung|Einrichtung]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Dokumenten Manager&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/Dokumenten_Manager|Dokumenten Manager]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;DocuWare-Schnittstelle&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/DocuWare|DocuWare-Schnittstelle]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;OFML-Kalkulation&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/OFML-Kalkulation|OFML-Kalkulation]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Pascom&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/Pascom|Pascom]]&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/Pascom_Einrichtung|Einrichtung]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Versicherungsschaden&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/Versicherungsschaden|Versicherungsschaden]]&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/Energienachweise|Energienachweise]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Gutschriftsanzeigen&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Gutschriftsanzeigen|Gutschriftsanzeigen]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;OCPP Ladestationen&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/OCPP_Ladestationen|OCPP Wallboxen Abrechnung (Ladesäulen)]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/SteVe|SteVe]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Kameraverwaltung&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Kameraverwaltung|Kameraverwaltung]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;DataInOut&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/DataInOut|DataInOut]]&lt;br /&gt;
}}&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;REST-Schnittstelle&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer|REST-Server]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Einrichtung|Einrichtung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Skripting]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Beispiele&#039;&#039;&#039;|inhalt=&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/RESTServer/Beispiel1|PHP-Abruf von Steuerungsdaten]]&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/RESTServer/Beispiel2|Zeiterfassung]]&lt;br /&gt;
*[[OBS/Kostenpflichtige Module/RESTServer/Beispiel3|Externes Ticketsystem]]&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/RESTServer/Beispiel4|Pfad-Parameter]]&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/RESTServer/Beispiel5|Schreibzugriff mit Statuscodes, ETag und Idempotenz]]&lt;br /&gt;
*[[OBS/Kostenpflichtige_Module/RESTServer/Beispiel6|Datei-Upload und Ablage im DMS]]&lt;br /&gt;
}}&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Sammelverträge&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Sammelvertrag|Sammelvertrag]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Sammelvertrag/Container|Container-Stammdaten]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Craftboxx&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Craftboxx|Craftboxx]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/CraftboxxAPI|Craftboxx API Dokumentation]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;OpenMasterData / IDS&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/OpenMasterData|OpenMasterData]]&lt;br /&gt;
* [[BS/Kostenpflichtige Module/IDSConnect|IDSConnect]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;Sammelpositionen&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/Sammelpositionen|Sammelpositionen]]&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{Vorlage:Klapptext|kopf=&#039;&#039;&#039;KI-Mining&#039;&#039;&#039;|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining|KI-Mining]]&lt;br /&gt;
{{Vorlage:Klapptext|kopf=KI-Mining Apps|inhalt=&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Auftragsmonitor|Auftragsmonitor]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Lageroptimierung|Lageroptimierung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Kundenklassifizierung|Kundenklassifizierung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Cross-Selling|Cross-Selling]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Vertriebs-ToDos|Vertriebs-ToDos]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Vertriebs-ToDos pro|Vertriebs-ToDos pro]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/MPS-ToDos pro|MPS-ToDos pro]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Vertriebs-Wirkungsmonitor|Vertriebs-Wirkungsmonitor]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/KI-Mining/Führungs-Dashboard|Führungs-Dashboard]]&lt;br /&gt;
}}&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/small&amp;gt;&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/div&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;noinclude&amp;gt;&lt;br /&gt;
----&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;noinclude&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Folgender Code muss in die jeweils gewünschte Seite hinzugefügt werden:&lt;br /&gt;
 &amp;lt;nowiki&amp;gt;{{Kostenpflichtige Module}}&amp;lt;/nowiki&amp;gt;&lt;br /&gt;
[[Kategorie:Templates/Navigationen]]&lt;br /&gt;
&amp;lt;/noinclude&amp;gt;&lt;br /&gt;
[[Kategorie:OBS/Module]]&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Datei-Upload&amp;diff=64810</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Datei-Upload</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Datei-Upload&amp;diff=64810"/>
		<updated>2026-09-09T12:32:35Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: Die Seite wurde neu angelegt: „{{Kostenpflichtige Module}}  = Datei-Upload =  Ein Endpunkt nimmt Dateien nur entgegen, wenn er dafür freigeschaltet ist (&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;, siehe Endpunkte). Dann gilt nicht das JSON-Body-Limit von 10&amp;amp;nbsp;MB, sondern die pro Endpunkt konfigurierte Grösse (&amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;, Standard 25&amp;amp;nbsp;MB; Überschreitung -&amp;gt; &amp;#039;&amp;#039;&amp;#039;413&amp;#039;&amp;#039;&amp;#039;).  Diese Seite beschreibt die &amp;#039;&amp;#039;&amp;#039;Übertragung&amp;#039;&amp;#039;&amp;#039; - was ein…“&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
= Datei-Upload =&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt nimmt Dateien nur entgegen, wenn er dafür freigeschaltet ist&lt;br /&gt;
(&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;, siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]). Dann gilt nicht&lt;br /&gt;
das JSON-Body-Limit von 10&amp;amp;nbsp;MB, sondern die pro Endpunkt konfigurierte Grösse&lt;br /&gt;
(&amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;, Standard 25&amp;amp;nbsp;MB; Überschreitung -&amp;gt; &#039;&#039;&#039;413&#039;&#039;&#039;).&lt;br /&gt;
&lt;br /&gt;
Diese Seite beschreibt die &#039;&#039;&#039;Übertragung&#039;&#039;&#039; - was ein Client senden muss und was&lt;br /&gt;
der Server antwortet. Was das &#039;&#039;&#039;Skript&#039;&#039;&#039; davon sieht, sind vier Aufrufe&lt;br /&gt;
(&amp;lt;code&amp;gt;oReader.UploadPath()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;UploadName()&amp;lt;/code&amp;gt;,&lt;br /&gt;
&amp;lt;code&amp;gt;UploadType()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;UploadSha()&amp;lt;/code&amp;gt;) und steht unter&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Scripting#Datei-Uploads|Scripting]].&lt;br /&gt;
Ein vollständiges Beispiel zeigt&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6]].&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der Server legt die Datei in einem temporären Verzeichnis ab und&lt;br /&gt;
&#039;&#039;&#039;verschiebt sie nicht selbst&#039;&#039;&#039;. Übernimmt das Skript sie nicht an ihren&lt;br /&gt;
Zielort, bleibt sie dort liegen.}}&lt;br /&gt;
&lt;br /&gt;
== Einfacher Upload (multipart/form-data) ==&lt;br /&gt;
&lt;br /&gt;
Eine Datei pro Request. Name und Content-Type stammen aus der&lt;br /&gt;
&amp;lt;code&amp;gt;Content-Disposition&amp;lt;/code&amp;gt; der Datei-Partie (&amp;lt;code&amp;gt;filename=&amp;lt;/code&amp;gt;). Der&lt;br /&gt;
Server speichert die erste Datei-Partie und ruft das Skript auf.&lt;br /&gt;
&lt;br /&gt;
Die &#039;&#039;&#039;übrigen Partien&#039;&#039;&#039; des Formulars - alles ohne &amp;lt;code&amp;gt;filename=&amp;lt;/code&amp;gt; -&lt;br /&gt;
liest das Skript über &amp;lt;code&amp;gt;oReader.Form(&#039;&amp;amp;lt;name&amp;amp;gt;&#039;)&amp;lt;/code&amp;gt;. Der Wert ist UTF-8 und&lt;br /&gt;
auf 1024 Zeichen begrenzt, der Name auf Buchstaben, Ziffern und Unterstrich&lt;br /&gt;
reduziert. Zusatzangaben lassen sich damit im selben Request mitschicken, ohne sie&lt;br /&gt;
an die URL zu hängen:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cPfad : string;&lt;br /&gt;
    cName : string;&lt;br /&gt;
    cTyp  : string;&lt;br /&gt;
    cBemerk: string;&lt;br /&gt;
begin&lt;br /&gt;
    cPfad   := oReader.UploadPath();&lt;br /&gt;
    cName   := oReader.UploadName();&lt;br /&gt;
    cTyp    := oReader.UploadType();&lt;br /&gt;
    cBemerk := oReader.Form(&#039;bemerkung&#039;);   // Formularfeld derselben Uebertragung&lt;br /&gt;
&lt;br /&gt;
    // ... Datei aus cPfad ins DMS / Zielverzeichnis uebernehmen ...&lt;br /&gt;
&lt;br /&gt;
    oWriter.Str(&#039;status&#039;   , &#039;ok&#039;);&lt;br /&gt;
    oWriter.Str(&#039;dateiname&#039;, cName);&lt;br /&gt;
    oWriter.Status(201);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Resumable/Chunked Upload (Content-Range) ==&lt;br /&gt;
&lt;br /&gt;
Für grosse Dateien überträgt der Client die Datei in Teilstücken (Chunks) mit dem&lt;br /&gt;
Header &amp;lt;code&amp;gt;Content-Range: bytes START-END/TOTAL&amp;lt;/code&amp;gt;. Der Server hängt die&lt;br /&gt;
Chunks &#039;&#039;&#039;sequentiell&#039;&#039;&#039; an eine Session-Datei an und behandelt unvollständige&lt;br /&gt;
Uploads selbst - das Endpunkt-Skript läuft &#039;&#039;&#039;erst beim vollständigen Upload&#039;&#039;&#039;,&lt;br /&gt;
und dann liefern &#039;&#039;oReader.UploadPath()&#039;&#039;, &#039;&#039;UploadName()&#039;&#039; und &#039;&#039;UploadType()&#039;&#039;&lt;br /&gt;
dasselbe wie beim einfachen Upload. Für die Teilstücke läuft es &#039;&#039;&#039;nicht&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Name und Content-Type sind im &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;-Protokoll nicht enthalten;&lt;br /&gt;
der Client liefert sie deshalb &#039;&#039;&#039;im ersten Chunk&#039;&#039;&#039; über den Header&lt;br /&gt;
&amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt; (Format wie bei tus: kommagetrennte Paare&lt;br /&gt;
&amp;lt;code&amp;gt;schlüssel base64wert&amp;lt;/code&amp;gt;, Werte Base64-kodiert):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
Upload-Metadata: filename ZmlsZS5wZGY=,filetype YXBwbGljYXRpb24vcGRm&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Erkannt werden die Schlüssel &amp;lt;code&amp;gt;filename&amp;lt;/code&amp;gt; (Pflicht) und&lt;br /&gt;
&amp;lt;code&amp;gt;filetype&amp;lt;/code&amp;gt; (optional). Fehlt &amp;lt;code&amp;gt;filename&amp;lt;/code&amp;gt; im ersten Chunk,&lt;br /&gt;
wird der Upload mit &#039;&#039;&#039;400&#039;&#039;&#039; abgelehnt, ohne dass eine Datei angelegt wird.&lt;br /&gt;
Folge-Chunks und Statusabfragen müssen die Metadaten nicht erneut senden.&lt;br /&gt;
&lt;br /&gt;
Ablauf (vom Client gesteuert, der Server antwortet jeweils):&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Request !! Server-Antwort&lt;br /&gt;
|-&lt;br /&gt;
| Erster Chunk &amp;lt;code&amp;gt;Content-Range: bytes 0-1048575/5000000&amp;lt;/code&amp;gt; + &amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt; || &#039;&#039;&#039;308&#039;&#039;&#039; Resume Incomplete + Header &amp;lt;code&amp;gt;Upload-Id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;Upload-Offset&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| weitere Chunks (jeweils &amp;lt;code&amp;gt;Upload-Id&amp;lt;/code&amp;gt; mitsenden) || &#039;&#039;&#039;308&#039;&#039;&#039; mit aktualisiertem &amp;lt;code&amp;gt;Upload-Offset&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| letzter Chunk (Bereich erreicht TOTAL) || normale Skript-Antwort (z.B. &#039;&#039;&#039;200&#039;&#039;&#039;/&#039;&#039;&#039;201&#039;&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| Statusabfrage &amp;lt;code&amp;gt;Content-Range: bytes */5000000&amp;lt;/code&amp;gt; || aktueller &amp;lt;code&amp;gt;Upload-Offset&amp;lt;/code&amp;gt;, ohne anzuhängen (Resume)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Header !! Richtung !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| Content-Range || Request || &amp;lt;code&amp;gt;bytes START-END/TOTAL&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;bytes */TOTAL&amp;lt;/code&amp;gt; nur als Statusabfrage&lt;br /&gt;
|-&lt;br /&gt;
| Upload-Metadata || Request || tus-Metadaten im ersten Chunk: &amp;lt;code&amp;gt;filename&amp;lt;/code&amp;gt; (Pflicht), &amp;lt;code&amp;gt;filetype&amp;lt;/code&amp;gt; (optional), Base64-kodiert&lt;br /&gt;
|-&lt;br /&gt;
| 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.&lt;br /&gt;
|-&lt;br /&gt;
| Upload-Offset || Response || Anzahl der bereits gespeicherten Bytes (= Startoffset des nächsten Chunks)&lt;br /&gt;
|-&lt;br /&gt;
| Range || Response || Bereits gespeicherter Bereich (&amp;lt;code&amp;gt;bytes=0-N&amp;lt;/code&amp;gt;)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Der Server hängt einen Chunk &#039;&#039;&#039;nur an, wenn START dem bereits gespeicherten Stand&lt;br /&gt;
entspricht&#039;&#039;&#039;. Bei Abweichung (doppelter Chunk, Lücke) wird nicht angehängt,&lt;br /&gt;
sondern der aktuelle &#039;&#039;Upload-Offset&#039;&#039; zurückgemeldet - der Client setzt ab dort&lt;br /&gt;
neu auf. Eine separate Statusabfrage ist für ein Resume daher nicht zwingend nötig.&lt;br /&gt;
&lt;br /&gt;
Überschreitet ein Chunk bzw. die Gesamtgrösse das Limit (&#039;&#039;re_upload_size&#039;&#039;),&lt;br /&gt;
antwortet der Server mit &#039;&#039;&#039;413 Payload Too Large&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der Upload-Status liegt prozesslokal im Speicher. Nach einem Neustart des Servers muss ein unvollständiger Upload neu begonnen werden.}}&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel1&amp;diff=64809</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel1</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel1&amp;diff=64809"/>
		<updated>2026-09-09T12:32:15Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 1: Daten-Abruf mit JWT-Authentifizierung=&lt;br /&gt;
&lt;br /&gt;
In diesem Beispiel werden Steuerungs-Variablen aus OBS abgerufen und auf einer Webseite ausgegeben. Der Zugang ist mit JWT-Pflicht eingerichtet, der Konsument muss sich daher zuerst anmelden und einen Token bezogen haben.&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; mit Bindung 0.0.0.0:443&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Web-Dashboard&#039;&#039;&lt;br /&gt;
** API-Key: zufaellig generiert&lt;br /&gt;
** JWT aktiv, JWT-Endpunkt &#039;&#039;oauth&#039;&#039;, JWT-Key zufaellig, JWT-Exp 60 (Minuten)&lt;br /&gt;
** CORS-Origins: &#039;&#039;https://dashboard.kunde.de&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Endpunkt:&#039;&#039;&#039; &#039;&#039;steuerung/v1&#039;&#039;, dem Profil &#039;&#039;Public-API&#039;&#039; zugeordnet&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Web-Dashboard&#039;&#039; fuer Endpunkt &#039;&#039;steuerung/v1&#039;&#039; freigeschaltet&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierungs-Skript (Zugang)==&lt;br /&gt;
&lt;br /&gt;
Wird ueber die Zugaenge-Liste mit &#039;&#039;&#039;F7&#039;&#039;&#039; geoeffnet. Pruefen, ob Benutzername/Passwort gegen die OBS-Benutzerverwaltung passen.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Authenticate(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUser : string;&lt;br /&gt;
    cPass : string;&lt;br /&gt;
    cSql  : string;&lt;br /&gt;
    qUser : TxFQuery;&lt;br /&gt;
    lOk   : Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    cUser := &#039;&#039;;&lt;br /&gt;
    cPass := &#039;&#039;;&lt;br /&gt;
    oReader.Str(&#039;username&#039;, cUser);&lt;br /&gt;
    oReader.Str(&#039;password&#039;, cPass);&lt;br /&gt;
&lt;br /&gt;
    cSql := &#039;SELECT u_nr, u_name FROM benutzer&#039; +&lt;br /&gt;
            &#039; WHERE u_login = &#039; + DB_SQLVal(cUser) +&lt;br /&gt;
            &#039; AND u_passwort_hash = &#039; + DB_SQLVal(HashPasswort(cPass)) +&lt;br /&gt;
            &#039; AND u_aktiv = &#039; + DB_SQLVal(&#039;1&#039;);&lt;br /&gt;
&lt;br /&gt;
    // DB_SOpen liefert true, wenn die ABFRAGE lief - nicht, wenn ein&lt;br /&gt;
    // Datensatz gefunden wurde. Ohne die EoF-Pruefung wuerde jede&lt;br /&gt;
    // Anmeldung gelingen, sobald das SQL fehlerfrei ist.&lt;br /&gt;
    lOk := false;&lt;br /&gt;
    if (DB_SOpen(oDB, cSql, qUser)) then begin&lt;br /&gt;
        if (not qUser.EoF) then begin&lt;br /&gt;
            lOk := true;&lt;br /&gt;
            oWriter.Int(&#039;status&#039;           , 1);&lt;br /&gt;
            oWriter.Str(&#039;_OBS_JWT_ID&#039;      , qUser.A2C(&#039;u_nr&#039;));&lt;br /&gt;
            oWriter.Str(&#039;_OBS_JWT_SUBJECT&#039; , qUser.A2C(&#039;u_name&#039;));&lt;br /&gt;
            oWriter.Str(&#039;_OBS_JWT_AUDIENCE&#039;, &#039;dashboard&#039;);&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(qUser);&lt;br /&gt;
&lt;br /&gt;
    if (not lOk) then begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;, 9);&lt;br /&gt;
        oWriter.Str(&#039;error&#039; , &#039;Benutzer oder Passwort ist falsch&#039;);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Hier steht in &#039;&#039;_OBS_JWT_ID&#039;&#039; die Benutzernummer - derselbe Wert also bei&lt;br /&gt;
jeder Anmeldung desselben Benutzers. Das ist zulässig: der Server führt seine&lt;br /&gt;
Sitzungen über eigene Kennungen (&#039;&#039;sid&#039;&#039;/&#039;&#039;rid&#039;&#039;), nicht über das &#039;&#039;jti&#039;&#039; des&lt;br /&gt;
Skripts. Mehrfache und parallele Anmeldungen eines Benutzers sind damit&lt;br /&gt;
unproblematisch.}}&lt;br /&gt;
&lt;br /&gt;
==Endpunkt-Skript &#039;&#039;steuerung/v1&#039;&#039;==&lt;br /&gt;
&lt;br /&gt;
Liefert eine Liste von Steuerungs-Werten zurueck. Die Auswertung der JWT-Claims sorgt dafuer, dass nur Audience &#039;&#039;dashboard&#039;&#039; Daten erhaelt.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// Ein Wert als Listeneintrag. Der Helfer bekommt den WRITER und schreibt an&lt;br /&gt;
// der Stelle hinein, an der er aufgerufen wird - er gibt kein Fragment zurueck.&lt;br /&gt;
// Der leere Feldname ist der Eintrag einer Liste.&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
procedure _AddVar(oWriter: TxRestWriter; const cVar: string);&lt;br /&gt;
var cTitel : string;&lt;br /&gt;
    cData  : string;&lt;br /&gt;
    cEinh  : string;&lt;br /&gt;
    lString: Boolean;&lt;br /&gt;
    lAlign : Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    if (ST_Variable(oDB, cVar, cTitel, cData, cEinh, lString, lAlign)) then begin&lt;br /&gt;
        oWriter.ObjBegin(&#039;&#039;);&lt;br /&gt;
            oWriter.Str(&#039;variable&#039;, cVar);&lt;br /&gt;
            oWriter.Str(&#039;titel&#039;   , cTitel);&lt;br /&gt;
            oWriter.Str(&#039;wert&#039;    , cData);&lt;br /&gt;
            oWriter.Str(&#039;einheit&#039; , cEinh);&lt;br /&gt;
        oWriter.ObjEnd;&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
begin&lt;br /&gt;
    // Audience-Pruefung: nur Dashboard-Tokens akzeptieren. Die Ablehnung ist&lt;br /&gt;
    // ein 403 und keine 200 mit Fehlertext - oWriter.Error baut dieselbe&lt;br /&gt;
    // Huelle, die auch die Server-eigenen Fehler tragen.&lt;br /&gt;
    if (oReader.Param(&#039;_OBS_JWT_AUDIENCE&#039;) &amp;lt;&amp;gt; &#039;dashboard&#039;) then begin&lt;br /&gt;
        oWriter.Error(403, &#039;FORBIDDEN_ROLE&#039;, &#039;Token nicht für diese Anwendung ausgestellt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Die Antwort ist eine nackte Liste, kein Objekt: RootArr sagt das, und&lt;br /&gt;
    // zwar VOR dem ersten Eintrag. Ein ArrEnd gibt es dazu nicht - die&lt;br /&gt;
    // Wurzelklammer setzt der Server.&lt;br /&gt;
    oWriter.RootArr();&lt;br /&gt;
    _AddVar(oWriter, &#039;WERT_1&#039;);&lt;br /&gt;
    _AddVar(oWriter, &#039;WERT_2&#039;);&lt;br /&gt;
    _AddVar(oWriter, &#039;WERT_3&#039;);&lt;br /&gt;
    _AddVar(oWriter, &#039;WERT_4&#039;);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==JavaScript-Client (Browser)==&lt;br /&gt;
&lt;br /&gt;
Im Browser werden zwei Aufrufe gemacht: erst Token holen, dann Daten lesen.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;javascript&amp;quot; line&amp;gt;&lt;br /&gt;
const API_BASE = &#039;https://api.meinserver.de&#039;;&lt;br /&gt;
const API_KEY  = &#039;[API-KEY]&#039;;&lt;br /&gt;
&lt;br /&gt;
async function login(username, password) {&lt;br /&gt;
    const res = await fetch(`${API_BASE}/oauth/`, {&lt;br /&gt;
        method:  &#039;POST&#039;,&lt;br /&gt;
        headers: { &#039;Content-Type&#039;: &#039;application/json&#039;, &#039;apikey&#039;: API_KEY },&lt;br /&gt;
        body:    JSON.stringify({ username, password })&lt;br /&gt;
    });&lt;br /&gt;
    if (!res.ok) throw new Error(&#039;Login fehlgeschlagen&#039;);&lt;br /&gt;
    const data = await res.json();&lt;br /&gt;
    return data.token;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
async function ladeSteuerung(token) {&lt;br /&gt;
    const res = await fetch(`${API_BASE}/steuerung/v1`, {&lt;br /&gt;
        method:  &#039;GET&#039;,&lt;br /&gt;
        headers: {&lt;br /&gt;
            &#039;apikey&#039;:        API_KEY,&lt;br /&gt;
            &#039;Authorization&#039;: &#039;Bearer &#039; + token&lt;br /&gt;
        }&lt;br /&gt;
    });&lt;br /&gt;
    if (!res.ok) throw new Error(&#039;Abruf fehlgeschlagen: &#039; + res.status);&lt;br /&gt;
    return res.json();&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
async function zeigeTabelle() {&lt;br /&gt;
    const token = await login(&#039;mitarbeiter&#039;, &#039;geheim&#039;);&lt;br /&gt;
    const werte = await ladeSteuerung(token);&lt;br /&gt;
&lt;br /&gt;
    const tbl = document.getElementById(&#039;steuerung&#039;);&lt;br /&gt;
    werte.forEach(w =&amp;gt; {&lt;br /&gt;
        const tr = document.createElement(&#039;tr&#039;);&lt;br /&gt;
        tr.innerHTML = `&amp;lt;td&amp;gt;${w.titel}&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;${w.wert} ${w.einheit}&amp;lt;/td&amp;gt;`;&lt;br /&gt;
        tbl.appendChild(tr);&lt;br /&gt;
    });&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
zeigeTabelle();&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Test mit curl==&lt;br /&gt;
&lt;br /&gt;
Token holen:&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;username\&amp;quot;:\&amp;quot;mitarbeiter\&amp;quot;,\&amp;quot;password\&amp;quot;:\&amp;quot;geheim\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/oauth/&lt;br /&gt;
&lt;br /&gt;
Antwort:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:       &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;accessToken&amp;quot;: &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:   3600,&lt;br /&gt;
  &amp;quot;serverTime&amp;quot;:  &amp;quot;2026-08-19T09:12:33+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Da das Skript kein &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; liefert, enthält die Antwort kein&lt;br /&gt;
&#039;&#039;refreshToken&#039;&#039; - der Client meldet sich nach Ablauf der 60 Minuten neu an.&lt;br /&gt;
&lt;br /&gt;
Daten abrufen:&lt;br /&gt;
&lt;br /&gt;
 curl -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJhbGciOi...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/steuerung/v1&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Zweistufige Anmeldung: API-Key + JWT.&lt;br /&gt;
* Sichere Trennung von Konsumenten-Gruppen ueber die Audience-Claim.&lt;br /&gt;
* Eigenes Authentifizierungs-Skript pro Zugang.&lt;br /&gt;
* Browser-Zugriff ueber CORS-erlaubtes Origin.&lt;br /&gt;
* Anmeldung ohne Refresh-Token: einfachster Fall, der Token laeuft nach der Zeit aus &#039;&#039;JWT-Exp&#039;&#039; ab.&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel6&amp;diff=64808</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel6</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel6&amp;diff=64808"/>
		<updated>2026-09-09T12:32:06Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 6: Datei-Upload und Ablage=&lt;br /&gt;
&lt;br /&gt;
Dieses Beispiel zeigt einen Endpunkt, der &#039;&#039;&#039;Dateien entgegennimmt&#039;&#039;&#039;: Ein&lt;br /&gt;
Aussendienst-Mitarbeiter fotografiert vor Ort ein Gerät, die App lädt das Foto zum&lt;br /&gt;
Auftrag hoch, der Server legt es ab und verknüpft es mit dem Vorgang.&lt;br /&gt;
&lt;br /&gt;
Der Ablauf ist derselbe für Belege aus einem Scanner, Unterschriften-Bilder oder&lt;br /&gt;
Prüfprotokolle als PDF. Behandelt werden beide Übertragungsarten - der einfache&lt;br /&gt;
Upload für kleine Dateien und der fortsetzbare Upload für grosse.&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; (Port 443)&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Mobile-App&#039;&#039;, API-Key zufällig generiert, JWT aktiv&lt;br /&gt;
* &#039;&#039;&#039;Endpunkt:&#039;&#039;&#039; &#039;&#039;/orders/{uid}/photos&#039;&#039;, Profil &#039;&#039;Public-API&#039;&#039;&lt;br /&gt;
** &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; auf &#039;&#039;&#039;Ja&#039;&#039;&#039; setzen (&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;)&lt;br /&gt;
** &#039;&#039;&#039;Max. Upload-Grösse&#039;&#039;&#039; auf &#039;&#039;20&#039;&#039; (MB) setzen&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Mobile-App&#039;&#039; für den Endpunkt freischalten&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ohne den Haken &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; lehnt der Server jeden Upload mit&lt;br /&gt;
&#039;&#039;&#039;415&#039;&#039;&#039; ab und das Skript läuft gar nicht erst an. Das ist die häufigste Ursache,&lt;br /&gt;
wenn ein Upload &amp;quot;ohne erkennbaren Grund&amp;quot; scheitert.}}&lt;br /&gt;
&lt;br /&gt;
==Was der Server erledigt, bevor das Skript läuft==&lt;br /&gt;
&lt;br /&gt;
Der Server nimmt die Datei entgegen, prüft die Grösse, legt sie in einem temporären&lt;br /&gt;
Verzeichnis ab und übergibt dem Skript drei Parameter:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadPath()&amp;lt;/code&amp;gt; || Vollständiger Pfad zur temporären Datei&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadName()&amp;lt;/code&amp;gt; || Originaldateiname, wie ihn der Client gesendet hat&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadType()&amp;lt;/code&amp;gt; || Content-Type der Datei&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadSha()&amp;lt;/code&amp;gt; || SHA-256 der gespeicherten Datei (hex, klein)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Zwei Dinge sind beim Schreiben des Skripts wichtig:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Bei Uploads gibt es keinen JSON-Körper&#039;&#039;&#039; - &amp;lt;code&amp;gt;oReader.HasBody()&amp;lt;/code&amp;gt; ist &amp;lt;code&amp;gt;false&amp;lt;/code&amp;gt;, der Request-Body ist die Datei. Zusatzangaben (Kategorie, Bemerkung) kommen deshalb als &#039;&#039;&#039;Query-Parameter&#039;&#039;&#039; mit - oder, bei &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt;, als weitere &#039;&#039;&#039;Formularfelder&#039;&#039;&#039;, lesbar über &amp;lt;code&amp;gt;oReader.Form(&#039;&amp;amp;lt;name&amp;amp;gt;&#039;)&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Die temporäre Datei bleibt liegen, wenn das Skript sie nicht übernimmt.&#039;&#039;&#039; Der Server räumt sie nicht selbst weg.&lt;br /&gt;
&lt;br /&gt;
==Endpunkt-Skript &#039;&#039;/orders/{uid}/photos&#039;&#039;==&lt;br /&gt;
&lt;br /&gt;
Das Skript prüft den Dateityp, baut einen Zielpfad aus Auftrag und Zeitstempel,&lt;br /&gt;
verschiebt die Datei dorthin und schreibt einen Verweis in die Datenbank.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
const ZIEL_BASIS = &#039;D:\OBS\Daten\Auftragsfotos\&#039;;&lt;br /&gt;
var cUid      : string;&lt;br /&gt;
    cTmpPfad  : string;&lt;br /&gt;
    cOrigName : string;&lt;br /&gt;
    cTyp      : string;&lt;br /&gt;
    cEndung   : string;&lt;br /&gt;
    cKategorie: string;&lt;br /&gt;
    cZielDir  : string;&lt;br /&gt;
    cZielName : string;&lt;br /&gt;
    cZielPfad : string;&lt;br /&gt;
    qNeu      : TqSQL;&lt;br /&gt;
begin&lt;br /&gt;
    cUid       := oReader.Path(&#039;uid&#039;);&lt;br /&gt;
    cTmpPfad   := oReader.UploadPath();&lt;br /&gt;
    cOrigName  := oReader.UploadName();&lt;br /&gt;
    cTyp       := oReader.UploadType();&lt;br /&gt;
    cKategorie := oReader.Param(&#039;kategorie&#039;);   // Query-Parameter, nicht Body&lt;br /&gt;
&lt;br /&gt;
    // 1) Gehoert der Auftrag zum Mandanten aus dem Token?&lt;br /&gt;
    if (not AuftragGehoertZuMandant(oReader.Claim(&#039;tenant&#039;), cUid)) then begin&lt;br /&gt;
        oWriter.Error(404, &#039;NOT_FOUND&#039;, &#039;Auftrag nicht gefunden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // 2) Dateityp gegen eine Whitelist pruefen - niemals gegen eine&lt;br /&gt;
    //    Blacklist, und niemals dem Content-Type allein vertrauen.&lt;br /&gt;
    cEndung := Lower(ExtractFileExt(cOrigName));&lt;br /&gt;
    if ((cEndung &amp;lt;&amp;gt; &#039;.jpg&#039;) and (cEndung &amp;lt;&amp;gt; &#039;.jpeg&#039;) and (cEndung &amp;lt;&amp;gt; &#039;.png&#039;) and (cEndung &amp;lt;&amp;gt; &#039;.pdf&#039;)) then begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Nur JPG, PNG und PDF sind zulässig&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // 3) Zielname selbst bilden. Den Originalnamen NICHT als Pfad&lt;br /&gt;
    //    verwenden - er kommt vom Client und kann &#039;..\&#039; enthalten.&lt;br /&gt;
    cZielDir  := ZIEL_BASIS + DToS(Date()) + &#039;\&#039; + cUid + &#039;\&#039;;&lt;br /&gt;
    cZielName := DToSF(Now()) + &#039;_&#039; + GlobalUID() + cEndung;&lt;br /&gt;
    cZielPfad := cZielDir + cZielName;&lt;br /&gt;
&lt;br /&gt;
    MyForceDirectories(cZielDir);&lt;br /&gt;
&lt;br /&gt;
    if (not FMove(cTmpPfad, cZielPfad)) then begin&lt;br /&gt;
        // Fehlgeschlagenes Verschieben ist ein Serverproblem -&amp;gt; 500.&lt;br /&gt;
        // Der Idempotency-Key wird bei 5xx wieder freigegeben, der&lt;br /&gt;
        // Client darf denselben Schluessel erneut senden.&lt;br /&gt;
        oWriter.Error(500, &#039;INTERNAL_ERROR&#039;, &#039;Datei konnte nicht abgelegt werden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // 4) Verweis in der Datenbank ablegen&lt;br /&gt;
    qNeu := qSqlInit(oDB, &#039;AUFTRAG_DOKUMENT&#039;);&lt;br /&gt;
    qNeu.qSet(&#039;ad_auftrag&#039;  , cUid);&lt;br /&gt;
    qNeu.qSet(&#039;ad_pfad&#039;     , cZielPfad);&lt;br /&gt;
    qNeu.qSet(&#039;ad_dateiname&#039;, cOrigName);&lt;br /&gt;
    qNeu.qSet(&#039;ad_typ&#039;      , cTyp);&lt;br /&gt;
    qNeu.qSet(&#039;ad_kategorie&#039;, cKategorie);&lt;br /&gt;
    qNeu.qSet(&#039;ad_datum&#039;    , Now());&lt;br /&gt;
    // Rueckgabewert auswerten: ohne das bestaetigt der Endpunkt eine&lt;br /&gt;
    // Ablage, die es nicht gibt - die Datei liegt dann im Zielverzeichnis,&lt;br /&gt;
    // aber ohne Verweis in der Datenbank.&lt;br /&gt;
    if (not qNeu.SaveData(NEW_RECORD)) then begin&lt;br /&gt;
        qSqlFree(qNeu);&lt;br /&gt;
        oWriter.Error(500, &#039;INTERNAL_ERROR&#039;, &#039;Verweis konnte nicht gespeichert werden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
    qSqlFree(qNeu);&lt;br /&gt;
&lt;br /&gt;
    // 5) 201 mit Location auf die abgelegte Datei&lt;br /&gt;
    oWriter.Str(&#039;dateiname&#039;, cZielName);&lt;br /&gt;
    oWriter.Int(&#039;groesse&#039;  , FSize(cZielPfad));&lt;br /&gt;
    oWriter.Status(201);&lt;br /&gt;
    oWriter.Header(&#039;Location&#039;, &#039;/orders/&#039; + cUid + &#039;/photos/&#039; + cZielName);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Soll die Datei statt in ein Verzeichnis in das &#039;&#039;&#039;OBS-Dokumenten-System&#039;&#039;&#039;&lt;br /&gt;
wandern, ersetzt der DMS-Aufruf die Schritte 3 bis 5. Welche Funktion dafür&lt;br /&gt;
zuständig ist, hängt vom eingesetzten Dokumenttyp ab - bitte mit dem OBS-Support&lt;br /&gt;
klären.}}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Fünf Fehlerausgänge, fünf Zeilen.&#039;&#039;&#039; Vorher baute jeder von ihnen&lt;br /&gt;
sein &#039;&#039;error&#039;&#039;-Objekt, seinen Statuscode und seine &#039;&#039;traceId&#039;&#039; von Hand - fünfmal&lt;br /&gt;
dasselbe Muster, das an einer Stelle abweichen kann, ohne dass es auffällt.&lt;br /&gt;
&amp;lt;code&amp;gt;oWriter.Error&amp;lt;/code&amp;gt; setzt alles davon, auch die &#039;&#039;traceId&#039;&#039;, und verwirft&lt;br /&gt;
einen bereits begonnenen Körper. Ein &#039;&#039;exit&#039;&#039; danach genügt.}}&lt;br /&gt;
&lt;br /&gt;
==Test mit curl==&lt;br /&gt;
&lt;br /&gt;
Einfacher Upload einer Datei (&#039;&#039;-F&#039;&#039; erzeugt &#039;&#039;multipart/form-data&#039;&#039;):&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -F &amp;quot;datei=@C:\Fotos\geraet.jpg&amp;quot; ^&lt;br /&gt;
      &amp;quot;https://api.meinserver.de/orders/4711/photos?kategorie=VORHER&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Antwort:&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 201 Created&lt;br /&gt;
 Location: /orders/4711/photos/20260817091233_A1B2C3.jpg&lt;br /&gt;
 X-Trace-Id: 20260817T091233123-00001A&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;dateiname&amp;quot;:&amp;quot;20260817091233_A1B2C3.jpg&amp;quot;,&amp;quot;groesse&amp;quot;:248113}&lt;br /&gt;
&lt;br /&gt;
Ein Upload an einen Endpunkt &#039;&#039;&#039;ohne&#039;&#039;&#039; Upload-Freigabe:&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 415 Unsupported Media Type&lt;br /&gt;
&lt;br /&gt;
Eine Datei über der eingestellten Grenze:&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 413 Payload Too Large&lt;br /&gt;
&lt;br /&gt;
==Grosse Dateien: fortsetzbarer Upload==&lt;br /&gt;
&lt;br /&gt;
Bei einem Foto aus dem Mobilfunknetz reisst die Verbindung schnell einmal ab. Für&lt;br /&gt;
solche Fälle überträgt der Client die Datei in Teilstücken und kann nach einem&lt;br /&gt;
Abbruch dort weitermachen, wo er aufgehört hat. &#039;&#039;&#039;Am Endpunkt-Skript ändert sich&lt;br /&gt;
dafür nichts&#039;&#039;&#039; - der Server sammelt die Teilstücke selbst ein und ruft das Skript&lt;br /&gt;
erst auf, wenn die Datei vollständig ist.&lt;br /&gt;
&lt;br /&gt;
Der erste Teil trägt den Dateinamen (Base64-kodiert im Header&lt;br /&gt;
&amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt;) und liefert eine &amp;lt;code&amp;gt;Upload-Id&amp;lt;/code&amp;gt; zurück:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Content-Range: bytes 0-1048575/5000000&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Upload-Metadata: filename Z2VyYWV0LmpwZw==,filetype aW1hZ2UvanBlZw==&amp;quot; ^&lt;br /&gt;
      --data-binary &amp;quot;@teil1.bin&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711/photos&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 308 Resume Incomplete&lt;br /&gt;
 Upload-Id: 7f2a9c84...&lt;br /&gt;
 Upload-Offset: 1048576&lt;br /&gt;
 Range: bytes=0-1048575&lt;br /&gt;
&lt;br /&gt;
Jeder weitere Teil sendet die &#039;&#039;Upload-Id&#039;&#039; mit. Der letzte Teil bekommt die&lt;br /&gt;
normale Antwort des Skripts (hier &#039;&#039;&#039;201&#039;&#039;&#039;). Nach einem Abbruch fragt der Client&lt;br /&gt;
den Stand ab und setzt dort auf:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Content-Range: bytes */5000000&amp;quot; -H &amp;quot;Upload-Id: 7f2a9c84...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711/photos&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 308 Resume Incomplete&lt;br /&gt;
 Upload-Offset: 3145728&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der Zwischenstand liegt im Arbeitsspeicher des Dienstes. Wird der&lt;br /&gt;
REST-Dienst neu gestartet, muss ein unvollständiger Upload von vorn beginnen.}}&lt;br /&gt;
&lt;br /&gt;
==Doppelte Uploads vermeiden==&lt;br /&gt;
&lt;br /&gt;
Bricht die Verbindung ab, nachdem der Server die Datei schon abgelegt hat, würde ein&lt;br /&gt;
Wiederholversuch dasselbe Foto ein zweites Mal einliefern. Dagegen sendet der Client&lt;br /&gt;
den Header &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; - für jedes Foto einen eigenen, über alle&lt;br /&gt;
Wiederholversuche hinweg denselben:&lt;br /&gt;
&lt;br /&gt;
 -H &amp;quot;Idempotency-Key: foto-4711-0003&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Der Server erkennt die Wiederholung und liefert die &#039;&#039;&#039;gespeicherte 201-Antwort&#039;&#039;&#039;&lt;br /&gt;
samt Header &amp;lt;code&amp;gt;Idempotent-Replay: true&amp;lt;/code&amp;gt; zurück, ohne das Skript erneut zu&lt;br /&gt;
starten. Es entsteht also weder eine zweite Datei noch ein zweiter DB-Eintrag.&lt;br /&gt;
Details: [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]], Abschnitt&lt;br /&gt;
Idempotenz.&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Endpunkt mit &#039;&#039;&#039;Datei-Upload-Freigabe&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_upload&amp;lt;/code&amp;gt;) und eigener Grössenbegrenzung.&lt;br /&gt;
* Zugriff auf die hochgeladene Datei über &#039;&#039;oReader.UploadPath()&#039;&#039;, &#039;&#039;UploadName()&#039;&#039; und &#039;&#039;UploadType()&#039;&#039;.&lt;br /&gt;
* Zusatzangaben als &#039;&#039;&#039;Query-Parameter&#039;&#039;&#039; oder Formularfeld, weil bei Uploads kein JSON-Körper vorliegt.&lt;br /&gt;
* &#039;&#039;&#039;Whitelist&#039;&#039;&#039; für zulässige Dateitypen und ein &#039;&#039;&#039;selbst gebildeter Zielname&#039;&#039;&#039; - der Originalname landet nie im Pfad.&lt;br /&gt;
* Übernahme der temporären Datei mit &#039;&#039;FMove&#039;&#039;; ohne diesen Schritt bleibt sie liegen.&lt;br /&gt;
* Antwort mit &#039;&#039;&#039;201&#039;&#039;&#039; und &#039;&#039;Location&#039;&#039;-Header.&lt;br /&gt;
* Fortsetzbarer Upload ohne jede Änderung am Skript.&lt;br /&gt;
* Schutz gegen doppelte Einlieferung über den &#039;&#039;Idempotency-Key&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==Siehe auch==&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]] - Upload-Parameter und Protokoll im Detail&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]] - Freigabe, Grössenlimit, Idempotenz&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel 5]] - Schreibzugriff mit Statuscodes und ETag&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel2&amp;diff=64807</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel2</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel2&amp;diff=64807"/>
		<updated>2026-09-09T12:31:56Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 2: Zeiterfassung als komplette Webanwendung=&lt;br /&gt;
&lt;br /&gt;
Dieses Script dient zur Zeiterfassung über den REST-Server. Bei Seitenaufruf wird eine Liste von Kunden geladen und in einer Liste angezeigt. &lt;br /&gt;
&lt;br /&gt;
Durch Rechtsklick auf einen Eintrag wird eine Liste an zugehörigen Tätigkeiten geladen, die nach Klick einen Timer starten.&lt;br /&gt;
&lt;br /&gt;
Per Javascript werden Startzeit, Startdatum und andere Informationen wie Kunde und Personalnummer über die PHP-Scripte und das Endpunkt-Script in einer Datenbank gespeichert.&lt;br /&gt;
&lt;br /&gt;
Der Status wird hierbei auf &amp;quot;1&amp;quot; gesetzt. Pausenzeiten werden nach Beenden einer Pause in der Datenbank gespeichert.&lt;br /&gt;
&lt;br /&gt;
Durch Drücken des Stop-Buttons wird eine Lightbox mit einer Zusammenfassung aller zum Timer gehörenden Daten angezeigt. Hier hat man die Möglichkeit Tätigkeiten, abgelaufene Zeit und &lt;br /&gt;
&lt;br /&gt;
Pausenzeit nachträglich zu bearbeiten. Durch Klick auf &amp;quot;Speichern&amp;quot; oder Druck auf F2 werden Endzeit und Enddatum gespeichert und der Status auf &amp;quot;21&amp;quot; gesetzt.&lt;br /&gt;
&lt;br /&gt;
Nach Reload der Webseite wird anhand des Status in der Datenbank nach laufenden Timern gesucht und diese gegebenenfalls gestartet.&lt;br /&gt;
&lt;br /&gt;
= Endpunkt-Script =&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
procedure _AddJob(oWriter: TxRestWriter; id: String);&lt;br /&gt;
var cSql        : String;&lt;br /&gt;
    A_Query     : TxFQuery;&lt;br /&gt;
begin&lt;br /&gt;
    cSql    :=  &#039;SELECT CONCAT(za_text, &amp;quot;:&amp;quot;,sys_uid) AS Job FROM zeiterfart &#039; +&lt;br /&gt;
                &#039;WHERE za_psnr = &#039; + DB_SQLVal(id) + &#039; AND za_inaktiv &amp;lt;&amp;gt; &amp;quot;1&amp;quot; &#039; +&lt;br /&gt;
                &#039;ORDER BY za_order&#039;;&lt;br /&gt;
    if (DB_xSOpen(&#039;Y009LHNBTY&#039;, oDB, cSql, A_Query, False)) then begin&lt;br /&gt;
        while (not A_Query.EoF) do begin&lt;br /&gt;
            oWriter.Str(&#039;&#039;, A_Query.A2C(&#039;Job&#039;));&lt;br /&gt;
            A_Query.Next();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(A_Query);&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
procedure _AddCustomer(oWriter: TxRestWriter);&lt;br /&gt;
var cSql        : String;&lt;br /&gt;
    A_Query     : TxFQuery;&lt;br /&gt;
begin&lt;br /&gt;
    cSql    :=&#039;  SELECT * FROM&#039; + &lt;br /&gt;
              &#039;  (SELECT&#039; +&lt;br /&gt;
              &#039;  ps_nr,&#039; +&lt;br /&gt;
              &#039;  ps_such AS Kunde,&#039; +&lt;br /&gt;
              &#039;  perssta.sys_uid AS ps_sys_uid,&#039; +&lt;br /&gt;
              &#039;  &amp;quot;PE&amp;quot; AS TYP&#039; +&lt;br /&gt;
              &#039;  FROM eigenschaften&#039; +&lt;br /&gt;
              &#039;  LEFT JOIN perssta ON se_ref1 AND perssta.sys_uid = se_ref2&#039; +&lt;br /&gt;
              &#039;  WHERE se_nr = 0087&#039; + &lt;br /&gt;
              &#039;  AND ps_nr &amp;lt;&amp;gt; 100000&#039; +&lt;br /&gt;
              &#039;  UNION SELECT p_nr AS ps_nr,&#039; +&lt;br /&gt;
              &#039;  p_name1 AS ps_such,&#039; +&lt;br /&gt;
              &#039;  projekte.sys_uid AS ps_sys_uid,&#039; +&lt;br /&gt;
              &#039;  &amp;quot;PR&amp;quot; AS TYP&#039; +&lt;br /&gt;
              &#039;  FROM eigenschaften&#039; +&lt;br /&gt;
              &#039;  LEFT JOIN projekte ON se_ref1 = p_nr&#039; +&lt;br /&gt;
              &#039;  AND projekte.sys_uid = se_ref2 WHERE se_nr = 0087)&#039; +&lt;br /&gt;
              &#039;  A WHERE ps_nr IS NOT NULL&#039; +&lt;br /&gt;
              &#039;  ORDER BY Kunde &#039;;&lt;br /&gt;
              &lt;br /&gt;
    if (DB_xSOpen(&#039;Y009LHNBTY&#039;, oDB, cSql, A_Query, False)) then begin&lt;br /&gt;
        while (not A_Query.EoF) do begin&lt;br /&gt;
            oWriter.Str(&#039;&#039;, A_Query.A2C(&#039;Kunde&#039;));&lt;br /&gt;
            A_Query.Next();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(A_Query);&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
procedure _AddID(oWriter: TxRestWriter; kunde: String);&lt;br /&gt;
var cSql        : String;&lt;br /&gt;
    A_Query     : TxFQuery;&lt;br /&gt;
begin&lt;br /&gt;
    cSql    :=&#039;  SELECT * FROM&#039; + &lt;br /&gt;
              &#039;  (SELECT&#039; +&lt;br /&gt;
              &#039;  ps_nr,&#039; +&lt;br /&gt;
              &#039;  ps_such AS Kunde,&#039; +&lt;br /&gt;
              &#039;  perssta.sys_uid AS ps_sys_uid,&#039; +&lt;br /&gt;
              &#039;  &amp;quot;PE&amp;quot; AS TYP&#039; +&lt;br /&gt;
              &#039;  FROM eigenschaften&#039; +&lt;br /&gt;
              &#039;  LEFT JOIN perssta ON se_ref1 AND perssta.sys_uid = se_ref2&#039; +&lt;br /&gt;
              &#039;  WHERE se_nr = 0087&#039; + &lt;br /&gt;
              &#039;  AND ps_nr &amp;lt;&amp;gt; 100000&#039; +&lt;br /&gt;
              &#039;  UNION SELECT p_nr AS ps_nr,&#039; +&lt;br /&gt;
              &#039;  p_name1 AS ps_such,&#039; +&lt;br /&gt;
              &#039;  projekte.sys_uid AS ps_sys_uid,&#039; +&lt;br /&gt;
              &#039;  &amp;quot;PR&amp;quot; AS TYP&#039; +&lt;br /&gt;
              &#039;  FROM eigenschaften&#039; +&lt;br /&gt;
              &#039;  LEFT JOIN projekte ON se_ref1 = p_nr&#039; +&lt;br /&gt;
              &#039;  AND projekte.sys_uid = se_ref2 WHERE se_nr = 0087)&#039; +&lt;br /&gt;
              &#039;  A WHERE ps_nr IS NOT NULL&#039; +&lt;br /&gt;
              &#039;  AND Kunde = &#039; + DB_SQLVal(kunde) +&lt;br /&gt;
              &#039;  ORDER BY Kunde &#039;;&lt;br /&gt;
              &lt;br /&gt;
    if (DB_xSOpen(&#039;Y009LHNBTY&#039;, oDB, cSql, A_Query, False)) then begin&lt;br /&gt;
        while (not A_Query.EoF) do begin&lt;br /&gt;
            oWriter.Str(&#039;&#039;, A_Query.A2C(&#039;ps_nr&#039;));&lt;br /&gt;
            A_Query.Next();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(A_Query);&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
procedure _AddCHECK(oWriter: TxRestWriter);&lt;br /&gt;
var cSql        : String;&lt;br /&gt;
    A_Query     : TxFQuery;&lt;br /&gt;
begin&lt;br /&gt;
    cSql    :=  &#039;SELECT ze_starttime, zeiterfuser.sys_uid AS sysuid, zk_psnr, &#039; +&lt;br /&gt;
                &#039;za_text, ps_such, ze_startdate FROM &#039; +&lt;br /&gt;
                &#039;zeiterfuser LEFT JOIN zeiterfkomm ON &#039; +&lt;br /&gt;
                &#039;zk_messunguid = zeiterfuser.SYS_UID LEFT JOIN zeiterfart &#039; +&lt;br /&gt;
                &#039;ON zeiterfart.sys_uid = zeiterfuser.ze_uid AND &#039; +&lt;br /&gt;
                &#039;za_psnr = zeiterfkomm.zk_psnr LEFT JOIN perssta ON &#039; +&lt;br /&gt;
                &#039;ps_nr = zeiterfkomm.zk_psnr WHERE ze_status = 01 &#039;;&lt;br /&gt;
              &lt;br /&gt;
    if (DB_xSOpen(&#039;Y009LHNBTY&#039;, oDB, cSql, A_Query, False)) then begin&lt;br /&gt;
        while (not A_Query.EoF) do begin&lt;br /&gt;
            oWriter.Str(&#039;&#039;, A_Query.A2C(&#039;ze_starttime&#039;));&lt;br /&gt;
            oWriter.Str(&#039;&#039;, A_Query.A2C(&#039;zk_psnr&#039;));&lt;br /&gt;
            oWriter.Str(&#039;&#039;, A_Query.A2C(&#039;za_text&#039;));&lt;br /&gt;
            oWriter.Str(&#039;&#039;, A_Query.A2C(&#039;ps_such&#039;));&lt;br /&gt;
            oWriter.Str(&#039;&#039;, A_Query.A2C(&#039;sysuid&#039;));&lt;br /&gt;
            oWriter.Str(&#039;&#039;, A_Query.A2C(&#039;ze_startdate&#039;));&lt;br /&gt;
            A_Query.Next();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(A_Query);&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
procedure _getPauseTime(oWriter: TxRestWriter; sysuid: String);&lt;br /&gt;
var cSql        : String;&lt;br /&gt;
    A_Query     : TxFQuery;&lt;br /&gt;
begin&lt;br /&gt;
    cSql    :=  &#039;SELECT * FROM zeiterfuser WHERE sys_uid = &#039; + DB_SQLVal(sysuid);&lt;br /&gt;
    &lt;br /&gt;
    if (DB_xSOpen(&#039;Y009LHNBTY&#039;, oDB, cSql, A_Query, False)) then begin&lt;br /&gt;
        while (not A_Query.EoF) do begin&lt;br /&gt;
            oWriter.Str(&#039;&#039;, A_Query.A2C(&#039;ze_pausezeit&#039;));&lt;br /&gt;
            A_Query.Next();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(A_Query);&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//----------------Funktionen----------------------------------//&lt;br /&gt;
&lt;br /&gt;
procedure POST(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cChoice: String;&lt;br /&gt;
begin&lt;br /&gt;
    cChoice := oReader.Param(&#039;choice&#039;);&lt;br /&gt;
&lt;br /&gt;
    // Jede dieser Antworten ist eine nackte Liste von Zeichenketten - genau&lt;br /&gt;
    // so, wie das Javascript sie mit JSON.parse erwartet. RootArr sagt das&lt;br /&gt;
    // und steht deshalb VOR dem ersten Eintrag.&lt;br /&gt;
    if (cChoice = &#039;job&#039;) then begin&lt;br /&gt;
        oWriter.RootArr();&lt;br /&gt;
        _AddJob(oWriter, oReader.Param(&#039;id&#039;));&lt;br /&gt;
    end else if (cChoice = &#039;customer&#039;) then begin&lt;br /&gt;
        oWriter.RootArr();&lt;br /&gt;
        _AddCustomer(oWriter);&lt;br /&gt;
    end else if (cChoice = &#039;id&#039;) then begin&lt;br /&gt;
        oWriter.RootArr();&lt;br /&gt;
        _AddID(oWriter, oReader.Param(&#039;selectedKunde&#039;));&lt;br /&gt;
    end else if (cChoice = &#039;check&#039;) then begin&lt;br /&gt;
        oWriter.RootArr();&lt;br /&gt;
        _AddCHECK(oWriter);&lt;br /&gt;
    end else if (cChoice = &#039;pauseTime&#039;) then begin&lt;br /&gt;
        oWriter.RootArr();&lt;br /&gt;
        _GetPauseTime(oWriter, oReader.Param(&#039;sysuid&#039;));&lt;br /&gt;
    end else begin&lt;br /&gt;
        // Eine unbekannte Auswahl war vorher eine leere Antwort - der Aufrufer&lt;br /&gt;
        // sah einen Tippfehler nicht. Jetzt sagt es der Statuscode.&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Unbekannte Auswahl: &#039; + cChoice);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
procedure PUT(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var xZeit        : TqSQL;&lt;br /&gt;
    cUSER_SYSUID : String;&lt;br /&gt;
    cKOMM_SYSUID : String;&lt;br /&gt;
    lNew         : Boolean;&lt;br /&gt;
    cSql         : String;&lt;br /&gt;
    dDBNow       : TDateTime;&lt;br /&gt;
    cChoice      : String;&lt;br /&gt;
begin&lt;br /&gt;
    cChoice := oReader.Param(&#039;choice&#039;);&lt;br /&gt;
    writeln(&#039;Choice: &#039; + cChoice);&lt;br /&gt;
&lt;br /&gt;
    if (cChoice = &#039;start&#039;) then begin&lt;br /&gt;
        dDBNow  := DB_Now(oDB);&lt;br /&gt;
        lNew    := True;&lt;br /&gt;
        if (empty(cUSER_SYSUID)) then begin&lt;br /&gt;
            cUSER_SYSUID := GetNewId(oDB);&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        xZeit           := qSqlInit(oDB, &#039;zeiterfuser&#039;);&lt;br /&gt;
        xZeit.lNoSysUID := True;&lt;br /&gt;
&lt;br /&gt;
        writeln(&#039;SYSUID: &#039; + oReader.Param(&#039;jobID&#039;));&lt;br /&gt;
&lt;br /&gt;
        xZeit.qSet(&#039;ze_user&#039;            , oReader.Param(&#039;user&#039;));&lt;br /&gt;
        xZeit.qSet(&#039;ze_starttime&#039;       , oReader.Param(&#039;uhrzeit&#039;));&lt;br /&gt;
        xZeit.qSet(&#039;ze_eigen&#039;           , &#039;0101&#039;);&lt;br /&gt;
        xZeit.qSet(&#039;ze_startdate&#039;       , oReader.Param(&#039;datum&#039;));&lt;br /&gt;
        xZeit.qSet(&#039;ze_endtime&#039;         , oReader.Param(&#039;endTime&#039;));&lt;br /&gt;
        xZeit.qSet(&#039;ze_enddate&#039;         , oReader.Param(&#039;endDate&#039;));&lt;br /&gt;
        xZeit.qSet(&#039;ze_zeit&#039;            , &#039;0&#039;);&lt;br /&gt;
        xZeit.qSet(&#039;ze_pausezeit&#039;       , &#039;0&#039;);&lt;br /&gt;
        xZeit.qSet(&#039;ze_status&#039;          , oReader.Param(&#039;status&#039;));&lt;br /&gt;
        xZeit.qSet(&#039;ze_berechnen&#039;       , 0);&lt;br /&gt;
        xZeit.qSet(&#039;ze_officestar&#039;      , 0);&lt;br /&gt;
        xZeit.qSet(&#039;ze_repa&#039;            , 0);&lt;br /&gt;
        xZeit.qSet(&#039;ze_vorbereitung&#039;    , 0);&lt;br /&gt;
        xZeit.qSet(&#039;ze_uid&#039;             , oReader.Param(&#039;jobID&#039;));&lt;br /&gt;
        xZeit.qSet(&#039;sys_uid&#039;            , cUSER_SYSUID);&lt;br /&gt;
&lt;br /&gt;
        // SaveData meldet einen Fehler ueber den RUECKGABEWERT, nicht ueber eine&lt;br /&gt;
        // Exception. Ohne diese Pruefung antwortet der Endpunkt mit Erfolg,&lt;br /&gt;
        // obwohl nichts geschrieben wurde.&lt;br /&gt;
        if (not xZeit.SaveData(lNew)) then begin&lt;br /&gt;
            qSQLFree(xZeit);&lt;br /&gt;
            oWriter.Error(500, &#039;INTERNAL_ERROR&#039;, &#039;Zeiterfassung konnte nicht gespeichert werden&#039;);&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
        qSQLFree(xZeit);&lt;br /&gt;
&lt;br /&gt;
        //---------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
        xZeit := qSqlInit(oDB,&#039;ZEITERFKOMM&#039;);&lt;br /&gt;
        xZeit.lNoSysUID := True;&lt;br /&gt;
&lt;br /&gt;
        if (empty(cKOMM_SYSUID)) then begin&lt;br /&gt;
            cKOMM_SYSUID := GetNewId(oDB);&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        xZeit.qSet(&#039;zk_psnr&#039;       , oReader.Param(&#039;psnr&#039;));&lt;br /&gt;
        xZeit.qSet(&#039;zk_starttime&#039;  , oReader.Param(&#039;uhrzeit&#039;));&lt;br /&gt;
        xZeit.qSet(&#039;zk_startdate&#039;  , oReader.Param(&#039;datum&#039;));&lt;br /&gt;
        xZeit.qSet(&#039;zk_kommentar&#039;  , oReader.Param(&#039;comm&#039;));&lt;br /&gt;
        xZeit.qSet(&#039;zk_messunguid&#039; , cUSER_SYSUID);&lt;br /&gt;
        xZeit.qSet(&#039;sys_uid&#039;       , cKOMM_SYSUID);&lt;br /&gt;
&lt;br /&gt;
        if (not xZeit.SaveData(lNew)) then begin&lt;br /&gt;
            qSQLFree(xZeit);&lt;br /&gt;
            oWriter.Error(500, &#039;INTERNAL_ERROR&#039;, &#039;Kommentar konnte nicht gespeichert werden&#039;);&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
        qSQLFree(xZeit);&lt;br /&gt;
&lt;br /&gt;
        // Die UID steht jetzt in einem FELD und nicht mehr als nackter Text im&lt;br /&gt;
        // Koerper. Der Writer kann nur JSON schreiben - und das ist gut so:&lt;br /&gt;
        // eine Antwort, die manchmal &#039;ERROR: ...&#039; und manchmal eine UID war,&lt;br /&gt;
        // liess sich vom Aufrufer nicht unterscheiden. Das Javascript liest&lt;br /&gt;
        // dafuer JSON.parse(...).uid, siehe timer.js.&lt;br /&gt;
        oWriter.Str(&#039;uid&#039;, cUSER_SYSUID);&lt;br /&gt;
&lt;br /&gt;
    end else if (cChoice = &#039;stop&#039;) then begin&lt;br /&gt;
&lt;br /&gt;
        // Jeder Wert aus der Anfrage geht durch DB_SQLVal - auch die, die wie&lt;br /&gt;
        // Zahlen aussehen. Der Kommentar ist Freitext aus dem Browser, und&lt;br /&gt;
        // ohne Quoting beendet ein Apostroph darin das SQL-Statement.&lt;br /&gt;
        cSql := &#039;UPDATE zeiterfuser, zeiterfkomm &#039;   +&lt;br /&gt;
                &#039; SET zeiterfuser.ze_zeit = &#039;        + DB_SQLVal(oReader.Param(&#039;elapsedTime&#039;)) +&lt;br /&gt;
                &#039;, zeiterfuser.ze_endtime = &#039;        + DB_SQLVal(oReader.Param(&#039;vergZeit&#039;)) +&lt;br /&gt;
                &#039;, zeiterfuser.ze_enddate = &#039;        + DB_SQLVal(oReader.Param(&#039;stopTimeValue&#039;)) +&lt;br /&gt;
                &#039;, zeiterfuser.ze_pausezeit = &#039;      + DB_SQLVal(oReader.Param(&#039;elapsedPauseTime&#039;)) +&lt;br /&gt;
                &#039;, zeiterfuser.ze_status = &#039;         + DB_SQLVal(oReader.Param(&#039;status&#039;)) +&lt;br /&gt;
                &#039;, zeiterfkomm.zk_kommentar = &#039;      + DB_SQLVal(oReader.Param(&#039;comment&#039;)) +&lt;br /&gt;
                &#039; WHERE zeiterfuser.sys_uid = &#039;      + DB_SQLVal(oReader.Param(&#039;uid&#039;)) +&lt;br /&gt;
                &#039; AND zeiterfkomm.zk_messunguid = &#039;  + DB_SQLVal(oReader.Param(&#039;uid&#039;));&lt;br /&gt;
&lt;br /&gt;
        if (DB_SQLExec(oDB, cSql)) then begin&lt;br /&gt;
            oWriter.Str(&#039;status&#039;, &#039;ok&#039;);&lt;br /&gt;
        end else begin&lt;br /&gt;
            // Ein Fehlschlag ist ein 500 und keine 200 mit dem Wort &#039;Fehler!&#039;&lt;br /&gt;
            // im Koerper - sonst haelt der Aufrufer ihn fuer einen Erfolg.&lt;br /&gt;
            oWriter.Error(500, &#039;INTERNAL_ERROR&#039;, &#039;Die Daten konnten nicht gespeichert werden&#039;);&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
    end else if (cChoice = &#039;pause&#039;) then begin&lt;br /&gt;
        writeln(&#039;Pausezeit: &#039; + oReader.Param(&#039;pauseTime&#039;));&lt;br /&gt;
        cSql := &#039;UPDATE zeiterfuser SET ze_pausezeit &#039; +&lt;br /&gt;
                &#039;= ze_pausezeit + &#039;    + DB_SQLVal(oReader.Param(&#039;pauseTime&#039;)) +&lt;br /&gt;
                &#039; WHERE sys_uid = &#039;    + DB_SQLVal(oReader.Param(&#039;sysuid&#039;));&lt;br /&gt;
&lt;br /&gt;
        if (DB_SQLExec(oDB, cSql)) then begin&lt;br /&gt;
            oWriter.Str(&#039;status&#039;, &#039;ok&#039;);&lt;br /&gt;
        end else begin&lt;br /&gt;
            oWriter.Error(500, &#039;INTERNAL_ERROR&#039;, &#039;Die Pausenzeit konnte nicht gespeichert werden&#039;);&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
    end else begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Unbekannte Auswahl: &#039; + cChoice);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
= HTML-Datei index.php =&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;html&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;!DOCTYPE html&amp;gt;&lt;br /&gt;
&amp;lt;html lang=&amp;quot;de-DE&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;head&amp;gt;&lt;br /&gt;
        &amp;lt;title&amp;gt;Ihr IT-Partner in Stade - Ernst Bergau GmbH&amp;lt;/title&amp;gt;&lt;br /&gt;
        &amp;lt;link rel=&amp;quot;stylesheet&amp;quot; type=&amp;quot;text/css&amp;quot; href=&amp;quot;timestyle.css&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;/head&amp;gt;&lt;br /&gt;
    &amp;lt;script&amp;gt;&lt;br /&gt;
        &amp;lt;?php&lt;br /&gt;
            include(&#039;timer.js&#039;);&lt;br /&gt;
        ?&amp;gt;&lt;br /&gt;
    &amp;lt;/script&amp;gt;&lt;br /&gt;
    &amp;lt;body&amp;gt;&lt;br /&gt;
        &amp;lt;!-- //--------------------------------------------------------------------------------------------------------------// --&amp;gt;&lt;br /&gt;
        &amp;lt;!-- //---------------------------------------------------HTML-------------------------------------------------------// --&amp;gt;&lt;br /&gt;
        &amp;lt;!-- //--------------------------------------------------------------------------------------------------------------// --&amp;gt;&lt;br /&gt;
&lt;br /&gt;
        &amp;lt;textarea id=&amp;quot;search&amp;quot; type=&amp;quot;text&amp;quot; rows=&amp;quot;1&amp;quot; cols=&amp;quot;50&amp;quot; oninput=&amp;quot;searchCustomers(this.value)&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
&lt;br /&gt;
        &amp;lt;div class=&amp;quot;kundenliste&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;ul id=&amp;quot;kundenListe&amp;quot;&amp;gt;&amp;lt;/ul&amp;gt;&lt;br /&gt;
        &amp;lt;/div&amp;gt;&lt;br /&gt;
        &amp;lt;table class=&amp;quot;timer&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;tbody id=&amp;quot;timerList&amp;quot;&amp;gt;&amp;lt;/tbody&amp;gt;&lt;br /&gt;
        &amp;lt;/table&amp;gt;&lt;br /&gt;
        &amp;lt;table class=&amp;quot;tabelleUeberschriften&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;thead&amp;gt;&lt;br /&gt;
                &amp;lt;tr&amp;gt;&lt;br /&gt;
                    &amp;lt;th class=&amp;quot;kunde&amp;quot;&amp;gt;Kunde&amp;lt;/th&amp;gt;&lt;br /&gt;
                    &amp;lt;th class=&amp;quot;konto&amp;quot;&amp;gt;Konto&amp;lt;/th&amp;gt;&lt;br /&gt;
                    &amp;lt;th class=&amp;quot;laufzeit&amp;quot;&amp;gt;Laufzeit&amp;lt;/th&amp;gt;&lt;br /&gt;
                    &amp;lt;th class=&amp;quot;status&amp;quot;&amp;gt;Status&amp;lt;/th&amp;gt;&lt;br /&gt;
                &amp;lt;/tr&amp;gt;&lt;br /&gt;
            &amp;lt;/thead&amp;gt;&lt;br /&gt;
        &amp;lt;/table&amp;gt;&lt;br /&gt;
        &lt;br /&gt;
&lt;br /&gt;
        &amp;lt;div class=&amp;quot;lightbox&amp;quot; id=&amp;quot;lightbox&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;label class=&amp;quot;commLabel&amp;quot; id=&amp;quot;commLabel&amp;quot; for=&amp;quot;comment&amp;quot;&amp;gt;Kommentar&amp;lt;/label&amp;gt;&lt;br /&gt;
            &amp;lt;textarea class=&amp;quot;comment&amp;quot; id=&amp;quot;comment&amp;quot; rows=&amp;quot;4&amp;quot; cols=&amp;quot;50&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;div class=&amp;quot;bezZeitkonto&amp;quot;&amp;gt;Zeitkonto&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;select class=&amp;quot;dropButtonsLightbox&amp;quot; id=&amp;quot;tätigkeit2Dropdown&amp;quot;&amp;gt;&amp;lt;/select&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;div class=&amp;quot;bezDatum&amp;quot;&amp;gt;Startdatum&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;date&amp;quot; class=&amp;quot;date&amp;quot; id=&amp;quot;date&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;textarea disabled class=&amp;quot;uhrzeit&amp;quot; id=&amp;quot;uhrzeit&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;div class=&amp;quot;bezPerson&amp;quot;&amp;gt;Person&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;textarea disabled class=&amp;quot;eingPerson&amp;quot; id=&amp;quot;eingPerson&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
            &amp;lt;textarea disabled class=&amp;quot;eingPersonZusatz&amp;quot; id=&amp;quot;eingPersonZusatz&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Std&amp;quot;&amp;gt;Std&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;number&amp;quot; class=&amp;quot;outStd&amp;quot; id=&amp;quot;outStd&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Min&amp;quot;&amp;gt;Min&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;number&amp;quot; class=&amp;quot;outMin&amp;quot; id=&amp;quot;outMin&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Sek&amp;quot;&amp;gt;Sek&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;number&amp;quot; class=&amp;quot;outSek&amp;quot; id=&amp;quot;outSek&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Pausenzeiten&amp;quot;&amp;gt;Davon Pausenzeiten&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Std2&amp;quot;&amp;gt;Std&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;number&amp;quot; class=&amp;quot;outStd2&amp;quot; id=&amp;quot;outStd2&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Min2&amp;quot;&amp;gt;Min&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;number&amp;quot; class=&amp;quot;outMin2&amp;quot; id=&amp;quot;outMin2&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Sek2&amp;quot;&amp;gt;Sek&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;number&amp;quot; class=&amp;quot;outSek2&amp;quot; id=&amp;quot;outSek2&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;button class=&amp;quot;saveButton&amp;quot; id=&amp;quot;saveButton&amp;quot;&amp;gt;Speichern&amp;lt;/button&amp;gt;&lt;br /&gt;
            &amp;lt;button class=&amp;quot;closeButton&amp;quot; id=&amp;quot;closeButton&amp;quot; onclick=&amp;quot;closeLightboxOnSchliessen()&amp;quot;&amp;gt;Schließen&amp;lt;/button&amp;gt; &lt;br /&gt;
        &amp;lt;/div&amp;gt;&lt;br /&gt;
        &lt;br /&gt;
        &amp;lt;div id=&amp;quot;zweiteListe&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;ul id=&amp;quot;taetigkeitListe&amp;quot;&amp;gt;&amp;lt;/ul&amp;gt;&lt;br /&gt;
        &amp;lt;/div&amp;gt;&lt;br /&gt;
    &amp;lt;/body&amp;gt;&lt;br /&gt;
&amp;lt;/html&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
= Javascript-Datei timer.js =&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;javascript&amp;quot;&amp;gt;&lt;br /&gt;
var api_key             = &#039;Y00C75PODJ&#039;;&lt;br /&gt;
var url                 = &#039;http://127.0.0.1:8099/Zeiterfa/zeiterfa/v1&#039;;&lt;br /&gt;
var zaehler             = -1;&lt;br /&gt;
var timerInterval;      &lt;br /&gt;
var pauseTimerInterval; &lt;br /&gt;
var startTimeValue;     &lt;br /&gt;
var selectedID          = 0;&lt;br /&gt;
var elapsedTime;        &lt;br /&gt;
var uid;                &lt;br /&gt;
var idStop; &lt;br /&gt;
var time;&lt;br /&gt;
var selID               = [];&lt;br /&gt;
var job                 = [];&lt;br /&gt;
var sys_uid             = [];&lt;br /&gt;
var timers              = [];    &lt;br /&gt;
var pauseTimers         = [];  &lt;br /&gt;
var pauseTime           = [];&lt;br /&gt;
var startdate           = [];&lt;br /&gt;
var uhrzeit             = [];&lt;br /&gt;
var datum               = [];&lt;br /&gt;
var statusTimer         = [];       &lt;br /&gt;
var selectedJob         = [];&lt;br /&gt;
var selectedKunde       = [];      &lt;br /&gt;
var tempSelectedKunde   = [];&lt;br /&gt;
var parameter           = [];&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//--------------------------------------------------Seitenaufruf---------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Bei Seitenaufruf werden die Funktionen----//&lt;br /&gt;
//----getCustomer und checkTimer aufgerufen und----//&lt;br /&gt;
//----die Kundenliste gefüllt und geprüft,----//&lt;br /&gt;
//----ob laufende timer vorhanden sind----//&lt;br /&gt;
&lt;br /&gt;
window.onload = function() {&lt;br /&gt;
&lt;br /&gt;
    getCustomer();&lt;br /&gt;
    checkTimer();&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//-------------------------------------------------Daten auslesen--------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Eine Liste der Kunden wird ausgelesenund gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function getCustomer() {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;POST&#039;;&lt;br /&gt;
    var choice = &#039;customer&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;getData.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            var responseData = JSON.parse(request.responseText);&lt;br /&gt;
            populateListeKunde(responseData);&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Bei Rechtsklick auf Kunden wird----//&lt;br /&gt;
//----seine ID ausgelesen und gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function getCustomerID(selectedKunde, callback) {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;POST&#039;;&lt;br /&gt;
    var choice = &#039;id&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;selectedKunde=&amp;quot;       + encodeURIComponent(selectedKunde);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;getData.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            selectedID = JSON.parse(request.responseText);&lt;br /&gt;
            callback(selectedID);&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Anhand der Kunden-ID wird eine Liste von Tätigkeiten----//&lt;br /&gt;
//----für jeden Kunden ausgelesen und gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function getJob(id, callback) {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;POST&#039;;&lt;br /&gt;
    var choice = &#039;job&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;id=&amp;quot;                  + encodeURIComponent(id);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;getData.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            var responseData = JSON.parse(request.responseText);&lt;br /&gt;
            var Data         = responseData.toString();&lt;br /&gt;
            var teile        = Data.split(&amp;quot;,&amp;quot;);&lt;br /&gt;
            var jobid = [];&lt;br /&gt;
&lt;br /&gt;
            for (i=0; i&amp;lt;teile.length; i++) {&lt;br /&gt;
                job[i]   = teile[i].split(&amp;quot;:&amp;quot;)[0];&lt;br /&gt;
                jobid[i] = teile[i].split(&amp;quot;:&amp;quot;)[1];&lt;br /&gt;
            }&lt;br /&gt;
            callback(job, jobid);&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Tätigkeitsliste wird nochmal zur Weiterverarbeitung gelesen----//&lt;br /&gt;
&lt;br /&gt;
function getJobs(id) {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;POST&#039;;&lt;br /&gt;
    var choice = &#039;job&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;id=&amp;quot;                  + encodeURIComponent(id);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;getData.php&amp;quot;, false);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            var responseData = JSON.parse(request.responseText);&lt;br /&gt;
            var data = responseData.toString();&lt;br /&gt;
            var teile = data.split(&amp;quot;,&amp;quot;);&lt;br /&gt;
            var jobid = [];&lt;br /&gt;
&lt;br /&gt;
            for (var i = 0; i &amp;lt; teile.length; i++) {&lt;br /&gt;
                job[i] = teile[i].split(&amp;quot;:&amp;quot;)[0];&lt;br /&gt;
                jobid[i] = teile[i].split(&amp;quot;:&amp;quot;)[1];&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
//----Gespeicherte Pausezeit wird ausgelesen----//&lt;br /&gt;
&lt;br /&gt;
function getPauseTime(sysuid, id) {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;POST&#039;;&lt;br /&gt;
    var choice = &#039;pauseTime&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params  =   &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;sysuid=&amp;quot;              + encodeURIComponent(sysuid);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;getData.php&amp;quot;, false);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            pauseTime[id].value = JSON.parse(request.responseText);&lt;br /&gt;
&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//---------------------------------------------Listen--------------------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Eine Liste wird mit Kundendaten gefüllt und angezeigt----//&lt;br /&gt;
&lt;br /&gt;
function populateListeKunde(data) {&lt;br /&gt;
&lt;br /&gt;
    data.forEach(function(item) {&lt;br /&gt;
        var listItem = document.createElement(&amp;quot;li&amp;quot;);&lt;br /&gt;
        listItem.textContent = item;&lt;br /&gt;
&lt;br /&gt;
        listItem.addEventListener(&amp;quot;contextmenu&amp;quot;, function(event) {&lt;br /&gt;
            event.preventDefault();&lt;br /&gt;
&lt;br /&gt;
            var selectedKunde = item;&lt;br /&gt;
            getCustomerID(selectedKunde, function(selectedID) {&lt;br /&gt;
                getJob(selectedID, function(job, jobid) {&lt;br /&gt;
                    showSecondList(job, jobid, item);&lt;br /&gt;
                });&lt;br /&gt;
            });&lt;br /&gt;
        });&lt;br /&gt;
&lt;br /&gt;
        kundenListe.appendChild(listItem);&lt;br /&gt;
    });&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Eine zweite Liste wird mit Jobs gefüllt,----//&lt;br /&gt;
//----angezeigt und bei Klick auf einen Eintrag---//&lt;br /&gt;
//-------die Funktion startTimer aufgerufen-------//&lt;br /&gt;
&lt;br /&gt;
function showSecondList(job, jobid, kundeItem) {&lt;br /&gt;
&lt;br /&gt;
    var zweiteListe = document.getElementById(&amp;quot;zweiteListe&amp;quot;);&lt;br /&gt;
    zweiteListe.innerHTML = &amp;quot;&amp;quot;;&lt;br /&gt;
&lt;br /&gt;
    job.forEach(function(item) {&lt;br /&gt;
        var listItem = document.createElement(&amp;quot;li&amp;quot;);&lt;br /&gt;
        listItem.textContent = item;&lt;br /&gt;
    &lt;br /&gt;
        (function(capturedItem) {&lt;br /&gt;
            listItem.addEventListener(&amp;quot;click&amp;quot;, function() {&lt;br /&gt;
                startTimer(job, jobid, capturedItem, kundeItem);&lt;br /&gt;
            });&lt;br /&gt;
        }) (item);&lt;br /&gt;
        zweiteListe.appendChild(listItem);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    zweiteListe.style.display = &amp;quot;block&amp;quot;; &lt;br /&gt;
    document.addEventListener(&#039;keydown&#039;, closeSecondList);&lt;br /&gt;
    document.addEventListener(&#039;click&#039;, closeSecondList);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Nach Klick auf Eintrag wird die Liste geschlossen----//&lt;br /&gt;
function closeSecondList() {&lt;br /&gt;
&lt;br /&gt;
    var zweiteListe = document.getElementById(&amp;quot;zweiteListe&amp;quot;);&lt;br /&gt;
    zweiteListe.style.display = &amp;quot;none&amp;quot;; &lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//---------------------------------------------Daten speichern-----------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Startzeit, -datum, ID und Job werden gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function saveStartTime(user, uhrzeit, datum, endTime, endDate, status, psnr, Job, jobID) {&lt;br /&gt;
    &lt;br /&gt;
    var method = &#039;PUT&#039;;&lt;br /&gt;
    var choice = &#039;start&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;user=&amp;quot;                + encodeURIComponent(user) +&lt;br /&gt;
                    &amp;quot;&amp;amp;uhrzeit=&amp;quot;             + encodeURIComponent(uhrzeit) +&lt;br /&gt;
                    &amp;quot;&amp;amp;datum=&amp;quot;               + encodeURIComponent(datum) +&lt;br /&gt;
                    &amp;quot;&amp;amp;endTime=&amp;quot;             + encodeURIComponent(endTime) +&lt;br /&gt;
                    &amp;quot;&amp;amp;endDate=&amp;quot;             + encodeURIComponent(endDate) +&lt;br /&gt;
                    &amp;quot;&amp;amp;status=&amp;quot;              + encodeURIComponent(status)+&lt;br /&gt;
                    &amp;quot;&amp;amp;psnr=&amp;quot;                + encodeURIComponent(psnr)+&lt;br /&gt;
                    &amp;quot;&amp;amp;Job=&amp;quot;                 + encodeURIComponent(Job)+&lt;br /&gt;
                    &amp;quot;&amp;amp;jobID=&amp;quot;               + encodeURIComponent(jobID);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;saveTime.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            // Die Antwort ist JSON: {&amp;quot;uid&amp;quot;:&amp;quot;...&amp;quot;} - vorher stand die UID als&lt;br /&gt;
            // nackter Text im Koerper.&lt;br /&gt;
            sys_uid[zaehler].value = JSON.parse(request.responseText).uid;&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
//----Pausezeit wird gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function savePauseTime(pauseTime, id) {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;PUT&#039;;&lt;br /&gt;
    var choice = &#039;pause&#039;;&lt;br /&gt;
    &lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;      + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;         + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;      + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;      + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;pauseTime=&amp;quot;   + encodeURIComponent(pauseTime) +&lt;br /&gt;
                    &amp;quot;&amp;amp;sysuid=&amp;quot;      + encodeURIComponent(sys_uid[id].value);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;saveTime.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            // alert(request.responseText);&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
//----Stopzeit und -datum wird gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function saveStopTime(stopTimeValue, elapsedTime, elapsedPauseTime, status, uid, vergZeit, comment) {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;PUT&#039;;&lt;br /&gt;
    var choice = &#039;stop&#039;;&lt;br /&gt;
    &lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;stopTimeValue=&amp;quot;       + encodeURIComponent(stopTimeValue) +&lt;br /&gt;
                    &amp;quot;&amp;amp;elapsedTime=&amp;quot;         + encodeURIComponent(elapsedTime) +&lt;br /&gt;
                    &amp;quot;&amp;amp;elapsedPauseTime=&amp;quot;    + encodeURIComponent(elapsedPauseTime) +&lt;br /&gt;
                    &amp;quot;&amp;amp;status=&amp;quot;              + encodeURIComponent(status) +&lt;br /&gt;
                    &amp;quot;&amp;amp;uid=&amp;quot;                 + encodeURIComponent(uid) +&lt;br /&gt;
                    &amp;quot;&amp;amp;vergZeit=&amp;quot;            + encodeURIComponent(vergZeit)+&lt;br /&gt;
                    &amp;quot;&amp;amp;comment=&amp;quot;            + encodeURIComponent(comment);&lt;br /&gt;
    &lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;saveTime.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200 &amp;amp;&amp;amp; timers.length === 0) {&lt;br /&gt;
            location.reload();&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//---------------------------------------------Like-Suche----------------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Durchsucht die Liste der Kunden auf alle einegegebenen Zeichen----//&lt;br /&gt;
&lt;br /&gt;
function searchCustomers(text) {&lt;br /&gt;
&lt;br /&gt;
    text = text.toLowerCase();&lt;br /&gt;
    var kundenListe = document.getElementById(&amp;quot;kundenListe&amp;quot;);&lt;br /&gt;
    var listItems = kundenListe.getElementsByTagName(&amp;quot;li&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    for (i=0; i&amp;lt;listItems.length; i++) {&lt;br /&gt;
        var item = listItems[i].textContent.toLowerCase();&lt;br /&gt;
        if (item.includes(text)) {&lt;br /&gt;
            listItems[i].style.display = &amp;quot;block&amp;quot;;&lt;br /&gt;
        }&lt;br /&gt;
        else {&lt;br /&gt;
            listItems[i].style.display = &amp;quot;none&amp;quot;;&lt;br /&gt;
        }&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//------------------------------------------Vorhandenen Timer checken----------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Überprüft, ob es Einträge in Datenbank ohne----//&lt;br /&gt;
//----Endzeit gibt und startet entsprechend Timer----//&lt;br /&gt;
&lt;br /&gt;
function checkTimer() {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;POST&#039;;&lt;br /&gt;
    var choice = &#039;check&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;getData.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200 &amp;amp;&amp;amp; timers.length === 0) {&lt;br /&gt;
            var responseData = JSON.parse(request.responseText);&lt;br /&gt;
            &lt;br /&gt;
            if (responseData.length !== 0) {&lt;br /&gt;
                startTimeValue = new Date();&lt;br /&gt;
                var minutes = startTimeValue.getMinutes();&lt;br /&gt;
                var seconds = startTimeValue.getSeconds();&lt;br /&gt;
                var month = startTimeValue.getMonth() + 1;&lt;br /&gt;
&lt;br /&gt;
                responseData = responseData.toString();&lt;br /&gt;
                var teile = responseData.split(&amp;quot;,&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
                for (var i=0; i&amp;lt;teile.length; i+=6) {&lt;br /&gt;
                    uhrzeit.push({value: startTimeValue.getHours() + &amp;quot;:&amp;quot; + (minutes &amp;lt; 10 ? &amp;quot;0&amp;quot; + minutes : minutes) + &amp;quot;:&amp;quot; + (seconds &amp;lt; 10 ? &amp;quot;0&amp;quot; + seconds : seconds)});&lt;br /&gt;
                    datum.push({value: startTimeValue.getFullYear() + &amp;quot;-&amp;quot; + (month &amp;lt; 10 ? &amp;quot;0&amp;quot; + month : month) + &amp;quot;-&amp;quot; + startTimeValue.getDate()});&lt;br /&gt;
&lt;br /&gt;
                    zaehler++;&lt;br /&gt;
&lt;br /&gt;
                    statusTimer.push({value: &#039;Running&#039;});&lt;br /&gt;
                    pauseTimers.push({value: 0});&lt;br /&gt;
                    pauseTime.push({value: 0});&lt;br /&gt;
                    timers.push({value: teile[i]});&lt;br /&gt;
                }&lt;br /&gt;
&lt;br /&gt;
                var j=0;&lt;br /&gt;
                for (var i=1; i&amp;lt;teile.length; i+=6) {&lt;br /&gt;
                    selID.push({value: teile[i]});&lt;br /&gt;
                    getJobs(selID[j].value);&lt;br /&gt;
                    j++;&lt;br /&gt;
                } &lt;br /&gt;
&lt;br /&gt;
                fillArray(2, selectedJob, teile);&lt;br /&gt;
                fillArray(3, selectedKunde, teile);&lt;br /&gt;
                fillArray(4, sys_uid, teile);&lt;br /&gt;
                fillArray(5, startdate, teile);&lt;br /&gt;
&lt;br /&gt;
                for (var i=0; i&amp;lt;timers.length; i++) {&lt;br /&gt;
                    var startZeitTeil = timers[i].value.split(&amp;quot;:&amp;quot;);&lt;br /&gt;
                    var startDatumTeil = startdate[i].value.split(&amp;quot;.&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
                    var diffMilli = new Date() - getStartdatum(startDatumTeil, startZeitTeil);&lt;br /&gt;
                    timers[i].value = Math.floor(diffMilli / 1000); &lt;br /&gt;
                }   &lt;br /&gt;
&lt;br /&gt;
                selecteID = 0;&lt;br /&gt;
                startInterval(job);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Arrays werden an diese Funktion übergeben und gefüllt----//&lt;br /&gt;
&lt;br /&gt;
function fillArray(LZ, array, teile) {&lt;br /&gt;
&lt;br /&gt;
    var arrLZ = 0;&lt;br /&gt;
    for (var i=LZ; i&amp;lt;teile.length; i+=6) {&lt;br /&gt;
        array.push({value: teile[i]});&lt;br /&gt;
        arrLZ++;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Startdatum wird errechnet----//&lt;br /&gt;
function getStartdatum(startDatumTeil, startZeitTeil) {&lt;br /&gt;
&lt;br /&gt;
    var startdatum = new Date(&lt;br /&gt;
        parseInt(startDatumTeil[2]),&lt;br /&gt;
        parseInt(startDatumTeil[1]) -1,&lt;br /&gt;
        parseInt(startDatumTeil[0]),&lt;br /&gt;
        parseInt(startZeitTeil[0]),&lt;br /&gt;
        parseInt(startZeitTeil[1]),&lt;br /&gt;
        parseInt(startZeitTeil[2])&lt;br /&gt;
    );&lt;br /&gt;
    return startdatum;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//-------------------------------------------------Interval--------------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Interval für Timer wird gestartet----//&lt;br /&gt;
&lt;br /&gt;
function startInterval(job, item, kundeItem, index) {&lt;br /&gt;
&lt;br /&gt;
    timerInterval = setInterval(function() {&lt;br /&gt;
&lt;br /&gt;
        for (i=0; i&amp;lt;timers.length; i++) {&lt;br /&gt;
&lt;br /&gt;
            timers[i].value += 1;&lt;br /&gt;
        }&lt;br /&gt;
        updateUI(job, item, kundeItem, index);&lt;br /&gt;
    }, 1000);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Interval für Pausentimer wird gestartet----// &lt;br /&gt;
&lt;br /&gt;
function pauseInterval(job, item, kundeItem, id) {&lt;br /&gt;
&lt;br /&gt;
    pauseTimerInterval = setInterval(function() {&lt;br /&gt;
&lt;br /&gt;
        for (i=0; i&amp;lt;pauseTimers.length; i++) {&lt;br /&gt;
&lt;br /&gt;
            pauseTimers[i].value += 1;&lt;br /&gt;
        }&lt;br /&gt;
        updateUI(job, item, kundeItem);&lt;br /&gt;
    }, 1000);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//------------------------------------------------Timer starten----------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----startTimer bereitet die nötigen Arrays vor,----//&lt;br /&gt;
//----speichert Startzeit und -datum,----//&lt;br /&gt;
//----beschreibt Arrays und Variablen entsprechend----//&lt;br /&gt;
//----und ruft die Funktion saveStartTime auf----//&lt;br /&gt;
&lt;br /&gt;
function startTimer(jobs, jobid, selJob, kundeItem) {&lt;br /&gt;
&lt;br /&gt;
    var user = 999;&lt;br /&gt;
    var endTime = &amp;quot;00:00:00&amp;quot;;&lt;br /&gt;
    var endDate = &amp;quot;0000-00-00&amp;quot;;&lt;br /&gt;
    var status = &#039;01&#039;;&lt;br /&gt;
    zaehler++;&lt;br /&gt;
    &lt;br /&gt;
    startTimeValue = new Date();&lt;br /&gt;
&lt;br /&gt;
    var minutes = startTimeValue.getMinutes();&lt;br /&gt;
    var seconds = startTimeValue.getSeconds();&lt;br /&gt;
    uhrzeit.push({value: startTimeValue.getHours() + &amp;quot;:&amp;quot; + (minutes &amp;lt; 10 ? &amp;quot;0&amp;quot; + minutes : minutes) + &amp;quot;:&amp;quot; + (seconds &amp;lt; 10 ? &amp;quot;0&amp;quot; + seconds : seconds)});&lt;br /&gt;
&lt;br /&gt;
    var month = startTimeValue.getMonth() + 1;&lt;br /&gt;
    datum.push({value: startTimeValue.getFullYear() + &amp;quot;-&amp;quot; + (month &amp;lt; 10 ? &amp;quot;0&amp;quot; + month : month) + &amp;quot;-&amp;quot; + startTimeValue.getDate()});&lt;br /&gt;
&lt;br /&gt;
    statusTimer.push({value: &#039;Running&#039;});&lt;br /&gt;
    pauseTimers.push({value: 0});&lt;br /&gt;
    pauseTime.push({value: 0});&lt;br /&gt;
    timers.push({value: 0});&lt;br /&gt;
    selectedKunde.push({value: kundeItem});&lt;br /&gt;
    selectedJob.push({value: selJob});&lt;br /&gt;
    sys_uid.push({value: 0});&lt;br /&gt;
&lt;br /&gt;
    var index = jobs.indexOf(selJob);&lt;br /&gt;
    &lt;br /&gt;
    saveStartTime(user, uhrzeit[zaehler].value, datum[zaehler].value, endTime, endDate, status, selectedID, selJob, jobid[index]);&lt;br /&gt;
    &lt;br /&gt;
    clearInterval(timerInterval);&lt;br /&gt;
    startInterval(jobs, selJob, kundeItem, index);&lt;br /&gt;
&lt;br /&gt;
    zweiteListe.style.display = &amp;quot;none&amp;quot;;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//------------------------------------------------Timer stoppen----------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Stopt den Timer, ändert den Status und ruft openLightbox auf----//&lt;br /&gt;
&lt;br /&gt;
function stopTimer(job, item, kundeItem, index, id) {&lt;br /&gt;
&lt;br /&gt;
    idStop = id;&lt;br /&gt;
    statusTimer[id].value = &#039;Stopped&#039;;&lt;br /&gt;
    openLightbox(job, item, kundeItem, index, id);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//------------------------------------------------Timer pausieren--------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Pausiert den Timer und ändert den Status----//&lt;br /&gt;
&lt;br /&gt;
function pauseTimer(job, item, kundeItem, id) {&lt;br /&gt;
&lt;br /&gt;
    pauseTimers[id].value = 0;&lt;br /&gt;
    clearInterval(pauseTimerInterval);&lt;br /&gt;
    pauseInterval(job, item, kundeItem, id);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//----------------------------------------------Button Pause-Start-------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Ruft die Funktion pauseTimer oder----//&lt;br /&gt;
//----savePauseTimer auf----//&lt;br /&gt;
&lt;br /&gt;
function toggleButton(job, item, kundeItem, id) {&lt;br /&gt;
&lt;br /&gt;
    var button = document.getElementById(id);&lt;br /&gt;
&lt;br /&gt;
    if (button.innerHTML === &amp;quot;Pause&amp;quot;) {&lt;br /&gt;
&lt;br /&gt;
        statusTimer[id].value = &#039;Paused&#039;;&lt;br /&gt;
        pauseTimer(job, item, kundeItem, id);&lt;br /&gt;
    } &lt;br /&gt;
    else {&lt;br /&gt;
&lt;br /&gt;
        statusTimer[id].value = &amp;quot;Running&amp;quot;;&lt;br /&gt;
        savePauseTime(pauseTimers[id].value, id);&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//------------------------------------------------Ausgabe des Timers-----------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Zeigt eine Liste mit einem oder mehreren----//&lt;br /&gt;
//----Timern und Buttons, verteilt dynamisch ID&#039;s----//&lt;br /&gt;
//----und ruft entsprechend der gedrückten----//&lt;br /&gt;
//----Buttons Funktionen auf----//&lt;br /&gt;
&lt;br /&gt;
function updateUI(job, item, kundeItem, index) {&lt;br /&gt;
    var timerList = document.getElementById(&amp;quot;timerList&amp;quot;);&lt;br /&gt;
    timerList.innerHTML = &#039;&#039;;&lt;br /&gt;
    &lt;br /&gt;
    for (i=0; i&amp;lt;timers.length; i++) {&lt;br /&gt;
        var neueReihe = document.createElement(&#039;tr&#039;)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        //----Name des Kunden wird ausgegeben----//&lt;br /&gt;
&lt;br /&gt;
        var kundeEintrag = document.createElement(&#039;td&#039;);&lt;br /&gt;
        kundeEintrag.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
        kundeEintrag.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;;&lt;br /&gt;
        kundeEintrag.style.width = &amp;quot;30%&amp;quot;;&lt;br /&gt;
        kundeEintrag.style.left = &amp;quot;0%&amp;quot;; &lt;br /&gt;
        kundeEintrag.style.textAlign = &amp;quot;center&amp;quot;;&lt;br /&gt;
        kundeEintrag.classList.add(&#039;kunde_k&#039;);&lt;br /&gt;
        kundeEintrag.textContent = selectedKunde[i].value;&lt;br /&gt;
        timerList.appendChild(kundeEintrag);&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        //----Gewählter Job wird ausgegeben----//&lt;br /&gt;
&lt;br /&gt;
        var kontoEintrag = document.createElement(&#039;td&#039;);&lt;br /&gt;
        kontoEintrag.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
        kontoEintrag.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;; &lt;br /&gt;
        kontoEintrag.style.width = &amp;quot;30%&amp;quot;;&lt;br /&gt;
        kontoEintrag.style.left = &amp;quot;30%&amp;quot;; &lt;br /&gt;
        kontoEintrag.style.textAlign = &amp;quot;center&amp;quot;;&lt;br /&gt;
        kontoEintrag.classList.add(&#039;konto_k&#039;);&lt;br /&gt;
        kontoEintrag.textContent = selectedJob[i].value;&lt;br /&gt;
        timerList.appendChild(kontoEintrag);&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        //----Gestartete Timer werden in Liste----//&lt;br /&gt;
        //----ausgegeben, pausierte Timer werden----//&lt;br /&gt;
        //----Liste ausgegeben, sobald Timer gestoppt----//&lt;br /&gt;
        //----wird Ausgabe in Lightbox----//&lt;br /&gt;
&lt;br /&gt;
        if (statusTimer[i].value === &amp;quot;Running&amp;quot;) {&lt;br /&gt;
            var laufzeitElement = document.createElement(&#039;td&#039;);&lt;br /&gt;
            laufzeitElement.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
            laufzeitElement.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;; &lt;br /&gt;
            laufzeitElement.style.width = &amp;quot;15%&amp;quot;;&lt;br /&gt;
            laufzeitElement.style.left = &amp;quot;60%&amp;quot;; &lt;br /&gt;
            laufzeitElement.style.textAlign = &amp;quot;center&amp;quot;;&lt;br /&gt;
            laufzeitElement.classList.add(&#039;laufzeit_l&#039;);&lt;br /&gt;
            laufzeitElement.textContent = formatElapsedTime(timers[i].value);&lt;br /&gt;
            timerList.appendChild(laufzeitElement);&lt;br /&gt;
        }&lt;br /&gt;
        else if (statusTimer[i].value === &#039;Paused&#039;) {&lt;br /&gt;
            var laufzeitElement = document.createElement(&#039;td&#039;);&lt;br /&gt;
            laufzeitElement.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
            laufzeitElement.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;; &lt;br /&gt;
            laufzeitElement.style.width = &amp;quot;15%&amp;quot;;&lt;br /&gt;
            laufzeitElement.style.left = &amp;quot;60%&amp;quot;; &lt;br /&gt;
            laufzeitElement.style.textAlign = &amp;quot;center&amp;quot;;&lt;br /&gt;
            laufzeitElement.classList.add(&#039;laufzeit_l&#039;);&lt;br /&gt;
            laufzeitElement.textContent = formatElapsedTime(pauseTimers[i].value);&lt;br /&gt;
            timerList.appendChild(laufzeitElement);&lt;br /&gt;
        }&lt;br /&gt;
        else if (statusTimer[i].value === &#039;Stopped&#039;) {&lt;br /&gt;
            var laufzeitElement = document.createElement(&#039;td&#039;);&lt;br /&gt;
            laufzeitElement.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
            laufzeitElement.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;; &lt;br /&gt;
            laufzeitElement.style.width = &amp;quot;15%&amp;quot;;&lt;br /&gt;
            laufzeitElement.style.left = &amp;quot;60%&amp;quot;; &lt;br /&gt;
            laufzeitElement.style.textAlign = &amp;quot;center&amp;quot;;&lt;br /&gt;
            laufzeitElement.classList.add(&#039;laufzeit_l&#039;);&lt;br /&gt;
            laufzeitElement.textContent = formatElapsedTime(pauseTimers[i].value);&lt;br /&gt;
            timerList.appendChild(laufzeitElement);&lt;br /&gt;
&lt;br /&gt;
            var zeit = formatElapsedTime(timers[i].value);&lt;br /&gt;
            zeit = zeit.toString();&lt;br /&gt;
            var teile = zeit.split(&amp;quot;:&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
            var h = teile[0];&lt;br /&gt;
            var m = teile[1];&lt;br /&gt;
            var s = teile[2];&lt;br /&gt;
&lt;br /&gt;
            var outStdField = document.getElementById(&amp;quot;outStd&amp;quot;);&lt;br /&gt;
            var outMinField = document.getElementById(&amp;quot;outMin&amp;quot;);&lt;br /&gt;
            var outSekField = document.getElementById(&amp;quot;outSek&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
            if (document.activeElement !== outStdField &amp;amp;&amp;amp; document.activeElement !== outMinField &amp;amp;&amp;amp; document.activeElement !== outSekField) {&lt;br /&gt;
                document.getElementById(&amp;quot;outStd&amp;quot;).value = h; &lt;br /&gt;
                document.getElementById(&amp;quot;outMin&amp;quot;).value = m;&lt;br /&gt;
                document.getElementById(&amp;quot;outSek&amp;quot;).value = s;&lt;br /&gt;
            }&lt;br /&gt;
&lt;br /&gt;
            var hEnd = document.getElementById(&amp;quot;outStd&amp;quot;).value;&lt;br /&gt;
            var mEnd = document.getElementById(&amp;quot;outMin&amp;quot;).value;&lt;br /&gt;
            var sEnd = document.getElementById(&amp;quot;outSek&amp;quot;).value;&lt;br /&gt;
            time = hEnd + &amp;quot;:&amp;quot; + mEnd + &amp;quot;:&amp;quot; + sEnd;&lt;br /&gt;
        }&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        //----Status (Running, Paused, Stopped) wird ausgegeben----//&lt;br /&gt;
&lt;br /&gt;
        var statusTimerElement = document.createElement(&#039;td&#039;);&lt;br /&gt;
        statusTimerElement.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
        statusTimerElement.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;; &lt;br /&gt;
        statusTimerElement.style.width = &amp;quot;15%&amp;quot;;&lt;br /&gt;
        statusTimerElement.style.left = &amp;quot;75%&amp;quot;; &lt;br /&gt;
        statusTimerElement.style.textAlign = &amp;quot;center&amp;quot;;&lt;br /&gt;
        statusTimerElement.classList.add(&#039;status_s&#039;);&lt;br /&gt;
        statusTimerElement.textContent = statusTimer[i].value;&lt;br /&gt;
        timerList.appendChild(statusTimerElement);&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        //----Pause Button mit dynamisch vergebener ID wird ausgegeben----//&lt;br /&gt;
&lt;br /&gt;
        if (statusTimer[i].value === &amp;quot;Running&amp;quot;) {&lt;br /&gt;
            var pauseButton = document.createElement(&#039;button&#039;)&lt;br /&gt;
            pauseButton.id = i;&lt;br /&gt;
            pauseButton.classList.add(&#039;pauseButton&#039;);&lt;br /&gt;
            pauseButton.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
            pauseButton.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;;&lt;br /&gt;
            pauseButton.style.left = &amp;quot;90%&amp;quot;; &lt;br /&gt;
            pauseButton.style.height = &amp;quot;9.4%&amp;quot;;&lt;br /&gt;
            pauseButton.style.minWidth = &amp;quot;3%&amp;quot;;&lt;br /&gt;
            pauseButton.style.fontSize = &amp;quot;80%&amp;quot;;&lt;br /&gt;
            pauseButton.textContent = &amp;quot;Pause&amp;quot;&lt;br /&gt;
            pauseButton.addEventListener(&#039;click&#039;, function() {toggleButton(job, item, kundeItem, this.id)});&lt;br /&gt;
            timerList.appendChild(pauseButton);&lt;br /&gt;
        }&lt;br /&gt;
        else if (statusTimer[i].value === &amp;quot;Paused&amp;quot;) {&lt;br /&gt;
            var pauseButton = document.createElement(&#039;button&#039;)&lt;br /&gt;
            pauseButton.id = i;&lt;br /&gt;
            pauseButton.classList.add(&#039;pauseButton&#039;);&lt;br /&gt;
            pauseButton.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
            pauseButton.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;;&lt;br /&gt;
            pauseButton.style.left = &amp;quot;90%&amp;quot;;&lt;br /&gt;
            pauseButton.style.height = &amp;quot;9.4%&amp;quot;;&lt;br /&gt;
            pauseButton.style.minWidth = &amp;quot;3%&amp;quot;;&lt;br /&gt;
            pauseButton.style.fontSize = &amp;quot;80%&amp;quot;;&lt;br /&gt;
            pauseButton.textContent = &amp;quot;Start&amp;quot;&lt;br /&gt;
            pauseButton.addEventListener(&#039;click&#039;, function() {toggleButton(job, item, kundeItem, this.id)});&lt;br /&gt;
            timerList.appendChild(pauseButton);&lt;br /&gt;
        }&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        //----Stop Button mit dynamisch vergebener ID wird ausgegeben----//&lt;br /&gt;
&lt;br /&gt;
        var stopButton = document.createElement(&#039;button&#039;)&lt;br /&gt;
        stopButton.id = i;&lt;br /&gt;
        stopButton.classList.add(&#039;stopButton&#039;);&lt;br /&gt;
        stopButton.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
        stopButton.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;;&lt;br /&gt;
        stopButton.style.left = &amp;quot;94%&amp;quot;;&lt;br /&gt;
        stopButton.style.height = &amp;quot;9.4%&amp;quot;;&lt;br /&gt;
        stopButton.style.width = &amp;quot;3%&amp;quot;;&lt;br /&gt;
        stopButton.style.fontSize = &amp;quot;80%&amp;quot;;&lt;br /&gt;
        stopButton.textContent = &amp;quot;Stop&amp;quot;&lt;br /&gt;
        stopButton.addEventListener(&#039;click&#039;, function() {stopTimer(job, item, kundeItem, index, this.id)});&lt;br /&gt;
        timerList.appendChild(stopButton);&lt;br /&gt;
        &lt;br /&gt;
        //----Für jeden aufgerufenen Timer wird----//&lt;br /&gt;
        //----eine neue Reihe ausgegeben----//&lt;br /&gt;
&lt;br /&gt;
        timerList.appendChild(neueReihe);&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------Sekunden in Uhrzeit umrechnen-------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Rechnet übergebene Sekunden in Stunden,----//&lt;br /&gt;
//----Minuten und Sekunden und gibt diese----//&lt;br /&gt;
//----an aufrufende Funktion zurück----//&lt;br /&gt;
&lt;br /&gt;
function formatElapsedTime(seconds) {&lt;br /&gt;
&lt;br /&gt;
    var minutes = Math.floor(seconds / 60);&lt;br /&gt;
    seconds %= 60;&lt;br /&gt;
    var hours = Math.floor(minutes / 60);&lt;br /&gt;
    minutes %= 60;&lt;br /&gt;
    function addLeadingZero(n) {&lt;br /&gt;
        return (n &amp;lt; 10 ? &#039;0&#039; : &#039;&#039;) + n;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    return addLeadingZero(hours) + &amp;quot;:&amp;quot; + addLeadingZero(minutes) + &amp;quot;:&amp;quot; + addLeadingZero(seconds);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//--------------------------------------------Uhrzeit in Sekunden umrechnen----------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Rechnet übergebene Stunden, Minuten----//&lt;br /&gt;
//----und Sekunden in Sekunden um und----//&lt;br /&gt;
//----gibt diese an aufrufende Funktion zurück----//&lt;br /&gt;
&lt;br /&gt;
function timeInSeconds(zeit) {&lt;br /&gt;
&lt;br /&gt;
    var teile = zeit.split(&#039;:&#039;);&lt;br /&gt;
    var Stunden = parseInt(teile[0], 10);&lt;br /&gt;
    var Minuten = parseInt(teile[1], 10);&lt;br /&gt;
    var Sekunden = parseInt(teile[2], 10);&lt;br /&gt;
    return Stunden * 3600 + Minuten * 60 + Sekunden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//--------------------------------------------------Lightbox-------------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Öffnet eine Lightbox und zeigt Kunde----//&lt;br /&gt;
//----Job, vergangene Zeit und evtl Pausenzeit an----//&lt;br /&gt;
//----und gibt Möglichkeit alle Zeiten anzupassen----//&lt;br /&gt;
&lt;br /&gt;
function openLightbox(job, item, kundeItem, index, id) {&lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&#039;lightbox&#039;).style.display = &#039;block&#039;;&lt;br /&gt;
&lt;br /&gt;
    var dateField = document.getElementById(&amp;quot;date&amp;quot;);&lt;br /&gt;
    dateField.value = datum[id].value;&lt;br /&gt;
    document.getElementById(&amp;quot;uhrzeit&amp;quot;).innerHTML = uhrzeit[id].value;&lt;br /&gt;
&lt;br /&gt;
    var commentTextarea = document.getElementById(&amp;quot;comment&amp;quot;);&lt;br /&gt;
    commentTextarea.value = selectedJob[id].value;&lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&amp;quot;eingPersonZusatz&amp;quot;).innerHTML = selectedKunde[id].value;&lt;br /&gt;
&lt;br /&gt;
    if (selectedID.length !== undefined) {&lt;br /&gt;
        document.getElementById(&amp;quot;eingPerson&amp;quot;).innerHTML = selectedID;&lt;br /&gt;
    }&lt;br /&gt;
    else {&lt;br /&gt;
        document.getElementById(&amp;quot;eingPerson&amp;quot;).innerHTML = selID[id].value;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
    //----Ausgabe der vergangenen Zeit in Lightbox----//&lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&amp;quot;outStd&amp;quot;).value = 00;                      &lt;br /&gt;
    document.getElementById(&amp;quot;outMin&amp;quot;).value = 00;                      &lt;br /&gt;
    document.getElementById(&amp;quot;outSek&amp;quot;).value = 00;                      &lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
    //----Ausgabe der Pausenzeiten in Lightbox----//&lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&amp;quot;outStd2&amp;quot;).value = 00;                      &lt;br /&gt;
    document.getElementById(&amp;quot;outMin2&amp;quot;).value = 00;                      &lt;br /&gt;
    document.getElementById(&amp;quot;outSek2&amp;quot;).value = 00;                           &lt;br /&gt;
    getPauseTime(sys_uid[id].value, id);                                &lt;br /&gt;
                                                                        &lt;br /&gt;
    var secondsPause = pauseTime[id].value;                             &lt;br /&gt;
    secondsPause %= 60;  &lt;br /&gt;
&lt;br /&gt;
    var minutesPause = Math.floor(pauseTime[id].value / 60);            &lt;br /&gt;
    minutesPause %= 60;    &lt;br /&gt;
&lt;br /&gt;
    var hoursPause = Math.floor(minutesPause / 60);                     &lt;br /&gt;
    hoursPause %= 60;   &lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&amp;quot;outStd2&amp;quot;).value = hoursPause;              &lt;br /&gt;
    document.getElementById(&amp;quot;outMin2&amp;quot;).value = minutesPause;       &lt;br /&gt;
&lt;br /&gt;
    if (secondsPause &amp;gt; 0) {&lt;br /&gt;
        document.getElementById(&amp;quot;outSek2&amp;quot;).value = secondsPause;        &lt;br /&gt;
    }                                                                   &lt;br /&gt;
    else {&lt;br /&gt;
        document.getElementById(&amp;quot;outSek2&amp;quot;).value = 0;                   &lt;br /&gt;
    }                                                                   &lt;br /&gt;
&lt;br /&gt;
    var dropdown2 = document.getElementById(&amp;quot;tätigkeit2Dropdown&amp;quot;);&lt;br /&gt;
    &lt;br /&gt;
    job.forEach(function(item, index) {&lt;br /&gt;
        var option = document.createElement(&amp;quot;option&amp;quot;);&lt;br /&gt;
        option.textContent = item;&lt;br /&gt;
        dropdown2.add(option)&lt;br /&gt;
&lt;br /&gt;
        if (item === selectedJob[id].value) {&lt;br /&gt;
            option.selected = true;&lt;br /&gt;
        }&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    document.addEventListener(&#039;keydown&#039;, closeLightboxOnEscape);&lt;br /&gt;
    document.addEventListener(&#039;keydown&#039;, closeLightboxOnF2);&lt;br /&gt;
}  &lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Lightbox schließen----//&lt;br /&gt;
&lt;br /&gt;
function closeLightbox() {&lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&#039;lightbox&#039;).style.display = &#039;none&#039;;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Funktion closeLightbox wird aufgerufen,----//&lt;br /&gt;
//----wenn der Schließen-Button gedrückt wird----//&lt;br /&gt;
&lt;br /&gt;
function closeLightboxOnSchliessen() {&lt;br /&gt;
&lt;br /&gt;
    statusTimer[idStop].value = &#039;Running&#039;;&lt;br /&gt;
    closeLightbox();&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Funktion closeLightbox wird aufgerufen,----//&lt;br /&gt;
//----wenn Escape gedrückt wird----//&lt;br /&gt;
&lt;br /&gt;
function closeLightboxOnEscape() {&lt;br /&gt;
&lt;br /&gt;
    if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        statusTimer[idStop].value = &#039;Running&#039;;&lt;br /&gt;
        closeLightbox();&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Funktion closeLightbox wird aufgerufen,----//&lt;br /&gt;
//----wenn F2 gedrückt wird----//&lt;br /&gt;
&lt;br /&gt;
function closeLightboxOnF2(event) {&lt;br /&gt;
&lt;br /&gt;
    if (event.key === &#039;F2&#039;) {&lt;br /&gt;
        saveBox();&lt;br /&gt;
        closeLightbox();&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Alle Daten in Lightbox werden gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function saveBox() {&lt;br /&gt;
&lt;br /&gt;
    var vergZeit = &#039;&amp;quot;&#039; + time + &#039;&amp;quot;&#039;;&lt;br /&gt;
&lt;br /&gt;
    var teile = time.split(&amp;quot;:&amp;quot;);&lt;br /&gt;
    var stunden = parseInt(teile[0]);&lt;br /&gt;
    var minuten = parseInt(teile[1]);&lt;br /&gt;
    var sekunden = parseInt(teile[2]);&lt;br /&gt;
    time = stunden * 3600 + minuten * 60 + sekunden;&lt;br /&gt;
&lt;br /&gt;
    elapsedTime = time;&lt;br /&gt;
&lt;br /&gt;
    var comment = &#039;&amp;quot;&#039; + document.getElementById(&amp;quot;comment&amp;quot;).value + &#039;&amp;quot;&#039;;&lt;br /&gt;
    &lt;br /&gt;
    var hPEnd = document.getElementById(&amp;quot;outStd2&amp;quot;).value;&lt;br /&gt;
    var mPEnd = document.getElementById(&amp;quot;outMin2&amp;quot;).value;&lt;br /&gt;
    var sPEnd = document.getElementById(&amp;quot;outSek2&amp;quot;).value;&lt;br /&gt;
    var pTime = hPEnd + &amp;quot;:&amp;quot; + mPEnd + &amp;quot;:&amp;quot; + sPEnd;&lt;br /&gt;
    pTime = timeInSeconds(pTime);&lt;br /&gt;
&lt;br /&gt;
    var uid = sys_uid[idStop].value;&lt;br /&gt;
    var month = startTimeValue.getMonth();&lt;br /&gt;
    var day = startTimeValue.getDate();&lt;br /&gt;
    month += 1;&lt;br /&gt;
    var stopTimeValue = &#039;&amp;quot;&#039; + startTimeValue.getFullYear() + &amp;quot;-&amp;quot; + (month &amp;lt; 10 ? &amp;quot;0&amp;quot; + month : month) + &amp;quot;-&amp;quot; + (day &amp;lt; 10 ? &amp;quot;0&amp;quot; + day : day) + &#039;&amp;quot;&#039;;&lt;br /&gt;
    &lt;br /&gt;
    var status = 21;&lt;br /&gt;
&lt;br /&gt;
    timers.splice(idStop, 1);&lt;br /&gt;
    pauseTimers.splice(idStop, 1);&lt;br /&gt;
    pauseTime.splice(idStop, 1);&lt;br /&gt;
    selectedKunde.splice(idStop, 1);&lt;br /&gt;
    selectedJob.splice(idStop, 1);&lt;br /&gt;
    statusTimer[idStop].value = &#039;Running&#039;;&lt;br /&gt;
    sys_uid.splice(idStop, 1);&lt;br /&gt;
&lt;br /&gt;
    if (zaehler &amp;gt; 0) {&lt;br /&gt;
        zaehler -= 1;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    closeLightbox();&lt;br /&gt;
&lt;br /&gt;
    if (timers.length === 0) {&lt;br /&gt;
        clearInterval(timerInterval);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    saveStopTime(stopTimeValue, elapsedTime, pTime, status, uid, vergZeit, comment);&lt;br /&gt;
} &lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Funktion saveBox wird aufgerufen,----//&lt;br /&gt;
//----wenn der Save-Button gedrückt wird----//&lt;br /&gt;
&lt;br /&gt;
document.addEventListener(&#039;DOMContentLoaded&#039;, function() {&lt;br /&gt;
        const saveButton = document.getElementById(&#039;saveButton&#039;);&lt;br /&gt;
        saveButton.addEventListener(&#039;click&#039;, function() {&lt;br /&gt;
            saveBox();&lt;br /&gt;
        })&lt;br /&gt;
});&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
= PHP-Script saveTime.php =&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;php&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
    $user               = 0;&lt;br /&gt;
    $uhrzeit            = 0;&lt;br /&gt;
    $datum              = 0;&lt;br /&gt;
    $endTime            = 0;&lt;br /&gt;
    $endDate            = 0;&lt;br /&gt;
    $status             = 0;&lt;br /&gt;
    $psnr               = 0;&lt;br /&gt;
    $comm               = 0;&lt;br /&gt;
    $jobID              = 0;&lt;br /&gt;
    $pauseTime          = 0;&lt;br /&gt;
    $sysuid             = 0;&lt;br /&gt;
    $stopTimeValue      = 0;&lt;br /&gt;
    $elapsedTime        = 0;&lt;br /&gt;
    $elapsedPauseTime   = 0;&lt;br /&gt;
    $status             = 0;&lt;br /&gt;
    $uid                = 0;&lt;br /&gt;
    $vergZeit           = 0;&lt;br /&gt;
&lt;br /&gt;
    if ($_SERVER[&#039;REQUEST_METHOD&#039;] === &#039;POST&#039;) &lt;br /&gt;
    {&lt;br /&gt;
        $api_key            = $_POST[&#039;api_key&#039;];&lt;br /&gt;
        $url                = $_POST[&#039;url&#039;];&lt;br /&gt;
        $method             = $_POST[&#039;method&#039;];&lt;br /&gt;
        $choice             = $_POST[&#039;choice&#039;];&lt;br /&gt;
&lt;br /&gt;
        if (isset($_POST[&#039;user&#039;])) {&lt;br /&gt;
            $user               = $_POST[&#039;user&#039;];&lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;uhrzeit&#039;])) {&lt;br /&gt;
            $uhrzeit            = $_POST[&#039;uhrzeit&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;datum&#039;])) {&lt;br /&gt;
            $datum              = $_POST[&#039;datum&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;endTime&#039;])) {&lt;br /&gt;
            $endTime            = $_POST[&#039;endTime&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;endDate&#039;])) {&lt;br /&gt;
            $endDate            = $_POST[&#039;endDate&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;status&#039;])) {&lt;br /&gt;
            $status             = $_POST[&#039;status&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;psnr&#039;])) {&lt;br /&gt;
            $psnr               = $_POST[&#039;psnr&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;Job&#039;])) {&lt;br /&gt;
            $comm               = $_POST[&#039;Job&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;jobID&#039;])) {&lt;br /&gt;
            $jobID              = $_POST[&#039;jobID&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;pauseTime&#039;])) {&lt;br /&gt;
            $pauseTime          = $_POST[&#039;pauseTime&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;sysuid&#039;])) {&lt;br /&gt;
            $sysuid             = $_POST[&#039;sysuid&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;stopTimeValue&#039;])) {&lt;br /&gt;
            $stopTimeValue      = $_POST[&#039;stopTimeValue&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;elapsedTime&#039;])) {&lt;br /&gt;
            $elapsedTime        = $_POST[&#039;elapsedTime&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;elapsedPauseTime&#039;])) {&lt;br /&gt;
            $elapsedPauseTime   = $_POST[&#039;elapsedPauseTime&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;status&#039;])) {&lt;br /&gt;
            $status             = $_POST[&#039;status&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;uid&#039;])) {&lt;br /&gt;
            $uid                = $_POST[&#039;uid&#039;];&lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;vergZeit&#039;])) {&lt;br /&gt;
            $vergZeit           = $_POST[&#039;vergZeit&#039;];&lt;br /&gt;
        }&lt;br /&gt;
&lt;br /&gt;
        $params = array(    &#039;user&#039;              =&amp;gt; $user, &lt;br /&gt;
                            &#039;uhrzeit&#039;           =&amp;gt; $uhrzeit, &lt;br /&gt;
                            &#039;datum&#039;             =&amp;gt; $datum, &lt;br /&gt;
                            &#039;endTime&#039;           =&amp;gt; $endTime, &lt;br /&gt;
                            &#039;endDate&#039;           =&amp;gt; $endDate, &lt;br /&gt;
                            &#039;status&#039;            =&amp;gt; $status, &lt;br /&gt;
                            &#039;psnr&#039;              =&amp;gt; $psnr,&lt;br /&gt;
                            &#039;comm&#039;              =&amp;gt; $comm,&lt;br /&gt;
                            &#039;jobID&#039;             =&amp;gt; $jobID,&lt;br /&gt;
                            &#039;pauseTime&#039;         =&amp;gt; $pauseTime,&lt;br /&gt;
                            &#039;sysuid&#039;            =&amp;gt; $sysuid,&lt;br /&gt;
                            &#039;stopTimeValue&#039;     =&amp;gt; $stopTimeValue,&lt;br /&gt;
                            &#039;elapsedTime&#039;       =&amp;gt; $elapsedTime,&lt;br /&gt;
                            &#039;elapsedPauseTime&#039;  =&amp;gt; $elapsedPauseTime,&lt;br /&gt;
                            &#039;status&#039;            =&amp;gt; $status,&lt;br /&gt;
                            &#039;uid&#039;               =&amp;gt; $uid,&lt;br /&gt;
                            &#039;vergZeit&#039;          =&amp;gt; $vergZeit,&lt;br /&gt;
                            &#039;choice&#039;            =&amp;gt; $choice);&lt;br /&gt;
&lt;br /&gt;
        $curl = curl_init($url);&lt;br /&gt;
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);&lt;br /&gt;
        curl_setopt($curl, CURLOPT_HTTPHEADER, array(&#039;apikey: &#039;.$api_key));&lt;br /&gt;
        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);&lt;br /&gt;
        curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($params));&lt;br /&gt;
        $response = curl_exec($curl);&lt;br /&gt;
&lt;br /&gt;
        if ($response === false) {&lt;br /&gt;
            if($error) {&lt;br /&gt;
                echo &amp;quot;curl_Fehler: &amp;quot;.$error;&lt;br /&gt;
            }&lt;br /&gt;
        } &lt;br /&gt;
        else {&lt;br /&gt;
            echo $response;&lt;br /&gt;
        }&lt;br /&gt;
    }&lt;br /&gt;
?&amp;gt;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
= PHP-Script getData.php =&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;php&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
    $sysuid             = 0;&lt;br /&gt;
    $id                 = 0;&lt;br /&gt;
    $selectedKunde      = 0;&lt;br /&gt;
            &lt;br /&gt;
    if ($_SERVER[&#039;REQUEST_METHOD&#039;] === &#039;POST&#039;) &lt;br /&gt;
    {&lt;br /&gt;
        $api_key        = $_POST[&#039;api_key&#039;];&lt;br /&gt;
        $url            = $_POST[&#039;url&#039;];&lt;br /&gt;
        $method         = $_POST[&#039;method&#039;];&lt;br /&gt;
        $choice         = $_POST[&#039;choice&#039;];&lt;br /&gt;
        &lt;br /&gt;
        if (isset($_POST[&#039;id&#039;])) {&lt;br /&gt;
            $id = $_POST[&#039;id&#039;];&lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;sysuid&#039;])) {&lt;br /&gt;
            $sysuid = $_POST[&#039;sysuid&#039;];&lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;selectedKunde&#039;])) {&lt;br /&gt;
            $selectedKunde  = $_POST[&#039;selectedKunde&#039;];&lt;br /&gt;
        }&lt;br /&gt;
&lt;br /&gt;
        $params = array(    &#039;sysuid&#039;            =&amp;gt; $sysuid,&lt;br /&gt;
                            &#039;id&#039;                =&amp;gt; $id, &lt;br /&gt;
                            &#039;selectedKunde&#039;     =&amp;gt; $selectedKunde,&lt;br /&gt;
                            &#039;choice&#039;            =&amp;gt; $choice);&lt;br /&gt;
        &lt;br /&gt;
        $curl = curl_init($url);&lt;br /&gt;
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);&lt;br /&gt;
        curl_setopt($curl, CURLOPT_HTTPHEADER, array(&#039;apikey: &#039;.$api_key));&lt;br /&gt;
        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);&lt;br /&gt;
        curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($params));&lt;br /&gt;
        $response = curl_exec($curl);&lt;br /&gt;
&lt;br /&gt;
        if ($response === false) {&lt;br /&gt;
            if($error) {&lt;br /&gt;
                echo &amp;quot;curl_Fehler: &amp;quot;.$error;&lt;br /&gt;
            }&lt;br /&gt;
        } &lt;br /&gt;
        else {&lt;br /&gt;
            echo $response;&lt;br /&gt;
        }&lt;br /&gt;
    }&lt;br /&gt;
?&amp;gt;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
= CSS-Datei timestyle.css =&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;css&amp;quot;&amp;gt;&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
/*-----------------------------------------Allgemein-----------------------------------------------*/&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
&lt;br /&gt;
body &lt;br /&gt;
{&lt;br /&gt;
    font-family: &#039;Arial&#039;, sans-serif;&lt;br /&gt;
    display: flex;&lt;br /&gt;
    justify-content: right;&lt;br /&gt;
    align-items: flex-start;&lt;br /&gt;
    height: 100%;&lt;br /&gt;
    background-image: url(&#039;web_background_3.png&#039;);&lt;br /&gt;
    background-repeat: no-repeat;&lt;br /&gt;
    background-size: cover; &lt;br /&gt;
    background-attachment: fixed;&lt;br /&gt;
}&lt;br /&gt;
ul &lt;br /&gt;
{&lt;br /&gt;
    list-style-type: none;&lt;br /&gt;
}&lt;br /&gt;
li &lt;br /&gt;
{&lt;br /&gt;
    padding: 0.5em;&lt;br /&gt;
    cursor: pointer;&lt;br /&gt;
    display: flex; &lt;br /&gt;
    justify-content: space-between;&lt;br /&gt;
    align-items: center;&lt;br /&gt;
    font-size: 0.85em;&lt;br /&gt;
} &lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
/*-------------------------------------------Suche-------------------------------------------------*/&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
&lt;br /&gt;
#search&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 8%;&lt;br /&gt;
    left: 42%;&lt;br /&gt;
    width: 15%;&lt;br /&gt;
    height: 2.8%;&lt;br /&gt;
    resize: none;&lt;br /&gt;
    border: 0.04em solid #e4e4e4;&lt;br /&gt;
    border-radius: 0.1em;&lt;br /&gt;
    background-color: white;&lt;br /&gt;
    font-size: 2em;&lt;br /&gt;
    overflow: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
/*-----------------------------------------Timerausgabe--------------------------------------------*/&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
&lt;br /&gt;
.kundenliste&lt;br /&gt;
{&lt;br /&gt;
    width: 70%;&lt;br /&gt;
    height: 30%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 43.5%;&lt;br /&gt;
    left: 50%;&lt;br /&gt;
    transform: translate(-50%, -50%);&lt;br /&gt;
    border-top-left-radius: 0.2em;&lt;br /&gt;
    border-top-right-radius: 0.2em;&lt;br /&gt;
    overflow: auto;&lt;br /&gt;
    background-color: white;&lt;br /&gt;
    font-size: 0.85vw;&lt;br /&gt;
}&lt;br /&gt;
.tabelleUeberschriften&lt;br /&gt;
{&lt;br /&gt;
    width: 70%;&lt;br /&gt;
    min-height: 3%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 60%;&lt;br /&gt;
    left: 50%;&lt;br /&gt;
    transform: translate(-50%, -50%);&lt;br /&gt;
    /* border-top: 0.14em solid #ccc;&lt;br /&gt;
    border-bottom: 0.14em solid #ccc; */&lt;br /&gt;
    background-color: white;&lt;br /&gt;
    box-shadow: 0 0px 3px rgba(0, 0, 0, 0.5);&lt;br /&gt;
    font-size: 0.9vw;&lt;br /&gt;
}&lt;br /&gt;
#timerList&lt;br /&gt;
{&lt;br /&gt;
    width: 70%;&lt;br /&gt;
    height: 25%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 73.5%;&lt;br /&gt;
    left: 50%;&lt;br /&gt;
    transform: translate(-50%, -50%);&lt;br /&gt;
    overflow: auto;&lt;br /&gt;
    border-bottom-left-radius: 0.2em;&lt;br /&gt;
    border-bottom-right-radius: 0.2em;&lt;br /&gt;
    /* border: 0.14em solid red; */&lt;br /&gt;
    background-color: white;&lt;br /&gt;
    font-size: 0.85vw;&lt;br /&gt;
} &lt;br /&gt;
.kunde&lt;br /&gt;
{&lt;br /&gt;
    width: 30%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    left: 0%;&lt;br /&gt;
}&lt;br /&gt;
.konto&lt;br /&gt;
{&lt;br /&gt;
    width: 30%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    left: 30%;&lt;br /&gt;
}&lt;br /&gt;
.laufzeit&lt;br /&gt;
{&lt;br /&gt;
    width: 15%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    left: 60%;&lt;br /&gt;
}&lt;br /&gt;
.status&lt;br /&gt;
{&lt;br /&gt;
    width: 15%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    left: 75%;&lt;br /&gt;
}&lt;br /&gt;
.buttons&lt;br /&gt;
{&lt;br /&gt;
    width: 10%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    left: 90%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
/*-------------------------------------------Lightbox----------------------------------------------*/&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
&lt;br /&gt;
.lightbox&lt;br /&gt;
{&lt;br /&gt;
    display: none;&lt;br /&gt;
    width: 52%;&lt;br /&gt;
    height: 62%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 61.5%;&lt;br /&gt;
    left: 67%;&lt;br /&gt;
    border-radius: 0.15em;&lt;br /&gt;
    background-color: white;&lt;br /&gt;
    transform: translate(-50%, -50%);&lt;br /&gt;
    flex-direction: column;&lt;br /&gt;
    align-items: center;&lt;br /&gt;
    box-shadow: 0 10px 2000px rgba(0, 0, 0, 0.5);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
/*-----------------------------------------Lightbox-Inhalt-----------------------------------------*/&lt;br /&gt;
&lt;br /&gt;
.commLabel&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 5%;&lt;br /&gt;
    left: 5%;   &lt;br /&gt;
    font-weight: bold;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
}&lt;br /&gt;
.comment&lt;br /&gt;
{&lt;br /&gt;
    width: 90%;&lt;br /&gt;
    height: 38%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 10%;&lt;br /&gt;
    left: 5%;&lt;br /&gt;
    resize: none;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    font-size: 0.9vw;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
.bezZeitkonto&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 55%;&lt;br /&gt;
    left: 5%;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
}&lt;br /&gt;
.dropButtonsLightbox&lt;br /&gt;
{&lt;br /&gt;
    width: 37%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 54%;&lt;br /&gt;
    left: 15%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.bezDatum&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 55%;&lt;br /&gt;
    left: 58%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.date&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 54%;&lt;br /&gt;
    left: 68%;&lt;br /&gt;
    width: 15%;&lt;br /&gt;
    height: 4%;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
}&lt;br /&gt;
.uhrzeit&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 54%;&lt;br /&gt;
    left: 85.5%;&lt;br /&gt;
    width: 10%;&lt;br /&gt;
    height: 3.9%;&lt;br /&gt;
    padding: 0.1em;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
    color: gray;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    resize: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
.bezPerson&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 68%;&lt;br /&gt;
    left: 5%;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
}&lt;br /&gt;
.eingPerson&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 68%;&lt;br /&gt;
    left: 15%;&lt;br /&gt;
    width: 10%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    resize: none;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
    color: gray;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
}&lt;br /&gt;
.eingPersonZusatz&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 68%;&lt;br /&gt;
    left: 26%;&lt;br /&gt;
    width: 25.7%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    overflow: auto;&lt;br /&gt;
    resize: none;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
    color: gray;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
}&lt;br /&gt;
.Std&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 63%;&lt;br /&gt;
    left: 66%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.Min&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 63%;&lt;br /&gt;
    left: 78%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.Sek&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 63%;&lt;br /&gt;
    left: 91%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.outStd&lt;br /&gt;
{&lt;br /&gt;
    width: 6%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 68%;&lt;br /&gt;
    left: 64%;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.outMin&lt;br /&gt;
{&lt;br /&gt;
    width: 6%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 68%;&lt;br /&gt;
    left: 76.5%;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.outSek&lt;br /&gt;
{&lt;br /&gt;
    width: 6%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 68%;&lt;br /&gt;
    left: 89%;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
.Pausenzeiten&lt;br /&gt;
{&lt;br /&gt;
    position: absolute;&lt;br /&gt;
    top: 79%;&lt;br /&gt;
    left: 71%;&lt;br /&gt;
    font-weight: bold;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
}&lt;br /&gt;
.Std2&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 84%;&lt;br /&gt;
    left: 66%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.Min2&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 84%;&lt;br /&gt;
    left: 78%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.Sek2&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 84%;&lt;br /&gt;
    left: 91%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.outStd2&lt;br /&gt;
{&lt;br /&gt;
    width: 6%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 89%;&lt;br /&gt;
    left: 64%;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.outMin2&lt;br /&gt;
{&lt;br /&gt;
    width: 6%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 89%;&lt;br /&gt;
    left: 76.5%;&lt;br /&gt;
    border: 0.1em  solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.outSek2&lt;br /&gt;
{&lt;br /&gt;
    width: 6%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 89%;&lt;br /&gt;
    left: 89%;&lt;br /&gt;
    border: 0.1em  solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.saveButton&lt;br /&gt;
{&lt;br /&gt;
    min-width: 8%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 89%;&lt;br /&gt;
    left: 5%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.closeButton&lt;br /&gt;
{&lt;br /&gt;
    min-width: 8%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 89%;&lt;br /&gt;
    left: 14%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
/*--------------------------------Tätigkeitenliste (zweite Liste)----------------------------------*/&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
&lt;br /&gt;
#zweiteListe &lt;br /&gt;
{&lt;br /&gt;
    display: none;&lt;br /&gt;
    width: 25%;&lt;br /&gt;
    max-height: 50%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 47%;&lt;br /&gt;
    left: 44%;&lt;br /&gt;
    padding: 1%;&lt;br /&gt;
    overflow: auto;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    transform: translate(-50%, -50%);&lt;br /&gt;
    background-color: #FAFAFA;&lt;br /&gt;
    box-shadow: 0 10px 2000px rgba(0, 0, 0, 0.5);&lt;br /&gt;
    font-size: 0.85vw;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel4&amp;diff=64806</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel4</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel4&amp;diff=64806"/>
		<updated>2026-09-09T12:31:45Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
= Beispiel 4 – Pfad-Parameter (REST-Routing über Templates) =&lt;br /&gt;
&lt;br /&gt;
Dieses Beispiel zeigt das &#039;&#039;&#039;Pfad-Template-Routing&#039;&#039;&#039; mit Platzhaltern. Eine&lt;br /&gt;
Auftrags-Ressource wird über drei Endpunkte abgebildet:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Methode + Pfad !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;GET /orders&amp;lt;/code&amp;gt; || Liste aller offenen Aufträge&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;GET /orders/{uid}&amp;lt;/code&amp;gt; || Einzelner Auftrag per UID&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;PUT /orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || Status eines Auftrags-Moduls ändern&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Platzhalter &amp;lt;code&amp;gt;{uid}&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;{code}&amp;lt;/code&amp;gt; stehen im Skript als&lt;br /&gt;
&amp;lt;code&amp;gt;oReader.Path(&#039;uid&#039;)&amp;lt;/code&amp;gt; bzw. &amp;lt;code&amp;gt;oReader.Path(&#039;code&#039;)&amp;lt;/code&amp;gt; zur&lt;br /&gt;
Verfügung.&lt;br /&gt;
&lt;br /&gt;
== Einrichtung in OBS ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; auf &amp;lt;code&amp;gt;0.0.0.0:443&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Orders-Client&#039;&#039; mit API-Key, Zugriff auf die drei Endpunkte.&lt;br /&gt;
* &#039;&#039;&#039;Endpunkte&#039;&#039;&#039; (je ein Eintrag in &amp;lt;code&amp;gt;RESTSRV_ENDPOINTS&amp;lt;/code&amp;gt;, alle Profil &#039;&#039;Public-API&#039;&#039;):&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Endpunkt !! Pfad-Template !! Skript-Methode&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;Get&amp;lt;/code&amp;gt; (Liste)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;Get&amp;lt;/code&amp;gt; (Einzel)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;Put&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Endpunkt-Skript 1: &amp;lt;code&amp;gt;GET /orders&amp;lt;/code&amp;gt; (Liste) ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
// Hilfsfunktion: die FELDER eines Auftrags. Der Helfer bekommt den Writer und&lt;br /&gt;
// schreibt in das Objekt, das der Aufrufer schon geoeffnet hat - so laesst sich&lt;br /&gt;
// derselbe Auftrag einmal als Listeneintrag und einmal als ganze Antwort&lt;br /&gt;
// ausgeben, ohne den Code zu verdoppeln.&lt;br /&gt;
procedure _OrderFelder(oWriter: TxRestWriter; qOrder: TqSQL;&lt;br /&gt;
                       const cOffset: string);&lt;br /&gt;
begin&lt;br /&gt;
    oWriter.Str (&#039;uid&#039;   , qOrder.A2UID());&lt;br /&gt;
    oWriter.Str (&#039;nr&#039;    , qOrder.A2C(&#039;au_nr&#039;));&lt;br /&gt;
    oWriter.Str (&#039;kunde&#039; , qOrder.A2C(&#039;au_kunde&#039;));&lt;br /&gt;
    oWriter.Str (&#039;status&#039;, qOrder.A2C(&#039;au_status&#039;));&lt;br /&gt;
    // ISO-8601 mit Offset - siehe den Hinweis unter der Liste.&lt;br /&gt;
    oWriter.StrN(&#039;aenderung&#039;, RestIsoFromDate(qOrder.A2D(&#039;au_aend_dat&#039;), cOffset));&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cSql   : string;&lt;br /&gt;
    qData  : TqSQL;&lt;br /&gt;
    cOffset: string;&lt;br /&gt;
begin&lt;br /&gt;
    cOffset := oReader.Offset();&lt;br /&gt;
    // Die Antwort ist eine nackte Liste - RootArr vor dem ersten Eintrag.&lt;br /&gt;
    // Findet die Abfrage nichts, ist es eine leere Liste und kein leeres&lt;br /&gt;
    // Objekt: deshalb steht der Aufruf VOR der Abfrage.&lt;br /&gt;
    oWriter.RootArr();&lt;br /&gt;
&lt;br /&gt;
    cSql := &#039;SELECT * FROM auftraege&#039; +&lt;br /&gt;
            &#039; WHERE au_status &amp;lt;&amp;gt; &#039; + DB_SQLVal(&#039;9&#039;) +&lt;br /&gt;
            &#039; ORDER BY au_nr&#039;;&lt;br /&gt;
    if (DB_SOpen(oDB, cSql, qData)) then begin&lt;br /&gt;
        while (not qData.EoF) do begin&lt;br /&gt;
            oWriter.ObjBegin(&#039;&#039;);&lt;br /&gt;
                _OrderFelder(oWriter, qData, cOffset);&lt;br /&gt;
            oWriter.ObjEnd;&lt;br /&gt;
            qData.Next();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(qData);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Endpunkt-Skript 2: &amp;lt;code&amp;gt;GET /orders/{uid}&amp;lt;/code&amp;gt; (Einzel) ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
// Derselbe Helfer wie im Listen-Skript - hier schreibt er in die WURZEL, weil&lt;br /&gt;
// der Auftrag selbst die Antwort ist. Eine eigene Fehlerfunktion braucht es&lt;br /&gt;
// nicht mehr: oWriter.Error setzt Statuscode, code, message, uid und traceId.&lt;br /&gt;
// Derselbe Helfer wie im Listen-Skript - hier schreibt er in die WURZEL,&lt;br /&gt;
// weil der Auftrag selbst die Antwort ist.&lt;br /&gt;
procedure _OrderFelder(oWriter: TxRestWriter; qOrder: TqSQL;&lt;br /&gt;
                       const cOffset: string);&lt;br /&gt;
begin&lt;br /&gt;
    oWriter.Str (&#039;uid&#039;   , qOrder.A2UID());&lt;br /&gt;
    oWriter.Str (&#039;nr&#039;    , qOrder.A2C(&#039;au_nr&#039;));&lt;br /&gt;
    oWriter.Str (&#039;kunde&#039; , qOrder.A2C(&#039;au_kunde&#039;));&lt;br /&gt;
    oWriter.Str (&#039;status&#039;, qOrder.A2C(&#039;au_status&#039;));&lt;br /&gt;
    oWriter.StrN(&#039;aenderung&#039;, RestIsoFromDate(qOrder.A2D(&#039;au_aend_dat&#039;), cOffset));&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUid     : string;&lt;br /&gt;
    cSql     : string;&lt;br /&gt;
    qData    : TqSQL;&lt;br /&gt;
    lGefunden: Boolean;&lt;br /&gt;
    cOffset  : string;&lt;br /&gt;
begin&lt;br /&gt;
    // Pfad-Parameter aus /orders/{uid}&lt;br /&gt;
    cUid    := oReader.Path(&#039;uid&#039;);&lt;br /&gt;
    cOffset := oReader.Offset();&lt;br /&gt;
    if (cUid = &#039;&#039;) then begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Pfad-Parameter &amp;quot;uid&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    lGefunden := false;&lt;br /&gt;
    cSql := &#039;SELECT * FROM auftraege WHERE sys_uid = &#039; + DB_SQLVal(cUid);&lt;br /&gt;
    if (DB_SOpen(oDB, cSql, qData)) then begin&lt;br /&gt;
        if (not qData.EoF) then begin&lt;br /&gt;
            _OrderFelder(oWriter, qData, cOffset);&lt;br /&gt;
            lGefunden := true;&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(qData);&lt;br /&gt;
&lt;br /&gt;
    if (not lGefunden) then begin&lt;br /&gt;
        oWriter.Error(404, &#039;NOT_FOUND&#039;, &#039;Auftrag nicht gefunden&#039;);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Endpunkt-Skript 3: &amp;lt;code&amp;gt;PUT /orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUid   : string;&lt;br /&gt;
    cCode  : string;&lt;br /&gt;
    cStatus: string;&lt;br /&gt;
    cSql   : string;&lt;br /&gt;
    qChk   : TqSQL;&lt;br /&gt;
    xMod   : TqSQL;&lt;br /&gt;
    cModUid: string;&lt;br /&gt;
    lOk    : Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    // Beide Pfad-Parameter&lt;br /&gt;
    cUid  := oReader.Path(&#039;uid&#039;);&lt;br /&gt;
    cCode := oReader.Path(&#039;code&#039;);&lt;br /&gt;
    if (cUid = &#039;&#039;) or (cCode = &#039;&#039;) then begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Pfad-Parameter &amp;quot;uid&amp;quot; oder &amp;quot;code&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Nutzdaten kommen aus dem JSON-Body. Der Rueckgabewert sagt, ob das Feld&lt;br /&gt;
    // ueberhaupt gesendet wurde - eine eigene Hilfsfunktion dafuer braucht es&lt;br /&gt;
    // nicht mehr.&lt;br /&gt;
    cStatus := &#039;&#039;;&lt;br /&gt;
    oReader.Str(&#039;status&#039;, cStatus);&lt;br /&gt;
    if (cStatus = &#039;&#039;) then begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Feld &amp;quot;status&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Existenzpruefung - und zugleich der Schreib-Index. Ein SELECT auf die&lt;br /&gt;
    // sys_uid liefert beides; ein &#039;SELECT *&#039; waere hier verschenkte Arbeit.&lt;br /&gt;
    cSql := &#039;SELECT sys_uid FROM auftrag_module&#039; +&lt;br /&gt;
            &#039; WHERE am_auftrag = &#039; + DB_SQLVal(cUid) +&lt;br /&gt;
            &#039; AND am_code = &#039; + DB_SQLVal(cCode) + &#039; LIMIT 1&#039;;&lt;br /&gt;
    cModUid := &#039;&#039;;&lt;br /&gt;
    if (DB_SOpen(oDB, cSql, qChk)) then begin&lt;br /&gt;
        if (not qChk.EoF) then begin&lt;br /&gt;
            cModUid := qChk.A2UID();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(qChk);&lt;br /&gt;
&lt;br /&gt;
    if (cModUid = &#039;&#039;) then begin&lt;br /&gt;
        oWriter.Error(404, &#039;NOT_FOUND&#039;, &#039;Modul nicht gefunden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Update&lt;br /&gt;
    // Der Satz wird ueber seine sys_uid geschrieben - qSqlRead waere hier&lt;br /&gt;
    // ein zusaetzlicher Lesezugriff, und ein unveraenderter Wert wuerde gar&lt;br /&gt;
    // kein Write erzeugen (SaveData liefert dann False).&lt;br /&gt;
    xMod := qSqlInit(oDB, &#039;auftrag_module&#039;);&lt;br /&gt;
    xMod.qSet(&#039;sys_uid&#039;  , cModUid);&lt;br /&gt;
    xMod.qSet(&#039;am_status&#039;, cStatus);&lt;br /&gt;
    // Rueckgabewert auswerten - ein Schreibfehler wirft keine Exception&lt;br /&gt;
    lOk := xMod.SaveData(UPDATE_RECORD);&lt;br /&gt;
    qSqlFree(xMod);&lt;br /&gt;
&lt;br /&gt;
    if (not lOk) then begin&lt;br /&gt;
        oWriter.Error(500, &#039;INTERNAL_ERROR&#039;, &#039;Status konnte nicht gespeichert werden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    oWriter.Str(&#039;status&#039;, &#039;ok&#039;);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Test mit curl ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Liste&lt;br /&gt;
curl -H &amp;quot;apikey: GEHEIM123&amp;quot; https://api.meinserver.de/orders&lt;br /&gt;
&lt;br /&gt;
# Einzelner Auftrag (uid als Pfad-Parameter)&lt;br /&gt;
curl -H &amp;quot;apikey: GEHEIM123&amp;quot; https://api.meinserver.de/orders/4711&lt;br /&gt;
&lt;br /&gt;
# Modul-Status aendern (uid + code als Pfad-Parameter, status im Body)&lt;br /&gt;
curl -X PUT \&lt;br /&gt;
     -H &amp;quot;apikey: GEHEIM123&amp;quot; \&lt;br /&gt;
     -H &amp;quot;Content-Type: application/json&amp;quot; \&lt;br /&gt;
     -d &#039;{&amp;quot;status&amp;quot;:&amp;quot;21&amp;quot;}&#039; \&lt;br /&gt;
     https://api.meinserver.de/orders/4711/modules/A1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Was dieses Beispiel zeigt ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Eine Ressource, mehrere Pfad-Formen&#039;&#039;&#039; über getrennte Endpunkt-Einträge mit eigenem Skript und eigener Berechtigung.&lt;br /&gt;
* &#039;&#039;&#039;Pfad-Parameter&#039;&#039;&#039; werden über &amp;lt;code&amp;gt;{name}&amp;lt;/code&amp;gt; im Template erfasst und im Skript mit &amp;lt;code&amp;gt;oReader.Path(&#039;name&#039;)&amp;lt;/code&amp;gt; gelesen.&lt;br /&gt;
* Reihenfolge der Skript-Parameter: &#039;&#039;&#039;&amp;lt;code&amp;gt;oReader&amp;lt;/code&amp;gt; zuerst, &amp;lt;code&amp;gt;oWriter&amp;lt;/code&amp;gt; danach&#039;&#039;&#039;.&lt;br /&gt;
* &#039;&#039;&#039;Ein Helfer bekommt den Writer&#039;&#039;&#039; und schreibt an der Stelle hinein, an der er gerufen wird. Derselbe &amp;lt;code&amp;gt;_OrderFelder&amp;lt;/code&amp;gt; bedient den Listeneintrag und die Einzelantwort - einmal in einem &amp;lt;code&amp;gt;ObjBegin(&#039;&#039;)&amp;lt;/code&amp;gt;, einmal in der Wurzel.&lt;br /&gt;
* Präzedenz: ein statisches Segment (z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders/summary&amp;lt;/code&amp;gt;) hätte Vorrang vor &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Siehe auch ==&lt;br /&gt;
&lt;br /&gt;
* [[/OBS/Kostenpflichtige_Module/RESTServer/Endpunkte|Endpunkte]]&lt;br /&gt;
* [[/OBS/Kostenpflichtige_Module/RESTServer/Scripting|Scripting]]&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel3&amp;diff=64805</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel3</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel3&amp;diff=64805"/>
		<updated>2026-09-09T12:31:37Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 3: Datensatz anlegen mit JSON-Body=&lt;br /&gt;
&lt;br /&gt;
Dieses Beispiel zeigt einen Endpunkt mit allen vier CRUD-Zugriffen (GET, POST, PUT und - anstelle von DELETE - PATCH, siehe den Hinweis beim Schliessen). Es geht um Tickets eines externen Servicedesks, der Tickets ueber den OBS REST-Server in OBS einliefern, abrufen, aktualisieren und schliessen kann.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Dieses Beispiel adressiert ein einzelnes Ticket über einen Query-Parameter (&#039;&#039;?id...&#039;&#039;). Alternativ lässt sich dieselbe Ressource über ein Pfad-Template wie &#039;&#039;/tickets/{id}&#039;&#039; ansprechen; die ID steht dann als &#039;&#039;oReader.Path(&#039;id&#039;)&#039;&#039; bereit. Ein vollständiges Beispiel dazu zeigt [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4 - Pfad-Parameter]].}}&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039;, Bindung 0.0.0.0:443&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Servicedesk-X&#039;&#039;&lt;br /&gt;
** API-Key: zufaellig generiert&lt;br /&gt;
** Host: &#039;&#039;servicedesk.kunde.de&#039;&#039; (DNS-gebunden, IP-Zugriff wird abgelehnt)&lt;br /&gt;
** JWT: nicht aktiv&lt;br /&gt;
* &#039;&#039;&#039;Endpunkt:&#039;&#039;&#039; &#039;&#039;tickets/v1&#039;&#039;, Profil &#039;&#039;Public-API&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Servicedesk-X&#039;&#039; fuer Endpunkt &#039;&#039;tickets/v1&#039;&#039; freigeschaltet&lt;br /&gt;
&lt;br /&gt;
==Endpunkt-Skript &#039;&#039;tickets/v1&#039;&#039;==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// Hilfsprozedur: die FELDER eines Tickets. Sie bekommt den Writer und schreibt&lt;br /&gt;
// in das Objekt, das der Aufrufer geoeffnet hat - einmal als Listeneintrag,&lt;br /&gt;
// einmal in die Wurzel. Ein Helfer, der ein Fragment als String zurueckgibt,&lt;br /&gt;
// waere der alte Weg; dabei ging die Reihenfolge im Zusammenbau verloren.&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
procedure _TicketFelder(oWriter: TxRestWriter; qTicket: TxFQuery;&lt;br /&gt;
                        const cOffset: string);&lt;br /&gt;
begin&lt;br /&gt;
    oWriter.Str (&#039;id&#039;     , qTicket.A2UID());&lt;br /&gt;
    oWriter.Str (&#039;nr&#039;     , qTicket.A2C(&#039;ti_nr&#039;));&lt;br /&gt;
    oWriter.Str (&#039;betreff&#039;, qTicket.A2C(&#039;ti_betreff&#039;));&lt;br /&gt;
    oWriter.Str (&#039;status&#039; , qTicket.A2C(&#039;ti_status&#039;));&lt;br /&gt;
    oWriter.Str (&#039;beschr&#039; , qTicket.A2C(&#039;ti_beschr&#039;));&lt;br /&gt;
    // Zeitpunkte gehen als ISO-8601 MIT OFFSET ueber die Leitung, nicht im&lt;br /&gt;
    // SQL-Format: DTToSQL liefert &#039;JJJJ-MM-TT hh:mm:ss&#039; ohne Zeitzone, und der&lt;br /&gt;
    // Konsument muss dann raten, in welcher er steht. RestIsoFromDate nimmt den&lt;br /&gt;
    // Offset des Servers dazu. Ein leeres Datum ergibt null, nicht 1899.&lt;br /&gt;
    oWriter.StrN(&#039;aenderung&#039;, RestIsoFromDate(qTicket.A2D(&#039;ti_aend_dat&#039;), cOffset));&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
// Fuer den Body-Zugriff und fuer Fehlerantworten braucht es keine eigenen&lt;br /&gt;
// Hilfsfunktionen mehr: oReader liest typisiert, oWriter.Error baut die&lt;br /&gt;
// vollstaendige Fehlerhuelle samt traceId.&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// GET   /tickets/v1?id=...   - einzelnes Ticket&lt;br /&gt;
// GET   /tickets/v1          - alle offenen Tickets&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cSql     : string;&lt;br /&gt;
    qData    : TxFQuery;&lt;br /&gt;
    cId      : string;&lt;br /&gt;
    cOffset  : string;&lt;br /&gt;
    lGefunden: Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    cId     := oReader.Param(&#039;id&#039;);&lt;br /&gt;
    // Der Offset des Servers geht in jeden Zeitstempel der Antwort.&lt;br /&gt;
    cOffset := oReader.Offset();&lt;br /&gt;
&lt;br /&gt;
    if (not Empty(cId)) then begin&lt;br /&gt;
        lGefunden := false;&lt;br /&gt;
        cSql := &#039;SELECT * FROM tickets WHERE sys_uid = &#039; + DB_SQLVal(cId);&lt;br /&gt;
        if (DB_SOpen(oDB, cSql, qData)) then begin&lt;br /&gt;
            // EoF gehoert dazu: DB_SOpen sagt nur, dass die ABFRAGE lief.&lt;br /&gt;
            if (not qData.EoF) then begin&lt;br /&gt;
                _TicketFelder(oWriter, qData, cOffset);&lt;br /&gt;
                lGefunden := true;&lt;br /&gt;
            end;&lt;br /&gt;
        end;&lt;br /&gt;
        DB_Close(qData);&lt;br /&gt;
&lt;br /&gt;
        if (not lGefunden) then begin&lt;br /&gt;
            oWriter.Error(404, &#039;NOT_FOUND&#039;, &#039;Ticket nicht gefunden&#039;);&lt;br /&gt;
        end;&lt;br /&gt;
    end else begin&lt;br /&gt;
        // Die Liste ist eine nackte Liste. RootArr steht vor der Abfrage:&lt;br /&gt;
        // kein Treffer heisst leere Liste, nicht leeres Objekt.&lt;br /&gt;
        oWriter.RootArr();&lt;br /&gt;
        cSql := &#039;SELECT * FROM tickets WHERE ti_status &amp;lt;&amp;gt; &amp;quot;9&amp;quot; ORDER BY ti_aend_dat DESC&#039;;&lt;br /&gt;
        if (DB_SOpen(oDB, cSql, qData)) then begin&lt;br /&gt;
            while (not qData.EoF) do begin&lt;br /&gt;
                oWriter.ObjBegin(&#039;&#039;);&lt;br /&gt;
                    _TicketFelder(oWriter, qData, cOffset);&lt;br /&gt;
                oWriter.ObjEnd;&lt;br /&gt;
                qData.Next();&lt;br /&gt;
            end;&lt;br /&gt;
        end;&lt;br /&gt;
        DB_Close(qData);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// POST  /tickets/v1          - neues Ticket anlegen&lt;br /&gt;
// Body: { &amp;quot;betreff&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;beschr&amp;quot;:&amp;quot;...&amp;quot; }&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var xTicket : TqSQL;&lt;br /&gt;
    cId     : string;&lt;br /&gt;
    cBetreff: string;&lt;br /&gt;
    cBeschr : string;&lt;br /&gt;
    lOk     : Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    // HasBody unterscheidet &amp;quot;kein Koerper&amp;quot; von &amp;quot;Koerper ohne das Feld&amp;quot; - das&lt;br /&gt;
    // erste ist ein 400, das zweite ein 422.&lt;br /&gt;
    if (not oReader.HasBody()) then begin&lt;br /&gt;
        oWriter.Error(400, &#039;BAD_REQUEST&#039;, &#039;JSON-Body erforderlich&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    cBetreff := &#039;&#039;;&lt;br /&gt;
    cBeschr  := &#039;&#039;;&lt;br /&gt;
    oReader.Str(&#039;betreff&#039;, cBetreff);&lt;br /&gt;
    oReader.Str(&#039;beschr&#039; , cBeschr);&lt;br /&gt;
&lt;br /&gt;
    if (Empty(cBetreff)) then begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Feld &amp;quot;betreff&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    cId := GetNewId(oDB);&lt;br /&gt;
&lt;br /&gt;
    xTicket := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
    xTicket.lNoSysUID := True;&lt;br /&gt;
    try&lt;br /&gt;
        xTicket.qSet(&#039;sys_uid&#039;    , cId);&lt;br /&gt;
        xTicket.qSet(&#039;ti_nr&#039;      , DB_NeuNum(oDB, &#039;tickets&#039;, &#039;ti_nr&#039;, NEUNUM_HOLE, &#039;&#039;, &#039;&#039;, 1, 999999, &#039;0&#039;));&lt;br /&gt;
        xTicket.qSet(&#039;ti_betreff&#039; , cBetreff);&lt;br /&gt;
        xTicket.qSet(&#039;ti_beschr&#039;  , cBeschr);&lt;br /&gt;
        xTicket.qSet(&#039;ti_status&#039;  , &#039;1&#039;);&lt;br /&gt;
        xTicket.qSet(&#039;ti_anl_dat&#039; , Now());&lt;br /&gt;
        xTicket.qSet(&#039;ti_aend_dat&#039;, Now());&lt;br /&gt;
        // SaveData liefert einen Boolean. Ein SQL-Fehler - etwa Duplicate entry&lt;br /&gt;
        // auf einem eindeutigen Index - wirft KEINE Exception und steht in&lt;br /&gt;
        // keinem Protokoll. Ohne diese Auswertung antwortet der Endpunkt mit&lt;br /&gt;
        // 201, ohne etwas angelegt zu haben.&lt;br /&gt;
        lOk := xTicket.SaveData(NEW_RECORD);&lt;br /&gt;
    finally&lt;br /&gt;
        qSqlFree(xTicket);&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    if (not lOk) then begin&lt;br /&gt;
        oWriter.Error(500, &#039;INTERNAL_ERROR&#039;, &#039;Ticket konnte nicht angelegt werden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    oWriter.Str(&#039;id&#039;    , cId);&lt;br /&gt;
    oWriter.Str(&#039;status&#039;, &#039;created&#039;);&lt;br /&gt;
    oWriter.Status(201);&lt;br /&gt;
    oWriter.Header(&#039;Location&#039;, &#039;/tickets/v1?id=&#039; + cId);&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// PUT   /tickets/v1          - Ticket aktualisieren&lt;br /&gt;
// Body: { &amp;quot;id&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;status&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;beschr&amp;quot;:&amp;quot;...&amp;quot; }&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var xTicket: TqSQL;&lt;br /&gt;
    lOk    : Boolean;&lt;br /&gt;
    cId    : string;&lt;br /&gt;
    cStat  : string;&lt;br /&gt;
    cBeschr: string;&lt;br /&gt;
begin&lt;br /&gt;
    if (not oReader.HasBody()) then begin&lt;br /&gt;
        oWriter.Error(400, &#039;BAD_REQUEST&#039;, &#039;JSON-Body erforderlich&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    cId     := &#039;&#039;;&lt;br /&gt;
    cStat   := &#039;&#039;;&lt;br /&gt;
    cBeschr := &#039;&#039;;&lt;br /&gt;
    oReader.Str(&#039;id&#039;    , cId);&lt;br /&gt;
    oReader.Str(&#039;status&#039;, cStat);&lt;br /&gt;
    oReader.Str(&#039;beschr&#039;, cBeschr);&lt;br /&gt;
&lt;br /&gt;
    if (Empty(cId)) then begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Feld &amp;quot;id&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    if (not DB_LSeek(oDB, &#039;tickets&#039;, &#039;sys_uid = &#039; + DB_SQLVal(cId))) then begin&lt;br /&gt;
        oWriter.Error(404, &#039;NOT_FOUND&#039;, &#039;Ticket nicht gefunden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Zum Aendern qSqlInit mit sys_uid, nicht qSqlRead: qSqlRead liest den&lt;br /&gt;
    // Altsatz und schreibt nur Aenderungen - ein unveraenderter Wert erzeugt&lt;br /&gt;
    // gar kein Write und SaveData liefert False.&lt;br /&gt;
    xTicket := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
    xTicket.qSet(&#039;sys_uid&#039;, cId);&lt;br /&gt;
    try&lt;br /&gt;
        if (not Empty(cStat))   then xTicket.qSet(&#039;ti_status&#039;  , cStat);&lt;br /&gt;
        if (not Empty(cBeschr)) then xTicket.qSet(&#039;ti_beschr&#039;  , cBeschr);&lt;br /&gt;
        xTicket.qSet(&#039;ti_aend_dat&#039;, Now());&lt;br /&gt;
        lOk := xTicket.SaveData(UPDATE_RECORD);&lt;br /&gt;
    finally&lt;br /&gt;
        qSqlFree(xTicket);&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    if (not lOk) then begin&lt;br /&gt;
        oWriter.Error(500, &#039;INTERNAL_ERROR&#039;, &#039;Ticket konnte nicht geaendert werden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    oWriter.Str(&#039;status&#039;, &#039;updated&#039;);&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// PATCH /tickets/v1?id=...   - Ticket schliessen (Soft-Delete via Status=9)&lt;br /&gt;
//&lt;br /&gt;
// NICHT als DELETE: &#039;Delete&#039; ist in der Skriptsprache eine Standardprozedur,&lt;br /&gt;
// eine eigene Prozedur dieses Namens laesst sich nicht uebersetzen. Solange das&lt;br /&gt;
// so ist, laeuft ein Loeschen ueber PATCH oder POST. Siehe den Hinweis unten.&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
procedure Patch(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var xTicket: TqSQL;&lt;br /&gt;
    cId    : string;&lt;br /&gt;
    lOk    : Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    cId := oReader.Param(&#039;id&#039;);&lt;br /&gt;
    if (Empty(cId)) then begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Parameter &amp;quot;id&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    if (not DB_LSeek(oDB, &#039;tickets&#039;, &#039;sys_uid = &#039; + DB_SQLVal(cId))) then begin&lt;br /&gt;
        oWriter.Error(404, &#039;NOT_FOUND&#039;, &#039;Ticket nicht gefunden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    xTicket := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
    xTicket.qSet(&#039;sys_uid&#039;, cId);&lt;br /&gt;
    try&lt;br /&gt;
        xTicket.qSet(&#039;ti_status&#039;  , &#039;9&#039;);&lt;br /&gt;
        xTicket.qSet(&#039;ti_aend_dat&#039;, Now());&lt;br /&gt;
        lOk := xTicket.SaveData(UPDATE_RECORD);&lt;br /&gt;
    finally&lt;br /&gt;
        qSqlFree(xTicket);&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    if (not lOk) then begin&lt;br /&gt;
        oWriter.Error(500, &#039;INTERNAL_ERROR&#039;, &#039;Ticket konnte nicht geschlossen werden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    oWriter.Str(&#039;status&#039;, &#039;closed&#039;);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Achtung|&#039;&#039;&#039;Ein DELETE-Endpunkt kann derzeit kein Skript haben.&#039;&#039;&#039; Der Server&lt;br /&gt;
ruft die Prozedur, die genauso heisst wie das HTTP-Verb - fuer DELETE also&lt;br /&gt;
&amp;lt;code&amp;gt;Delete&amp;lt;/code&amp;gt;. Dieser Name ist in der Skriptsprache belegt&lt;br /&gt;
(Standardprozedur zum Loeschen aus einer Zeichenkette), und die Deklaration&lt;br /&gt;
laesst sich nicht uebersetzen; die Meldung nennt dabei nur die Zeile, nicht die&lt;br /&gt;
Ursache. Bis das geloest ist: das Loeschen als &amp;lt;code&amp;gt;PATCH&amp;lt;/code&amp;gt; oder&lt;br /&gt;
&amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt; mit einer Aktion abbilden. Das Abmelden am JWT-Endpunkt ist&lt;br /&gt;
davon nicht betroffen - es laeuft als DELETE &#039;&#039;&#039;ohne&#039;&#039;&#039; Skript vollstaendig in&lt;br /&gt;
der Engine.}}&lt;br /&gt;
&lt;br /&gt;
==Aufruf mit curl==&lt;br /&gt;
&lt;br /&gt;
Liste aller offenen Tickets:&lt;br /&gt;
&lt;br /&gt;
 curl -H &amp;quot;apikey: [API-KEY]&amp;quot; https://api.meinserver.de/tickets/v1&lt;br /&gt;
&lt;br /&gt;
Einzelnes Ticket:&lt;br /&gt;
&lt;br /&gt;
 curl -H &amp;quot;apikey: [API-KEY]&amp;quot; &amp;quot;https://api.meinserver.de/tickets/v1?id=abc-123&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Neues Ticket anlegen:&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;betreff\&amp;quot;:\&amp;quot;Drucker offline\&amp;quot;,\&amp;quot;beschr\&amp;quot;:\&amp;quot;Etage 2, Raum 23\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/tickets/v1&lt;br /&gt;
&lt;br /&gt;
Ticket aktualisieren:&lt;br /&gt;
&lt;br /&gt;
 curl -X PUT -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;id\&amp;quot;:\&amp;quot;abc-123\&amp;quot;,\&amp;quot;status\&amp;quot;:\&amp;quot;2\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/tickets/v1&lt;br /&gt;
&lt;br /&gt;
Ticket schliessen:&lt;br /&gt;
&lt;br /&gt;
 curl -X PATCH -H &amp;quot;apikey: [API-KEY]&amp;quot; &amp;quot;https://api.meinserver.de/tickets/v1?id=abc-123&amp;quot;&lt;br /&gt;
&lt;br /&gt;
==Aufruf aus PHP==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;php&amp;quot; line&amp;gt;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
$apikey = &#039;[API-KEY]&#039;;&lt;br /&gt;
$base   = &#039;https://api.meinserver.de/tickets/v1&#039;;&lt;br /&gt;
&lt;br /&gt;
function rest($method, $url, $apikey, $body = null) {&lt;br /&gt;
    $ch = curl_init();&lt;br /&gt;
    curl_setopt($ch, CURLOPT_URL           , $url);&lt;br /&gt;
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);&lt;br /&gt;
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST , $method);&lt;br /&gt;
    $headers = [&#039;apikey: &#039; . $apikey];&lt;br /&gt;
    if ($body !== null) {&lt;br /&gt;
        $headers[] = &#039;Content-Type: application/json&#039;;&lt;br /&gt;
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));&lt;br /&gt;
    }&lt;br /&gt;
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);&lt;br /&gt;
    $res = curl_exec($ch);&lt;br /&gt;
    curl_close($ch);&lt;br /&gt;
    return json_decode($res, true);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
// Neues Ticket anlegen&lt;br /&gt;
$neu = rest(&#039;POST&#039;, $base, $apikey, [&lt;br /&gt;
    &#039;betreff&#039; =&amp;gt; &#039;Drucker offline&#039;,&lt;br /&gt;
    &#039;beschr&#039;  =&amp;gt; &#039;Etage 2, Raum 23&#039;&lt;br /&gt;
]);&lt;br /&gt;
$id = $neu[&#039;id&#039;];&lt;br /&gt;
&lt;br /&gt;
// Status aktualisieren&lt;br /&gt;
rest(&#039;PUT&#039;, $base, $apikey, [&#039;id&#039; =&amp;gt; $id, &#039;status&#039; =&amp;gt; &#039;2&#039;]);&lt;br /&gt;
&lt;br /&gt;
// Liste auslesen&lt;br /&gt;
$alle = rest(&#039;GET&#039;, $base, $apikey);&lt;br /&gt;
foreach ($alle as $t) {&lt;br /&gt;
    echo $t[&#039;nr&#039;] . &#039; - &#039; . $t[&#039;betreff&#039;] . PHP_EOL;&lt;br /&gt;
}&lt;br /&gt;
?&amp;gt;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Saubere Trennung der CRUD-Methoden in einem Endpunkt.&lt;br /&gt;
* Verwendung des JSON-Body fuer komplexere Eingangsdaten (POST, PUT).&lt;br /&gt;
* Verwendung von Query-Parametern fuer einfache Werte (GET, PATCH).&lt;br /&gt;
* Soft-Delete ueber Status-Aenderung statt physischer Loeschung - und warum er als PATCH und nicht als DELETE laeuft.&lt;br /&gt;
* Eindeutige Audit-UIDs (&#039;&#039;Y00CXXXXxx&#039;&#039;) fuer jeden DB-Zugriff zur Nachvollziehbarkeit.&lt;br /&gt;
* Host-gebundener Zugang als zweite Sicherheitsebene neben dem API-Key.&lt;br /&gt;
* Alternativ: ID per Pfad-Parameter statt Query-Parameter (siehe [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4]]).&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel5&amp;diff=64804</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel5</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel5&amp;diff=64804"/>
		<updated>2026-09-09T12:31:25Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz=&lt;br /&gt;
&lt;br /&gt;
Dieses Beispiel zeigt einen schreibenden Endpunkt für eine mobile App: Aufträge werden mit echten HTTP-Statuscodes aktualisiert, konkurrierende Änderungen über ETag/If-Match abgesichert (Optimistic Concurrency) und doppelte Sendungen über einen Idempotency-Key abgefangen. Die Anmeldung nutzt JWT mit Custom-Claims (Mandant, Rollen) und einem Refresh-Token.&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; (Port 443)&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Mobile-App&#039;&#039;&lt;br /&gt;
** API-Key: zufällig generiert&lt;br /&gt;
** JWT aktiv, JWT-Endpunkt &#039;&#039;auth&#039;&#039;, JWT-Key zufällig, JWT-Exp 60 (Minuten)&lt;br /&gt;
* &#039;&#039;&#039;Endpunkte&#039;&#039;&#039; (beide dem Profil &#039;&#039;Public-API&#039;&#039; zugeordnet):&lt;br /&gt;
** &#039;&#039;orders/{uid}&#039;&#039; - Auftrag lesen/Ändern&lt;br /&gt;
** &#039;&#039;orders/{uid}/material&#039;&#039; - Material erfassen&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Mobile-App&#039;&#039; für beide Endpunkte freigeschaltet&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Für das Abmelden gibt es &#039;&#039;&#039;keinen eigenen Endpunkt&#039;&#039;&#039;. Es läuft über&lt;br /&gt;
ein &#039;&#039;DELETE&#039;&#039; auf den JWT-Endpunkt &#039;&#039;auth&#039;&#039; und braucht daher weder eine Zeile&lt;br /&gt;
in &#039;&#039;RESTSRV_ENDPOINTS&#039;&#039; noch eine Berechtigung.}}&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierungs-Skript (Zugang)==&lt;br /&gt;
&lt;br /&gt;
Stellt Mandant und Rollen als Custom-Claims aus und löst über &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; ein Refresh-Token aus. Bei einem Refresh ruft der Server im selben Skript die Methode &#039;&#039;Refresh&#039;&#039; auf. Die Hilfsfunktionen (&#039;&#039;PasswortPasst&#039;&#039;, &#039;&#039;TechnikerMandant&#039;&#039;, &#039;&#039;TechnikerRollen&#039;&#039;, &#039;&#039;TechnikerAktiv&#039;&#039;, &#039;&#039;AuftragLesen&#039;&#039;, &#039;&#039;AuftragVersion&#039;&#039;, &#039;&#039;AuftragSpeichern&#039;&#039;, &#039;&#039;MaterialAnlegen&#039;&#039;, &#039;&#039;ArtikelPreis&#039;&#039;, &#039;&#039;PushRegistrierungLoeschen&#039;&#039;) sind illustrativ und projektabhängig - sie stehen für Ihre Fachlogik, nicht für eine Server-API.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Eine &#039;&#039;&#039;eigene Sperrtabelle für Refresh-jti ist nicht nötig&#039;&#039;&#039;.&lt;br /&gt;
Einmalgebrauch, Rotation und Widerruf führt der Server in &#039;&#039;RESTSRV_TOKEN&#039;&#039; -&lt;br /&gt;
das Skript entscheidet nur, &#039;&#039;&#039;ob&#039;&#039;&#039; und &#039;&#039;&#039;mit welchen Rechten&#039;&#039;&#039; die Sitzung&lt;br /&gt;
fortgesetzt wird.}}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Authenticate(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUser: string;&lt;br /&gt;
    cPass: string;&lt;br /&gt;
begin&lt;br /&gt;
    cUser := &#039;&#039;;&lt;br /&gt;
    cPass := &#039;&#039;;&lt;br /&gt;
    oReader.Str(&#039;username&#039;, cUser);&lt;br /&gt;
    oReader.Str(&#039;password&#039;, cPass);&lt;br /&gt;
&lt;br /&gt;
    if (PasswortPasst(cUser, cPass)) then begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;               , 1);&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));   // z.B. &amp;quot;tech,lead&amp;quot;&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());   // loest das Refresh-Token aus&lt;br /&gt;
        // _OBS_JWT_REFRESH_EXP weggelassen -&amp;gt; Default 90 Tage&lt;br /&gt;
    end else begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;, 9);&lt;br /&gt;
        oWriter.Str(&#039;error&#039; , &#039;Login fehlgeschlagen&#039;);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
procedure Refresh(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUser: string;&lt;br /&gt;
begin&lt;br /&gt;
    cUser := oReader.Subject();&lt;br /&gt;
&lt;br /&gt;
    // Das vorgelegte Refresh-Token hat der Server bereits geprueft und&lt;br /&gt;
    // entwertet. Hier wird nur entschieden, ob die Sitzung fortgesetzt&lt;br /&gt;
    // werden darf - und mit welchen Rechten.&lt;br /&gt;
    if (not TechnikerAktiv(cUser)) then begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;, 9);&lt;br /&gt;
        oWriter.Str(&#039;error&#039; , &#039;Konto ist nicht mehr aktiv, bitte neu anmelden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Mandant und Rollen NEU lesen, nicht aus dem alten Token uebernehmen -&lt;br /&gt;
    // sonst wirkt ein Rechteentzug erst beim naechsten Login.&lt;br /&gt;
    oWriter.Int(&#039;status&#039;               , 1);&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Endpunkt &#039;&#039;orders/{uid}&#039;&#039; - ändern mit ETag / If-Match==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;GET&#039;&#039; liefert den Auftrag samt &#039;&#039;ETag&#039;&#039; (Version), &#039;&#039;PUT&#039;&#039; prüft Rolle und &#039;&#039;If-Match&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
// AuftragLesen bekommt den WRITER und schreibt die Felder des Auftrags in die&lt;br /&gt;
// Wurzel; nVer kommt als var-Parameter zurueck. Vorher wurde ihm das&lt;br /&gt;
// Antwortobjekt hineingegeben - derselbe Gedanke, nur ohne Objekt.&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUid: string;&lt;br /&gt;
    nVer: Integer;&lt;br /&gt;
begin&lt;br /&gt;
    cUid := oReader.Path(&#039;uid&#039;);&lt;br /&gt;
    if (not AuftragLesen(oWriter, oReader.Claim(&#039;tenant&#039;), cUid, nVer)) then begin&lt;br /&gt;
        oWriter.Error(404, &#039;NOT_FOUND&#039;, &#039;Auftrag nicht gefunden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
    oWriter.Header(&#039;ETag&#039;, xStr(nVer));&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUid    : string;&lt;br /&gt;
    nAktuell: Integer;&lt;br /&gt;
    nIfMatch: Integer;&lt;br /&gt;
begin&lt;br /&gt;
    // nur Rolle &amp;quot;lead&amp;quot; darf ändern - Rolle kommt aus dem Token, nicht aus dem Body.&lt;br /&gt;
    // Mit Trennzeichen suchen: ein blosses Pos(&#039;lead&#039;, ...) wuerde auch in&lt;br /&gt;
    // &amp;quot;leadless&amp;quot; oder &amp;quot;teamlead&amp;quot; treffen.&lt;br /&gt;
    if (Pos(&#039;,lead,&#039;, &#039;,&#039; + oReader.Claim(&#039;roles&#039;) + &#039;,&#039;) = 0) then begin&lt;br /&gt;
        oWriter.Error(403, &#039;FORBIDDEN_ROLE&#039;, &#039;Diese Rolle darf nicht aendern&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    cUid     := oReader.Path(&#039;uid&#039;);&lt;br /&gt;
    nAktuell := AuftragVersion(cUid);&lt;br /&gt;
    nIfMatch := iVal(oReader.Param(&#039;if-match&#039;));&lt;br /&gt;
&lt;br /&gt;
    if (nIfMatch &amp;lt;&amp;gt; nAktuell) then begin&lt;br /&gt;
        // Der ETag gehoert AUCH an die Ablehnung - sonst kennt der Client den&lt;br /&gt;
        // gueltigen Wert nicht und sein naechster Versuch scheitert wieder.&lt;br /&gt;
        // Header und Error stoeren sich nicht: Error ersetzt nur den Koerper.&lt;br /&gt;
        oWriter.Header(&#039;ETag&#039;, xStr(nAktuell));&lt;br /&gt;
        oWriter.Error(409, &#039;VERSION_CONFLICT&#039;, &#039;Der Auftrag wurde zwischenzeitlich geaendert&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // AuftragSpeichern gibt den Rueckgabewert von SaveData durch. Ohne&lt;br /&gt;
    // diese Pruefung antwortet der Endpunkt mit 200 und einem neuen ETag,&lt;br /&gt;
    // obwohl nichts geschrieben wurde - siehe Scripting, Schreibfehler&lt;br /&gt;
    // erkennen.&lt;br /&gt;
    if (not AuftragSpeichern(cUid, oReader)) then begin   // setzt Version auf nAktuell + 1&lt;br /&gt;
        oWriter.Error(500, &#039;INTERNAL_ERROR&#039;, &#039;Der Auftrag konnte nicht gespeichert werden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    oWriter.Header(&#039;ETag&#039;, xStr(nAktuell + 1));&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Endpunkt &#039;&#039;orders/{uid}/material&#039;&#039; - idempotentes Anlegen==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;POST&#039;&#039; erfasst &#039;&#039;&#039;mehrere&#039;&#039;&#039; Materialpositionen in einem Aufruf: Validierungsfehler -&amp;gt; 422, erfolgreiches Anlegen -&amp;gt; 201 mit den vergebenen Positionen.&lt;br /&gt;
&lt;br /&gt;
Gegen doppelte Sendungen schickt der Client den Header &#039;&#039;Idempotency-Key&#039;&#039;. &#039;&#039;&#039;Das&lt;br /&gt;
Skript muss dafür nichts tun&#039;&#039;&#039; - der Server erkennt den Header, merkt sich das&lt;br /&gt;
Ergebnis und liefert bei einer Wiederholung mit demselben Schlüssel die&lt;br /&gt;
gespeicherte Antwort zurück, ohne das Skript erneut zu starten (siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]], Abschnitt&lt;br /&gt;
Idempotenz). Das Skript kümmert sich nur um seine Fachlogik:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
// Koerper:  {&amp;quot;positionen&amp;quot;:[{&amp;quot;artikel&amp;quot;:&amp;quot;4711&amp;quot;,&amp;quot;menge&amp;quot;:2},&lt;br /&gt;
//                          {&amp;quot;artikel&amp;quot;:&amp;quot;0815&amp;quot;,&amp;quot;menge&amp;quot;:1}]}&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUid     : string;&lt;br /&gt;
    cPos     : string;&lt;br /&gt;
    cArtikel : string;&lt;br /&gt;
    cNeueUid : string;&lt;br /&gt;
    nMenge   : Integer;&lt;br /&gt;
    nPreis   : Double;&lt;br /&gt;
    nGesamt  : Double;&lt;br /&gt;
    nAnz     : Integer;&lt;br /&gt;
    nI       : Integer;&lt;br /&gt;
begin&lt;br /&gt;
    cUid := oReader.Path(&#039;uid&#039;);&lt;br /&gt;
&lt;br /&gt;
    // 1) Gestalt pruefen. Count liefert 0, wenn das Feld fehlt ODER keine&lt;br /&gt;
    //    Liste ist - IsArr unterscheidet die beiden Faelle. Ein Objekt, wo&lt;br /&gt;
    //    eine Liste erwartet wird, ist etwas anderes als eine leere Liste.&lt;br /&gt;
    nAnz := oReader.Count(&#039;positionen&#039;);&lt;br /&gt;
    if (not oReader.IsArr(&#039;positionen&#039;)) then begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;positionen muss eine Liste sein&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
    if (nAnz = 0) then begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;positionen ist leer&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // 2) ALLE Positionen pruefen, BEVOR die erste gebucht wird. Sonst&lt;br /&gt;
    //    haette ein Fehler in Position 3 zwei gebuchte Zeilen und eine&lt;br /&gt;
    //    Fehlerantwort hinterlassen - der Client wiederholt und bucht die&lt;br /&gt;
    //    ersten zwei erneut.&lt;br /&gt;
    //&lt;br /&gt;
    //    Verschachtelung steht im Pfad: Punkt fuer Felder, eckige Klammern&lt;br /&gt;
    //    fuer Listeneintraege.&lt;br /&gt;
    for nI := 0 to nAnz - 1 do begin&lt;br /&gt;
        cPos     := &#039;positionen[&#039; + IntToStr(nI) + &#039;]&#039;;&lt;br /&gt;
        cArtikel := &#039;&#039;;&lt;br /&gt;
        nMenge   := 0;&lt;br /&gt;
        oReader.Str(cPos + &#039;.artikel&#039;, cArtikel);&lt;br /&gt;
        oReader.Int(cPos + &#039;.menge&#039;  , nMenge);&lt;br /&gt;
        if ((Empty(cArtikel)) or (nMenge &amp;lt;= 0)) then begin&lt;br /&gt;
            oWriter.Error(422, &#039;VALIDATION_FAILED&#039;,&lt;br /&gt;
                          &#039;Position &#039; + IntToStr(nI + 1) + &#039;: artikel und menge sind Pflicht&#039;);&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // 3) Buchen und antworten in EINEM Durchgang. Die Positionsnummern&lt;br /&gt;
    //    entstehen beim Buchen, also schreibt die Schleife sie an der Stelle,&lt;br /&gt;
    //    an die sie gehoeren - gesammelt und am Ende eingesetzt werden muss&lt;br /&gt;
    //    nichts.&lt;br /&gt;
    nGesamt := 0;&lt;br /&gt;
    oWriter.ArrBegin(&#039;positionen&#039;);&lt;br /&gt;
    for nI := 0 to nAnz - 1 do begin&lt;br /&gt;
        cPos     := &#039;positionen[&#039; + IntToStr(nI) + &#039;]&#039;;&lt;br /&gt;
        cArtikel := &#039;&#039;;&lt;br /&gt;
        nMenge   := 0;&lt;br /&gt;
        oReader.Str(cPos + &#039;.artikel&#039;, cArtikel);&lt;br /&gt;
        oReader.Int(cPos + &#039;.menge&#039;  , nMenge);&lt;br /&gt;
&lt;br /&gt;
        cNeueUid := MaterialAnlegen(cUid, cArtikel, nMenge);&lt;br /&gt;
        if (Empty(cNeueUid)) then begin&lt;br /&gt;
            // Der Stapel wird GESCHLOSSEN, bevor der Fehler gesetzt wird:&lt;br /&gt;
            // oWriter.Error verwirft zwar den begonnenen Koerper, aber ein&lt;br /&gt;
            // offener ArrBegin bleibt ein Strukturfehler - dann antwortet der&lt;br /&gt;
            // Server 500 mit &amp;quot;Verschachtelung nicht geschlossen&amp;quot; statt mit&lt;br /&gt;
            // dieser Meldung.&lt;br /&gt;
            oWriter.ArrEnd;&lt;br /&gt;
            oWriter.Error(500, &#039;INTERNAL_ERROR&#039;,&lt;br /&gt;
                          &#039;Position &#039; + IntToStr(nI + 1) + &#039; konnte nicht gebucht werden&#039;);&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        nPreis  := ArtikelPreis(cArtikel) * nMenge;&lt;br /&gt;
        nGesamt := nGesamt + nPreis;&lt;br /&gt;
&lt;br /&gt;
        oWriter.ObjBegin(&#039;&#039;);            // leerer Name = Listeneintrag&lt;br /&gt;
            oWriter.Str(&#039;uid&#039;    , cNeueUid);&lt;br /&gt;
            oWriter.Str(&#039;artikel&#039;, cArtikel);&lt;br /&gt;
            oWriter.Int(&#039;menge&#039;  , nMenge);&lt;br /&gt;
            // Zwei Nachkommastellen, immer mit Dezimalpunkt. Selbst&lt;br /&gt;
            // formatiert stand bei Werten ab 1000 der Tausendertrenner im&lt;br /&gt;
            // JSON - gueltiges JSON, falscher Wert, keine Meldung.&lt;br /&gt;
            oWriter.Num(&#039;preis&#039;  , nPreis, 2);&lt;br /&gt;
        oWriter.ObjEnd;&lt;br /&gt;
    end;&lt;br /&gt;
    oWriter.ArrEnd;&lt;br /&gt;
&lt;br /&gt;
    oWriter.Num(&#039;gesamt&#039;, nGesamt, 2);&lt;br /&gt;
    oWriter.Status(201);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Abmelden - ohne Endpunkt und ohne Skript==&lt;br /&gt;
&lt;br /&gt;
Beim Abmelden soll das Gerät seine Sitzung wirklich verlieren, nicht erst mit dem&lt;br /&gt;
Ablauf des Tokens. Dafür ist &#039;&#039;&#039;nichts einzurichten&#039;&#039;&#039;: ein &#039;&#039;DELETE&#039;&#039; auf den&lt;br /&gt;
JWT-Endpunkt mit dem Access-Token genügt, der Server sperrt die Sitzung in&lt;br /&gt;
&#039;&#039;RESTSRV_TOKEN&#039;&#039; und antwortet 204.&lt;br /&gt;
&lt;br /&gt;
Gesperrt wird die ganze Sitzung, also auch die Access-Token vorheriger&lt;br /&gt;
Erneuerungen. Ein zweiter Aufruf ist unschädlich und bleibt 204. Kann der Server&lt;br /&gt;
die Sitzung nicht sperren, antwortet er &#039;&#039;&#039;503&#039;&#039;&#039; mit &#039;&#039;Retry-After&#039;&#039; - die App&lt;br /&gt;
wiederholt den Abmeldevorgang dann.&lt;br /&gt;
&lt;br /&gt;
Nur wenn beim Abmelden zusätzlich etwas passieren soll - hier: die&lt;br /&gt;
Geräteregistrierung für Push löschen -, braucht es einen eigenen Endpunkt. Er&lt;br /&gt;
erledigt seine Fachlogik und ruft &#039;&#039;oWriter.JwtRevoke&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cAlle : string;&lt;br /&gt;
    lAlle : Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    PushRegistrierungLoeschen(oReader.Claim(&#039;technikerUid&#039;));&lt;br /&gt;
&lt;br /&gt;
    // Optional: {&amp;quot;alleGeraete&amp;quot;: true} meldet den Techniker ueberall ab.&lt;br /&gt;
    // Diese Entscheidung gehoert bewusst in ein Skript - sie braucht eine&lt;br /&gt;
    // Berechtigungspruefung, die der JWT-Endpunkt nicht leisten kann.&lt;br /&gt;
    lAlle := false;&lt;br /&gt;
    oReader.Bool(&#039;alleGeraete&#039;, lAlle);&lt;br /&gt;
    cAlle := IIF(lAlle, &#039;all&#039;, &#039;session&#039;);&lt;br /&gt;
&lt;br /&gt;
    oWriter.JwtRevoke(cAlle);&lt;br /&gt;
    oWriter.Status(204);&lt;br /&gt;
    oWriter.NoBody();&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Test mit curl==&lt;br /&gt;
&lt;br /&gt;
Token holen:&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;username\&amp;quot;:\&amp;quot;tech1\&amp;quot;,\&amp;quot;password\&amp;quot;:\&amp;quot;geheim\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:&amp;quot;eyJ...&amp;quot;,&amp;quot;accessToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&amp;quot;refreshToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:3600,&amp;quot;serverTime&amp;quot;:&amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Bei falschen Zugangsdaten antwortet der Server mit &#039;&#039;&#039;401&#039;&#039;&#039; und dem Text aus dem&lt;br /&gt;
Authenticate-Skript als &#039;&#039;error.message&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Auftrag lesen (liefert den ETag-Header):&lt;br /&gt;
&lt;br /&gt;
 curl -i -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
 ... ETag: 7&lt;br /&gt;
&lt;br /&gt;
ändern mit korrektem If-Match -&amp;gt; 200, mit veraltetem If-Match -&amp;gt; 409:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X PUT -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;If-Match: 7&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;status\&amp;quot;:\&amp;quot;erledigt\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
&lt;br /&gt;
Material idempotent erfassen (zweiter Aufruf mit gleichem Key -&amp;gt; gleiche Antwort, keine Doppelbuchung):&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Idempotency-Key: 9c84-7f2a-...&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;positionen\&amp;quot;:[{\&amp;quot;artikel\&amp;quot;:\&amp;quot;A100\&amp;quot;,\&amp;quot;menge\&amp;quot;:3},{\&amp;quot;artikel\&amp;quot;:\&amp;quot;B200\&amp;quot;,\&amp;quot;menge\&amp;quot;:1}]}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711/material&lt;br /&gt;
&lt;br /&gt;
Die Antwort des zweiten Aufrufs trägt zusätzlich den Header&lt;br /&gt;
&#039;&#039;Idempotent-Replay: true&#039;&#039; - daran ist erkennbar, dass sie aus dem Speicher kam&lt;br /&gt;
und nichts erneut gebucht wurde.&lt;br /&gt;
&lt;br /&gt;
Token erneuern (Refresh-Token im Authorization-Header an denselben JWT-Endpunkt):&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer &amp;lt;refreshToken&amp;gt;&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
Die Antwort enthält ein neues Paar. Das alte Refresh-Token ist damit verbraucht:&lt;br /&gt;
ein zweiter Aufruf mit demselben Token antwortet &#039;&#039;&#039;401&#039;&#039;&#039;. Erfolgt er innerhalb&lt;br /&gt;
von 60 Sekunden, bleibt die Sitzung bestehen (paralleler Refresh der App); später&lt;br /&gt;
gilt er als Wiedervorlage und die &#039;&#039;&#039;ganze Sitzung wird gesperrt&#039;&#039;&#039; - der&lt;br /&gt;
Techniker muss sich neu anmelden, und der Vorfall steht mit IP im Protokoll.&lt;br /&gt;
&lt;br /&gt;
Abmelden - DELETE auf denselben JWT-Endpunkt, Access-Token im Header:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X DELETE -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 204 No Content&lt;br /&gt;
&lt;br /&gt;
Derselbe Token danach noch einmal verwendet:&lt;br /&gt;
&lt;br /&gt;
 curl -i -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 401 Unauthorized&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;error&amp;quot;: {&lt;br /&gt;
    &amp;quot;code&amp;quot;:    &amp;quot;AUTH_EXPIRED&amp;quot;,&lt;br /&gt;
    &amp;quot;uid&amp;quot;:     &amp;quot;Y00G9TOK09&amp;quot;,&lt;br /&gt;
    &amp;quot;message&amp;quot;: &amp;quot;Die Sitzung ist nicht mehr gültig, bitte neu anmelden&amp;quot;,&lt;br /&gt;
    &amp;quot;traceId&amp;quot;: &amp;quot;20260819T091233123-00001A&amp;quot;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Echte HTTP-Statuscodes aus dem Skript: 201, 403, 404, 409, 422, 500.&lt;br /&gt;
* Eine &#039;&#039;&#039;Liste aus dem Körper lesen&#039;&#039;&#039; über &#039;&#039;oReader.Count&#039;&#039; und Pfade wie &#039;&#039;positionen[0].artikel&#039;&#039; - ohne Cast, ohne &#039;&#039;.Items[i]&#039;&#039;.&lt;br /&gt;
* Eine &#039;&#039;&#039;benannte Liste von Objekten schreiben&#039;&#039;&#039; (&#039;&#039;ArrBegin&#039;&#039; / &#039;&#039;ObjBegin(&#039;&#039;)&#039;&#039; / &#039;&#039;ArrEnd&#039;&#039;) und &#039;&#039;&#039;Kommazahlen&#039;&#039;&#039; über &#039;&#039;oWriter.Num(name, wert, 2)&#039;&#039;.&lt;br /&gt;
* &#039;&#039;&#039;Erst prüfen, dann schreiben&#039;&#039;&#039;: Alle Positionen werden validiert, bevor die erste gebucht wird - sonst hinterlässt ein Fehler in Position 3 zwei gebuchte Zeilen und eine Fehlerantwort.&lt;br /&gt;
* &#039;&#039;&#039;Den Stapel schließen, bevor ein Fehler gesetzt wird&#039;&#039;&#039; - ein offener &#039;&#039;ArrBegin&#039;&#039; ist auch bei &#039;&#039;oWriter.Error&#039;&#039; ein Strukturfehler.&lt;br /&gt;
* Jeder Schreibzugriff wird auf Erfolg geprüft - ein gescheitertes &#039;&#039;SaveData&#039;&#039; darf nicht als 200 oder 201 beim Client ankommen (siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting#Schreibfehler erkennen|Schreibfehler erkennen]]).&lt;br /&gt;
* Optimistic Concurrency über &#039;&#039;ETag&#039;&#039; (GET) und &#039;&#039;If-Match&#039;&#039; (PUT) -&amp;gt; 409 VERSION_CONFLICT.&lt;br /&gt;
* Idempotente Schreibzugriffe über den &#039;&#039;Idempotency-Key&#039;&#039; - &#039;&#039;&#039;vom Server erledigt&#039;&#039;&#039;, das Skript enthält dafür keine Zeile Code.&lt;br /&gt;
* Rollenprüfung aus dem Token-Claim über &#039;&#039;oReader.Claim(&#039;roles&#039;)&#039;&#039;, nicht aus dem Body.&lt;br /&gt;
* JWT mit Custom-Claims (&#039;&#039;tenant&#039;&#039;/&#039;&#039;roles&#039;&#039;) und Refresh-Token mit Rotation - &#039;&#039;&#039;vom Server erledigt&#039;&#039;&#039;, das Skript führt keine Sperrtabelle.&lt;br /&gt;
* Anmelden, Erneuern und Abmelden über &#039;&#039;&#039;einen&#039;&#039;&#039; Endpunkt - Abmelden als &#039;&#039;DELETE&#039;&#039;, ohne eigene Endpunkt-Zeile und ohne Skript.&lt;br /&gt;
* &#039;&#039;oWriter.JwtRevoke&#039;&#039; für den Fall, dass beim Abmelden zusätzlich Fachlogik laufen soll oder alle Geräte gemeint sind.&lt;br /&gt;
* &#039;&#039;traceId&#039;&#039; im Fehler-Body für die Support-Nachverfolgung (auch als Header &#039;&#039;X-Trace-Id&#039;&#039;).&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Scripting&amp;diff=64803</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Scripting</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Scripting&amp;diff=64803"/>
		<updated>2026-09-09T12:31:08Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Anleitung für Endpunkt-Skripte=&lt;br /&gt;
&lt;br /&gt;
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.&lt;br /&gt;
&lt;br /&gt;
==Sicherheitshinweise==&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Endpunkt-Skripte verarbeiten Daten aus dem Internet. Übergabeparameter dürfen niemals ungeprüft in SQL-Anweisungen eingebaut werden. Werte immer über &#039;&#039;DB_SQLVal&#039;&#039; bzw. Parameter-Bindings absichern, Eingabewerte gegen Whitelists prüfen.}}&lt;br /&gt;
&lt;br /&gt;
* Eingaben gegen erwartete Werte prüfen (z.B. Pflichtfelder, erlaubte Typen, Wertebereiche).&lt;br /&gt;
* Nur das zurückgeben, was der Konsument wirklich braucht - keine internen IDs, keine Sys-Felder, keine Passwörter.&lt;br /&gt;
* Bei Fehlern keine internen Details an den Konsumenten zurückgeben; stattdessen schreibt der Server ohnehin Detail-Einträge in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==Methoden-Signatur==&lt;br /&gt;
&lt;br /&gt;
Pro HTTP-Methode wird im Skript eine gleichnamige &#039;&#039;&#039;Prozedur&#039;&#039;&#039; implementiert. Der Server ruft genau die Prozedur auf, die zur Methode des eingehenden Requests passt.&lt;br /&gt;
&lt;br /&gt;
 procedure Get   (oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
 procedure Post  (oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
 procedure Put   (oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
 procedure Patch (oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
&lt;br /&gt;
Zwei Handles, kein Rückgabewert: &#039;&#039;&#039;oReader&#039;&#039;&#039; ist die Anfrage, &#039;&#039;&#039;oWriter&#039;&#039;&#039; die&lt;br /&gt;
Antwort. Beide erzeugt der Server, beide gehören ihm - im Skript wird nichts&lt;br /&gt;
angelegt und nichts freigegeben.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|&#039;&#039;&#039;DELETE hat keine Skript-Methode.&#039;&#039;&#039; &amp;lt;code&amp;gt;Delete&amp;lt;/code&amp;gt; ist in der&lt;br /&gt;
Skriptsprache eine Standardprozedur (Löschen aus einer Zeichenkette); eine eigene&lt;br /&gt;
Prozedur dieses Namens lässt sich nicht übersetzen, und der Fehler nennt nur die&lt;br /&gt;
Zeile der Deklaration. Wer einen DELETE-Endpunkt braucht, legt die Methode unter&lt;br /&gt;
einem anderen Verb ab oder trennt sie in ein eigenes Skript.}}&lt;br /&gt;
&lt;br /&gt;
Fehlt die zur Methode passende Funktion, antwortet der Server mit &#039;&#039;&#039;405 Method&lt;br /&gt;
Not Allowed&#039;&#039;&#039; (&amp;lt;code&amp;gt;METHOD_NOT_ALLOWED&amp;lt;/code&amp;gt;) und nennt im Header&lt;br /&gt;
&amp;lt;code&amp;gt;Allow&amp;lt;/code&amp;gt; die Verben, die dieses Skript tatsächlich anbietet - die Liste&lt;br /&gt;
stammt aus dem kompilierten Skript und kann deshalb nicht veralten:&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 405 Method Not Allowed&lt;br /&gt;
 Allow: GET, PUT&lt;br /&gt;
&lt;br /&gt;
Ein im Vertrag zugesagtes Verb braucht also seine Prozedur. Fehlt sie, ist das an&lt;br /&gt;
der Antwort sofort zu erkennen und &#039;&#039;&#039;kein&#039;&#039;&#039; Serverfehler.&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
==Die Anfrage lesen: oReader==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;oReader&#039;&#039; ist der einzige Zugang zur Anfrage - Query, Header, Formularfelder,&lt;br /&gt;
Pfadwerte, Token-Claims und Körper. Alles davon sind &#039;&#039;&#039;Zeichenketten&#039;&#039;&#039; oder&lt;br /&gt;
werden über einen typisierten Leser geholt.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Was er liefert&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;kundennr&#039;)&amp;lt;/code&amp;gt; || Query-Parameter, POST-Parameter (form-urlencoded) oder HTTP-Header. Getrimmt, leer wenn nicht gesendet&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Path(&#039;uid&#039;)&amp;lt;/code&amp;gt; || Wert eines Platzhalters aus dem Pfad-Template&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Form(&#039;bemerkung&#039;)&amp;lt;/code&amp;gt; || Formularfeld einer &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt;-Übertragung&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Claim(&#039;tenant&#039;)&amp;lt;/code&amp;gt; || Custom-Claim des vorgelegten Tokens&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Subject()&amp;lt;/code&amp;gt; || Subject des Tokens (&#039;&#039;sub&#039;&#039;-Claim)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.TraceId()&amp;lt;/code&amp;gt; || Korrelations-ID des Requests&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Offset()&amp;lt;/code&amp;gt; || UTC-Offset des Servers, z.B. &#039;&#039;+02:00&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.HasBody()&amp;lt;/code&amp;gt; || Wurde überhaupt ein JSON-Körper gesendet?&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Filterung durch den Server:&lt;br /&gt;
&lt;br /&gt;
* Geblockte Header werden nicht durchgereicht: &#039;&#039;authorization&#039;&#039;, &#039;&#039;cookie&#039;&#039;, &#039;&#039;proxy-authorization&#039;&#039;, &#039;&#039;x-forwarded-for&#039;&#039;, &#039;&#039;x-real-ip&#039;&#039;, &#039;&#039;apikey&#039;&#039;, &#039;&#039;api_key&#039;&#039;.&lt;br /&gt;
* Werte, die von aussen kommen, können die reservierten Namen nicht besetzen.&lt;br /&gt;
* Werte werden auf max. 1024 Zeichen begrenzt.&lt;br /&gt;
* Null-Bytes und Steuerzeichen (ausser Tab, CR, LF) werden entfernt.&lt;br /&gt;
&lt;br /&gt;
Zugriff im Skript:&lt;br /&gt;
&lt;br /&gt;
 cKundenNr := oReader.Param(&#039;kundennr&#039;);&lt;br /&gt;
 if (not Empty(cKundenNr)) then begin&lt;br /&gt;
     // ...&lt;br /&gt;
 end;&lt;br /&gt;
&lt;br /&gt;
===Der Körper: Zugriff über Pfade===&lt;br /&gt;
&lt;br /&gt;
Den JSON-Körper liest &#039;&#039;oReader&#039;&#039; über &#039;&#039;&#039;Pfade&#039;&#039;&#039;. Es gibt keine Unterobjekte im&lt;br /&gt;
Skript, keinen Cast, kein &#039;&#039;.Items[i]&#039;&#039; - und damit auch keine Lebensdauer, um die&lt;br /&gt;
sich jemand kümmern müsste.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Str(&#039;betreff&#039;, cWert)&amp;lt;/code&amp;gt; || Liest den Wert. &#039;&#039;&#039;Rückgabe Boolean&#039;&#039;&#039;: war das Feld da? Fehlt es, bleibt die Variable unverändert&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Int(&#039;menge&#039;, nWert)&amp;lt;/code&amp;gt; || dito, Ganzzahl&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Num(&#039;preis&#039;, nWert)&amp;lt;/code&amp;gt; || dito, Kommazahl. &#039;&#039;&#039;Der Dezimalpunkt aus JSON wird richtig gelesen&#039;&#039;&#039; - kein &#039;&#039;StrTran&#039;&#039; mehr&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Bool(&#039;aktiv&#039;, lWert)&amp;lt;/code&amp;gt; || dito, Boolean&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.NumDef(&#039;rabatt&#039;, nWert, 0)&amp;lt;/code&amp;gt; || wie &#039;&#039;Num&#039;&#039;, mit Vorgabewert&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Has(&#039;feld&#039;)&amp;lt;/code&amp;gt; || Ist das Feld vorhanden? (auch bei &#039;&#039;null&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Count(&#039;zeiten&#039;)&amp;lt;/code&amp;gt; || Anzahl Einträge einer Liste, 0 wenn keine Liste&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.IsArr(&#039;zeiten&#039;)&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;IsObj(&#039;zeiten[0]&#039;)&amp;lt;/code&amp;gt; || Gestalt prüfen&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Keys()&amp;lt;/code&amp;gt; || Alle Feldnamen der obersten Ebene, in Kommas eingefasst&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Verschachtelung steht im Pfad&#039;&#039;&#039; - Punkt für Felder, eckige Klammern für Listen:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cVon : string;&lt;br /&gt;
    nAnz : Integer;&lt;br /&gt;
    nI   : Integer;&lt;br /&gt;
begin&lt;br /&gt;
    nAnz := oReader.Count(&#039;zeiten&#039;);&lt;br /&gt;
    for nI := 0 to nAnz - 1 do begin&lt;br /&gt;
        if (oReader.Str(&#039;zeiten[&#039; + IntToStr(nI) + &#039;].von&#039;, cVon)) then begin&lt;br /&gt;
            // ... cVon verarbeiten ...&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Der Rückgabewert unterscheidet „nicht gesendet&amp;quot; von „leer&lt;br /&gt;
gesendet&amp;quot;.&#039;&#039;&#039; &amp;lt;code&amp;gt;oReader.Str(&#039;feld&#039;, cWert)&amp;lt;/code&amp;gt; liefert bei&lt;br /&gt;
&amp;lt;code&amp;gt;&amp;quot;feld&amp;quot;: null&amp;lt;/code&amp;gt; &#039;&#039;true&#039;&#039; mit leerem Wert und bei fehlendem Feld&lt;br /&gt;
&#039;&#039;false&#039;&#039;. Genau diese Unterscheidung braucht ein PATCH-artiger Aufruf: „Feld&lt;br /&gt;
löschen&amp;quot; und „Feld nicht anfassen&amp;quot; sehen sonst gleich aus.}}&lt;br /&gt;
&lt;br /&gt;
Ist gar kein Körper gesendet worden, liefert jeder Leser &#039;&#039;false&#039;&#039; und&lt;br /&gt;
&#039;&#039;HasBody()&#039;&#039; ist &#039;&#039;false&#039;&#039;. Maximale Body-Grösse: 10 MB.&lt;br /&gt;
&lt;br /&gt;
===Werte des Servers===&lt;br /&gt;
&lt;br /&gt;
Bei aktiver JWT-Authentifizierung stehen die Token-Claims über den Reader zur&lt;br /&gt;
Verfügung. Sie tragen intern weiterhin die Namen mit Präfix &#039;&#039;&#039;_OBS_&#039;&#039;&#039; - im&lt;br /&gt;
Skript werden sie aber über die Zugriffe der Tabelle oben gelesen und nicht mehr&lt;br /&gt;
über den Namen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Parameter            !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;_OBS_JWT_ID&#039;)&amp;lt;/code&amp;gt; || JWT-Id (&#039;&#039;jti&#039;&#039;-Claim) - typisch die User-Id. &#039;&#039;&#039;Muss nicht eindeutig sein&#039;&#039;&#039;: die Sitzung führt der Server über eigene Kennungen (siehe unten)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Subject()&amp;lt;/code&amp;gt; || Subject (&#039;&#039;sub&#039;&#039;-Claim) - typisch Benutzername&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;_OBS_JWT_AUDIENCE&#039;)&amp;lt;/code&amp;gt; || Audience (&#039;&#039;aud&#039;&#039;-Claim) - typisch Mandant / Rolle&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Claim(&#039;tenant&#039;)&amp;lt;/code&amp;gt; || Beliebiger Custom-Claim des Tokens, hier &#039;&#039;tenant&#039;&#039;; im Folge-Skript lesbar&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.TraceId()&amp;lt;/code&amp;gt; || Korrelations-ID des Requests (auch als Response-Header X-Trace-Id)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Unabhängig von JWT stehen ausserdem immer zur Verfügung:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Parameter            !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;_OBS_SERVER_TIME&#039;)&amp;lt;/code&amp;gt; || Aktuelle Serverzeit als ISO-8601 &#039;&#039;&#039;mit UTC-Offset&#039;&#039;&#039; (z.B. &#039;&#039;2026-08-17T09:12:33+02:00&#039;&#039;). Nützlich, um Datumswerte in derselben Zeitzone auszuliefern, in der der Server arbeitet, und damit ein Client seinen Uhren-Versatz bestimmen kann&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Offset()&amp;lt;/code&amp;gt; || Nur der Offset daraus, z.B. &#039;&#039;+02:00&#039;&#039;. Er geht in die Zeitfunktionen (siehe [[#Zeitangaben auf der Leitung|Zeitangaben]])&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Param(&#039;_OBS_JWT_EXP_SEC&#039;)&amp;lt;/code&amp;gt; || Laufzeit des Access-Tokens in Sekunden (&#039;&#039;JWT-Exp&#039;&#039; × 60). Damit kann ein Konfigurations-Endpunkt denselben Wert ausliefern, den der Token wirklich hat&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Diese Werte werden vom Server gesetzt und können vom Skript für Berechtigungs- und Mandantenprüfungen verwendet werden.&lt;br /&gt;
&lt;br /&gt;
Zusätzlich trägt jedes Token zwei &#039;&#039;&#039;Sitzungskennungen des Servers&#039;&#039;&#039;, lesbar als&lt;br /&gt;
&#039;&#039;oReader.Claim(&#039;sid&#039;)&#039;&#039; und &#039;&#039;oReader.Claim(&#039;rid&#039;)&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Claim !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| sid || Sitzung. Bleibt über alle Erneuerungen (Refresh) hinweg gleich und ist der Schlüssel für das Abmelden&lt;br /&gt;
|-&lt;br /&gt;
| rid || Zeilenkennung des einzelnen Token-Paares in &#039;&#039;RESTSRV_TOKEN&#039;&#039;. Bei jeder Erneuerung neu&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Beide sind OBS-UIDs (10 Zeichen) und werden vom Server &#039;&#039;&#039;nach&#039;&#039;&#039; den Claims des&lt;br /&gt;
Skripts gesetzt - ein Skript kann sie also auch mit einem eigenen Claim &#039;&#039;sid&#039;&#039;&lt;br /&gt;
nicht überschreiben. Für Fachlogik sind sie nicht gedacht; sie sind nützlich, um eine&lt;br /&gt;
Sitzung im Protokoll wiederzufinden.&lt;br /&gt;
&lt;br /&gt;
===Pfad-Parameter===&lt;br /&gt;
&lt;br /&gt;
Stammt der Endpunkt aus einem Pfad-Template mit Platzhaltern (siehe [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]), stehen die aus den Platzhaltern erfassten Werte über &#039;&#039;oReader.Path&#039;&#039; bereit:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Template !! Zugriff im Skript&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;oReader.Path(&#039;uid&#039;)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;oReader.Path(&#039;uid&#039;)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;oReader.Path(&#039;code&#039;)&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
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]].&lt;br /&gt;
&lt;br /&gt;
===Datei-Uploads===&lt;br /&gt;
&lt;br /&gt;
Ist der Endpunkt für Uploads freigeschaltet (&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;, siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]), nimmt der Server&lt;br /&gt;
hochgeladene Dateien entgegen, legt sie in einem temporären Verzeichnis ab und&lt;br /&gt;
übergibt dem Skript Pfad, Originalname und Content-Type über den Reader. Das&lt;br /&gt;
Skript entscheidet selbst über die weitere Verarbeitung (z.B. DMS-Ablage). Der&lt;br /&gt;
Body wird in diesem Fall &#039;&#039;&#039;nicht&#039;&#039;&#039; als JSON geparst - &#039;&#039;oReader.HasBody()&#039;&#039; ist&lt;br /&gt;
&#039;&#039;false&#039;&#039;. Es gilt nicht das JSON-Body-Limit (10&amp;amp;nbsp;MB), sondern die pro&lt;br /&gt;
Endpunkt konfigurierte Grösse (&#039;&#039;re_upload_size&#039;&#039;, Standard 25&amp;amp;nbsp;MB;&lt;br /&gt;
Überschreitung -&amp;gt; 413).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Wie die Datei übertragen wurde, spielt für das Skript keine Rolle.&#039;&#039;&#039; Beide&lt;br /&gt;
Wege - &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; und der resumable&lt;br /&gt;
&amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;-Upload - liefern dieselben vier Werte. Das&lt;br /&gt;
Übertragungsprotokoll samt Header, Statuscodes und Resume-Verhalten steht in&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]]:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadPath()&amp;lt;/code&amp;gt; || Vollständiger Pfad zur temporären Datei auf dem Server&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadName()&amp;lt;/code&amp;gt; || Originaldateiname&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadType()&amp;lt;/code&amp;gt; || Content-Type der Datei (Default &amp;lt;code&amp;gt;application/octet-stream&amp;lt;/code&amp;gt;, wenn der Client keinen angibt), kleingeschrieben&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.UploadSha()&amp;lt;/code&amp;gt; || SHA-256 der &#039;&#039;&#039;gespeicherten&#039;&#039;&#039; Datei, hex in Kleinbuchstaben. Damit lässt sich eine vom Client mitgesendete Prüfsumme vergleichen - erst dieser Vergleich macht aus ihr eine Zusicherung&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Der &#039;&#039;&#039;Dateiname ist Pflicht&#039;&#039;&#039;. Fehlt er, lehnt der Server den Upload mit&lt;br /&gt;
&#039;&#039;&#039;400 Bad Request&#039;&#039;&#039; ab und das Skript wird nicht ausgeführt - das Skript kann&lt;br /&gt;
sich also darauf verlassen, dass &#039;&#039;UploadPath()&#039;&#039; und &#039;&#039;UploadName()&#039;&#039; gefüllt&lt;br /&gt;
sind, sobald es läuft.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Die temporäre Datei wird nicht automatisch verschoben. Das Skript muss sie an ihren Zielort (DMS, Verzeichnis, ...) übernehmen.}}&lt;br /&gt;
&lt;br /&gt;
Zusatzangaben, die im selben Aufruf mitkommen, liest das Skript mit&lt;br /&gt;
&amp;lt;code&amp;gt;oReader.Form(&#039;&amp;amp;lt;name&amp;amp;gt;&#039;)&amp;lt;/code&amp;gt; - bei &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; sind&lt;br /&gt;
das die Partien ohne &amp;lt;code&amp;gt;filename=&amp;lt;/code&amp;gt;. Ein vollständiges Beispiel zeigt&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6]].&lt;br /&gt;
&lt;br /&gt;
==Die Antwort schreiben: oWriter==&lt;br /&gt;
&lt;br /&gt;
Der Writer schreibt &#039;&#039;&#039;sequentiell&#039;&#039;&#039;: jeder Aufruf hängt ein Feld an, an der&lt;br /&gt;
Stelle, an der er steht. Die äussere Klammer der Antwort setzt der Server. Wird&lt;br /&gt;
nichts geschrieben, antwortet er mit &#039;&#039;{}&#039;&#039;. Content-Type ist&lt;br /&gt;
&#039;&#039;application/json; charset=utf-8&#039;&#039;, der Status 200, sofern das Skript nichts&lt;br /&gt;
anderes setzt.&lt;br /&gt;
&lt;br /&gt;
Einfaches Beispiel:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
begin&lt;br /&gt;
    oWriter.Str(&#039;wert_string&#039;, &#039;123&#039;);&lt;br /&gt;
    oWriter.Int(&#039;wert_int&#039;   , 456);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Ergebnis&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Str(&#039;name&#039;, cWert)&amp;lt;/code&amp;gt; || Zeichenkette, immer maskiert&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.StrN(&#039;name&#039;, cWert)&amp;lt;/code&amp;gt; || wie &#039;&#039;Str&#039;&#039;, aber &#039;&#039;&#039;null&#039;&#039;&#039; statt einer leeren Zeichenkette&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Int(&#039;anz&#039;, nWert)&amp;lt;/code&amp;gt; || Ganzzahl&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Num(&#039;preis&#039;, nWert, 2)&amp;lt;/code&amp;gt; || Kommazahl mit fester Stellenzahl. &#039;&#039;&#039;Immer mit Dezimalpunkt&#039;&#039;&#039;, unabhängig von der Locale des Servers&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Bool(&#039;aktiv&#039;, lWert)&amp;lt;/code&amp;gt; || &#039;&#039;true&#039;&#039; / &#039;&#039;false&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Null(&#039;lat&#039;)&amp;lt;/code&amp;gt; || ausdrücklich &#039;&#039;null&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.ObjBegin(&#039;kunde&#039;)&amp;lt;/code&amp;gt; … &amp;lt;code&amp;gt;oWriter.ObjEnd&amp;lt;/code&amp;gt; || Unterobjekt&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.ArrBegin(&#039;dateien&#039;)&amp;lt;/code&amp;gt; … &amp;lt;code&amp;gt;oWriter.ArrEnd&amp;lt;/code&amp;gt; || Liste&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.RootArr()&amp;lt;/code&amp;gt; || Die &#039;&#039;&#039;Wurzel&#039;&#039;&#039; ist eine Liste statt eines Objekts. Muss vor dem ersten Feld stehen; ein &#039;&#039;ArrEnd&#039;&#039; gibt es dazu nicht&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Raw(&#039;meta&#039;, cJson&#039;)&amp;lt;/code&amp;gt; || Fertiges JSON-Fragment einbetten. Siehe unten&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;In einer Liste hat ein Eintrag keinen Namen&#039;&#039;&#039; - der leere Feldname ist genau&lt;br /&gt;
das:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
oWriter.ArrBegin(&#039;dateien&#039;);&lt;br /&gt;
    while (not q.EoF) do begin&lt;br /&gt;
        oWriter.ObjBegin(&#039;&#039;);&lt;br /&gt;
            oWriter.Str (&#039;uid&#039;      , q.A2C(&#039;f_uid&#039;));&lt;br /&gt;
            oWriter.Int (&#039;bytes&#039;    , q.A2I(&#039;f_bytes&#039;));&lt;br /&gt;
            oWriter.StrN(&#039;kategorie&#039;, AllTrim(q.A2C(&#039;f_kat&#039;)));   // null wenn leer&lt;br /&gt;
        oWriter.ObjEnd;&lt;br /&gt;
        q.Next();&lt;br /&gt;
    end;&lt;br /&gt;
oWriter.ArrEnd;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Ein Name in einer Liste und ein fehlender Name in einem Objekt sind beides&lt;br /&gt;
&#039;&#039;&#039;Strukturfehler&#039;&#039;&#039;. Der Server antwortet dann mit &#039;&#039;&#039;500&#039;&#039;&#039; und nennt die&lt;br /&gt;
Stelle im Protokoll - er liefert kein halbes JSON aus. Dasselbe gilt für einen&lt;br /&gt;
Stapel, der am Ende der Methode noch offen ist.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Ein Helfer bekommt den Writer als Parameter&#039;&#039;&#039; und schreibt an der&lt;br /&gt;
Stelle hinein, an der er aufgerufen wird - er gibt kein Fragment zurück. Damit&lt;br /&gt;
steht die Reihenfolge der Antwort im Code und nicht im Zusammenbau:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;procedure DateienSchreiben(oWriter: TxRestWriter; const cNr: string);&amp;lt;/code&amp;gt;}}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Raw ist der Notausgang und prüft.&#039;&#039;&#039; Er parst das Fragment und bettet den&lt;br /&gt;
&#039;&#039;&#039;Originaltext&#039;&#039;&#039; ein; ungültiges JSON gibt einen Serverfehler, der das Feld&lt;br /&gt;
nennt. Gebraucht wird er für ein gespeichertes, &#039;&#039;&#039;signiertes&#039;&#039;&#039; Dokument:&lt;br /&gt;
Parsen und Neuserialisieren würde die signierte Feldreihenfolge zerstören.&lt;br /&gt;
&lt;br /&gt;
===Zeitangaben auf der Leitung===&lt;br /&gt;
&lt;br /&gt;
Zeitstempel gehen als &#039;&#039;&#039;ISO-8601 mit Offset&#039;&#039;&#039; über die Leitung. Die Wandlung&lt;br /&gt;
gehört nicht ins Skript - dort saß der Fehler „zwei Stunden zu früh&amp;quot; an drei&lt;br /&gt;
Stellen gleichzeitig:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;RestIsoFromDate(dWert, cOffset)&amp;lt;/code&amp;gt; || OBS-Zeitpunkt -&amp;gt; &#039;&#039;2026-09-09T14:12:00+02:00&#039;&#039;. Leerer String bei leerem Datum&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;RestDateFromIso(cWert, cOffset)&amp;lt;/code&amp;gt; || ISO-Zeichenkette -&amp;gt; OBS-Zeitpunkt in &#039;&#039;&#039;Ortszeit des Servers&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oReader.Offset()&amp;lt;/code&amp;gt; || Der Offset, der in beide hineingeht&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;RestOffsetToMinutes(cOffset)&amp;lt;/code&amp;gt; || Offset in Minuten, für eigene Rechnungen&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==Antwort steuern: Statuscode, Header, traceId==&lt;br /&gt;
&lt;br /&gt;
Statuscode, Header und das Sperren einer Sitzung laufen über eigene Aufrufe des&lt;br /&gt;
Writers, &#039;&#039;&#039;nicht&#039;&#039;&#039; über Felder im Körper.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Wirkung&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Status(201)&amp;lt;/code&amp;gt; || HTTP-Statuscode (z.B. 201, 204, 400, 409, 422). Ohne Angabe: 200&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Header(&#039;ETag&#039;, &#039;7&#039;)&amp;lt;/code&amp;gt; || Beliebiger Response-Header, z.B. ETag, Location, Retry-After. CR/LF in Namen und Werten werden entfernt (Schutz vor Header-Injection)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.NoBody()&amp;lt;/code&amp;gt; || Kein Körper - für &#039;&#039;&#039;204&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.JwtRevoke(&#039;session&#039;)&amp;lt;/code&amp;gt; || Sperrt die Sitzung des vorgelegten Tokens (&#039;&#039;session&#039;&#039;) oder alle Sitzungen des Subjects (&#039;&#039;all&#039;&#039;). Damit wird ein Endpunkt zum Logout, ohne eigene Verwaltung. Scheitert das Sperren, antwortet der Server mit &#039;&#039;&#039;503&#039;&#039;&#039; statt mit der Antwort des Skripts - siehe [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]], Abschnitt Abmelden&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Feld „menge&amp;quot; fehlt&#039;)&amp;lt;/code&amp;gt; || Fertige Fehlerantwort in der Form des Servers, siehe [[#Fehlerbehandlung im Skript|Fehlerbehandlung]]&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;oWriter.HasError()&amp;lt;/code&amp;gt; || Steht schon eine Fehlerantwort im Writer? Für einen Verteiler, der danach noch etwas anhängen will&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Zusätzlich trägt jede Antwort den Header &#039;&#039;&#039;X-Trace-Id&#039;&#039;&#039; (Korrelations-ID). Dieselbe ID liegt dem Skript als &#039;&#039;oReader.TraceId()&#039;&#039; vor und erscheint in jeder Server-Fehler-Logzeile in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; - so lässt sich ein Fehler ohne Gerätezugriff im Log wiederfinden.&lt;br /&gt;
&lt;br /&gt;
===Beispiel: Anlegen mit 201, Location und ETag===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cNeueUid: string;&lt;br /&gt;
begin&lt;br /&gt;
    // ... Datensatz anlegen, neue UID + Version (ETag) ermitteln ...&lt;br /&gt;
&lt;br /&gt;
    oWriter.Str(&#039;uid&#039;, cNeueUid);&lt;br /&gt;
    oWriter.Status(201);&lt;br /&gt;
    oWriter.Header(&#039;Location&#039;, &#039;/v1/orders/&#039; + cNeueUid);&lt;br /&gt;
    oWriter.Header(&#039;ETag&#039;    , &#039;1&#039;);&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Beispiel: Strukturierter Fehler mit Statuscode und traceId===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var nMenge: Integer;&lt;br /&gt;
begin&lt;br /&gt;
    if (not oReader.Int(&#039;menge&#039;, nMenge)) then begin&lt;br /&gt;
        // Statuscode, code, message, uid und traceId in EINEM Aufruf - dieselbe&lt;br /&gt;
        // Huelle, die auch die Server-eigenen Fehler tragen.&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Feld &amp;quot;menge&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
    // ...&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Beispiel: Optimistic Concurrency (ETag / If-Match)===&lt;br /&gt;
&lt;br /&gt;
Mit Statuscode, Headern und dem lesbaren Header &#039;&#039;If-Match&#039;&#039; führt das Skript je Datensatz einen Versionszähler und lehnt veraltete Schreibzugriffe mit 409 ab:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Put(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var nAktuell: Integer;&lt;br /&gt;
    nIfMatch: Integer;&lt;br /&gt;
begin&lt;br /&gt;
    nAktuell := AuftragVersion(oReader.Path(&#039;uid&#039;));&lt;br /&gt;
    nIfMatch := iVal(oReader.Param(&#039;if-match&#039;));&lt;br /&gt;
&lt;br /&gt;
    if (nIfMatch &amp;lt;&amp;gt; nAktuell) then begin&lt;br /&gt;
        // Der ETag gehoert AUCH an die Ablehnung - sonst kennt der Client den&lt;br /&gt;
        // gueltigen Wert nicht und sein naechster Versuch scheitert wieder.&lt;br /&gt;
        oWriter.Header(&#039;ETag&#039;, xStr(nAktuell));&lt;br /&gt;
        oWriter.Error(409, &#039;VERSION_CONFLICT&#039;, &#039;Der Datensatz wurde zwischenzeitlich geaendert&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // ... speichern, Version hochzählen ...&lt;br /&gt;
    oWriter.Header(&#039;ETag&#039;, xStr(nAktuell + 1));&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Idempotenz - was das Skript beachten muss==&lt;br /&gt;
&lt;br /&gt;
Sendet ein Konsument bei &#039;&#039;POST&#039;&#039;/&#039;&#039;PUT&#039;&#039;/&#039;&#039;PATCH&#039;&#039;/&#039;&#039;DELETE&#039;&#039; den Header&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, fängt der &#039;&#039;&#039;Server&#039;&#039;&#039; doppelte Sendungen ab. Das&lt;br /&gt;
Skript braucht dafür &#039;&#039;&#039;keine eigene Logik&#039;&#039;&#039; - keine Schlüssel-Tabelle, keine&lt;br /&gt;
Prüfung am Anfang der Methode.&lt;br /&gt;
&lt;br /&gt;
Was das Skript wissen muss:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Antwort des Skripts !! Wirkung auf den Idempotenz-Speicher&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;2xx&#039;&#039;&#039; || Statuscode, Body und Header werden gespeichert. Eine Wiederholung mit demselben Schlüssel bekommt genau diese Antwort zurück, das Skript läuft nicht erneut&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;4xx&#039;&#039;&#039; || Der Schlüssel wird &#039;&#039;&#039;freigegeben&#039;&#039;&#039;. Der Konsument darf denselben Schlüssel nach Korrektur des Inhalts erneut verwenden&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;5xx&#039;&#039;&#039; oder Exception || Der Schlüssel bleibt belegt, der Fall wird protokolliert. Eine Wiederholung wird abgelehnt, bis der Fall geklärt ist&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Daraus folgen zwei Regeln für Endpunkt-Skripte:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Fachliche Ablehnungen als 4xx melden&#039;&#039;&#039; (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.&lt;br /&gt;
* &#039;&#039;&#039;Die Antwort muss vollständig sein.&#039;&#039;&#039; Was zurückgegeben wird, wird eingefroren. Ein Feld, das erst der zweite Aufruf ergänzen würde, kommt beim Konsumenten nie an.&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
Der vollständige Ablauf und die Statuscodes stehen unter&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]].&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierungs-Skript==&lt;br /&gt;
&lt;br /&gt;
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 &#039;&#039;Authenticate&#039;&#039; bereitstellen:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Authenticate(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUser: string;&lt;br /&gt;
    cPass: string;&lt;br /&gt;
begin&lt;br /&gt;
    // Eingangsdaten lesen&lt;br /&gt;
    cUser := &#039;&#039;;&lt;br /&gt;
    cPass := &#039;&#039;;&lt;br /&gt;
    oReader.Str(&#039;username&#039;, cUser);&lt;br /&gt;
    oReader.Str(&#039;password&#039;, cPass);&lt;br /&gt;
&lt;br /&gt;
    // Prüfung gegen eigene Tabelle, Hash-Verfahren, LDAP, ...&lt;br /&gt;
    if (PasswortPasst(cUser, cPass)) then begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;           , 1);&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_ID&#039;      , cUser);&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_SUBJECT&#039; , cUser);&lt;br /&gt;
        oWriter.Str(&#039;_OBS_JWT_AUDIENCE&#039;, &#039;mandant1&#039;);&lt;br /&gt;
    end else begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;, 9);&lt;br /&gt;
        oWriter.Str(&#039;error&#039; , &#039;Login fehlgeschlagen&#039;);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Im Anmeldeskript bleiben die _OBS_JWT_*-Felder Felder der&lt;br /&gt;
Antwort&#039;&#039;&#039; - anders als Status und Header, die zu Aufrufen des Writers geworden&lt;br /&gt;
sind. Der Grund: die Token-Ausstellung liest sie aus dem fertigen Körper, und&lt;br /&gt;
genau dort erwartet sie sie. Geschrieben werden sie deshalb wie jedes andere&lt;br /&gt;
Feld, nur eben mit dem Writer.}}&lt;br /&gt;
&lt;br /&gt;
Rückgabewerte:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! status !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| 1      || Erfolgreich, der Server erzeugt aus den &#039;&#039;_OBS_JWT_*&#039;&#039;-Werten einen Token (HS256)&lt;br /&gt;
|-&lt;br /&gt;
| 9      || Misserfolg, der Server antwortet mit &#039;&#039;&#039;401&#039;&#039;&#039; und reicht den Wert von &#039;&#039;error&#039;&#039; als &#039;&#039;error.message&#039;&#039; an den Client durch (zusätzlich protokolliert)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der Text aus &#039;&#039;error&#039;&#039; geht bei &#039;&#039;status&#039;&#039; = 9 &#039;&#039;&#039;an den Konsumenten&#039;&#039;&#039;&lt;br /&gt;
und wird typischerweise direkt unter dem Passwortfeld angezeigt. Er sollte für&lt;br /&gt;
Endanwender verständlich sein und keine internen Details verraten.}}&lt;br /&gt;
&lt;br /&gt;
Liefert das Skript einen anderen Status als 1 oder 9, ist gar nicht lauffähig oder&lt;br /&gt;
gibt kein auswertbares JSON zurück, antwortet der Server mit &#039;&#039;&#039;403&#039;&#039;&#039; und einer&lt;br /&gt;
generischen Meldung - ein defektes Anmeldeskript soll dem Anwender nicht als&lt;br /&gt;
„Passwort falsch&amp;quot; erscheinen.&lt;br /&gt;
&lt;br /&gt;
Kann der Server die &#039;&#039;&#039;Sitzungszeile&#039;&#039;&#039; zum ausgestellten Token nicht schreiben&lt;br /&gt;
(Tabelle fehlt, Datenbankproblem), liefert er &#039;&#039;&#039;kein Token&#039;&#039;&#039; aus und antwortet&lt;br /&gt;
mit &#039;&#039;&#039;503&#039;&#039;&#039; und &#039;&#039;Retry-After&#039;&#039;. Auch das ist bewusst kein 401/403: die&lt;br /&gt;
Zugangsdaten waren richtig, der Versuch darf wiederholt werden.&lt;br /&gt;
&lt;br /&gt;
===Aufbau der Token-Antwort===&lt;br /&gt;
&lt;br /&gt;
Bei Erfolg baut der Server die Antwort aus den Token-Feldern &#039;&#039;&#039;und allen weiteren&lt;br /&gt;
Feldern, die das Skript zurückgibt&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;benutzerId&amp;quot;: &amp;quot;4711&amp;quot;, &amp;quot;tenant&amp;quot;: &amp;quot;nord&amp;quot;, &amp;quot;roles&amp;quot;: [&amp;quot;TECHNIKER&amp;quot;],&lt;br /&gt;
  &amp;quot;token&amp;quot;:       &amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;accessToken&amp;quot;: &amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;refreshToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:   28800,&lt;br /&gt;
  &amp;quot;serverTime&amp;quot;:  &amp;quot;2026-08-17T09:12:33+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld !! Herkunft&lt;br /&gt;
|-&lt;br /&gt;
| token / accessToken || Derselbe Access-Token unter zwei Namen. &#039;&#039;accessToken&#039;&#039; erwarten die meisten Client-Bibliotheken, &#039;&#039;token&#039;&#039; bleibt für bestehende Konsumenten erhalten&lt;br /&gt;
|-&lt;br /&gt;
| refreshToken || Nur wenn das Skript &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; geliefert hat&lt;br /&gt;
|-&lt;br /&gt;
| expiresIn || Laufzeit des Access-Tokens in &#039;&#039;&#039;Sekunden&#039;&#039;&#039; (&#039;&#039;JWT-Exp&#039;&#039; × 60)&lt;br /&gt;
|-&lt;br /&gt;
| serverTime || Serverzeit ISO-8601 mit Offset&lt;br /&gt;
|-&lt;br /&gt;
| alle übrigen Felder || &#039;&#039;&#039;Frei vom Skript bestimmt.&#039;&#039;&#039; Übernommen wird alles ausser &#039;&#039;status&#039;&#039; und den &#039;&#039;_OBS_&#039;&#039;-Steuerfeldern&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Damit kann das Anmeldeskript alles mitliefern, was der Client direkt nach dem Login&lt;br /&gt;
braucht (Benutzer-Id, Mandant, Rollen, Berechtigungen) - ohne dass dieser dafür&lt;br /&gt;
einen zweiten Aufruf absetzen muss. Ein Feld, das genauso heisst wie eines der&lt;br /&gt;
Token-Felder oben, wird verworfen; die Engine setzt diese selbst.&lt;br /&gt;
&lt;br /&gt;
===Custom-Claims, serverTime und Refresh===&lt;br /&gt;
&lt;br /&gt;
Das Authenticate-Skript kann dem Token zusätzlich &#039;&#039;&#039;beliebige Custom-Claims&#039;&#039;&#039; mitgeben - z.B. Mandant und Rollen - und optional ein &#039;&#039;&#039;Refresh-Token&#039;&#039;&#039; anstoßen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! RÜckgabefeld !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_CLAIM_&amp;amp;lt;name&amp;amp;gt; || Beliebiger Custom-Claim, z.B. _OBS_JWT_CLAIM_tenant, _OBS_JWT_CLAIM_roles. Im Folge-Skript lesbar als &amp;lt;code&amp;gt;oReader.Claim(&#039;&amp;amp;lt;name&amp;amp;gt;&#039;)&amp;lt;/code&amp;gt;.&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_REFRESH_ID || (optional) jti des Refresh-Tokens. Nur wenn gesetzt, stellt der Server ein Refresh-Token aus.&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_REFRESH_EXP || (optional) Lebensdauer des Refresh-Tokens in Minuten (Default 90 Tage = 129600).&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Jedes Token trägt automatisch den Claim &#039;&#039;token_use&#039;&#039; (&#039;&#039;access&#039;&#039; bzw. &#039;&#039;refresh&#039;&#039;) sowie die Sitzungskennungen &#039;&#039;sid&#039;&#039; und &#039;&#039;rid&#039;&#039; des Servers. Die Antwort enthält bei Erfolg &#039;&#039;serverTime&#039;&#039; (ISO-8601 mit Offset), bei ausgestelltem Refresh zusätzlich &#039;&#039;refreshToken&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;refreshToken&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;serverTime&amp;quot;:&amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Mandant und Rollen immer aus dem Token lesen (&#039;&#039;oReader.Claim(…)&#039;&#039;), nie aus Body oder Query.}}&lt;br /&gt;
&lt;br /&gt;
====Refresh-Methode====&lt;br /&gt;
&lt;br /&gt;
Der Refresh läuft über &#039;&#039;&#039;denselben JWT-Endpunkt&#039;&#039;&#039; und &#039;&#039;&#039;dasselbe Skript&#039;&#039;&#039;. Liegt am JWT-Endpunkt ein &#039;&#039;Authorization: Bearer &amp;lt;token&amp;gt;&#039;&#039; mit einem Refresh-Token vor, ruft der Server statt &#039;&#039;Authenticate&#039;&#039; die Methode &#039;&#039;&#039;Refresh&#039;&#039;&#039; auf (ohne Bearer: Login; &#039;&#039;DELETE&#039;&#039; mit Access-Token: Abmelden, ohne Skript).&lt;br /&gt;
&lt;br /&gt;
Bevor das Skript läuft, hat der Server das vorgelegte Refresh-Token verifiziert&lt;br /&gt;
(Signatur, Ablauf, &#039;&#039;token_use=refresh&#039;&#039;) und in der Sitzung &#039;&#039;&#039;entwertet&#039;&#039;&#039;.&lt;br /&gt;
Einmalgebrauch, Rotation und Sperrliste sind damit Sache des Servers. Das Skript&lt;br /&gt;
liefert nur noch die aktuellen Claims - genau das ist der Sinn des Aufrufs: eine&lt;br /&gt;
zwischenzeitlich geänderte Rolle oder ein gewechselter Mandant wirken spätestens&lt;br /&gt;
mit der nächsten Erneuerung.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Eine &#039;&#039;&#039;eigene Sperrtabelle im Skript ist nicht mehr nötig&#039;&#039;&#039; und sollte&lt;br /&gt;
entfernt werden. Wer sie weiterführt, rotiert zweimal - einmal im Skript, einmal im&lt;br /&gt;
Server - und riskiert, dass beide Seiten unterschiedlicher Meinung sind. Der&lt;br /&gt;
Server sieht die Skript-Tabelle nicht, und das Skript sieht die Sitzung nicht.}}&lt;br /&gt;
&lt;br /&gt;
Ein bereits benutztes Refresh-Token wird mit &#039;&#039;&#039;401&#039;&#039;&#039; abgelehnt. Erfolgt die&lt;br /&gt;
erneute Vorlage innerhalb von 60 Sekunden, bleibt die Sitzung bestehen (paralleler&lt;br /&gt;
Refresh einer App); danach gilt sie als Wiedervorlage und die &#039;&#039;&#039;gesamte Sitzung&lt;br /&gt;
wird gesperrt&#039;&#039;&#039;. Details in [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
&lt;br /&gt;
Das alte Refresh-jti steht weiterhin als &#039;&#039;oReader.Param(&#039;_OBS_JWT_ID&#039;)&#039;&#039; bereit,&lt;br /&gt;
falls das Skript es für eigene Protokollierung braucht.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Refresh(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUser: string;&lt;br /&gt;
begin&lt;br /&gt;
    cUser := oReader.Subject();&lt;br /&gt;
&lt;br /&gt;
    // Der Server hat das vorgelegte Refresh-Token bereits geprueft und&lt;br /&gt;
    // entwertet. Hier wird nur entschieden, ob der Benutzer die Sitzung&lt;br /&gt;
    // fortsetzen darf - und mit welchen Rechten.&lt;br /&gt;
    if (not BenutzerAktiv(cUser)) then begin&lt;br /&gt;
        oWriter.Int(&#039;status&#039;, 9);&lt;br /&gt;
        oWriter.Str(&#039;error&#039; , &#039;Konto ist nicht mehr aktiv, bitte neu anmelden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Rolle und Mandant neu lesen, nicht aus dem alten Token uebernehmen -&lt;br /&gt;
    // sonst wirkt ein Rechteentzug erst beim naechsten Login.&lt;br /&gt;
    oWriter.Int(&#039;status&#039;               , 1);&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));&lt;br /&gt;
    oWriter.Str(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());   // loest das neue Refresh-Token aus&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Abmelden (Logout)===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Für das Abmelden braucht das Skript keine Methode.&#039;&#039;&#039; Es läuft über ein&lt;br /&gt;
&#039;&#039;DELETE&#039;&#039; auf denselben JWT-Endpunkt (Access-Token im &#039;&#039;Authorization&#039;&#039;-Header)&lt;br /&gt;
und wird komplett in der Engine erledigt: die Sitzung wird gesperrt, die Antwort&lt;br /&gt;
ist 204. Eine Methode &#039;&#039;Logout&#039;&#039; gibt es nicht und wird nicht aufgerufen.&lt;br /&gt;
Details: [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]], Phase 4.&lt;br /&gt;
&lt;br /&gt;
Wer beim Abmelden zusätzlich fachlich etwas tun will - eine Geräteregistrierung&lt;br /&gt;
löschen, einen Push-Token verwerfen, den Vorgang protokollieren - nimmt einen&lt;br /&gt;
&#039;&#039;&#039;gewöhnlichen Endpunkt&#039;&#039;&#039; und setzt dort &#039;&#039;_OBS_JWT_REVOKE&#039;&#039;. Dasselbe gilt für&lt;br /&gt;
&amp;quot;auf allen Geräten abmelden&amp;quot;, weil das eine Berechtigungsentscheidung braucht:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
begin&lt;br /&gt;
    GeraeteRegistrierungLoeschen(oReader.Subject());&lt;br /&gt;
&lt;br /&gt;
    // &#039;session&#039; = diese Sitzung, &#039;all&#039; = alle Sitzungen des Subjects&lt;br /&gt;
    oWriter.JwtRevoke(&#039;session&#039;);&lt;br /&gt;
    oWriter.Status(204);&lt;br /&gt;
    oWriter.NoBody();&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Gesperrt wird in beiden Wegen die &#039;&#039;&#039;ganze Sitzung&#039;&#039;&#039;, also auch die noch nicht&lt;br /&gt;
abgelaufenen Access-Token vorheriger Erneuerungen. Ein wiederholter Aufruf ist&lt;br /&gt;
unschädlich. Lässt sich die Sitzung nicht sperren, überschreibt der Server die&lt;br /&gt;
Antwort des Skripts mit &#039;&#039;&#039;503&#039;&#039;&#039; - ein Client darf nicht glauben, er sei&lt;br /&gt;
abgemeldet, während sein Token weiterläuft.&lt;br /&gt;
&lt;br /&gt;
==Fehlerbehandlung im Skript==&lt;br /&gt;
&lt;br /&gt;
Tritt im Skript eine Exception auf oder schlägt die Syntax-Prüfung fehl, antwortet der Server mit 500 &#039;&#039;Interner Fehler&#039;&#039; und protokolliert die Detail-Meldung in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; (mit Skript-Fehlertext). Der Konsument sieht keine internen Details.&lt;br /&gt;
&lt;br /&gt;
Server-eigene Fehler (Routing, Rate-Limit, Token, Auffangnetz) haben ein&lt;br /&gt;
einheitliches Format mit einem &#039;&#039;error&#039;&#039;-Objekt - Aufbau siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer|Übersicht]].&lt;br /&gt;
&lt;br /&gt;
Für einen spezifischen Fehler an den Konsumenten gibt es &#039;&#039;&#039;einen&#039;&#039;&#039; Aufruf. Er&lt;br /&gt;
baut dieselbe Hülle, die auch die Server-eigenen Fehler tragen - Statuscode,&lt;br /&gt;
&#039;&#039;code&#039;&#039;, &#039;&#039;message&#039;&#039;, &#039;&#039;uid&#039;&#039; und &#039;&#039;traceId&#039;&#039; -, sodass beide Wege nicht&lt;br /&gt;
auseinanderlaufen können:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
begin&lt;br /&gt;
    if (Empty(oReader.Param(&#039;kundennr&#039;))) then begin&lt;br /&gt;
        oWriter.Error(422, &#039;VALIDATION_FAILED&#039;, &#039;Parameter &amp;quot;kundennr&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
    // ...&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Error verwirft einen schon begonnenen Körper&#039;&#039;&#039; und setzt seinen&lt;br /&gt;
eigenen. Ein Skript darf also getrost Felder schreiben und danach noch&lt;br /&gt;
abbrechen - was der Client sieht, ist die Fehlerantwort. Nur ein &#039;&#039;&#039;offener&lt;br /&gt;
Stapel&#039;&#039;&#039; (ein &#039;&#039;ObjBegin&#039;&#039; ohne &#039;&#039;ObjEnd&#039;&#039;) bleibt auch dann ein Strukturfehler.}}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein fachlicher Fehler gehört auf einen &#039;&#039;&#039;4xx&#039;&#039;&#039;-Statuscode, nicht auf&lt;br /&gt;
200 mit Fehlertext im Body. Nur so erkennt der Konsument den Fehlschlag ohne den&lt;br /&gt;
Body auszuwerten - und nur so gibt der Server einen belegten&lt;br /&gt;
&#039;&#039;Idempotency-Key&#039;&#039; wieder frei (siehe Abschnitt Idempotenz).}}&lt;br /&gt;
&lt;br /&gt;
===Ändern: qSqlInit statt qSqlRead===&lt;br /&gt;
&lt;br /&gt;
Zum &#039;&#039;&#039;Ändern&#039;&#039;&#039; eines vorhandenen Satzes wird ebenfalls &amp;lt;code&amp;gt;qSqlInit&amp;lt;/code&amp;gt;&lt;br /&gt;
benutzt - der Satz wird über &amp;lt;code&amp;gt;sys_uid&amp;lt;/code&amp;gt; adressiert:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
q := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
q.qSet(&#039;sys_uid&#039;  , cSysUid);      // Schreib-Index auf den Originalsatz&lt;br /&gt;
q.qSet(&#039;ti_status&#039;, &#039;9&#039;);&lt;br /&gt;
if (not q.SaveData(UPDATE_RECORD)) then begin&lt;br /&gt;
    // ... Fehler behandeln, siehe unten&lt;br /&gt;
end;&lt;br /&gt;
qSqlFree(q);&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&amp;lt;code&amp;gt;qSqlRead&amp;lt;/code&amp;gt; ist dafür die falsche Wahl, aus zwei Gründen:&lt;br /&gt;
&lt;br /&gt;
* Es liest den &#039;&#039;&#039;kompletten Altsatz&#039;&#039;&#039; ein - ein zusätzlicher Lesezugriff, der Zeit kostet.&lt;br /&gt;
* Es schreibt anschliessend nur die &#039;&#039;&#039;Änderungen&#039;&#039;&#039;. Ist der neue Wert gleich dem alten, entsteht gar kein Write, und &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt; liefert &#039;&#039;&#039;False&#039;&#039;&#039; - ununterscheidbar von einem echten Fehlschlag.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ohne &amp;lt;code&amp;gt;sys_uid&amp;lt;/code&amp;gt; legt &amp;lt;code&amp;gt;qSqlInit&amp;lt;/code&amp;gt; einen &#039;&#039;&#039;neuen&#039;&#039;&#039;&lt;br /&gt;
Datensatz an, statt den vorhandenen zu ändern. Die &amp;lt;code&amp;gt;sys_uid&amp;lt;/code&amp;gt; ist der&lt;br /&gt;
Schreib-Index und gehört bei jeder Änderung gesetzt.}}&lt;br /&gt;
&lt;br /&gt;
Damit entfällt auch das übliche Paar aus &amp;lt;code&amp;gt;DB_LSeek&amp;lt;/code&amp;gt; und&lt;br /&gt;
&amp;lt;code&amp;gt;qSqlRead&amp;lt;/code&amp;gt;: &#039;&#039;&#039;eine&#039;&#039;&#039; Abfrage auf die &amp;lt;code&amp;gt;sys_uid&amp;lt;/code&amp;gt; liefert&lt;br /&gt;
zugleich die Existenzprüfung und den Schreib-Index.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
cUid := &#039;&#039;;&lt;br /&gt;
if (DB_SOpen(oDB, &#039;SELECT sys_uid FROM tickets WHERE &#039; + cWhere + &#039; LIMIT 1&#039;, q)) then begin&lt;br /&gt;
    if (not q.EoF) then begin&lt;br /&gt;
        cUid := q.A2UID();&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
DB_Close(q);&lt;br /&gt;
&lt;br /&gt;
if (not Empty(cUid)) then begin&lt;br /&gt;
    // ändern: qSqlInit + sys_uid&lt;br /&gt;
end else begin&lt;br /&gt;
    // anlegen: qSqlInit ohne sys_uid&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Mehrere Felder sind EIN Schreibvorgang===&lt;br /&gt;
&lt;br /&gt;
Ein &amp;lt;code&amp;gt;qSqlInit&amp;lt;/code&amp;gt;-Objekt nimmt beliebig viele &amp;lt;code&amp;gt;qSet&amp;lt;/code&amp;gt; entgegen&lt;br /&gt;
und schreibt sie mit &#039;&#039;&#039;einem&#039;&#039;&#039; &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt;. Fallen an derselben&lt;br /&gt;
Zeile mehrere Felder an, gehören sie in dasselbe Objekt:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
// FALSCH: fünf Felder, fünf Schreibvorgänge - und jeder Setzer, der sich&lt;br /&gt;
// seine sys_uid selbst holt, bringt zusätzlich eine eigene Abfrage mit&lt;br /&gt;
FeldSetzen(cUid, &#039;ti_status&#039; , &#039;9&#039;);&lt;br /&gt;
FeldSetzen(cUid, &#039;ti_grund&#039;  , cGrund);&lt;br /&gt;
FeldSetzen(cUid, &#039;ti_von&#039;    , cBenutzer);&lt;br /&gt;
&lt;br /&gt;
// RICHTIG: ein Schreibobjekt, ein SaveData&lt;br /&gt;
q := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
q.qSet(&#039;sys_uid&#039;  , cUid);&lt;br /&gt;
q.qSet(&#039;ti_status&#039;, &#039;9&#039;);&lt;br /&gt;
q.qSet(&#039;ti_grund&#039; , cGrund);&lt;br /&gt;
q.qSet(&#039;ti_von&#039;   , cBenutzer);&lt;br /&gt;
if (not q.SaveData(UPDATE_RECORD)) then begin&lt;br /&gt;
    // ... Fehler behandeln&lt;br /&gt;
end;&lt;br /&gt;
qSqlFree(q);&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Das ist nicht nur eine Frage der Geschwindigkeit: einzeln geschriebene Felder&lt;br /&gt;
sind &#039;&#039;&#039;nicht atomar&#039;&#039;&#039;. Bricht der dritte Schreibvorgang ab, steht die Zeile&lt;br /&gt;
halb geändert da - Grund gesetzt, Zeitpunkt nicht.&lt;br /&gt;
&lt;br /&gt;
Wird nichts gesetzt, darf auch nicht geschrieben werden: das Schreibobjekt dann&lt;br /&gt;
mit &amp;lt;code&amp;gt;qSqlFree&amp;lt;/code&amp;gt; freigeben, statt ein leeres &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt;&lt;br /&gt;
abzusetzen.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein Setzer für &#039;&#039;&#039;ein&#039;&#039;&#039; Feld (&amp;lt;code&amp;gt;FeldSetzen(cUid, cFeld, cWert)&amp;lt;/code&amp;gt;)&lt;br /&gt;
ist bequem und deshalb verführerisch. Er versteckt aber je Aufruf eine eigene&lt;br /&gt;
Abfrage und ein eigenes &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt;. Für einen Vorgang mit mehreren&lt;br /&gt;
Feldern gehört ein &#039;&#039;&#039;Editier-Helfer&#039;&#039;&#039; her, der das Schreibobjekt liefert -&lt;br /&gt;
geschrieben wird einmal am Ende.}}&lt;br /&gt;
&lt;br /&gt;
===Keine Abfrage je Zeile===&lt;br /&gt;
&lt;br /&gt;
Was in einer Liste je Zeile nachgeschlagen wird, kostet &#039;&#039;&#039;N&#039;&#039;&#039; Abfragen für&lt;br /&gt;
&#039;&#039;&#039;eine&#039;&#039;&#039; Antwort. Bei fünfzig Aufträgen sind das fünfzig Abfragen für ein&lt;br /&gt;
einziges Feld. Der Ausweg ist immer derselbe:&lt;br /&gt;
&lt;br /&gt;
* Werte aus einer 1:1-Beziehung über einen &amp;lt;code&amp;gt;LEFT JOIN&amp;lt;/code&amp;gt; mitnehmen - bei einem &#039;&#039;&#039;eindeutigen&#039;&#039;&#039; Index auf dem Join-Feld kann er die Zeilen nicht vervielfachen.&lt;br /&gt;
* Einzelwerte aus einer 1:n-Beziehung über eine &#039;&#039;&#039;Unterabfrage&#039;&#039;&#039; in der SELECT-Liste holen, nicht über einen Join (der Join würde die Zeilen vervielfachen).&lt;br /&gt;
* Eine echte Unterliste (Positionen, Dateien) nur dann nachladen, wenn ein &#039;&#039;&#039;Zählfeld&#039;&#039;&#039; in der Hauptabfrage sagt, dass es überhaupt eine gibt.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein GET soll &#039;&#039;&#039;lesen&#039;&#039;&#039;. Schreibvorgänge im Lesepfad - etwas&lt;br /&gt;
&amp;quot;beim ersten Ausliefern festschreiben&amp;quot; - vervielfachen sich mit der Zeilenzahl&lt;br /&gt;
und treffen den Aufrufer, der nur eine Liste angefordert hat.}}&lt;br /&gt;
&lt;br /&gt;
===Schreibfehler erkennen===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;&amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt; liefert einen Boolean - und der ist die einzige&lt;br /&gt;
Fehlermeldung, die es gibt.&#039;&#039;&#039; (Auch der &#039;&#039;&#039;Parameter&#039;&#039;&#039; ist ein Boolean:&lt;br /&gt;
&#039;&#039;NEW_RECORD&#039;&#039; und &#039;&#039;UPDATE_RECORD&#039;&#039; sind Wahrheitswerte, keine Zahlen.) Schlägt das Schreiben in der Datenbank fehl&lt;br /&gt;
(&#039;&#039;Duplicate entry&#039;&#039; auf einem eindeutigen Index, zu langer Wert, fehlende&lt;br /&gt;
Spalte), dann&lt;br /&gt;
&lt;br /&gt;
* wird &#039;&#039;&#039;keine&#039;&#039;&#039; Exception ausgelöst,&lt;br /&gt;
* steht &#039;&#039;&#039;nichts&#039;&#039;&#039; in &#039;&#039;RESTSRV_PROTO&#039;&#039;,&lt;br /&gt;
* läuft das Skript weiter und antwortet mit dem Erfolg, den es selbst formuliert.&lt;br /&gt;
&lt;br /&gt;
Wer den Aufruf als Anweisung schreibt, baut damit einen stillen Datenverlust ein.&lt;br /&gt;
Weil in einem Endpunkt schnell ein Dutzend Schreibvorgänge zusammenkommen, gehört&lt;br /&gt;
die Prüfung in eine eigene kleine Prozedur - am besten in die gemeinsame Lib:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Speichern(q: TqSQL; lNeu: Boolean; const cWas: string);&lt;br /&gt;
var lOk: Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    lOk := q.SaveData(lNeu);&lt;br /&gt;
    qSqlFree(q);                     // auch im Fehlerfall freigeben&lt;br /&gt;
    if (not lOk) then begin&lt;br /&gt;
        raise Exception.Create(&#039;Schreiben fehlgeschlagen: &#039; + cWas);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
// Aufruf statt SaveData + qSqlFree:&lt;br /&gt;
q := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
q.qSet(&#039;ti_betreff&#039;, cBetreff);&lt;br /&gt;
Speichern(q, NEW_RECORD, &#039;Ticket&#039;);&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Die Exception fängt der Server: er schreibt &#039;&#039;Schreiben fehlgeschlagen: Ticket&#039;&#039;&lt;br /&gt;
nach &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; und antwortet mit &#039;&#039;&#039;500&#039;&#039;&#039;. Liegt ein&lt;br /&gt;
&#039;&#039;Idempotency-Key&#039;&#039; an, wird er dabei &#039;&#039;&#039;freigegeben&#039;&#039;&#039; - die Anfrage ist also&lt;br /&gt;
wiederholbar (siehe Abschnitt Idempotenz).&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein fehlgeschlagenes Schreiben ist kein Fall, in dem ein Endpunkt&lt;br /&gt;
sinnvoll weiterlaufen kann: die Fachwirkung ist dann unvollständig. Wer trotzdem&lt;br /&gt;
weitermachen will - etwa weil mehrere unabhängige Sätze geschrieben werden -,&lt;br /&gt;
prüft den Rückgabewert und baut selbst eine Antwort. Ignorieren darf man ihn&lt;br /&gt;
nie.}}&lt;br /&gt;
&lt;br /&gt;
Gängige Codes, die auch der Server selbst verwendet: &#039;&#039;VALIDATION_FAILED&#039;&#039; (422),&lt;br /&gt;
&#039;&#039;NOT_FOUND&#039;&#039; (404), &#039;&#039;FORBIDDEN_ROLE&#039;&#039; (403), &#039;&#039;VERSION_CONFLICT&#039;&#039; (409).&lt;br /&gt;
Eigene, fachlich sprechende Codes sind erlaubt - wichtig ist, dass sie stabil&lt;br /&gt;
bleiben, weil Konsumenten darauf ihre Reaktion aufbauen.&lt;br /&gt;
&lt;br /&gt;
==Gemeinsamen Code auslagern==&lt;br /&gt;
&lt;br /&gt;
Wird Logik in mehreren Endpunkten gebraucht - Berechtigungsprüfungen,&lt;br /&gt;
JSON-Bausteine, Hilfsfunktionen -, muss sie nicht in jedes Skript kopiert werden.&lt;br /&gt;
Die Skript-Engine lädt Textbausteine beim Laden aus einer beliebigen Tabelle nach:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{$L T=&amp;quot;restsrv_endpoints&amp;quot; I=&amp;quot;re_pathtemplate&amp;quot; V=&amp;quot;/meinapp/lib&amp;quot; F=&amp;quot;re_script&amp;quot;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Attribut !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;T&#039;&#039;&#039; || Tabelle, aus der geladen wird&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;I&#039;&#039;&#039; || Spalte, über die gesucht wird&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;V&#039;&#039;&#039; || Wert, der in dieser Spalte stehen muss&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;F&#039;&#039;&#039; || Spalte, deren Inhalt an dieser Stelle eingesetzt wird&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Direktive steht üblicherweise direkt unter dem Kopfkommentar des Skripts. Der&lt;br /&gt;
Compiler sieht danach den zusammengesetzten Text - eine Funktion aus der Lib lässt&lt;br /&gt;
sich also aufrufen wie eine im Skript selbst geschriebene.&lt;br /&gt;
&lt;br /&gt;
===Bewährtes Muster: Lib als eigene Endpunkt-Zeile===&lt;br /&gt;
&lt;br /&gt;
Am wenigsten Verwaltung macht es, die Lib &#039;&#039;&#039;als eigene Zeile in&lt;br /&gt;
RESTSRV_ENDPOINTS&#039;&#039;&#039; abzulegen:&lt;br /&gt;
&lt;br /&gt;
# Endpunkt anlegen mit einem eigenen Pfad-Template, z.B. &amp;lt;code&amp;gt;/meinapp/lib&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Den Lib-Quelltext ins Skript-Feld dieser Zeile schreiben (F7).&lt;br /&gt;
# &#039;&#039;&#039;Keine Berechtigung&#039;&#039;&#039; in der Berechtigungs-Liste vergeben - das ist der Schutz: ohne Berechtigung beantwortet der Server einen Aufruf mit 403.&lt;br /&gt;
# In jedem nutzenden Endpunkt die Direktive oben einfügen.&lt;br /&gt;
&lt;br /&gt;
Damit ist die Lib reiner Ablageort und von aussen nicht nutzbar. Eine Änderung&lt;br /&gt;
wirkt auf alle einbindenden Endpunkte, ohne dass ein einziges Endpunkt-Skript&lt;br /&gt;
angefasst werden muss.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Lib und Skripte gemeinsam einspielen.&#039;&#039;&#039; Kommt in der Lib eine neue&lt;br /&gt;
Funktion hinzu, genügt es nicht, nur das nutzende Skript zu aktualisieren. Eine&lt;br /&gt;
ältere Lib lädt fehlerfrei - der Compiler meldet dann ausschliesslich die neuen&lt;br /&gt;
Namen als &#039;&#039;Unknown name&#039;&#039;, was leicht wie ein Fehler im Skript aussieht.}}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Auch hier greift der Endpunkt-Cache: eine Änderung an der Lib wird erst&lt;br /&gt;
nach Ablauf des TTL (bis zu 60 Sekunden) wirksam.}}&lt;br /&gt;
&lt;br /&gt;
===Wohin gehört welcher Code?===&lt;br /&gt;
&lt;br /&gt;
Drei Ebenen, und die Zuordnung entscheidet, wie schnell eine Änderung wirkt:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Art des Codes !! Wohin !! Wirksam&lt;br /&gt;
|-&lt;br /&gt;
| Fachlogik **einer** Anwendung - Feldnamen, Zustandswerte, Rollenregeln, Schreibziele || in die &#039;&#039;&#039;DWS-Lib dieser Anwendung&#039;&#039;&#039; (eigene Endpunkt-Zeile, per &amp;lt;code&amp;gt;{$L …}&amp;lt;/code&amp;gt; eingebunden) || nach Ablauf des Cache-TTL, ohne Build&lt;br /&gt;
|-&lt;br /&gt;
| Mechanik, die &#039;&#039;&#039;mehrere&#039;&#039;&#039; Anwendungen brauchen - und auch das OBS-Umfeld || nach &#039;&#039;&#039;obs_lib&#039;&#039;&#039; als Delphi-Unit || erst nach Build und Rollout&lt;br /&gt;
|-&lt;br /&gt;
| Mechanik, die &#039;&#039;&#039;nur&#039;&#039;&#039; der REST-Server braucht || in die Units des Servers || erst nach Build und Rollout&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Regel hat einen praktischen Grund und keinen ästhetischen: Fachlogik ändert&lt;br /&gt;
sich in Tagen, Mechanik in Monaten. Was in der Lib liegt, ist nach einer Minute&lt;br /&gt;
aktiv; was in einer Unit liegt, braucht einen Build. Wer Fachlogik nach unten&lt;br /&gt;
schiebt, tauscht Geschwindigkeit gegen nichts ein.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Eine Anwendung, die ihre eigene Lib hat, kopiert sie &#039;&#039;&#039;nicht&#039;&#039;&#039; von&lt;br /&gt;
einer anderen. Zwei Kopien derselben Lib driften auseinander, und die Abweichung&lt;br /&gt;
fällt erst auf, wenn eine der beiden Anwendungen etwas Falsches tut. Geteiltes&lt;br /&gt;
gehört eine Ebene tiefer.}}&lt;br /&gt;
&lt;br /&gt;
==Fallstricke im Skript==&lt;br /&gt;
&lt;br /&gt;
Die folgenden Punkte unterscheiden das Skript von normalem Delphi-Code und kosten&lt;br /&gt;
sonst eine Runde Fehlersuche:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Stolperstein !! Richtig&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;DB_SOpen(&#039;Y00…&#039;, oDB, …)&#039;&#039; - der AD-UID-Parameter aus dem Delphi-Code || Im Skript &#039;&#039;&#039;ohne UID&#039;&#039;&#039;: &amp;lt;code&amp;gt;DB_SOpen(oDB, cSql, q)&amp;lt;/code&amp;gt;. Gleiches gilt für &amp;lt;code&amp;gt;qSqlInit(oDB, &#039;tab&#039;)&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;qSqlRead(oDB, &#039;tab&#039;, cWhere)&amp;lt;/code&amp;gt; - jede dieser Funktionen beginnt mit &#039;&#039;oDB&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;oReader.Str&amp;amp;lt;T&amp;amp;gt;(…)&#039;&#039; oder irgendein Generic || Generics gibt es in der Skriptsprache nicht. Die Leser sind je Typ eigene Methoden: &#039;&#039;Str&#039;&#039;, &#039;&#039;Int&#039;&#039;, &#039;&#039;Num&#039;&#039;, &#039;&#039;Bool&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| Eine eigene Prozedur &#039;&#039;Delete&#039;&#039; für DELETE || &#039;&#039;Delete&#039;&#039; ist eine &#039;&#039;&#039;Standardprozedur&#039;&#039;&#039; der Sprache. Die Deklaration lässt sich nicht übersetzen, und die Meldung nennt nur die Zeile - nicht die Ursache&lt;br /&gt;
|-&lt;br /&gt;
| Ein Helfer, der ein JSON-Fragment als String zurückgibt || Den &#039;&#039;&#039;Writer als Parameter&#039;&#039;&#039; übergeben und an der richtigen Stelle hineinschreiben. Fragmente von Hand zusammenzusetzen ist genau der Weg, auf dem die Reihenfolge und die Klammern verloren gingen&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;oWriter.ArrEnd&#039;&#039; nach &#039;&#039;RootArr&#039;&#039; || &#039;&#039;RootArr&#039;&#039; öffnet &#039;&#039;&#039;keine&#039;&#039;&#039; Ebene, es bestimmt nur die Klammer der Wurzel. Ein &#039;&#039;ArrEnd&#039;&#039; dazu ist ein Strukturfehler&lt;br /&gt;
|-&lt;br /&gt;
| Zahlen selbst formatieren (&#039;&#039;NStr&#039;&#039;, &#039;&#039;StrTran&#039;&#039;) || &amp;lt;code&amp;gt;oWriter.Num(&#039;preis&#039;, nWert, 2)&amp;lt;/code&amp;gt;. Selbst formatiert stand bei Werten ab 1000 der Tausendertrenner im JSON - gültiges JSON, falscher Wert, keine Meldung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;EMPTY_DATE&#039;&#039; für ein leeres Datum || &#039;&#039;EMPTY_DATE&#039;&#039; ist im Skript ein &#039;&#039;&#039;String&#039;&#039;&#039;. Für Datumsvariablen und -vergleiche &#039;&#039;&#039;MINDATETIME&#039;&#039;&#039; verwenden&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;fVal&#039;&#039; auf einen JSON-Zahlenwert || JSON liefert den Dezimalpunkt, &#039;&#039;fVal&#039;&#039; erwartet die lokale Notation: vorher &amp;lt;code&amp;gt;StrTran(cVal, &#039;.&#039;, &#039;,&#039;)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;TStringList&#039;&#039; für Zwischenlisten || In Skripten unzuverlässig. Kleine Mengen über einen Delimiter-String führen (&amp;lt;code&amp;gt;&#039;&amp;amp;#124;&#039; + wert + &#039;&amp;amp;#124;&#039;&amp;lt;/code&amp;gt;) und mit &#039;&#039;Pos&#039;&#039; prüfen&lt;br /&gt;
|-&lt;br /&gt;
| Default-Parameter in eigenen Funktionen || Werden nicht unterstützt - alle Parameter ausschreiben&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;q.SaveData(…)&amp;lt;/code&amp;gt; als Anweisung schreiben || &#039;&#039;SaveData&#039;&#039; liefert einen &#039;&#039;&#039;Boolean&#039;&#039;&#039;. Ein SQL-Fehler beim Schreiben wirft &#039;&#039;&#039;keine&#039;&#039;&#039; Exception und landet in &#039;&#039;&#039;keinem&#039;&#039;&#039; Protokoll - der Rückgabewert ist die einzige Meldung. Immer auswerten, siehe [[#Schreibfehler erkennen|Schreibfehler erkennen]]&lt;br /&gt;
|-&lt;br /&gt;
| Je Feld ein eigener Setzer-Aufruf || Ein &amp;lt;code&amp;gt;qSqlInit&amp;lt;/code&amp;gt;-Objekt, beliebig viele &amp;lt;code&amp;gt;qSet&amp;lt;/code&amp;gt;, &#039;&#039;&#039;ein&#039;&#039;&#039; &amp;lt;code&amp;gt;SaveData&amp;lt;/code&amp;gt;. Einzeln geschrieben kostet jedes Feld eine eigene Abfrage samt eigenem Write - und der Vorgang ist nicht mehr atomar, siehe [[#Mehrere Felder sind EIN Schreibvorgang|Mehrere Felder]]&lt;br /&gt;
|-&lt;br /&gt;
| Ein Wert je Zeile nachgeschlagen || In einer Liste sind das N Abfragen für eine Antwort. Über &amp;lt;code&amp;gt;LEFT JOIN&amp;lt;/code&amp;gt; (1:1) oder Unterabfrage (1:n) mitnehmen, siehe [[#Keine Abfrage je Zeile|Keine Abfrage je Zeile]]&lt;br /&gt;
|-&lt;br /&gt;
| Ausgabeparameter vom Typ &amp;lt;code&amp;gt;var … : TDateTime&amp;lt;/code&amp;gt; || Bringt die Skript-VM zum &#039;&#039;&#039;Absturz&#039;&#039;&#039; (Assertion in &#039;&#039;dwsStack.pas&#039;&#039;), und zwar beim ersten &#039;&#039;&#039;Lesen&#039;&#039;&#039; des Werts - nicht schon beim Schreiben, die Ursache liegt also nicht dort, wo der Fehler auffällt. &amp;lt;code&amp;gt;var string&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;var Integer&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;var Double&amp;lt;/code&amp;gt; laufen. Zeitpunkte als ISO-Zeichenkette zurückgeben und erst beim Aufrufer wandeln&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==Skript-Cache==&lt;br /&gt;
&lt;br /&gt;
Der Server cached das kompilierte Skript pro Endpunkt (Schlüssel: &#039;&#039;sys_date&#039;&#039; 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.&lt;br /&gt;
&lt;br /&gt;
==Verfügbare Bibliotheken==&lt;br /&gt;
&lt;br /&gt;
Im Skript können alle OBS-Standard-Bibliotheken verwendet werden. Typische Einstiegspunkte:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;Base.Tools&#039;&#039; - String-, Datum-, IIF-, Empty-Helper&lt;br /&gt;
* &#039;&#039;Base.DB&#039;&#039; / &#039;&#039;Base.xQuery&#039;&#039; - Datenbank-Operationen&lt;br /&gt;
* &#039;&#039;Base.qSqlReg&#039;&#039; - Schreibzugriffe&lt;br /&gt;
* &#039;&#039;lib_ScriptIO&#039;&#039; - Writer und Reader, siehe unten. &#039;&#039;&#039;System.JSON braucht ein Endpunkt-Skript nicht&#039;&#039;&#039; - der Writer erzeugt die Antwort, der Reader liest den Körper&lt;br /&gt;
* &#039;&#039;Base.ToolsConst&#039;&#039; - Konstanten wie &#039;&#039;CRLF&#039;&#039;, &#039;&#039;SINGELQUOTE&#039;&#039;, &#039;&#039;MINDATETIME&#039;&#039;&lt;br /&gt;
* &#039;&#039;lib_ScriptIO&#039;&#039; - &#039;&#039;TxRestWriter&#039;&#039;, &#039;&#039;TxRestReader&#039;&#039; und die Zeitfunktionen &#039;&#039;RestIsoFromDate&#039;&#039; / &#039;&#039;RestDateFromIso&#039;&#039; / &#039;&#039;RestOffsetToMinutes&#039;&#039;. Writer und Reader bekommt das Skript als Parameter; die Zeitfunktionen ruft es direkt&lt;br /&gt;
&lt;br /&gt;
Die wichtigsten Aufrufe mit ihren Skript-Signaturen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Zweck !! Aufruf&lt;br /&gt;
|-&lt;br /&gt;
| Lesen || &amp;lt;code&amp;gt;DB_SOpen(oDB, cSql, q)&amp;lt;/code&amp;gt;, dann &amp;lt;code&amp;gt;q.EoF&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.Next()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.A2C(&#039;feld&#039;)&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2I&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2F&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2D&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2UID()&amp;lt;/code&amp;gt;, am Ende &amp;lt;code&amp;gt;DB_Close(q)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Neu anlegen || &amp;lt;code&amp;gt;q := qSqlInit(oDB, &#039;tab&#039;)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.qSet(&#039;feld&#039;, wert)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;if (not q.SaveData(NEW_RECORD)) then …&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;qSqlFree(q)&amp;lt;/code&amp;gt; – der Rückgabewert ist Pflicht, siehe [[#Schreibfehler erkennen|Schreibfehler erkennen]]&lt;br /&gt;
|-&lt;br /&gt;
| Ändern || &amp;lt;code&amp;gt;q := qSqlInit(oDB, &#039;tab&#039;)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.qSet(&#039;sys_uid&#039;, cUid)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.qSet(…)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;if (not q.SaveData(UPDATE_RECORD)) then …&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;qSqlFree(q)&amp;lt;/code&amp;gt; – &#039;&#039;&#039;nicht&#039;&#039;&#039; &amp;lt;code&amp;gt;qSqlRead&amp;lt;/code&amp;gt;, siehe [[#Ändern: qSqlInit statt qSqlRead|Ändern]]. Alle Felder derselben Zeile in &#039;&#039;&#039;ein&#039;&#039;&#039; Objekt, siehe [[#Mehrere Felder sind EIN Schreibvorgang|Mehrere Felder]]&lt;br /&gt;
|-&lt;br /&gt;
| Direkt ausführen || &amp;lt;code&amp;gt;DB_SqlExec(oDB, cSql)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Existenzprüfung || &amp;lt;code&amp;gt;DB_LSeek(oDB, &#039;tab&#039;, cWhere)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Werte quoten (Pflicht) || &amp;lt;code&amp;gt;DB_SQLVal(wert)&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Alle diese Funktionen beginnen im Skript mit &#039;&#039;oDB&#039;&#039;. Ein zusätzlicher&lt;br /&gt;
AD-UID-Parameter als erstes Argument gehört zur Delphi-Variante und lässt sich im&lt;br /&gt;
Skript nicht übersetzen.}}&lt;br /&gt;
&lt;br /&gt;
Konkrete Beispiele:&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel1|Beispiel 1: Daten-Abruf mit JWT]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel2|Beispiel 2: Zeiterfassung als komplette Webanwendung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel3|Beispiel 3: Datensatz anlegen mit JSON-Body (CRUD)]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4: Pfad-Parameter im Routing]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6: Datei-Upload und Ablage im DMS]]&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Endpunkte&amp;diff=64802</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Endpunkte</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Endpunkte&amp;diff=64802"/>
		<updated>2026-09-09T12:30:50Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
= Endpunkte =&lt;br /&gt;
&lt;br /&gt;
Ein &#039;&#039;&#039;Endpunkt&#039;&#039;&#039; stellt eine Adresse bereit, über die der REST-Server konkrete&lt;br /&gt;
Funktionen anbietet. Jeder Endpunkt wird durch ein OBS-Skript oder einen WebHook&lt;br /&gt;
realisiert und ist genau einem Server-Profil zugeordnet.&lt;br /&gt;
&lt;br /&gt;
== Routing über Pfad-Templates ==&lt;br /&gt;
&lt;br /&gt;
Das Routing erfolgt über die Spalte &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_pathtemplate&amp;lt;/code&amp;gt;).&lt;br /&gt;
Ein Template beschreibt den vollständigen Pfad nach dem Host und kann&lt;br /&gt;
&#039;&#039;&#039;Platzhalter&#039;&#039;&#039; enthalten:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Statische Segmente&#039;&#039;&#039; müssen exakt übereinstimmen (Groß-/Kleinschreibung wird ignoriert).&lt;br /&gt;
* &#039;&#039;&#039;Platzhalter&#039;&#039;&#039; in geschweiften Klammern – z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;{uid}&amp;lt;/code&amp;gt; – passen auf einen beliebigen Wert und werden als [[#Pfad-Parameter im Skript|Pfad-Parameter]] erfasst.&lt;br /&gt;
&lt;br /&gt;
Beispiele für gültige Templates:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Template !! Passt auf !! Pfad-Parameter&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; || –&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/4711&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;uid = 4711&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/4711/modules/A1&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;uid = 4711&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code = A1&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/kalender/v1&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/kalender/v1&amp;lt;/code&amp;gt; || –&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Präzedenz bei mehreren Treffern ===&lt;br /&gt;
&lt;br /&gt;
Passen mehrere Templates auf denselben Pfad, &#039;&#039;&#039;gewinnt das spezifischste&#039;&#039;&#039; –&lt;br /&gt;
also das mit den meisten statischen Segmenten. So schlägt &amp;lt;code&amp;gt;/orders/summary&amp;lt;/code&amp;gt;&lt;br /&gt;
das Template &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt;, während &amp;lt;code&amp;gt;/orders/4711&amp;lt;/code&amp;gt; auf&lt;br /&gt;
&amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; matcht.&lt;br /&gt;
&lt;br /&gt;
== Hauptfelder eines Endpunkts ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld !! Spalte !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_pathtemplate&amp;lt;/code&amp;gt; || &#039;&#039;&#039;Maßgeblich fürs Routing.&#039;&#039;&#039; Vollständiger Pfad mit Platzhaltern, z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Server&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_server&amp;lt;/code&amp;gt; || Zuordnung zum Server-Profil (erforderlich)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Aktiv&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_aktiv&amp;lt;/code&amp;gt; || Statusflag; inaktive Endpunkte liefern 404&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Skript&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_script&amp;lt;/code&amp;gt; || DwScript-Quelltext des Handlers (siehe [[/OBS/Kostenpflichtige_Module/RESTServer/Scripting|Scripting]])&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;WebHook&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_webhook&amp;lt;/code&amp;gt; || optional: Verweis auf einen Einmal-Endpunkt statt Skript&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Info&#039;&#039;&#039; || – || Dokumentations-Freitext&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_upload&amp;lt;/code&amp;gt; || Erlaubt Datei-Uploads an diesem Endpunkt (&amp;lt;code&amp;gt;0&amp;lt;/code&amp;gt; = aus, &amp;lt;code&amp;gt;1&amp;lt;/code&amp;gt; = an). Ohne Freigabe werden Upload-Anfragen mit 415 abgelehnt.&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Max. Upload-Grösse&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt; || Maximale Dateigrösse in MB. Leer/0 = Standard 25&amp;amp;nbsp;MB. Überschreitung wird mit 413 abgelehnt.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pfad-Parameter im Skript ==&lt;br /&gt;
&lt;br /&gt;
Die aus den Platzhaltern erfassten Werte liest das Endpunkt-Skript über&lt;br /&gt;
&amp;lt;code&amp;gt;oReader.Path&amp;lt;/code&amp;gt;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
procedure Get(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
var cUid: string;&lt;br /&gt;
begin&lt;br /&gt;
    cUid := oReader.Path(&#039;uid&#039;);   // aus /orders/{uid}&lt;br /&gt;
    // ...&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Diese Werte stammen aus dem Routing und können nicht durch den Client&lt;br /&gt;
überschrieben werden.&lt;br /&gt;
&lt;br /&gt;
== Datei-Uploads ==&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt nimmt Datei-Uploads nur entgegen, wenn er dafür freigeschaltet ist&lt;br /&gt;
(&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;). Andernfalls werden Upload-Anfragen mit&lt;br /&gt;
&#039;&#039;&#039;415 Unsupported Media Type&#039;&#039;&#039; abgelehnt und das Skript wird nicht ausgeführt.&lt;br /&gt;
&lt;br /&gt;
Die maximal zulässige Dateigrösse wird pro Endpunkt über &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;&lt;br /&gt;
(in MB) festgelegt; ohne Angabe gilt der Standard von &#039;&#039;&#039;25&amp;amp;nbsp;MB&#039;&#039;&#039;. Wird das&lt;br /&gt;
Limit überschritten, antwortet der Server mit &#039;&#039;&#039;413 Payload Too Large&#039;&#039;&#039;. Für&lt;br /&gt;
Uploads gilt nicht das JSON-Body-Limit (10&amp;amp;nbsp;MB), sondern dieses Endpunkt-Limit.&lt;br /&gt;
Der Originalname (beim resumable Upload über den Header &amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt;) ist &#039;&#039;&#039;Pflicht&#039;&#039;&#039;; fehlt er, wird der Upload mit 413/400 abgelehnt. Das Skript liest Pfad, Name, Content-Type und die SHA-256-Prüfsumme der gespeicherten Datei über &amp;lt;code&amp;gt;oReader.UploadPath()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;UploadName()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;UploadType()&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;UploadSha()&amp;lt;/code&amp;gt;; bei &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; die übrigen Formularfelder über &amp;lt;code&amp;gt;oReader.Form(&#039;&amp;amp;lt;name&amp;amp;gt;&#039;)&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Das Übertragungsprotokoll&#039;&#039;&#039; - Header, Statuscodes, Resume-Verhalten - steht auf einer eigenen Seite: [[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]].&lt;br /&gt;
&lt;br /&gt;
Unterstützt werden zwei Übertragungsarten:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Einfacher Upload&#039;&#039;&#039; per &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; (eine Datei pro Request).&lt;br /&gt;
* &#039;&#039;&#039;Resumable/Chunked Upload&#039;&#039;&#039; per &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt; (grosse Dateien, fortsetzbar nach Abbruch).&lt;br /&gt;
&lt;br /&gt;
Der Server legt die hochgeladene Datei in einem temporären Verzeichnis ab und&lt;br /&gt;
übergibt dem Endpunkt-Skript den Pfad. Was mit der Datei geschieht (Ablage,&lt;br /&gt;
DMS-Verknüpfung, Weiterverarbeitung), entscheidet allein das Skript. Details und&lt;br /&gt;
Beispiele: [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
== HTTP-Methode ==&lt;br /&gt;
&lt;br /&gt;
Welche Funktion aufgerufen wird, ergibt sich aus dem HTTP-Verb: Der Server ruft&lt;br /&gt;
die gleichnamige Skript-Methode auf (&amp;lt;code&amp;gt;Get&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Post&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Put&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Delete&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Patch&amp;lt;/code&amp;gt;).&lt;br /&gt;
Ein Template entspricht damit &#039;&#039;&#039;einem&#039;&#039;&#039; Endpunkt-Skript; unterschiedliche&lt;br /&gt;
Pfad-Formen (z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; vs. &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt;) sind&lt;br /&gt;
&#039;&#039;&#039;eigene Endpunkt-Einträge&#039;&#039;&#039; mit eigenem Skript und eigener Berechtigung.&lt;br /&gt;
&lt;br /&gt;
== URL-Struktur ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
http://[Host][:Port][Pfad-Template]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Beispiele:&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders/4711&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders/4711/modules/A1&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Versionierung ==&lt;br /&gt;
&lt;br /&gt;
Da es kein eigenes Versions-Feld mehr gibt, wird die Version als statisches&lt;br /&gt;
Segment ins Template aufgenommen, z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders/v1&amp;lt;/code&amp;gt; oder&lt;br /&gt;
&amp;lt;code&amp;gt;/v1/orders/{uid}&amp;lt;/code&amp;gt;. Änderung ohne Breaking Change:&lt;br /&gt;
&lt;br /&gt;
# Bestehenden Endpunkt unverändert lassen.&lt;br /&gt;
# Neuen Endpunkt mit gleichem Ressourcennamen, aber neuem Versions-Segment im Template anlegen.&lt;br /&gt;
# Berechtigungen für neue Konsumenten setzen.&lt;br /&gt;
# Alten Endpunkt deaktivieren, wenn die Migration abgeschlossen ist.&lt;br /&gt;
&lt;br /&gt;
== Zugriffskontrolle ==&lt;br /&gt;
&lt;br /&gt;
Endpunkte sind Server-Profilen zugeordnet; ein Zugang benötigt eine explizite&lt;br /&gt;
Berechtigung (Tabelle &amp;lt;code&amp;gt;RESTSRV_ACCESS&amp;lt;/code&amp;gt;), um einen Endpunkt nutzen zu&lt;br /&gt;
dürfen.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|&#039;&#039;&#039;Ein Endpunkt besteht aus zwei Zeilen, und beide werden von Hand&lt;br /&gt;
gepflegt:&#039;&#039;&#039; der Eintrag in &amp;lt;code&amp;gt;RESTSRV_ENDPOINTS&amp;lt;/code&amp;gt; und die Berechtigung&lt;br /&gt;
in &amp;lt;code&amp;gt;RESTSRV_ACCESS&amp;lt;/code&amp;gt;. Fehlt die zweite, antwortet der Endpunkt&lt;br /&gt;
&#039;&#039;&#039;403&#039;&#039;&#039; - und sieht dabei fertig aus: Pfad, Skript und Server-Profil stehen&lt;br /&gt;
korrekt da, der Aufruf kommt trotzdem nicht durch. Das ist die häufigste Ursache&lt;br /&gt;
für ein „der Endpunkt ist doch angelegt&amp;quot; und kostet jedes Mal eine&lt;br /&gt;
Fehlersuche, weil der Statuscode nach einem Rechteproblem des Konsumenten&lt;br /&gt;
aussieht und nicht nach einer fehlenden Konfigurationszeile.&lt;br /&gt;
&lt;br /&gt;
Beim Anlegen also &#039;&#039;&#039;immer beide Zeilen&#039;&#039;&#039;, und beim Prüfen eines&lt;br /&gt;
&amp;lt;code&amp;gt;403&amp;lt;/code&amp;gt; zuerst nachsehen, ob die zweite existiert.}} Weil jede Pfad-Form ein eigener Endpunkt-Eintrag ist, lässt sich der&lt;br /&gt;
Zugriff &#039;&#039;&#039;pro Pfad-Form&#039;&#039;&#039; granular vergeben (z.&amp;amp;nbsp;B. Lesen von&lt;br /&gt;
&amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; erlauben, aber das ändernde&lt;br /&gt;
&amp;lt;code&amp;gt;PUT /orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; nicht).&lt;br /&gt;
&lt;br /&gt;
== Caching ==&lt;br /&gt;
&lt;br /&gt;
Die Endpunkt-Definitionen werden pro Server-Profil zwischengespeichert&lt;br /&gt;
(TTL, Standard 60&amp;amp;nbsp;s). Neue oder geänderte Endpunkte/Templates werden daher&lt;br /&gt;
erst nach Ablauf des Caches (bzw. nach einer Cache-Invalidierung) wirksam – nicht&lt;br /&gt;
zwingend sofort.&lt;br /&gt;
&lt;br /&gt;
== HTTP-Fehlercodes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Code !! Ursache&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;404&#039;&#039;&#039; || Kein Template passt zum Pfad, Endpunkt inaktiv oder falsches Server-Profil&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;403&#039;&#039;&#039; || Zugang fehlt in der Berechtigungsliste&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;401&#039;&#039;&#039; || API-Key ungültig/fehlend, JWT abgelaufen oder Sitzung gesperrt (&#039;&#039;AUTH_EXPIRED&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;500&#039;&#039;&#039; || Skript- oder Syntax-Fehler (Details in &amp;lt;code&amp;gt;RESTSRV_PROTO&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;405&#039;&#039;&#039; || Das Endpunkt-Skript hat für die angefragte HTTP-Methode keine Funktion; der Header &amp;lt;code&amp;gt;Allow&amp;lt;/code&amp;gt; nennt die vorhandenen&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;415&#039;&#039;&#039; || Datei-Upload an einen Endpunkt, der dafür nicht freigeschaltet ist (&amp;lt;code&amp;gt;re_upload = 0&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;413&#039;&#039;&#039; || Hochgeladene Datei überschreitet die zulässige Maximalgrösse (&amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;429&#039;&#039;&#039; || Rate-Limit überschritten; die Antwort enthält den Header &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;503&#039;&#039;&#039; || Eine Anfrage mit demselben &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; wird gerade verarbeitet; die Antwort enthält &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;409&#039;&#039;&#039; || Ein früherer Aufruf mit demselben &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; ist ohne Ergebnis geblieben (&amp;lt;code&amp;gt;IDEMPOTENCY_UNRESOLVED&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;422&#039;&#039;&#039; || Derselbe &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; wurde mit &#039;&#039;&#039;anderem&#039;&#039;&#039; Inhalt gesendet (&amp;lt;code&amp;gt;IDEMPOTENCY_KEY_REUSED&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;400&#039;&#039;&#039; || Die Anfrage wurde unverschlüsselt an einen TLS-Port geschickt (&amp;lt;code&amp;gt;http&amp;lt;/code&amp;gt; statt &amp;lt;code&amp;gt;https&amp;lt;/code&amp;gt;). Die Abweisung erfolgt vor Authentifizierung und Endpunkt-Skript&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Jede Fehlerantwort besteht aus einem &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt;-Objekt mit&lt;br /&gt;
maschinenlesbarem Code - Aufbau siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer|Übersicht]].&lt;br /&gt;
&lt;br /&gt;
Daneben kann ein Endpunkt-Skript den Statuscode selbst setzen (z.B. &amp;lt;code&amp;gt;201&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;204&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;409&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;422&amp;lt;/code&amp;gt;) sowie Response-Header wie &amp;lt;code&amp;gt;ETag&amp;lt;/code&amp;gt; oder &amp;lt;code&amp;gt;Location&amp;lt;/code&amp;gt; - siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
== Idempotenz ==&lt;br /&gt;
&lt;br /&gt;
Schickt ein Konsument bei &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;PUT&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;PATCH&amp;lt;/code&amp;gt;&lt;br /&gt;
oder &amp;lt;code&amp;gt;DELETE&amp;lt;/code&amp;gt; den Header &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, sorgt der Server&lt;br /&gt;
selbst dafür, dass eine wiederholte Sendung &#039;&#039;&#039;keine Zweitwirkung&#039;&#039;&#039; hat. Das gilt&lt;br /&gt;
für &#039;&#039;&#039;jeden&#039;&#039;&#039; Endpunkt - es muss weder am Endpunkt etwas eingestellt noch im&lt;br /&gt;
Skript etwas programmiert werden.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Situation !! Antwort des Servers&lt;br /&gt;
|-&lt;br /&gt;
| Erster Aufruf || Endpunkt läuft normal, das Ergebnis wird zum Schlüssel gespeichert&lt;br /&gt;
|-&lt;br /&gt;
| Wiederholung, gleicher Inhalt || &#039;&#039;&#039;Gespeicherte Antwort&#039;&#039;&#039; (gleicher Statuscode, gleicher Body, gleiche Header) plus Header &amp;lt;code&amp;gt;Idempotent-Replay: true&amp;lt;/code&amp;gt;. Das Skript läuft &#039;&#039;&#039;nicht&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| Wiederholung, anderer Inhalt || &#039;&#039;&#039;422&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_KEY_REUSED&amp;lt;/code&amp;gt; - derselbe Schlüssel für einen anderen Inhalt ist ein Client-Fehler&lt;br /&gt;
|-&lt;br /&gt;
| Erster Aufruf läuft noch || &#039;&#039;&#039;503&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_IN_PROGRESS&amp;lt;/code&amp;gt; mit &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;. Beim nächsten Versuch liegt die gespeicherte Antwort vor&lt;br /&gt;
|-&lt;br /&gt;
| Früherer Aufruf ohne Ergebnis || &#039;&#039;&#039;409&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_UNRESOLVED&amp;lt;/code&amp;gt;. Nur noch nach einem &#039;&#039;&#039;harten Abbruch&#039;&#039;&#039; des Dienstes zwischen Verarbeitung und Festschreiben; ob die Buchung stattgefunden hat, ist unbekannt. Der Aufräumlauf gibt solche Einträge nach 24 Stunden frei und protokolliert sie&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Wichtig für die Auslegung eines Endpunkts:&lt;br /&gt;
&lt;br /&gt;
* Antwortet das Skript mit &#039;&#039;&#039;2xx&#039;&#039;&#039;, wird die Antwort gespeichert und bei einer Wiederholung erneut ausgeliefert.&lt;br /&gt;
* Antwortet das Skript mit &#039;&#039;&#039;4xx&#039;&#039;&#039; (fachliche Ablehnung), wird der Schlüssel wieder &#039;&#039;&#039;freigegeben&#039;&#039;&#039; - der Konsument darf ihn nach Korrektur erneut verwenden. Sonst würde ein einziger Validierungsfehler den Schlüssel dauerhaft blockieren.&lt;br /&gt;
* Endet die Verarbeitung mit &#039;&#039;&#039;5xx&#039;&#039;&#039; oder einem Skript-Fehler, wird der Schlüssel &#039;&#039;&#039;freigegeben&#039;&#039;&#039; und der Fall protokolliert. Der Konsument darf denselben Schlüssel erneut senden.&lt;br /&gt;
* Das ist eine bewusste Abwägung (geändert 2026-08-25): Bleibt der Schlüssel nach einem Serverfehler belegt, ist der Vorgang &#039;&#039;&#039;unwiederholbar&#039;&#039;&#039; - der Client bekommt dauerhaft 503 und müsste die erfasste Arbeit verwerfen. Garantierter Datenverlust wiegt schwerer als ein möglicher Doppelsatz.&lt;br /&gt;
* &#039;&#039;&#039;Folge für die Auslegung:&#039;&#039;&#039; ein schreibender Endpunkt sollte selbst wiederholbar sein, etwa über ein fachliches Merkmal des Clients. Wo reines Anhängen stattfindet (Positionen, Dateien), bleibt sonst ein Restrisiko.&lt;br /&gt;
&lt;br /&gt;
Der Schlüssel gilt je &#039;&#039;&#039;Zugang, Methode und Pfad&#039;&#039;&#039;. Derselbe&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; an einem anderen Endpunkt ist damit ein eigener&lt;br /&gt;
Vorgang - der Konsument muss ihn nicht global eindeutig vergeben, aber pro Vorgang&lt;br /&gt;
&#039;&#039;&#039;stabil wiederverwenden&#039;&#039;&#039; (also nicht bei jedem Wiederholversuch neu erzeugen).&lt;br /&gt;
&lt;br /&gt;
Die Einträge stehen in &amp;lt;code&amp;gt;RESTSRV_IDEMPOTENCY&amp;lt;/code&amp;gt; und werden nach 30 Tagen&lt;br /&gt;
automatisch aufgeräumt. Einträge ohne Ergebnis werden &#039;&#039;&#039;nicht&#039;&#039;&#039; automatisch&lt;br /&gt;
gelöscht, sondern im Protokoll gemeldet.&lt;br /&gt;
&lt;br /&gt;
== WebHooks ==&lt;br /&gt;
&lt;br /&gt;
WebHooks sind &#039;&#039;&#039;Einmal-Endpunkte&#039;&#039;&#039; für asynchrone Callbacks. Beim ersten&lt;br /&gt;
erfolgreichen Aufruf wird die Antwort in der Tabelle &amp;lt;code&amp;gt;REMOTE_HOOK_URL&amp;lt;/code&amp;gt;&lt;br /&gt;
gespeichert und der Endpunkt anschließend automatisch gelöscht.&lt;br /&gt;
&lt;br /&gt;
Typischer Einsatz: OBS stösst einen Vorgang bei einem Fremdsystem an (Bezahldienst,&lt;br /&gt;
Versanddienstleister) und gibt diesem eine Rückruf-Adresse mit, die nur &#039;&#039;&#039;ein&lt;br /&gt;
einziges Mal&#039;&#039;&#039; gültig ist. Damit kann die Adresse nicht später erneut - oder von&lt;br /&gt;
jemand anderem - benutzt werden.&lt;br /&gt;
&lt;br /&gt;
Ablauf:&lt;br /&gt;
&lt;br /&gt;
# OBS legt einen Eintrag in &amp;lt;code&amp;gt;REMOTE_HOOK_URL&amp;lt;/code&amp;gt; an und dazu einen Endpunkt, dessen Feld &#039;&#039;&#039;WebHook&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_webhook&amp;lt;/code&amp;gt;) auf diesen Eintrag zeigt. Ein Skript wird für diesen Endpunkt nicht gepflegt.&lt;br /&gt;
# Die Adresse dieses Endpunkts wird dem Fremdsystem als Callback-URL übergeben.&lt;br /&gt;
# Das Fremdsystem ruft die Adresse auf. Der Server legt den übergebenen Inhalt in &amp;lt;code&amp;gt;REMOTE_HOOK_URL.hu_response&amp;lt;/code&amp;gt; ab, antwortet mit &amp;lt;code&amp;gt;{&amp;quot;status&amp;quot;: &amp;quot;ok&amp;quot;}&amp;lt;/code&amp;gt; und &#039;&#039;&#039;löscht den Endpunkt&#039;&#039;&#039;.&lt;br /&gt;
# Der weiterverarbeitende OBS-Prozess findet die Rückmeldung in &amp;lt;code&amp;gt;hu_response&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein zweiter Aufruf derselben Adresse läuft ins Leere: Der Endpunkt&lt;br /&gt;
existiert nicht mehr, die Antwort ist 404. Das ist gewollt - ein WebHook ist keine&lt;br /&gt;
dauerhafte Schnittstelle. Für eine dauerhaft erreichbare Rückmelde-Adresse einen&lt;br /&gt;
normalen Endpunkt mit Skript anlegen.}}&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Zugaenge&amp;diff=64801</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Zugaenge</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Zugaenge&amp;diff=64801"/>
		<updated>2026-09-09T12:30:38Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Zugänge=&lt;br /&gt;
&lt;br /&gt;
Ein &#039;&#039;&#039;Zugang&#039;&#039;&#039; (Account) ist der Kanal, über den ein externer Konsument den REST-Server anspricht. Er bündelt API-Key, Host-Beschränkung, CORS-Konfiguration, optionale JWT-Authentifizierung und optionale mTLS-Subject-Prüfung.&lt;br /&gt;
&lt;br /&gt;
==Aufruf==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Stammdaten -&amp;gt; Z Weitere Stammdaten -&amp;gt; REST-Server -&amp;gt; Zugänge&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==Felder==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld          !! Beschreibung&lt;br /&gt;
|-&lt;br /&gt;
| Name          || Sprechender Name, erscheint im Protokoll und der Statistik&lt;br /&gt;
|-&lt;br /&gt;
| Aktiv         || Inaktive Zugänge werden bei der Authentifizierung mit 403 abgewiesen&lt;br /&gt;
|-&lt;br /&gt;
| API-Key       || Eindeutiger Schlüssel, den der Konsument im HTTP-Header &#039;&#039;apikey&#039;&#039; senden muss. Per Schlüsselsymbol generierbar&lt;br /&gt;
|-&lt;br /&gt;
| Host          || Optionaler Hostname oder IP-Adresse des Konsumenten. Stimmt die anfragende IP nicht überein, wird mit 403 abgelehnt&lt;br /&gt;
|-&lt;br /&gt;
| CORS-Origins  || Liste erlaubter Origins für Browser-Aufrufe, mehrere durch &#039;&#039;;&#039;&#039; getrennt. &#039;&#039;&#039;Leer heisst nicht „keine Einschränkung&amp;quot;, sondern „kein Browser-Zugriff&amp;quot;&#039;&#039;&#039; - siehe unten&lt;br /&gt;
|-&lt;br /&gt;
| mTLS-Subject  || Erwartete RDN-Komponente(n) im Client-Zertifikat-Subject, Komma-/Semikolon-getrennt (alle müssen vorkommen; nur sinnvoll bei mTLS-Servern)&lt;br /&gt;
|-&lt;br /&gt;
| JWT           || Schalter, ob für diesen Zugang ein JWT-Token nötig ist&lt;br /&gt;
|-&lt;br /&gt;
| JWT-Endpunkt  || Pfad, über den &#039;&#039;&#039;alle Token-Vorgänge&#039;&#039;&#039; laufen: Anmelden, Erneuern und Abmelden (z.B. &#039;&#039;oauth&#039;&#039; oder &#039;&#039;meineapp/dev/auth&#039;&#039;). Verglichen wird der &#039;&#039;&#039;vollständige Pfad&#039;&#039;&#039;, mehrstufige Angaben mit &#039;&#039;/&#039;&#039; sind also erlaubt&lt;br /&gt;
|-&lt;br /&gt;
| JWT-Key       || Geheimer Schlüssel zur Signierung/Verifikation, per Schlüsselsymbol generierbar&lt;br /&gt;
|-&lt;br /&gt;
| JWT-Exp       || Gültigkeitsdauer eines ausgestellten Access-Tokens in &#039;&#039;&#039;Minuten&#039;&#039;&#039;. Derselbe Wert wird dem Konsumenten in der Token-Antwort als &#039;&#039;expiresIn&#039;&#039; in &#039;&#039;&#039;Sekunden&#039;&#039;&#039; mitgeteilt und steht dem Endpunkt-Skript als &#039;&#039;_OBS_JWT_EXP_SEC&#039;&#039; zur Verfügung&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==API-Key==&lt;br /&gt;
&lt;br /&gt;
Der API-Key ist die Basis-Authentifizierung. Der Client übergibt ihn im HTTP-Header:&lt;br /&gt;
&lt;br /&gt;
 apikey: abcd-efgh-ijkl-mnop-qrstuvwxyz12&lt;br /&gt;
&lt;br /&gt;
Wird ein Aufruf ohne &#039;&#039;apikey&#039;&#039;-Header gestellt, sucht der Server intern nach einem Zugang mit API-Key &#039;&#039;&#039;*&#039;&#039;&#039; - damit lassen sich (mit Vorsicht!) auch öffentliche Endpunkte ohne Authentifizierung realisieren.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein Zugang mit API-Key &#039;&#039;*&#039;&#039; sollte nur für bewusst freigegebene Endpunkte verwendet werden und immer in Kombination mit einer Host-Beschränkung oder CORS-Beschränkung betrieben werden.}}&lt;br /&gt;
&lt;br /&gt;
==Host-Beschränkung==&lt;br /&gt;
&lt;br /&gt;
Ist im Feld &#039;&#039;&#039;Host&#039;&#039;&#039; ein Wert hinterlegt, akzeptiert der Server Anfragen unter diesem Zugang nur dann, wenn die Remote-IP exakt diesem Host entspricht oder durch DNS-Auflösung auf diese IP zeigt. DNS-Auflösungen werden 5 Minuten gecached.&lt;br /&gt;
&lt;br /&gt;
==CORS==&lt;br /&gt;
&lt;br /&gt;
Soll ein Endpunkt aus einer Browser-Anwendung (Single-Page-App, JavaScript) heraus aufgerufen werden, muss das Origin der Anwendung in der CORS-Liste des Zugangs eingetragen sein.&lt;br /&gt;
&lt;br /&gt;
Format der Liste:&lt;br /&gt;
&lt;br /&gt;
 https://app.kunde.de;https://test.kunde.de;https://localhost:8080&lt;br /&gt;
&lt;br /&gt;
Ablauf:&lt;br /&gt;
&lt;br /&gt;
# Browser sendet einen Preflight-Request (&#039;&#039;OPTIONS&#039;&#039;). Der REST-Server prüft das Origin gegen die globale Origin-Liste &#039;&#039;&#039;aller&#039;&#039;&#039; Accounts (Cache, TTL 60s) und antwortet mit 204 + CORS-Header, falls erlaubt.&lt;br /&gt;
# Der eigentliche Request wird gegen die Origin-Liste &#039;&#039;&#039;des aktuell verwendeten Zugangs&#039;&#039;&#039; geprüft.&lt;br /&gt;
# Stimmt das Origin nicht, wird mit 403 &#039;&#039;Origin nicht erlaubt für Account ...&#039;&#039; abgelehnt.&lt;br /&gt;
&lt;br /&gt;
Der Preflight kann nicht zugangsbezogen prüfen: der Browser schickt dabei keinen&lt;br /&gt;
&#039;&#039;apikey&#039;&#039; mit, der Zugang ist zu diesem Zeitpunkt unbekannt. &#039;&#039;&#039;Die Durchsetzung&lt;br /&gt;
liegt deshalb auf Schritt 2&#039;&#039;&#039;, nicht auf dem Preflight. Praktische Folge: trägt&lt;br /&gt;
&#039;&#039;&#039;ein&#039;&#039;&#039; Zugang auf dem Server ein &#039;&#039;*&#039;&#039; in seiner Liste, antwortet jeder&lt;br /&gt;
Preflight mit &#039;&#039;Access-Control-Allow-Origin: *&#039;&#039; - auch für Zugänge mit engerer&lt;br /&gt;
Liste. Deren eigentliche Anfrage wird trotzdem gegen die eigene Liste geprüft.&lt;br /&gt;
&lt;br /&gt;
Erlaubte Methoden: &#039;&#039;GET, POST, PUT, DELETE, PATCH&#039;&#039; (systemseitig festgelegt).&lt;br /&gt;
&lt;br /&gt;
Bei den &#039;&#039;&#039;Anfrage-Headern&#039;&#039;&#039; spiegelt der Server im Preflight zurück, was der&lt;br /&gt;
Browser in &#039;&#039;Access-Control-Request-Headers&#039;&#039; angefragt hat. Damit sind eigene&lt;br /&gt;
Header eines Projekts - &#039;&#039;Idempotency-Key&#039;&#039;, &#039;&#039;If-Match&#039;&#039;, später&lt;br /&gt;
&#039;&#039;Upload-Offset&#039;&#039;/&#039;&#039;Upload-Length&#039;&#039; - ohne Änderung am Server nutzbar. Fragt der&lt;br /&gt;
Preflight keine Header an, antwortet der Server mit der Liste&lt;br /&gt;
&#039;&#039;Content-Type, apikey, Authorization, Accept&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Die Header-Liste ist &#039;&#039;&#039;keine Schutzgrenze&#039;&#039;&#039; - das ist das Origin. Sie&lt;br /&gt;
sagt dem Browser nur, welche Header der Server versteht. Dazu kommt, dass die&lt;br /&gt;
Zugangsdaten dieser Schnittstelle in Headern stehen (&#039;&#039;apikey&#039;&#039;,&lt;br /&gt;
&#039;&#039;Authorization&#039;&#039;) und nicht in Cookies: es gibt also keine automatisch&lt;br /&gt;
mitgeschickte Berechtigung, die eine fremde Seite ausnutzen könnte.}}&lt;br /&gt;
&lt;br /&gt;
===Was „leer&amp;quot; bedeutet===&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Ein &#039;&#039;&#039;leeres&#039;&#039;&#039; Feld CORS-Origins heisst &#039;&#039;&#039;nicht&#039;&#039;&#039; „keine&lt;br /&gt;
Einschränkung&amp;quot;. Es heisst: &#039;&#039;&#039;jeder Aufruf mit &#039;&#039;Origin&#039;&#039;-Header wird mit 403&lt;br /&gt;
abgelehnt&#039;&#039;&#039;. Für Clients &#039;&#039;&#039;ohne&#039;&#039;&#039; &#039;&#039;Origin&#039;&#039;-Header - mobile Apps, Server,&lt;br /&gt;
curl - ändert das Feld gar nichts, sie werden nie gegen CORS geprüft.}}&lt;br /&gt;
&lt;br /&gt;
Daraus ergeben sich drei sinnvolle Einstellungen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld !! Wirkung&lt;br /&gt;
|-&lt;br /&gt;
| leer || Kein Browser-Zugriff. Richtig für Zugänge, die nur von Apps oder Servern benutzt werden&lt;br /&gt;
|-&lt;br /&gt;
| konkrete Origins || Browser-Zugriff nur von diesen Seiten. Der Regelfall für Web-Anwendungen&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;*&#039;&#039; || Browser-Zugriff von überall. Wirkt zusätzlich auf die Preflight-Antwort aller anderen Zugänge - bewusst und sparsam verwenden&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Für eine Browser-Anwendung ist dieses Feld besonders wichtig, weil ihr API-Key&lt;br /&gt;
zwangsläufig im JavaScript liegt und damit öffentlich ist. Die Origin-Liste&lt;br /&gt;
verhindert dann, dass jemand den Key nimmt und von seiner eigenen Seite benutzt.&lt;br /&gt;
Sie ist allerdings kein Ersatz für eine Zugriffskontrolle: wer den Key hat,&lt;br /&gt;
umgeht CORS mit einem einzigen Aufruf ausserhalb des Browsers.&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierung==&lt;br /&gt;
&lt;br /&gt;
Wird ein Zugang mit aktiver JWT-Pflicht aufgerufen, sind die folgenden Phasen zu durchlaufen (Phase 3 nur bei genutztem Refresh).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Anmelden, Erneuern und Abmelden laufen über denselben Endpunkt&#039;&#039;&#039; - den im&lt;br /&gt;
Feld JWT-Endpunkt hinterlegten Pfad. Ein eigener Endpunkt fürs Abmelden ist&lt;br /&gt;
nicht nötig und wäre auch umständlicher: der JWT-Endpunkt greift &#039;&#039;&#039;vor&#039;&#039;&#039; der&lt;br /&gt;
Adressauflösung und braucht deshalb weder eine Zeile in &#039;&#039;RESTSRV_ENDPOINTS&#039;&#039;&lt;br /&gt;
noch eine Berechtigung in &#039;&#039;RESTSRV_ACCESS&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Unterschieden wird nach Verb und Token-Art:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Ablauf&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;POST&#039;&#039; ohne &#039;&#039;Authorization&#039;&#039; || Anmelden (Skript-Methode &#039;&#039;Authenticate&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;POST&#039;&#039; mit Bearer, Refresh-Token || Erneuern (Skript-Methode &#039;&#039;Refresh&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;DELETE&#039;&#039; mit Bearer, Access-Token || Abmelden (kein Skript nötig)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;POST&#039;&#039; mit Bearer, Access-Token || &#039;&#039;&#039;401&#039;&#039;&#039; - unverändert&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Dass das Abmelden am Verb hängt und nicht allein an der Token-Art, ist&lt;br /&gt;
Absicht. Würde ein vorgelegtes Access-Token beim &#039;&#039;POST&#039;&#039; als Abmeldung gedeutet,&lt;br /&gt;
würde ein Client, der seine beiden Token verwechselt, den Anwender still&lt;br /&gt;
abmelden - statt das klare 401 zu bekommen, an dem der Fehler auffällt.}}&lt;br /&gt;
&lt;br /&gt;
===Phase 1: Token-Bezug===&lt;br /&gt;
&lt;br /&gt;
Der Konsument ruft den JWT-Endpunkt des Zugangs auf, z.B.:&lt;br /&gt;
&lt;br /&gt;
 POST https://api.meinserver.de/oauth/&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
 Body:   {&amp;quot;username&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;password&amp;quot;:&amp;quot;...&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Der Server führt das im Zugang hinterlegte &#039;&#039;&#039;Authentifizierungs-Skript&#039;&#039;&#039; aus (F7 in der Zugangs-Liste). Das Skript muss eine Prozedur &#039;&#039;Authenticate&#039;&#039; bereitstellen, die über den Writer schreibt:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;status&#039;&#039; = &#039;&#039;1&#039;&#039; bei Erfolg, &#039;&#039;9&#039;&#039; bei Misserfolg&lt;br /&gt;
* &#039;&#039;error&#039;&#039; = Fehlertext (bei status=9)&lt;br /&gt;
* &#039;&#039;_OBS_JWT_ID&#039;&#039;, &#039;&#039;_OBS_JWT_SUBJECT&#039;&#039;, &#039;&#039;_OBS_JWT_AUDIENCE&#039;&#039; für die JWT-Claims (optional)&lt;br /&gt;
* &#039;&#039;_OBS_JWT_CLAIM_&amp;amp;lt;name&amp;amp;gt;&#039;&#039; für beliebige Custom-Claims (z.B. Mandant &#039;&#039;tenant&#039;&#039;, Rollen &#039;&#039;roles&#039;&#039;); im Folge-Skript lesbar&lt;br /&gt;
* &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; (optional) löst die Ausstellung eines Refresh-Tokens aus; &#039;&#039;_OBS_JWT_REFRESH_EXP&#039;&#039; setzt dessen Lebensdauer in Minuten (Default 90 Tage)&lt;br /&gt;
&lt;br /&gt;
Die Signatur lautet &#039;&#039;procedure Authenticate(oReader: TxRestReader; oWriter: TxRestWriter);&#039;&#039; - Details unter [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]]. Bei Erfolg signiert der Server einen Token (HS256, Issuer &#039;&#039;OBS REST-Server&#039;&#039;, Lebenszeit gemäß JWT-Exp) und antwortet:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:        &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;accessToken&amp;quot;:  &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:    28800,&lt;br /&gt;
  &amp;quot;serverTime&amp;quot;:   &amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Zum Aufbau der Antwort:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;token&#039;&#039; und &#039;&#039;accessToken&#039;&#039; enthalten &#039;&#039;&#039;denselben&#039;&#039;&#039; Token. &#039;&#039;accessToken&#039;&#039; ist der Name, den die meisten Client-Bibliotheken erwarten; &#039;&#039;token&#039;&#039; bleibt für bestehende Konsumenten erhalten.&lt;br /&gt;
* &#039;&#039;expiresIn&#039;&#039; ist die Gültigkeit in &#039;&#039;&#039;Sekunden&#039;&#039;&#039; (JWT-Exp × 60).&lt;br /&gt;
* &#039;&#039;&#039;Alle weiteren Felder, die das Authenticate-Skript zurückgibt, werden mit ausgeliefert&#039;&#039;&#039; - mit Ausnahme von &#039;&#039;status&#039;&#039; und den &#039;&#039;_OBS_&#039;&#039;-Steuerfeldern. So kann das Skript z.B. eine Benutzer-Id, den Mandanten oder Rollen direkt in die Anmelde-Antwort legen, ohne dass der Client dafür einen zweiten Aufruf braucht.&lt;br /&gt;
&lt;br /&gt;
Beispiel mit Zusatzfeldern aus dem Skript:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;benutzerId&amp;quot;: &amp;quot;4711&amp;quot;, &amp;quot;tenant&amp;quot;: &amp;quot;nord&amp;quot;, &amp;quot;roles&amp;quot;: [&amp;quot;TECHNIKER&amp;quot;],&lt;br /&gt;
  &amp;quot;token&amp;quot;: &amp;quot;eyJ...&amp;quot;, &amp;quot;accessToken&amp;quot;: &amp;quot;eyJ...&amp;quot;, &amp;quot;refreshToken&amp;quot;: &amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;: 28800, &amp;quot;serverTime&amp;quot;: &amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Mit der Ausstellung legt der Server zu dem Token-Paar eine &#039;&#039;&#039;Sitzungszeile&#039;&#039;&#039; in&lt;br /&gt;
&#039;&#039;RESTSRV_TOKEN&#039;&#039; an. Lässt sich diese Zeile nicht schreiben (Tabelle fehlt,&lt;br /&gt;
Datenbankproblem), wird &#039;&#039;&#039;kein Token ausgeliefert&#039;&#039;&#039;: der Server antwortet mit&lt;br /&gt;
&#039;&#039;&#039;503&#039;&#039;&#039; und &#039;&#039;Retry-After&#039;&#039;. Das ist bewusst von der abgelehnten Anmeldung&lt;br /&gt;
unterschieden - die Zugangsdaten waren in Ordnung, der Versuch darf wiederholt&lt;br /&gt;
werden.&lt;br /&gt;
&lt;br /&gt;
Wird die Anmeldung abgelehnt (&#039;&#039;status&#039;&#039; = 9), antwortet der Server mit&lt;br /&gt;
&#039;&#039;&#039;401 Unauthorized&#039;&#039;&#039; und reicht den Text aus dem Feld &#039;&#039;error&#039;&#039; als&lt;br /&gt;
&#039;&#039;error.message&#039;&#039; an den Client durch - der Konsument kann ihn also direkt&lt;br /&gt;
anzeigen. Die Formulierung liegt damit beim Skript.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der durchgereichte Text ist für den Endanwender gedacht und sollte&lt;br /&gt;
keine internen Details verraten. „Benutzer oder Passwort ist falsch&amp;quot; ist eine gute&lt;br /&gt;
Meldung, „Kein Datensatz in BENUTZ mit b_name=meier&amp;quot; nicht.}}&lt;br /&gt;
&lt;br /&gt;
===Phase 2: Token-Verwendung===&lt;br /&gt;
&lt;br /&gt;
Bei allen folgenden Aufrufen sendet der Client den Token im &#039;&#039;Authorization&#039;&#039;-Header:&lt;br /&gt;
&lt;br /&gt;
 GET https://api.meinserver.de/kalender/v1&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
         Authorization: Bearer eyJhbGciOi...&lt;br /&gt;
&lt;br /&gt;
Der Server verifiziert Signatur, Issuer, Ausstellzeitpunkt und Ablauf (Toleranz: 20 Sekunden Clock-Skew) und prüft anschliessend die &#039;&#039;&#039;Sitzung&#039;&#039;&#039; in &#039;&#039;RESTSRV_TOKEN&#039;&#039;. Im Skript stehen die Claims als Parameter &#039;&#039;_OBS_JWT_ID&#039;&#039;, &#039;&#039;_OBS_JWT_SUBJECT&#039;&#039; und &#039;&#039;_OBS_JWT_AUDIENCE&#039;&#039; zur Verfügung.&lt;br /&gt;
&lt;br /&gt;
Die Sitzungsprüfung weist ein Token ab, das zu keiner Zeile gehört oder dessen&lt;br /&gt;
Sitzung gesperrt wurde - beides mit &#039;&#039;&#039;401&#039;&#039;&#039; und dem Code &#039;&#039;AUTH_EXPIRED&#039;&#039;. Der&lt;br /&gt;
Grund steht jeweils im Protokoll &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; unter &#039;&#039;TokenStore: ...&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein Token, das &#039;&#039;&#039;vor&#039;&#039;&#039; der Einführung dieser Prüfung ausgestellt&lt;br /&gt;
wurde, gehört zu keiner Sitzung und wird abgewiesen. Nach dem Update müssen sich&lt;br /&gt;
angemeldete Konsumenten also einmalig neu anmelden.}}&lt;br /&gt;
&lt;br /&gt;
===Phase 3: Token erneuern (Refresh)===&lt;br /&gt;
&lt;br /&gt;
Soll die App lange ohne Neuanmeldung arbeiten, stellt das Authenticate-Skript zusätzlich ein Refresh-Token aus (Feld &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039;). Zum Erneuern ruft der Client &#039;&#039;&#039;denselben JWT-Endpunkt&#039;&#039;&#039; auf, aber mit dem Refresh-Token im &#039;&#039;Authorization&#039;&#039;-Header:&lt;br /&gt;
&lt;br /&gt;
 POST https://api.meinserver.de/oauth/&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
         Authorization: Bearer eyJhbGciOi... (Refresh-Token)&lt;br /&gt;
&lt;br /&gt;
Der Server erkennt am Bearer-Token den Refresh-Fall, verifiziert das Token (Signatur, Ablauf, &#039;&#039;token_use=refresh&#039;&#039;), &#039;&#039;&#039;entwertet es in der Sitzung&#039;&#039;&#039; und ruft erst dann im selben Skript die Methode &#039;&#039;&#039;Refresh&#039;&#039;&#039; auf. Das Skript liefert nur noch die aktuellen Claims zurück - Rotation, Einmalgebrauch und Sperrliste erledigt der Server. Die Antwort ist ein rotiertes Paar &#039;&#039;{&amp;quot;token&amp;quot;,&amp;quot;accessToken&amp;quot;,&amp;quot;refreshToken&amp;quot;,&amp;quot;expiresIn&amp;quot;,&amp;quot;serverTime&amp;quot;}&#039;&#039; in &#039;&#039;&#039;derselben Sitzung&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Eine &#039;&#039;&#039;eigene Sperrtabelle im Skript ist nicht mehr nötig&#039;&#039;&#039; und sollte&lt;br /&gt;
entfernt werden. Wer sie weiterführt, rotiert zweimal: einmal im Skript, einmal im&lt;br /&gt;
Server. Der Server sieht die Skript-Tabelle nicht, und das Skript sieht die&lt;br /&gt;
Sitzung nicht.}}&lt;br /&gt;
&lt;br /&gt;
Verhalten bei einem bereits benutzten Refresh-Token:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Lage !! Antwort !! Wirkung auf die Sitzung&lt;br /&gt;
|-&lt;br /&gt;
| Erneute Vorlage &#039;&#039;&#039;innerhalb von 60 Sekunden&#039;&#039;&#039; || 401 &#039;&#039;AUTH_EXPIRED&#039;&#039; || Keine. Das ist der Normalfall einer App, deren parallele Anfragen gleichzeitig in den Refresh laufen - eine gewinnt, die anderen sollen die vom Gewinner gespeicherten Token benutzen&lt;br /&gt;
|-&lt;br /&gt;
| Erneute Vorlage &#039;&#039;&#039;später&#039;&#039;&#039; || 401 &#039;&#039;AUTH_EXPIRED&#039;&#039; || Die &#039;&#039;&#039;gesamte Sitzung wird gesperrt&#039;&#039;&#039;. Das Token wurde kopiert oder wiederholt, ohne dass ein Gewinner es noch brauchen könnte; der Vorfall wird mit IP protokolliert&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Details und ein vollständiges Beispiel siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
===Phase 4: Abmelden (Logout)===&lt;br /&gt;
&lt;br /&gt;
Abgemeldet wird mit einem &#039;&#039;&#039;DELETE&#039;&#039;&#039; auf denselben JWT-Endpunkt, das&lt;br /&gt;
Access-Token im &#039;&#039;Authorization&#039;&#039;-Header:&lt;br /&gt;
&lt;br /&gt;
 DELETE https://api.meinserver.de/oauth/&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
         Authorization: Bearer eyJhbGciOi... (Access-Token)&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 204 No Content&lt;br /&gt;
&lt;br /&gt;
Der Server sperrt die &#039;&#039;&#039;ganze Sitzung&#039;&#039;&#039; - also auch die noch nicht&lt;br /&gt;
abgelaufenen Access-Token aller vorherigen Erneuerungen. &#039;&#039;&#039;Ein Skript ist dafür&lt;br /&gt;
nicht nötig&#039;&#039;&#039;: es gibt keine Methode &#039;&#039;Logout&#039;&#039;, der Vorgang läuft komplett in&lt;br /&gt;
der Engine. Damit funktioniert das Abmelden für jeden Zugang mit aktiver&lt;br /&gt;
JWT-Pflicht, ohne dass am Authentifizierungs-Skript etwas geändert wird.&lt;br /&gt;
&lt;br /&gt;
* Ein wiederholter Aufruf ist unschädlich und bleibt &#039;&#039;&#039;204&#039;&#039;&#039; - auch wenn die Sitzung längst gesperrt ist.&lt;br /&gt;
* Ein abgelaufenes oder ungültiges Token ergibt &#039;&#039;&#039;401&#039;&#039;&#039; (es wird schon bei der Prüfung abgewiesen).&lt;br /&gt;
* Ein Refresh-Token statt des Access-Tokens ergibt &#039;&#039;&#039;401&#039;&#039;&#039;.&lt;br /&gt;
* Lässt sich die Sitzung nicht sperren, antwortet der Server &#039;&#039;&#039;503&#039;&#039;&#039; mit &#039;&#039;Retry-After&#039;&#039; - ein Client darf nicht glauben, er sei abgemeldet, während sein Token weiterläuft.&lt;br /&gt;
&lt;br /&gt;
====Alle Geräte abmelden====&lt;br /&gt;
&lt;br /&gt;
Der Fall „Gerät verloren&amp;quot; ist bewusst &#039;&#039;&#039;nicht&#039;&#039;&#039; am JWT-Endpunkt untergebracht:&lt;br /&gt;
er braucht eine fachliche Entscheidung darüber, wer das darf. Dafür gibt es das&lt;br /&gt;
&#039;&#039;oWriter.JwtRevoke&#039;&#039;, das jeder gewöhnliche Endpunkt aufrufen kann:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure Post(oReader: TxRestReader; oWriter: TxRestWriter);&lt;br /&gt;
begin&lt;br /&gt;
    // &#039;session&#039; = nur diese Sitzung, &#039;all&#039; = alle Sitzungen des Subjects&lt;br /&gt;
    oWriter.JwtRevoke(&#039;all&#039;);&lt;br /&gt;
    oWriter.Status(204);&lt;br /&gt;
    oWriter.NoBody();&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;all&#039;&#039; setzt voraus, dass das Authenticate-Skript &#039;&#039;_OBS_JWT_SUBJECT&#039;&#039;&lt;br /&gt;
gesetzt hat. Ohne Subject lässt sich die Menge der Sitzungen nicht bestimmen, und&lt;br /&gt;
der Aufruf antwortet mit 503.}}&lt;br /&gt;
&lt;br /&gt;
==Woher ein Anmelder seine Identität bekommt==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Diese Frage entscheidet das Skript des Zugangs, nicht der Server.&#039;&#039;&#039; Das&lt;br /&gt;
Authentifizierungs-Skript liegt je Zugang in &amp;lt;code&amp;gt;ra_jwt_script&amp;lt;/code&amp;gt; - und&lt;br /&gt;
damit gilt für jede Anwendung ihre eigene Regel.&lt;br /&gt;
&lt;br /&gt;
Das ist keine Kleinigkeit, sondern der Punkt, an dem sich zwei Anwendungen&lt;br /&gt;
unterscheiden. Ein Beispiel aus dem Betrieb: Ein Zugang für eine&lt;br /&gt;
Techniker-Anwendung lehnt jeden Benutzer ohne Mitarbeiterzuordnung&lt;br /&gt;
(&amp;lt;code&amp;gt;BENUTZ.b_mitarbeiter&amp;lt;/code&amp;gt;) ab - &#039;&#039;&#039;bewusst&#039;&#039;&#039;, denn ein Techniker ohne&lt;br /&gt;
Zuordnung bekäme eine leere Auftragsliste und würde den Fehler bei sich suchen.&lt;br /&gt;
Für einen Kundenzugang wäre genau dieselbe Regel falsch: sie träfe &#039;&#039;&#039;jeden&#039;&#039;&#039;&lt;br /&gt;
Anmelder.&lt;br /&gt;
&lt;br /&gt;
Wer einen zweiten Zugang für eine andere Anwendung anlegt, prüft deshalb als&lt;br /&gt;
Erstes: Woraus besteht die Identität dieser Anmelder, und welche Ablehnung ist&lt;br /&gt;
für sie richtig? Ein kopiertes Skript bringt die Regel der anderen Anwendung&lt;br /&gt;
mit, und sie fällt erst auf, wenn sich der erste echte Anmelder nicht anmelden&lt;br /&gt;
kann.&lt;br /&gt;
&lt;br /&gt;
==Sitzungen (RESTSRV_TOKEN)==&lt;br /&gt;
&lt;br /&gt;
Je ausgestelltem Token-Paar entsteht eine Zeile. Sie enthält die Sitzung, die&lt;br /&gt;
Zeilenkennung, das Subject, die beiden Laufzeiten, IP und Trace-ID der Ausstellung&lt;br /&gt;
sowie die Kennzeichen „verbraucht&amp;quot; und „gesperrt&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Rotation und Widerruf sind getrennt.&#039;&#039;&#039; Ein Refresh entwertet nur das Refresh-Token; der zugehörige Access-Token bleibt bis zu seinem Ablauf gültig. Sonst bekämen parallel laufende Anfragen ein 401, obwohl niemand abgemeldet ist.&lt;br /&gt;
* &#039;&#039;&#039;Die Sitzung überlebt die Rotation.&#039;&#039;&#039; Alle Paare einer Anmeldung tragen dieselbe Sitzungskennung - deshalb kann ein Logout sie gemeinsam sperren.&lt;br /&gt;
* &#039;&#039;&#039;Aufräumen läuft automatisch&#039;&#039;&#039; beim Serverstart und danach höchstens stündlich. Gelöscht wird nur, was nichts mehr entscheiden kann: Zeilen, deren Access- &#039;&#039;&#039;und&#039;&#039;&#039; Refresh-Laufzeit vorbei sind. Eine verbrauchte Zeile bleibt bis dahin stehen, weil sonst die Erkennung eines später vorgelegten gestohlenen Refresh-Tokens verloren ginge.&lt;br /&gt;
* &#039;&#039;&#039;Kein Eingriff nötig.&#039;&#039;&#039; Die Tabelle wird nicht gepflegt; sie ist Diagnose-Material. Alle Abweisungen stehen als &#039;&#039;TokenStore: ...&#039;&#039; in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==mTLS-Subject-Prüfung==&lt;br /&gt;
&lt;br /&gt;
Wird im Feld &#039;&#039;&#039;mTLS-Subject&#039;&#039;&#039; ein Wert hinterlegt, prüft der Server zusätzlich zur Standard-mTLS-Validierung, ob der Subject-DN des (CA-validierten) Client-Zertifikats die hinterlegten &#039;&#039;&#039;RDN-Komponenten&#039;&#039;&#039; enthält. Verglichen wird jede Komponente &#039;&#039;&#039;vollständig&#039;&#039;&#039; (kein Substring-Match) und case-insensitiv; mehrere Komponenten werden durch Komma oder Semikolon getrennt und müssen &#039;&#039;&#039;alle&#039;&#039;&#039; im Subject vorkommen.&lt;br /&gt;
&lt;br /&gt;
Beispiele für Subject-Strings im Cert (OneLine-DN):&lt;br /&gt;
&lt;br /&gt;
 /C=DE/O=Kunde GmbH/CN=client-prod.kunde.de&lt;br /&gt;
 /C=DE/O=Kunde GmbH/CN=client-test.kunde.de&lt;br /&gt;
&lt;br /&gt;
Mit &#039;&#039;&#039;CN=client-prod.kunde.de&#039;&#039;&#039; im Feld wird genau das prod-Cert akzeptiert, das test-Cert mit 401 abgewiesen. Da jede RDN-Komponente vollständig verglichen wird, matcht z.B. &#039;&#039;&#039;CN=client1&#039;&#039;&#039; nicht fälschlich auch &#039;&#039;&#039;CN=client10&#039;&#039;&#039;. Mehrere Bedingungen lassen sich mit Komma/Semikolon kombinieren, z.B. &#039;&#039;O=Kunde GmbH;CN=client-prod.kunde.de&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==Listen-Funktionen==&lt;br /&gt;
&lt;br /&gt;
In der Zugänge-Liste (allein):&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Einfg&#039;&#039;&#039; - neuer Zugang&lt;br /&gt;
* &#039;&#039;&#039;Return&#039;&#039;&#039; - Zugang bearbeiten&lt;br /&gt;
* &#039;&#039;&#039;F4&#039;&#039;&#039; - Sortierung&lt;br /&gt;
* &#039;&#039;&#039;F7&#039;&#039;&#039; - JWT-Authentifizierungs-Skript bearbeiten&lt;br /&gt;
* &#039;&#039;&#039;F8&#039;&#039;&#039; - Statistik für den Zugang&lt;br /&gt;
&lt;br /&gt;
Aus der Endpunkt-Liste via F6 geöffnet (Auswahl für Berechtigung):&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;F2&#039;&#039;&#039; - markierte Zugänge dem Endpunkt zuordnen&lt;br /&gt;
* &#039;&#039;&#039;F5&#039;&#039;&#039; - Zugang in der Liste markieren / Markierung aufheben&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Einrichtung&amp;diff=64800</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Einrichtung</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Einrichtung&amp;diff=64800"/>
		<updated>2026-09-09T12:30:27Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Einrichtung eines REST-Endpunktes=&lt;br /&gt;
&lt;br /&gt;
Die folgende Reihenfolge stellt sicher, dass ein lauffähiger Endpunkt mit möglichst wenigen Korrektur-Schleifen entsteht.&lt;br /&gt;
&lt;br /&gt;
==Vorbereitung==&lt;br /&gt;
&lt;br /&gt;
# OBS-Support kontaktieren und das REST-Server-Modul aktivieren lassen.&lt;br /&gt;
# Prüfen, ob auf dem Zielrechner (OBS-Server oder dedizierter REST-Server) Port und IP-Adresse frei sind.&lt;br /&gt;
# Bei TLS-Profilen: TLS-Zertifikat (PEM, ggf. mit Chain), privaten Schlüssel (PEM) und - falls mTLS - das CA-Root-Zertifikat bereithalten.&lt;br /&gt;
&lt;br /&gt;
==Schritt 1: Server-Profil anlegen==&lt;br /&gt;
&lt;br /&gt;
In &#039;&#039;&#039;Stammdaten -&amp;gt; Z Weitere Stammdaten -&amp;gt; REST-Server&#039;&#039;&#039; den Punkt &#039;&#039;&#039;Server&#039;&#039;&#039; öffnen und mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; ein neues Profil anlegen. Pflichtfelder:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Nr&#039;&#039;&#039; - eindeutige Nummer (1-99), wird beim Anlegen automatisch vorgeschlagen.&lt;br /&gt;
* &#039;&#039;&#039;Name&#039;&#039;&#039; - sprechender Name (z.B. &#039;&#039;Public-API&#039;&#039;, &#039;&#039;Internal-mTLS&#039;&#039;, &#039;&#039;Debug-Local&#039;&#039;).&lt;br /&gt;
* Auswahl des Modus:&lt;br /&gt;
** &#039;&#039;&#039;Standard-TLS&#039;&#039;&#039; (Default): Zertifikat + Key + Root-Zertifikat hinterlegen.&lt;br /&gt;
** &#039;&#039;&#039;mTLS&#039;&#039;&#039; aktivieren: zusätzlich CA-Root-Zertifikat hinterlegen, jeder Client muss ein gültiges Cert vorlegen.&lt;br /&gt;
** &#039;&#039;&#039;Kein SSL&#039;&#039;&#039; aktivieren: ausschliesslich für lokale Tests, der Server akzeptiert dann nur Plain-HTTP.&lt;br /&gt;
&lt;br /&gt;
Optional lassen sich am Profil eigene &#039;&#039;&#039;Rate-Limit-Schwellen&#039;&#039;&#039; hinterlegen. Ohne&lt;br /&gt;
Angabe gelten die Standardwerte (60-Sekunden-Fenster, 120 erfolgreiche und 10&lt;br /&gt;
fehlgeschlagene Anfragen je Schlüssel, 600 Anfragen je Profil). Für Anwendungen&lt;br /&gt;
ohne eigenen API-Key - typisch mobile Apps - sollten die Werte bewusst gesetzt&lt;br /&gt;
werden, weil dann alle Benutzer hinter einer IP auf denselben Zähler laufen.&lt;br /&gt;
&lt;br /&gt;
Details siehe [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]].&lt;br /&gt;
&lt;br /&gt;
==Schritt 2: Bindung anlegen==&lt;br /&gt;
&lt;br /&gt;
Im Server-Profil mit &#039;&#039;&#039;F6&#039;&#039;&#039; die Bindungs-Liste öffnen und mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; eine neue Bindung anlegen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Host&#039;&#039;&#039; - IP-Adresse, auf der gelauscht werden soll (z.B. &#039;&#039;0.0.0.0&#039;&#039; für alle Adressen, &#039;&#039;127.0.0.1&#039;&#039; für lokal).&lt;br /&gt;
* &#039;&#039;&#039;Port&#039;&#039;&#039; - Port-Nummer (typisch 443 für HTTPS, 8099 für Debug, ggf. abweichend).&lt;br /&gt;
* &#039;&#039;&#039;Standard&#039;&#039;&#039; setzen, wenn diese Bindung die Standard-Bindung des Servers ist (pro Server-Profil nur eine).&lt;br /&gt;
&lt;br /&gt;
==Schritt 3: REST-Dienst starten / neustarten==&lt;br /&gt;
&lt;br /&gt;
Damit Änderungen an Server-Profilen oder Bindungen wirksam werden, muss der OBS REST-Server-Dienst (oder die Konsole) gestartet bzw. neugestartet werden. Das Profil wird beim Start aus der Datenbank gelesen.&lt;br /&gt;
&lt;br /&gt;
==Schritt 4: Zugang anlegen==&lt;br /&gt;
&lt;br /&gt;
Unter &#039;&#039;&#039;Zugänge&#039;&#039;&#039; mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; einen neuen Zugang anlegen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Name&#039;&#039;&#039; - erscheint im Protokoll und in der Statistik.&lt;br /&gt;
* &#039;&#039;&#039;API-Key&#039;&#039;&#039; - eindeutig, mit dem Schlüsselsymbol kann ein zufälliger Key generiert werden.&lt;br /&gt;
* &#039;&#039;&#039;Host&#039;&#039;&#039; - optional die IP oder der Hostname des Konsumenten (sollte, wenn möglich, immer gesetzt werden).&lt;br /&gt;
* Optional: &#039;&#039;&#039;CORS-Origins&#039;&#039;&#039; und &#039;&#039;&#039;JWT-Konfiguration&#039;&#039;&#039;, siehe [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
&lt;br /&gt;
==Schritt 5: Endpunkt anlegen==&lt;br /&gt;
&lt;br /&gt;
Unter &#039;&#039;&#039;Endpunkte&#039;&#039;&#039; mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; einen neuen Endpunkt anlegen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Endpunkt&#039;&#039;&#039; - Ressourcen-/Anzeigename (z.B. &#039;&#039;orders&#039;&#039;, &#039;&#039;kalender&#039;&#039;); dient nur der Übersicht, nicht dem Routing.&lt;br /&gt;
* &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; - der maßgebliche Routing-Pfad, ggf. mit Platzhaltern (z.B. &#039;&#039;/orders&#039;&#039;, &#039;&#039;/orders/{uid}&#039;&#039;, &#039;&#039;/orders/{uid}/modules/{code}&#039;&#039;).&lt;br /&gt;
* &#039;&#039;&#039;Server&#039;&#039;&#039; - die Server-Auswahl bestimmt, über welches Server-Profil der Endpunkt erreichbar ist.&lt;br /&gt;
* &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; - nur setzen, wenn der Endpunkt Dateien entgegennehmen soll; dann auch die &#039;&#039;&#039;Max. Upload-Grösse&#039;&#039;&#039; in MB festlegen (siehe [[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6]]).&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Eine Versionskennung wird als statisches Segment ins Pfad-Template aufgenommen (z.B. &#039;&#039;/orders/v1&#039;&#039;); ein eigenes Versionsfeld gibt es nicht. Details: [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]].}}&lt;br /&gt;
&lt;br /&gt;
Anschliessend mit &#039;&#039;&#039;F7&#039;&#039;&#039; das Endpunkt-Skript pflegen (siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]]).&lt;br /&gt;
&lt;br /&gt;
==Schritt 6: Zugang für den Endpunkt freischalten==&lt;br /&gt;
&lt;br /&gt;
Aus der Endpunkt-Liste den neuen Endpunkt markieren und &#039;&#039;&#039;F6&#039;&#039;&#039; drücken. Es öffnet sich die Berechtigungs-Liste. Mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; kann ein oder mehrere Zugänge ausgewählt und mit &#039;&#039;&#039;F2&#039;&#039;&#039; übernommen werden. Erst nach dieser Freischaltung darf ein Zugang den Endpunkt aufrufen.&lt;br /&gt;
&lt;br /&gt;
==Schritt 7: Funktionsprüfung==&lt;br /&gt;
&lt;br /&gt;
Aufruf mit einem HTTP-Client (curl, Postman, Browser-Plug-in):&lt;br /&gt;
&lt;br /&gt;
 curl -H &amp;quot;apikey: [API-KEY]&amp;quot; https://api.meinserver.de/[Pfad-Template]&lt;br /&gt;
&lt;br /&gt;
Bei korrektem Setup erscheint die JSON-Antwort des Skripts. Fehlerfälle werden in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; protokolliert.&lt;br /&gt;
&lt;br /&gt;
==Schritt 8: Statistik und Protokoll prüfen==&lt;br /&gt;
&lt;br /&gt;
* In der Endpunkt-Liste &#039;&#039;&#039;F8&#039;&#039;&#039; für die Statistik des Endpunkts.&lt;br /&gt;
* In der Zugänge-Liste &#039;&#039;&#039;F8&#039;&#039;&#039; für die Statistik des Zugangs.&lt;br /&gt;
* In &#039;&#039;&#039;Stammdaten -&amp;gt; Z Weitere Stammdaten -&amp;gt; REST-Server -&amp;gt; Protokoll&#039;&#039;&#039; die Ereignisse prüfen.&lt;br /&gt;
&lt;br /&gt;
==Wartung der Server-Tabellen==&lt;br /&gt;
&lt;br /&gt;
Die Tabellen des REST-Servers pflegen sich selbst; ein regelmässiger Eingriff ist&lt;br /&gt;
nicht vorgesehen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Tabelle !! Aufräumen&lt;br /&gt;
|-&lt;br /&gt;
| RESTSRV_IDEMPOTENCY || Abgeschlossene Einträge werden nach 30 Tagen gelöscht. Offene Reservierungen bleiben absichtlich stehen und werden ab 24 Stunden im Protokoll gemeldet - sie sind ein Support-Fall, siehe Stolpersteine&lt;br /&gt;
|-&lt;br /&gt;
| RESTSRV_TOKEN || Zeilen werden gelöscht, sobald Access- &#039;&#039;&#039;und&#039;&#039;&#039; Refresh-Laufzeit vorbei sind. Läuft beim Serverstart und danach höchstens stündlich&lt;br /&gt;
|-&lt;br /&gt;
| RESTSRV_PROTO / RESTSRV_STATS || Wachsen mit dem Betrieb. Bei hohem Aufkommen empfiehlt sich ein turnusmässiges Archivieren&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==Häufige Stolpersteine==&lt;br /&gt;
&lt;br /&gt;
* Bindung angelegt, aber der Server wurde nicht neugestartet -&amp;gt; die Bindung ist noch nicht aktiv.&lt;br /&gt;
* Endpunkt einem falschen Server zugeordnet -&amp;gt; Aufruf liefert 404 &#039;&#039;Endpunkt nicht vorhanden oder inaktiv&#039;&#039;.&lt;br /&gt;
* Pfad-Template falsch geschrieben oder Segmentzahl passt nicht -&amp;gt; kein Template matcht, Aufruf liefert 404.&lt;br /&gt;
* Zugang für den Endpunkt nicht freigeschaltet -&amp;gt; Aufruf liefert 403 &#039;&#039;Keine Berechtigung für die Endpunkt-Nutzung&#039;&#039;.&lt;br /&gt;
* Falscher API-Key oder falscher Host beim Zugang -&amp;gt; Aufruf liefert 401 bzw. 403.&lt;br /&gt;
* mTLS aktiv, aber kein Client-Zertifikat installiert -&amp;gt; Verbindung wird während des TLS-Handshakes abgebrochen.&lt;br /&gt;
* Datei-Upload ohne gesetzten Haken &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; am Endpunkt -&amp;gt; Aufruf liefert 415, das Skript läuft nicht an.&lt;br /&gt;
* Skript geändert, aber der Aufruf verhält sich noch wie vorher -&amp;gt; Endpunkt-Cache abwarten (bis zu 60 Sekunden).&lt;br /&gt;
* Wiederholter Schreibaufruf liefert 422 &#039;&#039;IDEMPOTENCY_KEY_REUSED&#039;&#039; -&amp;gt; der Client sendet denselben &#039;&#039;Idempotency-Key&#039;&#039; mit geändertem Inhalt. Pro Vorgang genau ein Schlüssel, über alle Wiederholversuche hinweg unverändert.&lt;br /&gt;
* Wiederholter Schreibaufruf liefert 409 &#039;&#039;IDEMPOTENCY_UNRESOLVED&#039;&#039; -&amp;gt; ein früherer Versuch wurde hart abgebrochen (Dienst beendet). Ob gebucht wurde, ist offen: im Protokoll und in den Fachdaten prüfen. Der Aufräumlauf gibt den Eintrag nach 24 Stunden von selbst frei; wer schneller weitermachen will, entfernt ihn in &#039;&#039;&#039;RESTSRV_IDEMPOTENCY&#039;&#039;&#039;.&lt;br /&gt;
* Nach einem Update antworten &#039;&#039;&#039;alle&#039;&#039;&#039; bestehenden Token mit 401 &#039;&#039;AUTH_EXPIRED&#039;&#039; -&amp;gt; erwartetes Verhalten. Token, die vor der Einführung der Sitzungsprüfung ausgestellt wurden, gehören zu keiner Sitzung; die Konsumenten müssen sich einmalig neu anmelden.&lt;br /&gt;
* Anmeldung liefert 503 statt eines Tokens -&amp;gt; die Sitzungszeile konnte nicht geschrieben werden. Ursache im Protokoll unter &#039;&#039;TokenStore: ...&#039;&#039; nachsehen; typisch ist eine fehlende Tabelle &#039;&#039;&#039;RESTSRV_TOKEN&#039;&#039;&#039; nach einem Update ohne Datenbank-Abgleich.&lt;br /&gt;
* Abmelden liefert 503 -&amp;gt; die Sitzung liess sich nicht sperren. Der Client ist &#039;&#039;&#039;nicht&#039;&#039;&#039; abgemeldet und muss den Aufruf wiederholen; Details im Protokoll.&lt;br /&gt;
* Techniker wird beim Arbeiten unerwartet abgemeldet -&amp;gt; im Protokoll nach &#039;&#039;WIEDERVORLAGE&#039;&#039; suchen. Ein doppelt verwendetes Refresh-Token sperrt die Sitzung. Legt die App Token mehrfach parallel ab, gehört das clientseitig behoben.&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer&amp;diff=64799</id>
		<title>OBS/Kostenpflichtige Module/RESTServer</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer&amp;diff=64799"/>
		<updated>2026-09-09T12:30:07Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
=REST-Server=&lt;br /&gt;
{{Hinweis|Es handelt sich um ein kostenpflichtiges Modul. Die Schnittstelle muss über den OBS-Support aktiviert werden.}}&lt;br /&gt;
&lt;br /&gt;
==Was leistet der REST-Server?==&lt;br /&gt;
&lt;br /&gt;
Der REST-Server ist die &#039;&#039;&#039;universelle Schnittstelle&#039;&#039;&#039; Ihrer OBS-Installation nach außen. Er macht aus Ihrem OBS einen Dienst, mit dem andere Programme - Webseiten, Apps, Geräte, Kundensysteme, Cloud-Dienste - direkt sprechen können. Statt Daten manuell zu exportieren, zu mailen oder über Umwege bereitzustellen, holen sich angebundene Systeme genau die Information, die sie brauchen, in dem Moment, in dem sie sie brauchen - oder liefern neue Daten direkt in OBS ab.&lt;br /&gt;
&lt;br /&gt;
Für Anwender, die nicht selbst programmieren, bedeutet das: &#039;&#039;&#039;Was bisher nur manuell, per Datei-Import oder über Spezialschnittstellen ging, lässt sich jetzt automatisieren.&#039;&#039;&#039; Eine Webseite zeigt live aktuelle Lagerbestände. Mitarbeiter erfassen Zeiten über das Handy, ohne dass jemand Excel-Listen einpflegen muss. Ein Kunde stößt per Bestelltaste in seinem System einen Vorgang in Ihrem OBS an. Ein Lieferant meldet Wareneingänge automatisch zurück. Jeder dieser Anwendungsfälle wird einmal eingerichtet und läuft danach automatisch.&lt;br /&gt;
&lt;br /&gt;
Technisch gesehen ist der REST-Server ein in OBS integrierter, voll konfigurierbarer &#039;&#039;&#039;HTTP/HTTPS-Dienst&#039;&#039;&#039;, dessen Endpunkte über in OBS gepflegte Pascal-Skripte realisiert werden. Jeder Endpunkt bekommt eine eigene Adresse, eine eigene Logik und eine eigene Berechtigungsstruktur. Rückgaben erfolgen ausschliesslich im JSON-Format. Damit lassen sich beliebige Lese- und Schreibvorgänge auf der OBS-Datenbank realisieren, ohne dass externe Systeme direkten Datenbank-Zugriff erhalten - das ganze OBS-Regelwerk (Rechte, Validierung, Geschäftslogik) bleibt aktiv.&lt;br /&gt;
&lt;br /&gt;
Im Ergebnis ist der REST-Server kein einzelnes Feature, sondern ein &#039;&#039;&#039;Werkzeugkasten&#039;&#039;&#039;: Was an Daten oder Funktionen in OBS verfügbar ist, kann über den REST-Server auch nach außen angeboten - oder von außen entgegengenommen - werden. Die Bandbreite reicht vom einfachen Lese-Endpunkt für ein Webseiten-Widget bis hin zu kompletten B2B-Integrationen mit Mandanten- und Rollen-Trennung.&lt;br /&gt;
&lt;br /&gt;
Aufruf des Moduls in OBS:&lt;br /&gt;
&#039;&#039;&#039;Stammdaten&#039;&#039;&#039; -&amp;gt; &#039;&#039;&#039;Z Weitere Stammdaten&#039;&#039;&#039; -&amp;gt; &#039;&#039;&#039;REST-Server&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==Anwendungsbereiche==&lt;br /&gt;
&lt;br /&gt;
Die folgenden Einsatzfälle sind typische Beispiele, an denen sich der praktische Nutzen ablesen lässt. Es ist keine abschliessende Liste - alles, was sich in OBS-Skripten abbilden lässt, kann auch über einen Endpunkt bereitgestellt werden.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Dies sind Beispiele. Für die konkrete Entwicklung können, je nach Komplexität, weitere Kosten anfallen.}}&lt;br /&gt;
&lt;br /&gt;
===Webseiten und Kundenportale===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Live-Daten auf der eigenen Webseite:&#039;&#039;&#039; Lagerbestände, Verfügbarkeiten, Preise, Auftragsstatus aus OBS direkt anzeigen, ohne tägliche Datei-Exports.&lt;br /&gt;
* &#039;&#039;&#039;Kundenportale:&#039;&#039;&#039; Kunden sehen ihre offenen Posten, Auftragshistorie, Lieferscheine. Die Seite holt sich die Daten zur Anzeigezeit direkt aus OBS, jeder Kunde sieht nur seine eigenen Daten.&lt;br /&gt;
* &#039;&#039;&#039;Trackingseiten:&#039;&#039;&#039; Status einer Bestellung, eines Auftrags oder einer Reparatur für Endkunden, abrufbar über eine Sendungsnummer.&lt;br /&gt;
&lt;br /&gt;
===Mobile Anwendungen und Außendienst===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Mobile Zeiterfassung:&#039;&#039;&#039; Mitarbeiter buchen Anfang, Pause und Ende über Smartphone oder Tablet, die Daten landen direkt in OBS - ohne Zettelwirtschaft.&lt;br /&gt;
* &#039;&#039;&#039;QR-/Barcode-Scanner und Lagergeräte:&#039;&#039;&#039; Wareneingang, Inventur, Kommissionierung direkt in OBS abbilden, ohne zwischengeschaltete Software.&lt;br /&gt;
* &#039;&#039;&#039;Außendienst-Apps:&#039;&#039;&#039; Techniker sehen ihre Aufträge, dokumentieren Einsätze, erfassen Material - synchronisiert mit OBS.&lt;br /&gt;
&lt;br /&gt;
===Anbindung von Drittsystemen===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Webshops:&#039;&#039;&#039; Bestellungen werden direkt vom Shop in OBS gemeldet, Lagerbestände und Preise vom Shop bei OBS abgefragt.&lt;br /&gt;
* &#039;&#039;&#039;Buchhaltungs- und ERP-Systeme:&#039;&#039;&#039; Bidirektionaler Austausch von Belegen, Stammdaten und Buchungen.&lt;br /&gt;
* &#039;&#039;&#039;CRM- und Marketing-Tools:&#039;&#039;&#039; Personendaten, Aktivitäten oder Mailing-Empfänger werden synchron gehalten.&lt;br /&gt;
* &#039;&#039;&#039;Logistikdienstleister:&#039;&#039;&#039; Versandaufträge werden übergeben, Tracking-Informationen zurückgeliefert.&lt;br /&gt;
&lt;br /&gt;
===Automatisierte Rückmeldungen (WebHooks)===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Zahlungs-Callbacks:&#039;&#039;&#039; Bezahldienste (z.B. PayPal, Klarna, Stripe) melden den Zahlungseingang an einen WebHook-Endpunkt - OBS bucht automatisch.&lt;br /&gt;
* &#039;&#039;&#039;Versanddienste:&#039;&#039;&#039; Statusmeldungen (verschickt, zugestellt, retour) werden direkt im passenden Vorgang dokumentiert.&lt;br /&gt;
* &#039;&#039;&#039;Cloud-Workflows:&#039;&#039;&#039; Externe Automatisierungs-Plattformen (Make, Zapier, n8n, ...) stoßen OBS-Vorgänge an oder werden von OBS aus angesprochen.&lt;br /&gt;
&lt;br /&gt;
===Geschäftspartner-Kommunikation (B2B)===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Lieferanten-Schnittstelle:&#039;&#039;&#039; Bestellungen werden automatisch übergeben, Auftragsbestätigungen und Lieferavise zurückgespielt.&lt;br /&gt;
* &#039;&#039;&#039;Kunden-Schnittstelle:&#039;&#039;&#039; Grosskunden bestellen direkt aus ihrem ERP heraus, Stammdaten und Konditionen werden zentral gepflegt.&lt;br /&gt;
* &#039;&#039;&#039;Sichere Maschine-zu-Maschine-Kommunikation:&#039;&#039;&#039; Mit Mutual-TLS (mTLS) wird sichergestellt, dass nur die freigeschalteten Partnersysteme - und niemand sonst - mit OBS sprechen können.&lt;br /&gt;
&lt;br /&gt;
===Interne Microservices und Automatisierungen===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Eigene kleine Hilfs-Tools:&#039;&#039;&#039; Zeitschaltautomatik, Reporting-Generator, Sammelaktionen, die aus mehreren Quellen Daten in OBS verarbeiten.&lt;br /&gt;
* &#039;&#039;&#039;Verbindung mehrerer Standorte:&#039;&#039;&#039; Verteilte Systeme tauschen Daten über definierte Endpunkte aus, ohne offene Datenbankverbindungen.&lt;br /&gt;
* &#039;&#039;&#039;Datenbereitstellung für Dashboards und Auswertungen:&#039;&#039;&#039; BI-Tools (Power BI, Grafana, ...) lesen aufbereitete Kennzahlen direkt aus OBS.&lt;br /&gt;
&lt;br /&gt;
===Was bedeutet das wirtschaftlich?===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Manuelle Arbeit fällt weg.&#039;&#039;&#039; Daten werden nicht mehr aus OBS exportiert, per Mail geschickt und woanders eingelesen - sie fliessen direkt.&lt;br /&gt;
* &#039;&#039;&#039;Fehler werden weniger.&#039;&#039;&#039; Jede manuelle Stelle ist eine mögliche Fehlerquelle; jede Automatisierung eine Stelle, an der nichts mehr schiefgehen kann.&lt;br /&gt;
* &#039;&#039;&#039;Neue Geschäftsmodelle werden möglich.&#039;&#039;&#039; Kunden- und Lieferantenportale, Self-Service-Funktionen, App-Anbindungen lassen sich aufbauen, ohne ein zweites System pflegen zu müssen.&lt;br /&gt;
* &#039;&#039;&#039;Bestehende Software bleibt verbunden.&#039;&#039;&#039; Statt eine vorhandene Anwendung ablösen zu müssen, kann sie über den REST-Server an OBS angedockt werden - in beiden Richtungen.&lt;br /&gt;
* &#039;&#039;&#039;Skaliert mit:&#039;&#039;&#039; Was klein anfängt (ein einzelner Endpunkt für eine Webseite) kann zu einer kompletten Integrations-Plattform ausgebaut werden, ohne dass die Grundlage gewechselt werden muss.&lt;br /&gt;
&lt;br /&gt;
==Architektur im Überblick==&lt;br /&gt;
&lt;br /&gt;
Der REST-Server besteht aus mehreren aufeinander aufbauenden Bausteinen, die alle in OBS gepflegt werden:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Baustein     !! Tabelle              !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| Server       || RESTSRV_SERVER       || TLS-Profil + eigene HTTP-Server-Instanz, kann mehrfach existieren&lt;br /&gt;
|-&lt;br /&gt;
| Bindung      || RESTSRV_BINDINGS     || IP-Adresse + Port pro Server&lt;br /&gt;
|-&lt;br /&gt;
| Zugang       || RESTSRV_ACCOUNT      || API-Key, optionale Host- und CORS-Beschränkung, optionale JWT-Konfiguration&lt;br /&gt;
|-&lt;br /&gt;
| Endpunkt     || RESTSRV_ENDPOINTS    || Skript, das die Anfrage bearbeitet, einem Server fest zugeordnet&lt;br /&gt;
|-&lt;br /&gt;
| Berechtigung || RESTSRV_ACCESS       || Verbindet Zugang und Endpunkt&lt;br /&gt;
|-&lt;br /&gt;
| Idempotenz   || RESTSRV_IDEMPOTENCY  || Speicher für Idempotency-Keys und die dazu gelieferte Antwort&lt;br /&gt;
|-&lt;br /&gt;
| Sitzung      || RESTSRV_TOKEN        || Ausgestellte Token-Paare je Sitzung; Grundlage für Rotation und Widerruf&lt;br /&gt;
|-&lt;br /&gt;
| Protokoll    || RESTSRV_PROTO        || Laufende Ereignisse, Fehler, Audit-Einträge&lt;br /&gt;
|-&lt;br /&gt;
| Statistik    || RESTSRV_STATS        || Aufrufzähler, Antwortzeiten und HTTP-Status pro Endpunkt&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Eine Anfrage durchläuft folgende Stationen:&lt;br /&gt;
&lt;br /&gt;
# Rate-Limit-Prüfung (pro API-Key bzw. IP und global, Schwellen je Server-Profil)&lt;br /&gt;
# Authentifizierung über den API-Key (Header &#039;&#039;apikey&#039;&#039;)&lt;br /&gt;
# CORS-Prüfung (sofern &#039;&#039;Origin&#039;&#039;-Header gesetzt)&lt;br /&gt;
# Optional: JWT-Prüfung - Signatur, Ablauf und Sitzung (sofern für den Zugang aktiviert)&lt;br /&gt;
# Routing: Auflösung des Pfads über die Pfad-Templates der Endpunkte&lt;br /&gt;
# Prüfung der Berechtigung (Zugang -&amp;gt; Endpunkt)&lt;br /&gt;
# Idempotenz-Prüfung (nur bei POST/PUT/PATCH/DELETE &#039;&#039;&#039;mit&#039;&#039;&#039; &#039;&#039;Idempotency-Key&#039;&#039;-Header)&lt;br /&gt;
# Ausführung des Endpunkt-Skripts (oder WebHook-Antwort)&lt;br /&gt;
# Festschreiben des Ergebnisses im Idempotenz-Speicher (sofern in Schritt 7 reserviert)&lt;br /&gt;
# JSON-Antwort, Statistik-Eintrag, Protokoll-Eintrag&lt;br /&gt;
&lt;br /&gt;
==Adressierung==&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt wird über ein &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; angesprochen, das den Pfad nach dem Host beschreibt und Platzhalter enthalten kann:&lt;br /&gt;
&lt;br /&gt;
 http://[Hostadresse][:Port][Pfad-Template]&lt;br /&gt;
&lt;br /&gt;
Beispiele:&lt;br /&gt;
&lt;br /&gt;
 https://api.meinserver.de/kalender/v1&lt;br /&gt;
 https://api.meinserver.de/orders/{uid}&lt;br /&gt;
 https://api.meinserver.de/orders/{uid}/modules/{code}&lt;br /&gt;
&lt;br /&gt;
Statische Segmente müssen exakt passen; Platzhalter in geschweiften Klammern (z.B. {uid}) matchen einen beliebigen Wert und stehen im Skript als Pfad-Parameter zur Verfügung. Eine Versionskennung wird als statisches Segment ins Template aufgenommen (z.B. /kalender/v1), um eine Funktionalität zu verändern, ohne bestehende Konsumenten zu brechen. Details: [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]].&lt;br /&gt;
&lt;br /&gt;
==Unterstützte HTTP-Methoden==&lt;br /&gt;
&lt;br /&gt;
Der Server akzeptiert die Methoden &#039;&#039;&#039;GET&#039;&#039;&#039;, &#039;&#039;&#039;POST&#039;&#039;&#039;, &#039;&#039;&#039;PUT&#039;&#039;&#039;, &#039;&#039;&#039;DELETE&#039;&#039;&#039; und &#039;&#039;&#039;PATCH&#039;&#039;&#039; sowie &#039;&#039;&#039;OPTIONS&#039;&#039;&#039; (CORS-Preflight, ohne Skript-Aufruf). Jede Methode wird im Endpunkt-Skript als gleichnamige Funktion implementiert. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
==Datei-Uploads==&lt;br /&gt;
&lt;br /&gt;
Endpunkte, die dafür freigeschaltet sind (&amp;lt;code&amp;gt;re_upload&amp;lt;/code&amp;gt;), nehmen Dateien&lt;br /&gt;
per &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; oder als fortsetzbaren &#039;&#039;&#039;Resumable-Upload&#039;&#039;&#039;&lt;br /&gt;
(&amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;) entgegen. Die maximale Grösse wird pro Endpunkt&lt;br /&gt;
festgelegt (&amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;, Standard 25&amp;amp;nbsp;MB); Überschreitung&lt;br /&gt;
ergibt 413, ein Upload an einen nicht freigeschalteten Endpunkt 415. Der Server&lt;br /&gt;
legt die Datei temporär ab und übergibt dem Skript den Pfad; die weitere&lt;br /&gt;
Verarbeitung (z.B. DMS-Ablage) übernimmt das Skript. Details:&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
==Doppelte Sendungen abfangen (Idempotenz)==&lt;br /&gt;
&lt;br /&gt;
Ein Client, der nach einem Timeout dieselbe Anfrage erneut schickt, darf keine&lt;br /&gt;
Zweitbuchung auslösen. Der Server erledigt das selbst: Sendet ein Aufruf mit&lt;br /&gt;
&#039;&#039;&#039;POST&#039;&#039;&#039;, &#039;&#039;&#039;PUT&#039;&#039;&#039;, &#039;&#039;&#039;PATCH&#039;&#039;&#039; oder &#039;&#039;&#039;DELETE&#039;&#039;&#039; den Header&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, merkt sich der Server Schlüssel und Ergebnis in&lt;br /&gt;
&#039;&#039;&#039;RESTSRV_IDEMPOTENCY&#039;&#039;&#039;. Eine Wiederholung mit demselben Schlüssel und demselben&lt;br /&gt;
Inhalt bekommt die &#039;&#039;&#039;gespeicherte Antwort&#039;&#039;&#039; zurück - das Endpunkt-Skript läuft&lt;br /&gt;
gar nicht erst an.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Ohne den Header ändert sich nichts.&#039;&#039;&#039; Bestehende Endpunkte verhalten sich unverändert.&lt;br /&gt;
* &#039;&#039;&#039;Kein Schalter am Endpunkt.&#039;&#039;&#039; Die Mechanik greift automatisch, sobald der Header anliegt.&lt;br /&gt;
* &#039;&#039;&#039;Das Skript muss nichts tun.&#039;&#039;&#039; Eine eigene Idempotenz-Logik im Skript ist nicht mehr nötig.&lt;br /&gt;
&lt;br /&gt;
Details und die Statuscodes: [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
==Sitzungen und Abmelden==&lt;br /&gt;
&lt;br /&gt;
Ein JWT trägt seine Gültigkeit in sich: der Server prüft Signatur und Ablauf und&lt;br /&gt;
braucht dafür keine Datenbank. Genau deshalb liesse sich ein ausgegebener Token&lt;br /&gt;
von sich aus auch nicht mehr zurückziehen - ein verlorenes Gerät wäre bis zum&lt;br /&gt;
Ablauf des Tokens weiter nutzbar.&lt;br /&gt;
&lt;br /&gt;
Der Server führt deshalb zu jedem ausgestellten Token-Paar eine Zeile in&lt;br /&gt;
&#039;&#039;&#039;RESTSRV_TOKEN&#039;&#039;&#039; und prüft jeden Zugriff dagegen. Daraus ergeben sich drei&lt;br /&gt;
Eigenschaften:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Abmelden wirkt sofort.&#039;&#039;&#039; Ein &#039;&#039;DELETE&#039;&#039; auf den JWT-Endpunkt sperrt die Sitzung; alle Token dieser Sitzung sind damit unmittelbar ungültig, auch die noch nicht abgelaufenen. Anmelden, Erneuern und Abmelden laufen also über einen einzigen Endpunkt, und für das Abmelden ist kein Skript nötig.&lt;br /&gt;
* &#039;&#039;&#039;Ein Refresh-Token gilt genau einmal.&#039;&#039;&#039; Beim Erneuern wird es entwertet. Wird es später erneut vorgelegt, gilt das als Diebstahl oder Fehlfunktion: die betroffene Sitzung wird komplett gesperrt und der Vorfall protokolliert.&lt;br /&gt;
* &#039;&#039;&#039;Alle Geräte abmelden.&#039;&#039;&#039; Ein gewöhnlicher Endpunkt kann über das Antwortfeld &#039;&#039;_OBS_JWT_REVOKE&#039;&#039; auch alle Sitzungen eines Benutzers sperren - der Support-Fall „Gerät verloren&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Mit der Einführung dieser Prüfung verlieren &#039;&#039;&#039;alle vorher&lt;br /&gt;
ausgestellten Token&#039;&#039;&#039; ihre Gültigkeit, weil sie zu keiner Sitzung gehören.&lt;br /&gt;
Angemeldete Konsumenten müssen sich nach dem Update einmalig neu anmelden.}}&lt;br /&gt;
&lt;br /&gt;
Der Speicher pflegt sich selbst: Zeilen werden gelöscht, sobald sowohl das&lt;br /&gt;
Access- als auch das Refresh-Token abgelaufen sind. Details und die Skript-Seite:&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
&lt;br /&gt;
==Fehlerformat==&lt;br /&gt;
&lt;br /&gt;
Jede Fehlerantwort des Servers hat dieselbe Form: ein einziges &#039;&#039;error&#039;&#039;-Objekt&lt;br /&gt;
mit maschinenlesbarem Code, Support-Kennung, Meldung und Korrelations-ID:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;error&amp;quot;: {&lt;br /&gt;
    &amp;quot;code&amp;quot;:    &amp;quot;AUTH_EXPIRED&amp;quot;,&lt;br /&gt;
    &amp;quot;uid&amp;quot;:     &amp;quot;Y00E2RTPDY&amp;quot;,&lt;br /&gt;
    &amp;quot;message&amp;quot;: &amp;quot;Ungültiges oder abgelaufenes Token&amp;quot;,&lt;br /&gt;
    &amp;quot;traceId&amp;quot;: &amp;quot;20260817T091233123-00001A&amp;quot;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;code&#039;&#039;&#039; - sprechender Bezeichner (z.B. &#039;&#039;AUTH_EXPIRED&#039;&#039;, &#039;&#039;NOT_FOUND&#039;&#039;, &#039;&#039;RATE_LIMITED&#039;&#039;, &#039;&#039;INTERNAL_ERROR&#039;&#039;), an dem ein Client seine Reaktion festmachen kann, ohne Texte auszuwerten.&lt;br /&gt;
* &#039;&#039;&#039;uid&#039;&#039;&#039; - OBS-Kennung der Codestelle. Für die Support-Recherche gedacht, nicht zur Auswertung im Client.&lt;br /&gt;
* &#039;&#039;&#039;message&#039;&#039;&#039; - Text für den Client. Interne Fehlerdetails stehen nie darin, sondern ausschliesslich im Protokoll &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039;.&lt;br /&gt;
* &#039;&#039;&#039;traceId&#039;&#039;&#039; - Korrelations-ID. Steht zusätzlich im Header &#039;&#039;X-Trace-Id&#039;&#039; und in jeder zugehörigen Logzeile.&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt-Skript kann eigene Codes setzen; siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
{{Achtung|&#039;&#039;Fehlercode&#039;&#039;, &#039;&#039;Nachricht&#039;&#039; und &#039;&#039;traceId&#039;&#039; stehen &#039;&#039;&#039;nur&#039;&#039;&#039; im&lt;br /&gt;
&#039;&#039;error&#039;&#039;-Objekt, nicht flach daneben - der Wert von&lt;br /&gt;
&#039;&#039;Fehlercode&#039;&#039; heisst jetzt &#039;&#039;error.uid&#039;&#039;, &#039;&#039;Nachricht&#039;&#039; entspricht&lt;br /&gt;
&#039;&#039;error.message&#039;&#039;. Clients, die die flachen Felder auswerten, müssen angepasst&lt;br /&gt;
werden.}}&lt;br /&gt;
&lt;br /&gt;
==Sicherheitsmerkmale==&lt;br /&gt;
&lt;br /&gt;
Der REST-Server enthält eine Reihe von Schutzmechanismen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Rate-Limit:&#039;&#039;&#039; Standardmässig max. 10 fehlgeschlagene und 120 erfolgreiche Anfragen pro API-Key (Account) bzw. IP und 60 Sekunden, max. 600 Anfragen global pro 60 Sekunden. Beim Überschreiten wird HTTP 429 mit &#039;&#039;Retry-After&#039;&#039;-Header zurückgegeben. Die vier Schwellen lassen sich &#039;&#039;&#039;pro Server-Profil&#039;&#039;&#039; hinterlegen; die Zähler laufen ebenfalls getrennt je Profil, ein überlastetes Profil bremst das andere also nicht mit aus. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]].&lt;br /&gt;
* &#039;&#039;&#039;Body-Limit:&#039;&#039;&#039; max. 10 MB Request-Body (JSON), max. 1024 Zeichen pro Header- oder Query-Parameter. Datei-Uploads laufen über einen separaten Pfad und sind pro Endpunkt auf &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt; MB begrenzt (Standard 25&amp;amp;nbsp;MB).&lt;br /&gt;
* &#039;&#039;&#039;Sensible Felder&#039;&#039;&#039; (&#039;&#039;password&#039;&#039;, &#039;&#039;token&#039;&#039;, &#039;&#039;secret&#039;&#039;, &#039;&#039;apikey&#039;&#039;, &#039;&#039;authorization&#039;&#039;, &#039;&#039;cookie&#039;&#039;, ...) werden in Protokoll-Ausgaben automatisch maskiert.&lt;br /&gt;
* &#039;&#039;&#039;Widerrufbare Token:&#039;&#039;&#039; Jedes ausgestellte Token-Paar gehört zu einer Sitzung in &#039;&#039;&#039;RESTSRV_TOKEN&#039;&#039;&#039; und wird bei &#039;&#039;&#039;jedem&#039;&#039;&#039; Zugriff dagegen geprüft. Eine Abmeldung wirkt damit sofort und nicht erst mit dem Ablauf des Tokens; ein Refresh-Token gilt genau einmal. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
* &#039;&#039;&#039;Reserviertes Präfix:&#039;&#039;&#039; Parameter-Namen mit Präfix &#039;&#039;_OBS_&#039;&#039; können von aussen nicht gesetzt werden, sie sind für den Server reserviert (z.B. JWT-Claims).&lt;br /&gt;
* &#039;&#039;&#039;Geblockte Header:&#039;&#039;&#039; &#039;&#039;authorization&#039;&#039;, &#039;&#039;cookie&#039;&#039;, &#039;&#039;proxy-authorization&#039;&#039;, &#039;&#039;x-forwarded-for&#039;&#039;, &#039;&#039;x-real-ip&#039;&#039;, &#039;&#039;apikey&#039;&#039;, &#039;&#039;api_key&#039;&#039; werden nicht an das Skript durchgereicht.&lt;br /&gt;
* &#039;&#039;&#039;TLS:&#039;&#039;&#039; Bei Profilen mit aktivem TLS fährt der Server ohne Zertifikat und Key nicht hoch. Plain-HTTP ist nur für ein dediziertes Debug-Profil möglich.&lt;br /&gt;
* &#039;&#039;&#039;mTLS&#039;&#039;&#039; (Mutual TLS): erzwingt, dass jeder Client ein gültiges Client-Zertifikat vorlegt. Optional kann pro Zugang ein erwarteter Subject-DN hinterlegt werden.&lt;br /&gt;
* &#039;&#039;&#039;DNS-Cache:&#039;&#039;&#039; Host-zu-IP-Auflösungen für die Zugangs-Host-Prüfung werden 5 Minuten gecached.&lt;br /&gt;
&lt;br /&gt;
==Protokoll==&lt;br /&gt;
&lt;br /&gt;
Die Tabelle &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; enthält alle relevanten Ereignisse: erfolgreiche und abgelehnte Anfragen, TLS-/JWT-Fehler, Bindungs-Ereignisse, Skript-Fehler. Einträge sind nach Datum, Uhrzeit und Host (IP oder Account-Name) sortiert. Das Protokoll ist die erste Anlaufstelle bei Störungen. Jede Antwort trägt zudem eine Korrelations-ID (Response-Header &#039;&#039;X-Trace-Id&#039;&#039;), die in den Protokoll-Einträgen wiederzufinden ist.&lt;br /&gt;
&lt;br /&gt;
==Statistik==&lt;br /&gt;
&lt;br /&gt;
Die Tabelle &#039;&#039;&#039;RESTSRV_STATS&#039;&#039;&#039; enthält eine Statistik aller bearbeiteten Anfragen mit Zugang, Endpunkt, Methode, HTTP-Status und Laufzeit in Millisekunden. Aufruf der Statistik:&lt;br /&gt;
&lt;br /&gt;
* Aus der Endpunkt-Liste: F8 zeigt die Statistik des markierten Endpunkts.&lt;br /&gt;
* Aus der Zugänge-Liste: F8 zeigt die Statistik des markierten Zugangs.&lt;br /&gt;
&lt;br /&gt;
==Konsole==&lt;br /&gt;
&lt;br /&gt;
Wird der REST-Server nicht als Dienst, sondern interaktiv gestartet, erscheint eine farbige Konsole mit Live-Log. Die wichtigsten Befehle:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Befehl       !! Wirkung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;show &amp;lt;n&amp;gt;&#039;&#039; || Zeigt die Details zum Debug-Eintrag &#039;&#039;#n&#039;&#039; (z.B. Request-Header, Response-Body), JSON wird farbig formatiert&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;exit&#039;&#039; / &#039;&#039;quit&#039;&#039; || Beendet den Server geordnet&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Im Service-Modus (Windows-Dienst) ist die Konsole nicht sichtbar. Alle Einträge landen dort ausschliesslich in der Tabelle RESTSRV_PROTO.&lt;br /&gt;
&lt;br /&gt;
==Weiterführende Seiten==&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Einrichtung|Einrichtung]] - Schritt-für-Schritt-Anleitung&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]] - TLS, mTLS und Bindungen&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]] - API-Keys, JWT, CORS, Host-Beschränkung&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]] - Verwaltung, Berechtigungen, WebHooks&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]] - Aufbau der Endpunkt-Skripte&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Datei-Upload|Datei-Upload]] - Übertragungsprotokoll für Dateien (Multipart, resumable)&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel1|Beispiel 1: Daten-Abruf mit JWT]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel2|Beispiel 2: Zeiterfassung als komplette Webanwendung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel3|Beispiel 3: Datensatz anlegen mit JSON-Body (CRUD)]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4: Pfad-Parameter im Routing]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6: Datei-Upload und Ablage im DMS]]&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel6&amp;diff=64794</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel6</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel6&amp;diff=64794"/>
		<updated>2026-09-01T09:12:01Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 6: Datei-Upload und Ablage=&lt;br /&gt;
&lt;br /&gt;
Dieses Beispiel zeigt einen Endpunkt, der &#039;&#039;&#039;Dateien entgegennimmt&#039;&#039;&#039;: Ein&lt;br /&gt;
Aussendienst-Mitarbeiter fotografiert vor Ort ein Gerät, die App lädt das Foto zum&lt;br /&gt;
Auftrag hoch, der Server legt es ab und verknüpft es mit dem Vorgang.&lt;br /&gt;
&lt;br /&gt;
Der Ablauf ist derselbe für Belege aus einem Scanner, Unterschriften-Bilder oder&lt;br /&gt;
Prüfprotokolle als PDF. Behandelt werden beide Übertragungsarten - der einfache&lt;br /&gt;
Upload für kleine Dateien und der fortsetzbare Upload für grosse.&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; (Port 443)&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Mobile-App&#039;&#039;, API-Key zufällig generiert, JWT aktiv&lt;br /&gt;
* &#039;&#039;&#039;Endpunkt:&#039;&#039;&#039; &#039;&#039;/orders/{uid}/photos&#039;&#039;, Profil &#039;&#039;Public-API&#039;&#039;&lt;br /&gt;
** &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; auf &#039;&#039;&#039;Ja&#039;&#039;&#039; setzen (&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;)&lt;br /&gt;
** &#039;&#039;&#039;Max. Upload-Grösse&#039;&#039;&#039; auf &#039;&#039;20&#039;&#039; (MB) setzen&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Mobile-App&#039;&#039; für den Endpunkt freischalten&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ohne den Haken &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; lehnt der Server jeden Upload mit&lt;br /&gt;
&#039;&#039;&#039;415&#039;&#039;&#039; ab und das Skript läuft gar nicht erst an. Das ist die häufigste Ursache,&lt;br /&gt;
wenn ein Upload &amp;quot;ohne erkennbaren Grund&amp;quot; scheitert.}}&lt;br /&gt;
&lt;br /&gt;
==Was der Server erledigt, bevor das Skript läuft==&lt;br /&gt;
&lt;br /&gt;
Der Server nimmt die Datei entgegen, prüft die Grösse, legt sie in einem temporären&lt;br /&gt;
Verzeichnis ab und übergibt dem Skript drei Parameter:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Parameter !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_UPLOAD_PATH || Vollständiger Pfad zur temporären Datei&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_UPLOAD_NAME || Originaldateiname, wie ihn der Client gesendet hat&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_UPLOAD_CONTENTTYPE || Content-Type der Datei&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_UPLOAD_SHA256 || SHA-256 der gespeicherten Datei (hex, klein)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Zwei Dinge sind beim Schreiben des Skripts wichtig:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;&amp;lt;code&amp;gt;oBody&amp;lt;/code&amp;gt; ist bei Uploads immer &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt;.&#039;&#039;&#039; Der Request-Body ist die Datei, kein JSON. Zusatzangaben (Kategorie, Bemerkung) kommen deshalb als &#039;&#039;&#039;Query-Parameter&#039;&#039;&#039; mit - oder, bei &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt;, als weitere &#039;&#039;&#039;Formularfelder&#039;&#039;&#039;; die stehen dem Skript als &amp;lt;code&amp;gt;_OBS_FORM_&amp;amp;lt;name&amp;amp;gt;&amp;lt;/code&amp;gt; zur Verfügung.&lt;br /&gt;
* &#039;&#039;&#039;Die temporäre Datei bleibt liegen, wenn das Skript sie nicht übernimmt.&#039;&#039;&#039; Der Server räumt sie nicht selbst weg.&lt;br /&gt;
&lt;br /&gt;
==Endpunkt-Skript &#039;&#039;/orders/{uid}/photos&#039;&#039;==&lt;br /&gt;
&lt;br /&gt;
Das Skript prüft den Dateityp, baut einen Zielpfad aus Auftrag und Zeitstempel,&lt;br /&gt;
verschiebt die Datei dorthin und schreibt einen Verweis in die Datenbank.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
const ZIEL_BASIS = &#039;D:\OBS\Daten\Auftragsfotos\&#039;;&lt;br /&gt;
var oRes, oErr, oHdr: TJSONObject;&lt;br /&gt;
    cUid     : string;&lt;br /&gt;
    cTmpPfad : string;&lt;br /&gt;
    cOrigName: string;&lt;br /&gt;
    cTyp     : string;&lt;br /&gt;
    cEndung  : string;&lt;br /&gt;
    cKategorie: string;&lt;br /&gt;
    cZielDir : string;&lt;br /&gt;
    cZielName: string;&lt;br /&gt;
    cZielPfad: string;&lt;br /&gt;
    qNeu     : TqSQL;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUid       := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
        cTmpPfad   := oParams.Values[&#039;_OBS_UPLOAD_PATH&#039;];&lt;br /&gt;
        cOrigName  := oParams.Values[&#039;_OBS_UPLOAD_NAME&#039;];&lt;br /&gt;
        cTyp       := oParams.Values[&#039;_OBS_UPLOAD_CONTENTTYPE&#039;];&lt;br /&gt;
        cKategorie := oParams.Values[&#039;kategorie&#039;];   // Query-Parameter, nicht Body&lt;br /&gt;
&lt;br /&gt;
        // 1) Gehoert der Auftrag zum Mandanten aus dem Token?&lt;br /&gt;
        if (not AuftragGehoertZuMandant(oParams.Values[&#039;_OBS_JWT_CLAIM_tenant&#039;], cUid)) then begin&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;NOT_FOUND&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;Auftrag nicht gefunden&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 404);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // 2) Dateityp gegen eine Whitelist pruefen - niemals gegen eine&lt;br /&gt;
        //    Blacklist, und niemals dem Content-Type allein vertrauen.&lt;br /&gt;
        cEndung := Lower(ExtractFileExt(cOrigName));&lt;br /&gt;
        if ((cEndung &amp;lt;&amp;gt; &#039;.jpg&#039;) and (cEndung &amp;lt;&amp;gt; &#039;.jpeg&#039;) and (cEndung &amp;lt;&amp;gt; &#039;.png&#039;) and (cEndung &amp;lt;&amp;gt; &#039;.pdf&#039;)) then begin&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;VALIDATION_FAILED&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;Nur JPG, PNG und PDF sind zulässig&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 422);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // 3) Zielname selbst bilden. Den Originalnamen NICHT als Pfad&lt;br /&gt;
        //    verwenden - er kommt vom Client und kann &#039;..\&#039; enthalten.&lt;br /&gt;
        cZielDir  := ZIEL_BASIS + DToS(Date()) + &#039;\&#039; + cUid + &#039;\&#039;;&lt;br /&gt;
        cZielName := DToSF(Now()) + &#039;_&#039; + GlobalUID() + cEndung;&lt;br /&gt;
        cZielPfad := cZielDir + cZielName;&lt;br /&gt;
&lt;br /&gt;
        MyForceDirectories(cZielDir);&lt;br /&gt;
&lt;br /&gt;
        if (not FMove(cTmpPfad, cZielPfad)) then begin&lt;br /&gt;
            // Fehlgeschlagenes Verschieben ist ein Serverproblem -&amp;gt; 500.&lt;br /&gt;
            // Der Idempotency-Key wird bei 5xx wieder freigegeben, der&lt;br /&gt;
            // Client darf denselben Schluessel erneut senden.&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;INTERNAL_ERROR&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;Datei konnte nicht abgelegt werden&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 500);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // 4) Verweis in der Datenbank ablegen&lt;br /&gt;
        qNeu := qSqlInit(oDB, &#039;AUFTRAG_DOKUMENT&#039;);&lt;br /&gt;
        qNeu.qSet(&#039;ad_auftrag&#039;  , cUid);&lt;br /&gt;
        qNeu.qSet(&#039;ad_pfad&#039;     , cZielPfad);&lt;br /&gt;
        qNeu.qSet(&#039;ad_dateiname&#039;, cOrigName);&lt;br /&gt;
        qNeu.qSet(&#039;ad_typ&#039;      , cTyp);&lt;br /&gt;
        qNeu.qSet(&#039;ad_kategorie&#039;, cKategorie);&lt;br /&gt;
        qNeu.qSet(&#039;ad_datum&#039;    , Now());&lt;br /&gt;
        // Rueckgabewert auswerten: ohne das bestaetigt der Endpunkt eine&lt;br /&gt;
        // Ablage, die es nicht gibt - die Datei liegt dann im Zielverzeichnis,&lt;br /&gt;
        // aber ohne Verweis in der Datenbank.&lt;br /&gt;
        if (not qNeu.SaveData(NEW_RECORD)) then begin&lt;br /&gt;
            qSqlFree(qNeu);&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;INTERNAL_ERROR&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;Verweis konnte nicht gespeichert werden&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 500);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
        qSqlFree(qNeu);&lt;br /&gt;
&lt;br /&gt;
        // 5) 201 mit Location auf die abgelegte Datei&lt;br /&gt;
        oHdr := TJSONObject.Create();&lt;br /&gt;
        oHdr.AddPair(&#039;Location&#039;, &#039;/orders/&#039; + cUid + &#039;/photos/&#039; + cZielName);&lt;br /&gt;
&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 201);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
        oRes.AddPair(&#039;dateiname&#039;, cZielName);&lt;br /&gt;
        oRes.AddPair(&#039;groesse&#039;  , FSize(cZielPfad));&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Soll die Datei statt in ein Verzeichnis in das &#039;&#039;&#039;OBS-Dokumenten-System&#039;&#039;&#039;&lt;br /&gt;
wandern, ersetzt der DMS-Aufruf die Schritte 3 bis 5. Welche Funktion dafür&lt;br /&gt;
zuständig ist, hängt vom eingesetzten Dokumenttyp ab - bitte mit dem OBS-Support&lt;br /&gt;
klären.}}&lt;br /&gt;
&lt;br /&gt;
==Test mit curl==&lt;br /&gt;
&lt;br /&gt;
Einfacher Upload einer Datei (&#039;&#039;-F&#039;&#039; erzeugt &#039;&#039;multipart/form-data&#039;&#039;):&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -F &amp;quot;datei=@C:\Fotos\geraet.jpg&amp;quot; ^&lt;br /&gt;
      &amp;quot;https://api.meinserver.de/orders/4711/photos?kategorie=VORHER&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Antwort:&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 201 Created&lt;br /&gt;
 Location: /orders/4711/photos/20260817091233_A1B2C3.jpg&lt;br /&gt;
 X-Trace-Id: 20260817T091233123-00001A&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;dateiname&amp;quot;:&amp;quot;20260817091233_A1B2C3.jpg&amp;quot;,&amp;quot;groesse&amp;quot;:248113}&lt;br /&gt;
&lt;br /&gt;
Ein Upload an einen Endpunkt &#039;&#039;&#039;ohne&#039;&#039;&#039; Upload-Freigabe:&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 415 Unsupported Media Type&lt;br /&gt;
&lt;br /&gt;
Eine Datei über der eingestellten Grenze:&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 413 Payload Too Large&lt;br /&gt;
&lt;br /&gt;
==Grosse Dateien: fortsetzbarer Upload==&lt;br /&gt;
&lt;br /&gt;
Bei einem Foto aus dem Mobilfunknetz reisst die Verbindung schnell einmal ab. Für&lt;br /&gt;
solche Fälle überträgt der Client die Datei in Teilstücken und kann nach einem&lt;br /&gt;
Abbruch dort weitermachen, wo er aufgehört hat. &#039;&#039;&#039;Am Endpunkt-Skript ändert sich&lt;br /&gt;
dafür nichts&#039;&#039;&#039; - der Server sammelt die Teilstücke selbst ein und ruft das Skript&lt;br /&gt;
erst auf, wenn die Datei vollständig ist.&lt;br /&gt;
&lt;br /&gt;
Der erste Teil trägt den Dateinamen (Base64-kodiert im Header&lt;br /&gt;
&amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt;) und liefert eine &amp;lt;code&amp;gt;Upload-Id&amp;lt;/code&amp;gt; zurück:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Content-Range: bytes 0-1048575/5000000&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Upload-Metadata: filename Z2VyYWV0LmpwZw==,filetype aW1hZ2UvanBlZw==&amp;quot; ^&lt;br /&gt;
      --data-binary &amp;quot;@teil1.bin&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711/photos&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 308 Resume Incomplete&lt;br /&gt;
 Upload-Id: 7f2a9c84...&lt;br /&gt;
 Upload-Offset: 1048576&lt;br /&gt;
 Range: bytes=0-1048575&lt;br /&gt;
&lt;br /&gt;
Jeder weitere Teil sendet die &#039;&#039;Upload-Id&#039;&#039; mit. Der letzte Teil bekommt die&lt;br /&gt;
normale Antwort des Skripts (hier &#039;&#039;&#039;201&#039;&#039;&#039;). Nach einem Abbruch fragt der Client&lt;br /&gt;
den Stand ab und setzt dort auf:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Content-Range: bytes */5000000&amp;quot; -H &amp;quot;Upload-Id: 7f2a9c84...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711/photos&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 308 Resume Incomplete&lt;br /&gt;
 Upload-Offset: 3145728&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der Zwischenstand liegt im Arbeitsspeicher des Dienstes. Wird der&lt;br /&gt;
REST-Dienst neu gestartet, muss ein unvollständiger Upload von vorn beginnen.}}&lt;br /&gt;
&lt;br /&gt;
==Doppelte Uploads vermeiden==&lt;br /&gt;
&lt;br /&gt;
Bricht die Verbindung ab, nachdem der Server die Datei schon abgelegt hat, würde ein&lt;br /&gt;
Wiederholversuch dasselbe Foto ein zweites Mal einliefern. Dagegen sendet der Client&lt;br /&gt;
den Header &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; - für jedes Foto einen eigenen, über alle&lt;br /&gt;
Wiederholversuche hinweg denselben:&lt;br /&gt;
&lt;br /&gt;
 -H &amp;quot;Idempotency-Key: foto-4711-0003&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Der Server erkennt die Wiederholung und liefert die &#039;&#039;&#039;gespeicherte 201-Antwort&#039;&#039;&#039;&lt;br /&gt;
samt Header &amp;lt;code&amp;gt;Idempotent-Replay: true&amp;lt;/code&amp;gt; zurück, ohne das Skript erneut zu&lt;br /&gt;
starten. Es entsteht also weder eine zweite Datei noch ein zweiter DB-Eintrag.&lt;br /&gt;
Details: [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]], Abschnitt&lt;br /&gt;
Idempotenz.&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Endpunkt mit &#039;&#039;&#039;Datei-Upload-Freigabe&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_upload&amp;lt;/code&amp;gt;) und eigener Grössenbegrenzung.&lt;br /&gt;
* Zugriff auf die hochgeladene Datei über &#039;&#039;_OBS_UPLOAD_PATH&#039;&#039;, &#039;&#039;_OBS_UPLOAD_NAME&#039;&#039; und &#039;&#039;_OBS_UPLOAD_CONTENTTYPE&#039;&#039;.&lt;br /&gt;
* Zusatzangaben als &#039;&#039;&#039;Query-Parameter&#039;&#039;&#039;, weil &#039;&#039;oBody&#039;&#039; bei Uploads &#039;&#039;nil&#039;&#039; ist.&lt;br /&gt;
* &#039;&#039;&#039;Whitelist&#039;&#039;&#039; für zulässige Dateitypen und ein &#039;&#039;&#039;selbst gebildeter Zielname&#039;&#039;&#039; - der Originalname landet nie im Pfad.&lt;br /&gt;
* Übernahme der temporären Datei mit &#039;&#039;FMove&#039;&#039;; ohne diesen Schritt bleibt sie liegen.&lt;br /&gt;
* Antwort mit &#039;&#039;&#039;201&#039;&#039;&#039; und &#039;&#039;Location&#039;&#039;-Header.&lt;br /&gt;
* Fortsetzbarer Upload ohne jede Änderung am Skript.&lt;br /&gt;
* Schutz gegen doppelte Einlieferung über den &#039;&#039;Idempotency-Key&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==Siehe auch==&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]] - Upload-Parameter und Protokoll im Detail&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]] - Freigabe, Grössenlimit, Idempotenz&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel 5]] - Schreibzugriff mit Statuscodes und ETag&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel3&amp;diff=64793</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel3</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel3&amp;diff=64793"/>
		<updated>2026-09-01T09:11:48Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 3: Datensatz anlegen mit JSON-Body=&lt;br /&gt;
&lt;br /&gt;
Dieses Beispiel zeigt einen Endpunkt mit allen vier CRUD-Methoden (GET, POST, PUT, DELETE). Es geht um Tickets eines externen Servicedesks, der Tickets ueber den OBS REST-Server in OBS einliefern, abrufen, aktualisieren und schliessen kann.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Dieses Beispiel adressiert ein einzelnes Ticket über einen Query-Parameter (&#039;&#039;?id...&#039;&#039;). Alternativ lässt sich dieselbe Ressource über ein Pfad-Template wie &#039;&#039;/tickets/{id}&#039;&#039; ansprechen; die ID steht dann als &#039;&#039;oParams.Values[&#039;_OBS_PATH_id&#039;]&#039;&#039; bereit. Ein vollständiges Beispiel dazu zeigt [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4 - Pfad-Parameter]].}}&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039;, Bindung 0.0.0.0:443&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Servicedesk-X&#039;&#039;&lt;br /&gt;
** API-Key: zufaellig generiert&lt;br /&gt;
** Host: &#039;&#039;servicedesk.kunde.de&#039;&#039; (DNS-gebunden, IP-Zugriff wird abgelehnt)&lt;br /&gt;
** JWT: nicht aktiv&lt;br /&gt;
* &#039;&#039;&#039;Endpunkt:&#039;&#039;&#039; &#039;&#039;tickets/v1&#039;&#039;, Profil &#039;&#039;Public-API&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Servicedesk-X&#039;&#039; fuer Endpunkt &#039;&#039;tickets/v1&#039;&#039; freigeschaltet&lt;br /&gt;
&lt;br /&gt;
==Endpunkt-Skript &#039;&#039;tickets/v1&#039;&#039;==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// Hilfsfunktion: einzelnes Ticket als JSON-Objekt aufbauen&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function _TicketAsJSON(qTicket: TxFQuery): TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    result := TJSONObject.Create();&lt;br /&gt;
    result.AddPair(&#039;id&#039;        , qTicket.A2UID());&lt;br /&gt;
    result.AddPair(&#039;nr&#039;        , qTicket.A2C(&#039;ti_nr&#039;));&lt;br /&gt;
    result.AddPair(&#039;betreff&#039;   , qTicket.A2C(&#039;ti_betreff&#039;));&lt;br /&gt;
    result.AddPair(&#039;status&#039;    , qTicket.A2C(&#039;ti_status&#039;));&lt;br /&gt;
    result.AddPair(&#039;beschr&#039;    , qTicket.A2C(&#039;ti_beschr&#039;));&lt;br /&gt;
    result.AddPair(&#039;aenderung&#039; , DTToSQL(qTicket.A2D(&#039;ti_aend_dat&#039;)));&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// Hilfsfunktion: ein Feld aus dem JSON-Body als String lesen.&lt;br /&gt;
// Generics gibt es im Skript nicht - oBody.TryGetValue&amp;lt;string&amp;gt;(...) laesst sich&lt;br /&gt;
// nicht uebersetzen. GetValue liefert nil, wenn das Feld fehlt.&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function _BodyStr(oBody: TJSONObject; const cFeld: string): string;&lt;br /&gt;
var oVal: TJSONValue;&lt;br /&gt;
begin&lt;br /&gt;
    result := &#039;&#039;;&lt;br /&gt;
    if (oBody = nil) then begin&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
    oVal := oBody.GetValue(cFeld);&lt;br /&gt;
    if (Assigned(oVal)) then begin&lt;br /&gt;
        result := oVal.Value;&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// Hilfsfunktion: Fehlerantwort im einheitlichen Format aufbauen.&lt;br /&gt;
// Fachliche Ablehnungen als 4xx melden, nicht als 200 mit Fehlertext.&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function _Fehler(oParams: TStrings; nStatus: integer; const cCode, cMsg: string): string;&lt;br /&gt;
var oRes: TJSONObject;&lt;br /&gt;
    oErr: TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    oErr := TJSONObject.Create();&lt;br /&gt;
    oErr.AddPair(&#039;code&#039;   , cCode);&lt;br /&gt;
    oErr.AddPair(&#039;message&#039;, cMsg);&lt;br /&gt;
    oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, nStatus);&lt;br /&gt;
        oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// GET   /tickets/v1?id=...   - einzelnes Ticket&lt;br /&gt;
// GET   /tickets/v1          - alle offenen Tickets&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function Get(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var cSql  : string;&lt;br /&gt;
    qData : TxFQuery;&lt;br /&gt;
    oArr  : TJSONArray;&lt;br /&gt;
    cId   : string;&lt;br /&gt;
begin&lt;br /&gt;
    cId := oParams.Values[&#039;id&#039;];&lt;br /&gt;
&lt;br /&gt;
    if (not Empty(cId)) then begin&lt;br /&gt;
        cSql := &#039;SELECT * FROM tickets WHERE sys_uid = &#039; + DB_SQLVal(cId);&lt;br /&gt;
        if (DB_SOpen(oDB, cSql, qData)) then begin&lt;br /&gt;
            result := _TicketAsJSON(qData).ToJSON();&lt;br /&gt;
        end else begin&lt;br /&gt;
            result := _Fehler(oParams, 404, &#039;NOT_FOUND&#039;, &#039;Ticket nicht gefunden&#039;);&lt;br /&gt;
        end;&lt;br /&gt;
        DB_Close(qData);&lt;br /&gt;
    end else begin&lt;br /&gt;
        oArr := TJSONArray.Create();&lt;br /&gt;
        try&lt;br /&gt;
            cSql := &#039;SELECT * FROM tickets WHERE ti_status &amp;lt;&amp;gt; &amp;quot;9&amp;quot; ORDER BY ti_aend_dat DESC&#039;;&lt;br /&gt;
            if (DB_SOpen(oDB, cSql, qData)) then begin&lt;br /&gt;
                while (not qData.EoF) do begin&lt;br /&gt;
                    oArr.Add(_TicketAsJSON(qData));&lt;br /&gt;
                    qData.Next();&lt;br /&gt;
                end;&lt;br /&gt;
            end;&lt;br /&gt;
            DB_Close(qData);&lt;br /&gt;
&lt;br /&gt;
            result := oArr.ToJSON();&lt;br /&gt;
        finally&lt;br /&gt;
            MyFreeAndNil(oArr);&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// POST  /tickets/v1          - neues Ticket anlegen&lt;br /&gt;
// Body: { &amp;quot;betreff&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;beschr&amp;quot;:&amp;quot;...&amp;quot; }&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var xTicket : TqSQL;&lt;br /&gt;
    cId     : string;&lt;br /&gt;
    cBetreff: string;&lt;br /&gt;
    cBeschr : string;&lt;br /&gt;
    lOk     : Boolean;&lt;br /&gt;
    oRes    : TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    if (not Assigned(oBody)) then begin&lt;br /&gt;
        result := _Fehler(oParams, 400, &#039;BAD_REQUEST&#039;, &#039;JSON-Body erforderlich&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    cBetreff := _BodyStr(oBody, &#039;betreff&#039;);&lt;br /&gt;
    cBeschr  := _BodyStr(oBody, &#039;beschr&#039;);&lt;br /&gt;
&lt;br /&gt;
    if (Empty(cBetreff)) then begin&lt;br /&gt;
        result := _Fehler(oParams, 422, &#039;VALIDATION_FAILED&#039;, &#039;Feld &amp;quot;betreff&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    cId := GetNewId(oDB);&lt;br /&gt;
&lt;br /&gt;
    xTicket := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
    xTicket.lNoSysUID := True;&lt;br /&gt;
    try&lt;br /&gt;
        xTicket.qSet(&#039;sys_uid&#039;    , cId);&lt;br /&gt;
        xTicket.qSet(&#039;ti_nr&#039;      , DB_NeuNum(oDB, &#039;tickets&#039;, &#039;ti_nr&#039;, NEUNUM_HOLE, &#039;&#039;, &#039;&#039;, 1, 999999, &#039;0&#039;));&lt;br /&gt;
        xTicket.qSet(&#039;ti_betreff&#039; , cBetreff);&lt;br /&gt;
        xTicket.qSet(&#039;ti_beschr&#039;  , cBeschr);&lt;br /&gt;
        xTicket.qSet(&#039;ti_status&#039;  , &#039;1&#039;);&lt;br /&gt;
        xTicket.qSet(&#039;ti_anl_dat&#039; , Now());&lt;br /&gt;
        xTicket.qSet(&#039;ti_aend_dat&#039;, Now());&lt;br /&gt;
        // SaveData liefert einen Boolean. Ein SQL-Fehler - etwa Duplicate entry&lt;br /&gt;
        // auf einem eindeutigen Index - wirft KEINE Exception und steht in&lt;br /&gt;
        // keinem Protokoll. Ohne diese Auswertung antwortet der Endpunkt mit&lt;br /&gt;
        // 201, ohne etwas angelegt zu haben.&lt;br /&gt;
        lOk := xTicket.SaveData(NEW_RECORD);&lt;br /&gt;
    finally&lt;br /&gt;
        qSqlFree(xTicket);&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    if (not lOk) then begin&lt;br /&gt;
        result := _Fehler(oParams, 500, &#039;INTERNAL_ERROR&#039;, &#039;Ticket konnte nicht angelegt werden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        oRes.AddPair(&#039;id&#039;    , cId);&lt;br /&gt;
        oRes.AddPair(&#039;status&#039;, &#039;created&#039;);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// PUT   /tickets/v1          - Ticket aktualisieren&lt;br /&gt;
// Body: { &amp;quot;id&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;status&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;beschr&amp;quot;:&amp;quot;...&amp;quot; }&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function Put(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var xTicket: TqSQL;&lt;br /&gt;
    lOk    : Boolean;&lt;br /&gt;
    cId    : string;&lt;br /&gt;
    cStat  : string;&lt;br /&gt;
    cBeschr: string;&lt;br /&gt;
begin&lt;br /&gt;
    if (not Assigned(oBody)) then begin&lt;br /&gt;
        result := _Fehler(oParams, 400, &#039;BAD_REQUEST&#039;, &#039;JSON-Body erforderlich&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    cId     := _BodyStr(oBody, &#039;id&#039;);&lt;br /&gt;
    cStat   := _BodyStr(oBody, &#039;status&#039;);&lt;br /&gt;
    cBeschr := _BodyStr(oBody, &#039;beschr&#039;);&lt;br /&gt;
&lt;br /&gt;
    if (Empty(cId)) then begin&lt;br /&gt;
        result := _Fehler(oParams, 422, &#039;VALIDATION_FAILED&#039;, &#039;Feld &amp;quot;id&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    if (not DB_LSeek(oDB, &#039;tickets&#039;, &#039;sys_uid = &#039; + DB_SQLVal(cId))) then begin&lt;br /&gt;
        result := _Fehler(oParams, 404, &#039;NOT_FOUND&#039;, &#039;Ticket nicht gefunden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Zum Aendern qSqlInit mit sys_uid, nicht qSqlRead: qSqlRead liest den&lt;br /&gt;
    // Altsatz und schreibt nur Aenderungen - ein unveraenderter Wert erzeugt&lt;br /&gt;
    // gar kein Write und SaveData liefert False.&lt;br /&gt;
    xTicket := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
    xTicket.qSet(&#039;sys_uid&#039;, cId);&lt;br /&gt;
    try&lt;br /&gt;
        if (not Empty(cStat))   then xTicket.qSet(&#039;ti_status&#039;  , cStat);&lt;br /&gt;
        if (not Empty(cBeschr)) then xTicket.qSet(&#039;ti_beschr&#039;  , cBeschr);&lt;br /&gt;
        xTicket.qSet(&#039;ti_aend_dat&#039;, Now());&lt;br /&gt;
        lOk := xTicket.SaveData(UPDATE_RECORD);&lt;br /&gt;
    finally&lt;br /&gt;
        qSqlFree(xTicket);&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    if (not lOk) then begin&lt;br /&gt;
        result := _Fehler(oParams, 500, &#039;INTERNAL_ERROR&#039;, &#039;Ticket konnte nicht geaendert werden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    result := &#039;{&amp;quot;status&amp;quot;:&amp;quot;updated&amp;quot;}&#039;;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// DELETE /tickets/v1?id=...  - Ticket schliessen (Soft-Delete via Status=9)&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function Delete(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var xTicket: TqSQL;&lt;br /&gt;
    cId    : string;&lt;br /&gt;
    lOk    : Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    cId := oParams.Values[&#039;id&#039;];&lt;br /&gt;
    if (Empty(cId)) then begin&lt;br /&gt;
        result := _Fehler(oParams, 422, &#039;VALIDATION_FAILED&#039;, &#039;Parameter &amp;quot;id&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    if (not DB_LSeek(oDB, &#039;tickets&#039;, &#039;sys_uid = &#039; + DB_SQLVal(cId))) then begin&lt;br /&gt;
        result := _Fehler(oParams, 404, &#039;NOT_FOUND&#039;, &#039;Ticket nicht gefunden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    xTicket := qSqlInit(oDB, &#039;tickets&#039;);&lt;br /&gt;
    xTicket.qSet(&#039;sys_uid&#039;, cId);&lt;br /&gt;
    try&lt;br /&gt;
        xTicket.qSet(&#039;ti_status&#039;  , &#039;9&#039;);&lt;br /&gt;
        xTicket.qSet(&#039;ti_aend_dat&#039;, Now());&lt;br /&gt;
        lOk := xTicket.SaveData(UPDATE_RECORD);&lt;br /&gt;
    finally&lt;br /&gt;
        qSqlFree(xTicket);&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    if (not lOk) then begin&lt;br /&gt;
        result := _Fehler(oParams, 500, &#039;INTERNAL_ERROR&#039;, &#039;Ticket konnte nicht geschlossen werden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    result := &#039;{&amp;quot;status&amp;quot;:&amp;quot;closed&amp;quot;}&#039;;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Aufruf mit curl==&lt;br /&gt;
&lt;br /&gt;
Liste aller offenen Tickets:&lt;br /&gt;
&lt;br /&gt;
 curl -H &amp;quot;apikey: [API-KEY]&amp;quot; https://api.meinserver.de/tickets/v1&lt;br /&gt;
&lt;br /&gt;
Einzelnes Ticket:&lt;br /&gt;
&lt;br /&gt;
 curl -H &amp;quot;apikey: [API-KEY]&amp;quot; &amp;quot;https://api.meinserver.de/tickets/v1?id=abc-123&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Neues Ticket anlegen:&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;betreff\&amp;quot;:\&amp;quot;Drucker offline\&amp;quot;,\&amp;quot;beschr\&amp;quot;:\&amp;quot;Etage 2, Raum 23\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/tickets/v1&lt;br /&gt;
&lt;br /&gt;
Ticket aktualisieren:&lt;br /&gt;
&lt;br /&gt;
 curl -X PUT -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;id\&amp;quot;:\&amp;quot;abc-123\&amp;quot;,\&amp;quot;status\&amp;quot;:\&amp;quot;2\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/tickets/v1&lt;br /&gt;
&lt;br /&gt;
Ticket schliessen:&lt;br /&gt;
&lt;br /&gt;
 curl -X DELETE -H &amp;quot;apikey: [API-KEY]&amp;quot; &amp;quot;https://api.meinserver.de/tickets/v1?id=abc-123&amp;quot;&lt;br /&gt;
&lt;br /&gt;
==Aufruf aus PHP==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;php&amp;quot; line&amp;gt;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
$apikey = &#039;[API-KEY]&#039;;&lt;br /&gt;
$base   = &#039;https://api.meinserver.de/tickets/v1&#039;;&lt;br /&gt;
&lt;br /&gt;
function rest($method, $url, $apikey, $body = null) {&lt;br /&gt;
    $ch = curl_init();&lt;br /&gt;
    curl_setopt($ch, CURLOPT_URL           , $url);&lt;br /&gt;
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);&lt;br /&gt;
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST , $method);&lt;br /&gt;
    $headers = [&#039;apikey: &#039; . $apikey];&lt;br /&gt;
    if ($body !== null) {&lt;br /&gt;
        $headers[] = &#039;Content-Type: application/json&#039;;&lt;br /&gt;
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));&lt;br /&gt;
    }&lt;br /&gt;
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);&lt;br /&gt;
    $res = curl_exec($ch);&lt;br /&gt;
    curl_close($ch);&lt;br /&gt;
    return json_decode($res, true);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
// Neues Ticket anlegen&lt;br /&gt;
$neu = rest(&#039;POST&#039;, $base, $apikey, [&lt;br /&gt;
    &#039;betreff&#039; =&amp;gt; &#039;Drucker offline&#039;,&lt;br /&gt;
    &#039;beschr&#039;  =&amp;gt; &#039;Etage 2, Raum 23&#039;&lt;br /&gt;
]);&lt;br /&gt;
$id = $neu[&#039;id&#039;];&lt;br /&gt;
&lt;br /&gt;
// Status aktualisieren&lt;br /&gt;
rest(&#039;PUT&#039;, $base, $apikey, [&#039;id&#039; =&amp;gt; $id, &#039;status&#039; =&amp;gt; &#039;2&#039;]);&lt;br /&gt;
&lt;br /&gt;
// Liste auslesen&lt;br /&gt;
$alle = rest(&#039;GET&#039;, $base, $apikey);&lt;br /&gt;
foreach ($alle as $t) {&lt;br /&gt;
    echo $t[&#039;nr&#039;] . &#039; - &#039; . $t[&#039;betreff&#039;] . PHP_EOL;&lt;br /&gt;
}&lt;br /&gt;
?&amp;gt;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Saubere Trennung der CRUD-Methoden in einem Endpunkt.&lt;br /&gt;
* Verwendung des JSON-Body fuer komplexere Eingangsdaten (POST, PUT).&lt;br /&gt;
* Verwendung von Query-Parametern fuer einfache Werte (GET, DELETE).&lt;br /&gt;
* Soft-Delete ueber Status-Aenderung statt physischer Loeschung.&lt;br /&gt;
* Eindeutige Audit-UIDs (&#039;&#039;Y00CXXXXxx&#039;&#039;) fuer jeden DB-Zugriff zur Nachvollziehbarkeit.&lt;br /&gt;
* Host-gebundener Zugang als zweite Sicherheitsebene neben dem API-Key.&lt;br /&gt;
* Alternativ: ID per Pfad-Parameter statt Query-Parameter (siehe [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4]]).&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel4&amp;diff=64792</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel4</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel4&amp;diff=64792"/>
		<updated>2026-09-01T09:11:35Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
= Beispiel 4 – Pfad-Parameter (REST-Routing über Templates) =&lt;br /&gt;
&lt;br /&gt;
Dieses Beispiel zeigt das &#039;&#039;&#039;Pfad-Template-Routing&#039;&#039;&#039; mit Platzhaltern. Eine&lt;br /&gt;
Auftrags-Ressource wird über drei Endpunkte abgebildet:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Methode + Pfad !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;GET /orders&amp;lt;/code&amp;gt; || Liste aller offenen Aufträge&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;GET /orders/{uid}&amp;lt;/code&amp;gt; || Einzelner Auftrag per UID&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;PUT /orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || Status eines Auftrags-Moduls ändern&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Platzhalter &amp;lt;code&amp;gt;{uid}&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;{code}&amp;lt;/code&amp;gt; stehen im Skript als&lt;br /&gt;
&amp;lt;code&amp;gt;oParams.Values[&#039;_OBS_PATH_uid&#039;]&amp;lt;/code&amp;gt; bzw.&lt;br /&gt;
&amp;lt;code&amp;gt;oParams.Values[&#039;_OBS_PATH_code&#039;]&amp;lt;/code&amp;gt; zur Verfügung.&lt;br /&gt;
&lt;br /&gt;
== Einrichtung in OBS ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; auf &amp;lt;code&amp;gt;0.0.0.0:443&amp;lt;/code&amp;gt;.&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Orders-Client&#039;&#039; mit API-Key, Zugriff auf die drei Endpunkte.&lt;br /&gt;
* &#039;&#039;&#039;Endpunkte&#039;&#039;&#039; (je ein Eintrag in &amp;lt;code&amp;gt;RESTSRV_ENDPOINTS&amp;lt;/code&amp;gt;, alle Profil &#039;&#039;Public-API&#039;&#039;):&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Endpunkt !! Pfad-Template !! Skript-Methode&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;Get&amp;lt;/code&amp;gt; (Liste)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;Get&amp;lt;/code&amp;gt; (Einzel)&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;Put&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Endpunkt-Skript 1: &amp;lt;code&amp;gt;GET /orders&amp;lt;/code&amp;gt; (Liste) ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
// Hilfsfunktion: ein Feld aus dem JSON-Body als String lesen.&lt;br /&gt;
// Generics gibt es im Skript nicht - oBody.TryGetValue&amp;lt;string&amp;gt;(...) laesst sich&lt;br /&gt;
// nicht uebersetzen. GetValue liefert nil, wenn das Feld fehlt.&lt;br /&gt;
function _BodyStr(oBody: TJSONObject; const cFeld: string): string;&lt;br /&gt;
var oVal: TJSONValue;&lt;br /&gt;
begin&lt;br /&gt;
    result := &#039;&#039;;&lt;br /&gt;
    if (oBody = nil) then begin&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
    oVal := oBody.GetValue(cFeld);&lt;br /&gt;
    if (Assigned(oVal)) then begin&lt;br /&gt;
        result := oVal.Value;&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
// Hilfsfunktion: ein Auftrag als JSON-Objekt&lt;br /&gt;
function _OrderAsJSON(qOrder: TqSQL): TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    result := TJSONObject.Create();&lt;br /&gt;
    result.AddPair(&#039;uid&#039;      , qOrder.A2UID());&lt;br /&gt;
    result.AddPair(&#039;nr&#039;       , qOrder.A2C(&#039;au_nr&#039;));&lt;br /&gt;
    result.AddPair(&#039;kunde&#039;    , qOrder.A2C(&#039;au_kunde&#039;));&lt;br /&gt;
    result.AddPair(&#039;status&#039;   , qOrder.A2C(&#039;au_status&#039;));&lt;br /&gt;
    result.AddPair(&#039;aenderung&#039;, DTToSQL(qOrder.A2D(&#039;au_aend_dat&#039;)));&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
function Get(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var cSql : string;&lt;br /&gt;
    qData: TqSQL;&lt;br /&gt;
    oArr : TJSONArray;&lt;br /&gt;
begin&lt;br /&gt;
    oArr := TJSONArray.Create();&lt;br /&gt;
&lt;br /&gt;
    cSql := &#039;SELECT * FROM auftraege&#039; +&lt;br /&gt;
            &#039; WHERE au_status &amp;lt;&amp;gt; &#039; + DB_SQLVal(&#039;9&#039;) +&lt;br /&gt;
            &#039; ORDER BY au_nr&#039;;&lt;br /&gt;
    if (DB_SOpen(oDB, cSql, qData)) then begin&lt;br /&gt;
        while (not qData.EoF) do begin&lt;br /&gt;
            oArr.Add(_OrderAsJSON(qData));&lt;br /&gt;
            qData.Next();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    result := oArr.ToJSON();&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Endpunkt-Skript 2: &amp;lt;code&amp;gt;GET /orders/{uid}&amp;lt;/code&amp;gt; (Einzel) ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// Hilfsfunktion: Fehlerantwort im einheitlichen Format aufbauen.&lt;br /&gt;
// Fachliche Ablehnungen als 4xx melden, nicht als 200 mit Fehlertext.&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function _Fehler(oParams: TStrings; nStatus: integer; const cCode, cMsg: string): string;&lt;br /&gt;
var oRes: TJSONObject;&lt;br /&gt;
    oErr: TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    oErr := TJSONObject.Create();&lt;br /&gt;
    oErr.AddPair(&#039;code&#039;   , cCode);&lt;br /&gt;
    oErr.AddPair(&#039;message&#039;, cMsg);&lt;br /&gt;
    oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, nStatus);&lt;br /&gt;
        oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
function _OrderAsJSON(qOrder: TqSQL): TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    result := TJSONObject.Create();&lt;br /&gt;
    result.AddPair(&#039;uid&#039;      , qOrder.A2UID());&lt;br /&gt;
    result.AddPair(&#039;nr&#039;       , qOrder.A2C(&#039;au_nr&#039;));&lt;br /&gt;
    result.AddPair(&#039;kunde&#039;    , qOrder.A2C(&#039;au_kunde&#039;));&lt;br /&gt;
    result.AddPair(&#039;status&#039;   , qOrder.A2C(&#039;au_status&#039;));&lt;br /&gt;
    result.AddPair(&#039;aenderung&#039;, DTToSQL(qOrder.A2D(&#039;au_aend_dat&#039;)));&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
function Get(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var cUid : string;&lt;br /&gt;
    cSql : string;&lt;br /&gt;
    qData: TqSQL;&lt;br /&gt;
begin&lt;br /&gt;
    // Pfad-Parameter aus /orders/{uid}&lt;br /&gt;
    cUid := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
    if (cUid = &#039;&#039;) then begin&lt;br /&gt;
        result := _Fehler(oParams, 422, &#039;VALIDATION_FAILED&#039;, &#039;Pfad-Parameter &amp;quot;uid&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    cSql := &#039;SELECT * FROM auftraege WHERE sys_uid = &#039; + DB_SQLVal(cUid);&lt;br /&gt;
    if (DB_SOpen(oDB, cSql, qData)) and (not qData.EoF) then begin&lt;br /&gt;
        result := _OrderAsJSON(qData).ToJSON();&lt;br /&gt;
    end else begin&lt;br /&gt;
        result := _Fehler(oParams, 404, &#039;NOT_FOUND&#039;, &#039;Auftrag nicht gefunden&#039;);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Endpunkt-Skript 3: &amp;lt;code&amp;gt;PUT /orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// Hilfsfunktion: Fehlerantwort im einheitlichen Format aufbauen.&lt;br /&gt;
// Fachliche Ablehnungen als 4xx melden, nicht als 200 mit Fehlertext.&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function _Fehler(oParams: TStrings; nStatus: integer; const cCode, cMsg: string): string;&lt;br /&gt;
var oRes: TJSONObject;&lt;br /&gt;
    oErr: TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    oErr := TJSONObject.Create();&lt;br /&gt;
    oErr.AddPair(&#039;code&#039;   , cCode);&lt;br /&gt;
    oErr.AddPair(&#039;message&#039;, cMsg);&lt;br /&gt;
    oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, nStatus);&lt;br /&gt;
        oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
function Put(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var cUid   : string;&lt;br /&gt;
    cCode  : string;&lt;br /&gt;
    cStatus: string;&lt;br /&gt;
    cSql   : string;&lt;br /&gt;
    qChk   : TqSQL;&lt;br /&gt;
    xMod   : TqSQL;&lt;br /&gt;
    cModUid: string;&lt;br /&gt;
    lOk    : Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    // Beide Pfad-Parameter&lt;br /&gt;
    cUid  := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
    cCode := oParams.Values[&#039;_OBS_PATH_code&#039;];&lt;br /&gt;
    if (cUid = &#039;&#039;) or (cCode = &#039;&#039;) then begin&lt;br /&gt;
        result := _Fehler(oParams, 422, &#039;VALIDATION_FAILED&#039;, &#039;Pfad-Parameter &amp;quot;uid&amp;quot; oder &amp;quot;code&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Nutzdaten kommen aus dem JSON-Body&lt;br /&gt;
    cStatus := _BodyStr(oBody, &#039;status&#039;);&lt;br /&gt;
    if (cStatus = &#039;&#039;) then begin&lt;br /&gt;
        result := _Fehler(oParams, 422, &#039;VALIDATION_FAILED&#039;, &#039;Feld &amp;quot;status&amp;quot; fehlt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Existenzpruefung - und zugleich der Schreib-Index. Ein SELECT auf die&lt;br /&gt;
    // sys_uid liefert beides; ein &#039;SELECT *&#039; waere hier verschenkte Arbeit.&lt;br /&gt;
    cSql := &#039;SELECT sys_uid FROM auftrag_module&#039; +&lt;br /&gt;
            &#039; WHERE am_auftrag = &#039; + DB_SQLVal(cUid) +&lt;br /&gt;
            &#039; AND am_code = &#039; + DB_SQLVal(cCode) + &#039; LIMIT 1&#039;;&lt;br /&gt;
    cModUid := &#039;&#039;;&lt;br /&gt;
    if (DB_SOpen(oDB, cSql, qChk)) then begin&lt;br /&gt;
        if (not qChk.EoF) then begin&lt;br /&gt;
            cModUid := qChk.A2UID();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(qChk);&lt;br /&gt;
&lt;br /&gt;
    if (cModUid = &#039;&#039;) then begin&lt;br /&gt;
        result := _Fehler(oParams, 404, &#039;NOT_FOUND&#039;, &#039;Modul nicht gefunden&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    // Update&lt;br /&gt;
    // Der Satz wird ueber seine sys_uid geschrieben - qSqlRead waere hier&lt;br /&gt;
    // ein zusaetzlicher Lesezugriff, und ein unveraenderter Wert wuerde gar&lt;br /&gt;
    // kein Write erzeugen (SaveData liefert dann False).&lt;br /&gt;
    xMod := qSqlInit(oDB, &#039;auftrag_module&#039;);&lt;br /&gt;
    xMod.qSet(&#039;sys_uid&#039;  , cModUid);&lt;br /&gt;
    xMod.qSet(&#039;am_status&#039;, cStatus);&lt;br /&gt;
    // Rueckgabewert auswerten - ein Schreibfehler wirft keine Exception&lt;br /&gt;
    lOk := xMod.SaveData(UPDATE_RECORD);&lt;br /&gt;
    qSqlFree(xMod);&lt;br /&gt;
&lt;br /&gt;
    if (not lOk) then begin&lt;br /&gt;
        result := &#039;{&amp;quot;error&amp;quot;:{&amp;quot;code&amp;quot;:&amp;quot;INTERNAL_ERROR&amp;quot;,&amp;quot;message&amp;quot;:&amp;quot;Status konnte nicht gespeichert werden&amp;quot;},&amp;quot;_OBS_HTTP_STATUS&amp;quot;:500}&#039;;&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    result := &#039;{&amp;quot;status&amp;quot;:&amp;quot;ok&amp;quot;}&#039;;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Test mit curl ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Liste&lt;br /&gt;
curl -H &amp;quot;apikey: GEHEIM123&amp;quot; https://api.meinserver.de/orders&lt;br /&gt;
&lt;br /&gt;
# Einzelner Auftrag (uid als Pfad-Parameter)&lt;br /&gt;
curl -H &amp;quot;apikey: GEHEIM123&amp;quot; https://api.meinserver.de/orders/4711&lt;br /&gt;
&lt;br /&gt;
# Modul-Status aendern (uid + code als Pfad-Parameter, status im Body)&lt;br /&gt;
curl -X PUT \&lt;br /&gt;
     -H &amp;quot;apikey: GEHEIM123&amp;quot; \&lt;br /&gt;
     -H &amp;quot;Content-Type: application/json&amp;quot; \&lt;br /&gt;
     -d &#039;{&amp;quot;status&amp;quot;:&amp;quot;21&amp;quot;}&#039; \&lt;br /&gt;
     https://api.meinserver.de/orders/4711/modules/A1&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Was dieses Beispiel zeigt ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Eine Ressource, mehrere Pfad-Formen&#039;&#039;&#039; über getrennte Endpunkt-Einträge mit eigenem Skript und eigener Berechtigung.&lt;br /&gt;
* &#039;&#039;&#039;Pfad-Parameter&#039;&#039;&#039; werden über &amp;lt;code&amp;gt;{name}&amp;lt;/code&amp;gt; im Template erfasst und im Skript als &amp;lt;code&amp;gt;oParams.Values[&#039;_OBS_PATH_name&#039;]&amp;lt;/code&amp;gt; gelesen.&lt;br /&gt;
* Reihenfolge der Skript-Parameter: &#039;&#039;&#039;&amp;lt;code&amp;gt;oParams&amp;lt;/code&amp;gt; zuerst, &amp;lt;code&amp;gt;oBody&amp;lt;/code&amp;gt; danach&#039;&#039;&#039; (umgekehrt zur internen Dispatch-Reihenfolge).&lt;br /&gt;
* Präzedenz: ein statisches Segment (z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders/summary&amp;lt;/code&amp;gt;) hätte Vorrang vor &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
== Siehe auch ==&lt;br /&gt;
&lt;br /&gt;
* [[/OBS/Kostenpflichtige_Module/RESTServer/Endpunkte|Endpunkte]]&lt;br /&gt;
* [[/OBS/Kostenpflichtige_Module/RESTServer/Scripting|Scripting]]&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel5&amp;diff=64791</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel5</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel5&amp;diff=64791"/>
		<updated>2026-09-01T09:11:24Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz=&lt;br /&gt;
&lt;br /&gt;
Dieses Beispiel zeigt einen schreibenden Endpunkt für eine mobile App: Aufträge werden mit echten HTTP-Statuscodes aktualisiert, konkurrierende Änderungen über ETag/If-Match abgesichert (Optimistic Concurrency) und doppelte Sendungen über einen Idempotency-Key abgefangen. Die Anmeldung nutzt JWT mit Custom-Claims (Mandant, Rollen) und einem Refresh-Token.&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; (Port 443)&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Mobile-App&#039;&#039;&lt;br /&gt;
** API-Key: zufällig generiert&lt;br /&gt;
** JWT aktiv, JWT-Endpunkt &#039;&#039;auth&#039;&#039;, JWT-Key zufällig, JWT-Exp 60 (Minuten)&lt;br /&gt;
* &#039;&#039;&#039;Endpunkte&#039;&#039;&#039; (beide dem Profil &#039;&#039;Public-API&#039;&#039; zugeordnet):&lt;br /&gt;
** &#039;&#039;orders/{uid}&#039;&#039; - Auftrag lesen/Ändern&lt;br /&gt;
** &#039;&#039;orders/{uid}/material&#039;&#039; - Material erfassen&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Mobile-App&#039;&#039; für beide Endpunkte freigeschaltet&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Für das Abmelden gibt es &#039;&#039;&#039;keinen eigenen Endpunkt&#039;&#039;&#039;. Es läuft über&lt;br /&gt;
ein &#039;&#039;DELETE&#039;&#039; auf den JWT-Endpunkt &#039;&#039;auth&#039;&#039; und braucht daher weder eine Zeile&lt;br /&gt;
in &#039;&#039;RESTSRV_ENDPOINTS&#039;&#039; noch eine Berechtigung.}}&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierungs-Skript (Zugang)==&lt;br /&gt;
&lt;br /&gt;
Stellt Mandant und Rollen als Custom-Claims aus und löst über &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; ein Refresh-Token aus. Bei einem Refresh ruft der Server im selben Skript die Methode &#039;&#039;Refresh&#039;&#039; auf. Die Hilfsfunktionen (&#039;&#039;PasswortPasst&#039;&#039;, &#039;&#039;TechnikerMandant&#039;&#039;, &#039;&#039;TechnikerRollen&#039;&#039;) sind illustrativ und projektabhängig.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Eine &#039;&#039;&#039;eigene Sperrtabelle für Refresh-jti ist nicht nötig&#039;&#039;&#039;.&lt;br /&gt;
Einmalgebrauch, Rotation und Widerruf führt der Server in &#039;&#039;RESTSRV_TOKEN&#039;&#039; -&lt;br /&gt;
das Skript entscheidet nur, &#039;&#039;&#039;ob&#039;&#039;&#039; und &#039;&#039;&#039;mit welchen Rechten&#039;&#039;&#039; die Sitzung&lt;br /&gt;
fortgesetzt wird. Frühere Fassungen dieses Beispiels zeigten eine Skript-Tabelle;&lt;br /&gt;
wer sie übernommen hat, kann sie entfernen.}}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Authenticate(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes : TJSONObject;&lt;br /&gt;
    oVal : TJSONValue;&lt;br /&gt;
    cUser, cPass: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUser := &#039;&#039;; cPass := &#039;&#039;;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;username&#039;); if (Assigned(oVal)) then cUser := oVal.Value;&lt;br /&gt;
            oVal := oBody.GetValue(&#039;password&#039;); if (Assigned(oVal)) then cPass := oVal.Value;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        if (PasswortPasst(cUser, cPass)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;               , 1);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));   // z.B. &amp;quot;tech,lead&amp;quot;&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());   // loest das Refresh-Token aus&lt;br /&gt;
            // _OBS_JWT_REFRESH_EXP weggelassen -&amp;gt; Default 90 Tage&lt;br /&gt;
        end else begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;, 9);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039; , &#039;Login fehlgeschlagen&#039;);&lt;br /&gt;
        end;&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function Refresh(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes : TJSONObject;&lt;br /&gt;
    cUser: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUser := oParams.Values[&#039;_OBS_JWT_SUBJECT&#039;];&lt;br /&gt;
&lt;br /&gt;
        // Das vorgelegte Refresh-Token hat der Server bereits geprueft und&lt;br /&gt;
        // entwertet. Hier wird nur entschieden, ob die Sitzung fortgesetzt&lt;br /&gt;
        // werden darf - und mit welchen Rechten.&lt;br /&gt;
        if (not TechnikerAktiv(cUser)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;, 9);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039; , &#039;Konto ist nicht mehr aktiv, bitte neu anmelden&#039;);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // Mandant und Rollen NEU lesen, nicht aus dem alten Token uebernehmen -&lt;br /&gt;
        // sonst wirkt ein Rechteentzug erst beim naechsten Login.&lt;br /&gt;
        oRes.AddPair(&#039;status&#039;               , 1);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Endpunkt &#039;&#039;orders/{uid}&#039;&#039; - ändern mit ETag / If-Match==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;GET&#039;&#039; liefert den Auftrag samt &#039;&#039;ETag&#039;&#039; (Version), &#039;&#039;PUT&#039;&#039; prüft Rolle und &#039;&#039;If-Match&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Get(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oHdr: TJSONObject;&lt;br /&gt;
    cUid: string;&lt;br /&gt;
    nVer: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUid := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
        if (not AuftragLesen(oParams.Values[&#039;_OBS_JWT_CLAIM_tenant&#039;], cUid, oRes, nVer)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 404);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;NOT_FOUND&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
        oHdr := TJSONObject.Create();&lt;br /&gt;
        oHdr.AddPair(&#039;ETag&#039;, xStr(nVer));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function Put(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oHdr: TJSONObject;&lt;br /&gt;
    cUid: string;&lt;br /&gt;
    nAktuell, nIfMatch: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        // nur Rolle &amp;quot;lead&amp;quot; darf ändern - Rolle kommt aus dem Token, nicht aus dem Body.&lt;br /&gt;
        // Mit Trennzeichen suchen: ein blosses Pos(&#039;lead&#039;, ...) wuerde auch in&lt;br /&gt;
        // &amp;quot;leadless&amp;quot; oder &amp;quot;teamlead&amp;quot; treffen.&lt;br /&gt;
        if (Pos(&#039;,lead,&#039;, &#039;,&#039; + oParams.Values[&#039;_OBS_JWT_CLAIM_roles&#039;] + &#039;,&#039;) = 0) then begin&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 403);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;FORBIDDEN_ROLE&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        cUid     := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
        nAktuell := AuftragVersion(cUid);&lt;br /&gt;
        nIfMatch := iVal(oParams.Values[&#039;if-match&#039;]);&lt;br /&gt;
&lt;br /&gt;
        if (nIfMatch &amp;lt;&amp;gt; nAktuell) then begin&lt;br /&gt;
            oHdr := TJSONObject.Create();&lt;br /&gt;
            oHdr.AddPair(&#039;ETag&#039;, xStr(nAktuell));&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 409);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;VERSION_CONFLICT&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // AuftragSpeichern gibt den Rueckgabewert von SaveData durch. Ohne&lt;br /&gt;
        // diese Pruefung antwortet der Endpunkt mit 200 und einem neuen ETag,&lt;br /&gt;
        // obwohl nichts geschrieben wurde - siehe Scripting, Schreibfehler&lt;br /&gt;
        // erkennen.&lt;br /&gt;
        if (not AuftragSpeichern(cUid, oBody)) then begin   // setzt Version auf nAktuell + 1&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 500);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;INTERNAL_ERROR&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        oHdr := TJSONObject.Create();&lt;br /&gt;
        oHdr.AddPair(&#039;ETag&#039;, xStr(nAktuell + 1));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Endpunkt &#039;&#039;orders/{uid}/material&#039;&#039; - idempotentes Anlegen==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;POST&#039;&#039; erfasst eine Materialposition: Validierungsfehler -&amp;gt; 422, erfolgreiches Anlegen -&amp;gt; 201.&lt;br /&gt;
&lt;br /&gt;
Gegen doppelte Sendungen schickt der Client den Header &#039;&#039;Idempotency-Key&#039;&#039;. &#039;&#039;&#039;Das&lt;br /&gt;
Skript muss dafür nichts tun&#039;&#039;&#039; - der Server erkennt den Header, merkt sich das&lt;br /&gt;
Ergebnis und liefert bei einer Wiederholung mit demselben Schlüssel die&lt;br /&gt;
gespeicherte Antwort zurück, ohne das Skript erneut zu starten (siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]], Abschnitt&lt;br /&gt;
Idempotenz). Das Skript kümmert sich nur um seine Fachlogik:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oErr: TJSONObject;&lt;br /&gt;
    oVal: TJSONValue;&lt;br /&gt;
    cUid, cArtikel, cNeueUid: string;&lt;br /&gt;
    nMenge: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUid := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
&lt;br /&gt;
        // 1) Validierung -&amp;gt; 422 mit traceId.&lt;br /&gt;
        //    Wichtig: fachliche Ablehnung als 4xx melden, nicht als 200 mit&lt;br /&gt;
        //    Fehlertext - nur dann gibt der Server den Idempotency-Key wieder&lt;br /&gt;
        //    frei und der Client darf ihn nach Korrektur erneut verwenden.&lt;br /&gt;
        cArtikel := &#039;&#039;;&lt;br /&gt;
        nMenge   := 0;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;artikel&#039;); if (Assigned(oVal)) then cArtikel := oVal.Value;&lt;br /&gt;
            oVal := oBody.GetValue(&#039;menge&#039;);   if (Assigned(oVal)) then nMenge   := iVal(oVal.Value);&lt;br /&gt;
        end;&lt;br /&gt;
        if ((Empty(cArtikel)) or (nMenge &amp;lt;= 0)) then begin&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;VALIDATION_FAILED&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;artikel und menge sind Pflicht&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 422);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // 2) Buchen. Die Transaktionssteuerung bleibt beim Skript; der&lt;br /&gt;
        //    Idempotenz-Speicher des Servers läuft getrennt davon und&lt;br /&gt;
        //    umschliesst diesen Block nicht.&lt;br /&gt;
        cNeueUid := MaterialAnlegen(cUid, cArtikel, nMenge);&lt;br /&gt;
&lt;br /&gt;
        //    MaterialAnlegen liefert Leerstring, wenn SaveData fehlgeschlagen&lt;br /&gt;
        //    ist. Diese Pruefung ist Pflicht: ein 201 auf einen gescheiterten&lt;br /&gt;
        //    Schreibvorgang ist der Fehler, den niemand findet - der Client&lt;br /&gt;
        //    haelt die Position fuer gebucht und wiederholt nicht einmal.&lt;br /&gt;
        if (Empty(cNeueUid)) then begin&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;INTERNAL_ERROR&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;Die Position konnte nicht gebucht werden&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 500);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // 3) Antwort vollständig aufbauen - genau sie wird eingefroren und bei&lt;br /&gt;
        //    einer Wiederholung erneut ausgeliefert.&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 201);&lt;br /&gt;
        oRes.AddPair(&#039;uid&#039;, cNeueUid);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Abmelden - ohne Endpunkt und ohne Skript==&lt;br /&gt;
&lt;br /&gt;
Beim Abmelden soll das Gerät seine Sitzung wirklich verlieren, nicht erst mit dem&lt;br /&gt;
Ablauf des Tokens. Dafür ist &#039;&#039;&#039;nichts einzurichten&#039;&#039;&#039;: ein &#039;&#039;DELETE&#039;&#039; auf den&lt;br /&gt;
JWT-Endpunkt mit dem Access-Token genügt, der Server sperrt die Sitzung in&lt;br /&gt;
&#039;&#039;RESTSRV_TOKEN&#039;&#039; und antwortet 204.&lt;br /&gt;
&lt;br /&gt;
Gesperrt wird die ganze Sitzung, also auch die Access-Token vorheriger&lt;br /&gt;
Erneuerungen. Ein zweiter Aufruf ist unschädlich und bleibt 204. Kann der Server&lt;br /&gt;
die Sitzung nicht sperren, antwortet er &#039;&#039;&#039;503&#039;&#039;&#039; mit &#039;&#039;Retry-After&#039;&#039; - die App&lt;br /&gt;
wiederholt den Abmeldevorgang dann.&lt;br /&gt;
&lt;br /&gt;
Nur wenn beim Abmelden zusätzlich etwas passieren soll - hier: die&lt;br /&gt;
Geräteregistrierung für Push löschen -, braucht es einen eigenen Endpunkt. Er&lt;br /&gt;
erledigt seine Fachlogik und setzt &#039;&#039;_OBS_JWT_REVOKE&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes : TJSONObject;&lt;br /&gt;
    oVal : TJSONValue;&lt;br /&gt;
    cAlle: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        PushRegistrierungLoeschen(oParams.Values[&#039;_OBS_JWT_CLAIM_technikerUid&#039;]);&lt;br /&gt;
&lt;br /&gt;
        // Optional: {&amp;quot;alleGeraete&amp;quot;: true} meldet den Techniker ueberall ab.&lt;br /&gt;
        // Diese Entscheidung gehoert bewusst in ein Skript - sie braucht eine&lt;br /&gt;
        // Berechtigungspruefung, die der JWT-Endpunkt nicht leisten kann.&lt;br /&gt;
        cAlle := &#039;session&#039;;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;alleGeraete&#039;);&lt;br /&gt;
            if (Assigned(oVal)) and (Lower(oVal.Value) = &#039;true&#039;) then begin&lt;br /&gt;
                cAlle := &#039;all&#039;;&lt;br /&gt;
            end;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_REVOKE&#039; , cAlle);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 204);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Test mit curl==&lt;br /&gt;
&lt;br /&gt;
Token holen:&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;username\&amp;quot;:\&amp;quot;tech1\&amp;quot;,\&amp;quot;password\&amp;quot;:\&amp;quot;geheim\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:&amp;quot;eyJ...&amp;quot;,&amp;quot;accessToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&amp;quot;refreshToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:3600,&amp;quot;serverTime&amp;quot;:&amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Bei falschen Zugangsdaten antwortet der Server mit &#039;&#039;&#039;401&#039;&#039;&#039; und dem Text aus dem&lt;br /&gt;
Authenticate-Skript als &#039;&#039;error.message&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Auftrag lesen (liefert den ETag-Header):&lt;br /&gt;
&lt;br /&gt;
 curl -i -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
 ... ETag: 7&lt;br /&gt;
&lt;br /&gt;
ändern mit korrektem If-Match -&amp;gt; 200, mit veraltetem If-Match -&amp;gt; 409:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X PUT -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;If-Match: 7&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;status\&amp;quot;:\&amp;quot;erledigt\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
&lt;br /&gt;
Material idempotent erfassen (zweiter Aufruf mit gleichem Key -&amp;gt; gleiche Antwort, keine Doppelbuchung):&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Idempotency-Key: 9c84-7f2a-...&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;artikel\&amp;quot;:\&amp;quot;A100\&amp;quot;,\&amp;quot;menge\&amp;quot;:3}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711/material&lt;br /&gt;
&lt;br /&gt;
Die Antwort des zweiten Aufrufs trägt zusätzlich den Header&lt;br /&gt;
&#039;&#039;Idempotent-Replay: true&#039;&#039; - daran ist erkennbar, dass sie aus dem Speicher kam&lt;br /&gt;
und nichts erneut gebucht wurde.&lt;br /&gt;
&lt;br /&gt;
Token erneuern (Refresh-Token im Authorization-Header an denselben JWT-Endpunkt):&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer &amp;lt;refreshToken&amp;gt;&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
Die Antwort enthält ein neues Paar. Das alte Refresh-Token ist damit verbraucht:&lt;br /&gt;
ein zweiter Aufruf mit demselben Token antwortet &#039;&#039;&#039;401&#039;&#039;&#039;. Erfolgt er innerhalb&lt;br /&gt;
von 60 Sekunden, bleibt die Sitzung bestehen (paralleler Refresh der App); später&lt;br /&gt;
gilt er als Wiedervorlage und die &#039;&#039;&#039;ganze Sitzung wird gesperrt&#039;&#039;&#039; - der&lt;br /&gt;
Techniker muss sich neu anmelden, und der Vorfall steht mit IP im Protokoll.&lt;br /&gt;
&lt;br /&gt;
Abmelden - DELETE auf denselben JWT-Endpunkt, Access-Token im Header:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X DELETE -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 204 No Content&lt;br /&gt;
&lt;br /&gt;
Derselbe Token danach noch einmal verwendet:&lt;br /&gt;
&lt;br /&gt;
 curl -i -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 401 Unauthorized&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;error&amp;quot;: {&lt;br /&gt;
    &amp;quot;code&amp;quot;:    &amp;quot;AUTH_EXPIRED&amp;quot;,&lt;br /&gt;
    &amp;quot;uid&amp;quot;:     &amp;quot;Y00G9TOK09&amp;quot;,&lt;br /&gt;
    &amp;quot;message&amp;quot;: &amp;quot;Die Sitzung ist nicht mehr gültig, bitte neu anmelden&amp;quot;,&lt;br /&gt;
    &amp;quot;traceId&amp;quot;: &amp;quot;20260819T091233123-00001A&amp;quot;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Echte HTTP-Statuscodes aus dem Skript: 201, 403, 404, 409, 422, 500.&lt;br /&gt;
* Jeder Schreibzugriff wird auf Erfolg geprüft - ein gescheitertes &#039;&#039;SaveData&#039;&#039; darf nicht als 200 oder 201 beim Client ankommen (siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting#Schreibfehler erkennen|Schreibfehler erkennen]]).&lt;br /&gt;
* Optimistic Concurrency über &#039;&#039;ETag&#039;&#039; (GET) und &#039;&#039;If-Match&#039;&#039; (PUT) -&amp;gt; 409 VERSION_CONFLICT.&lt;br /&gt;
* Idempotente Schreibzugriffe über den &#039;&#039;Idempotency-Key&#039;&#039; - &#039;&#039;&#039;vom Server erledigt&#039;&#039;&#039;, das Skript enthält dafür keine Zeile Code.&lt;br /&gt;
* Rollenprüfung aus dem Token-Claim &#039;&#039;_OBS_JWT_CLAIM_roles&#039;&#039;, nicht aus dem Body.&lt;br /&gt;
* JWT mit Custom-Claims (&#039;&#039;tenant&#039;&#039;/&#039;&#039;roles&#039;&#039;) und Refresh-Token mit Rotation - &#039;&#039;&#039;vom Server erledigt&#039;&#039;&#039;, das Skript führt keine Sperrtabelle.&lt;br /&gt;
* Anmelden, Erneuern und Abmelden über &#039;&#039;&#039;einen&#039;&#039;&#039; Endpunkt - Abmelden als &#039;&#039;DELETE&#039;&#039;, ohne eigene Endpunkt-Zeile und ohne Skript.&lt;br /&gt;
* &#039;&#039;_OBS_JWT_REVOKE&#039;&#039; für den Fall, dass beim Abmelden zusätzlich Fachlogik laufen soll oder alle Geräte gemeint sind.&lt;br /&gt;
* &#039;&#039;traceId&#039;&#039; im Fehler-Body für die Support-Nachverfolgung (auch als Header &#039;&#039;X-Trace-Id&#039;&#039;).&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel2&amp;diff=64790</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel2</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel2&amp;diff=64790"/>
		<updated>2026-09-01T09:11:05Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 2: Zeiterfassung als komplette Webanwendung=&lt;br /&gt;
&lt;br /&gt;
Dieses Script dient zur Zeiterfassung über den REST-Server. Bei Seitenaufruf wird eine Liste von Kunden geladen und in einer Liste angezeigt. &lt;br /&gt;
&lt;br /&gt;
Durch Rechtsklick auf einen Eintrag wird eine Liste an zugehörigen Tätigkeiten geladen, die nach Klick einen Timer starten.&lt;br /&gt;
&lt;br /&gt;
Per Javascript werden Startzeit, Startdatum und andere Informationen wie Kunde und Personalnummer über die PHP-Scripte und das Endpunkt-Script in einer Datenbank gespeichert.&lt;br /&gt;
&lt;br /&gt;
Der Status wird hierbei auf &amp;quot;1&amp;quot; gesetzt. Pausenzeiten werden nach Beenden einer Pause in der Datenbank gespeichert.&lt;br /&gt;
&lt;br /&gt;
Durch Drücken des Stop-Buttons wird eine Lightbox mit einer Zusammenfassung aller zum Timer gehörenden Daten angezeigt. Hier hat man die Möglichkeit Tätigkeiten, abgelaufene Zeit und &lt;br /&gt;
&lt;br /&gt;
Pausenzeit nachträglich zu bearbeiten. Durch Klick auf &amp;quot;Speichern&amp;quot; oder Druck auf F2 werden Endzeit und Enddatum gespeichert und der Status auf &amp;quot;21&amp;quot; gesetzt.&lt;br /&gt;
&lt;br /&gt;
Nach Reload der Webseite wird anhand des Status in der Datenbank nach laufenden Timern gesucht und diese gegebenenfalls gestartet.&lt;br /&gt;
&lt;br /&gt;
= Endpunkt-Script =&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
procedure _AddJob(oArr: TJSONArray; id: String);&lt;br /&gt;
var cSql        : String;&lt;br /&gt;
    A_Query     : TxFQuery;&lt;br /&gt;
begin&lt;br /&gt;
    cSql    :=  &#039;SELECT CONCAT(za_text, &amp;quot;:&amp;quot;,sys_uid) AS Job FROM zeiterfart &#039; +&lt;br /&gt;
                &#039;WHERE za_psnr = &#039; + DB_SQLVal(id) + &#039; AND za_inaktiv &amp;lt;&amp;gt; &amp;quot;1&amp;quot; &#039; +&lt;br /&gt;
                &#039;ORDER BY za_order&#039;;&lt;br /&gt;
    if (DB_xSOpen(&#039;Y009LHNBTY&#039;, oDB, cSql, A_Query, False)) then begin&lt;br /&gt;
        while (not A_Query.EoF) do begin&lt;br /&gt;
            oArr.Add(A_Query.A2C(&#039;Job&#039;));&lt;br /&gt;
            A_Query.Next();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(A_Query);&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
procedure _AddCustomer(oArr: TJSONArray);&lt;br /&gt;
var cSql        : String;&lt;br /&gt;
    A_Query     : TxFQuery;&lt;br /&gt;
begin&lt;br /&gt;
    cSql    :=&#039;  SELECT * FROM&#039; + &lt;br /&gt;
              &#039;  (SELECT&#039; +&lt;br /&gt;
              &#039;  ps_nr,&#039; +&lt;br /&gt;
              &#039;  ps_such AS Kunde,&#039; +&lt;br /&gt;
              &#039;  perssta.sys_uid AS ps_sys_uid,&#039; +&lt;br /&gt;
              &#039;  &amp;quot;PE&amp;quot; AS TYP&#039; +&lt;br /&gt;
              &#039;  FROM eigenschaften&#039; +&lt;br /&gt;
              &#039;  LEFT JOIN perssta ON se_ref1 AND perssta.sys_uid = se_ref2&#039; +&lt;br /&gt;
              &#039;  WHERE se_nr = 0087&#039; + &lt;br /&gt;
              &#039;  AND ps_nr &amp;lt;&amp;gt; 100000&#039; +&lt;br /&gt;
              &#039;  UNION SELECT p_nr AS ps_nr,&#039; +&lt;br /&gt;
              &#039;  p_name1 AS ps_such,&#039; +&lt;br /&gt;
              &#039;  projekte.sys_uid AS ps_sys_uid,&#039; +&lt;br /&gt;
              &#039;  &amp;quot;PR&amp;quot; AS TYP&#039; +&lt;br /&gt;
              &#039;  FROM eigenschaften&#039; +&lt;br /&gt;
              &#039;  LEFT JOIN projekte ON se_ref1 = p_nr&#039; +&lt;br /&gt;
              &#039;  AND projekte.sys_uid = se_ref2 WHERE se_nr = 0087)&#039; +&lt;br /&gt;
              &#039;  A WHERE ps_nr IS NOT NULL&#039; +&lt;br /&gt;
              &#039;  ORDER BY Kunde &#039;;&lt;br /&gt;
              &lt;br /&gt;
    if (DB_xSOpen(&#039;Y009LHNBTY&#039;, oDB, cSql, A_Query, False)) then begin&lt;br /&gt;
        while (not A_Query.EoF) do begin&lt;br /&gt;
            oArr.Add(A_Query.A2C(&#039;Kunde&#039;));&lt;br /&gt;
            A_Query.Next();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(A_Query);&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
procedure _AddID(kunde: String; oArr: TJSONArray);&lt;br /&gt;
var cSql        : String;&lt;br /&gt;
    A_Query     : TxFQuery;&lt;br /&gt;
begin&lt;br /&gt;
    cSql    :=&#039;  SELECT * FROM&#039; + &lt;br /&gt;
              &#039;  (SELECT&#039; +&lt;br /&gt;
              &#039;  ps_nr,&#039; +&lt;br /&gt;
              &#039;  ps_such AS Kunde,&#039; +&lt;br /&gt;
              &#039;  perssta.sys_uid AS ps_sys_uid,&#039; +&lt;br /&gt;
              &#039;  &amp;quot;PE&amp;quot; AS TYP&#039; +&lt;br /&gt;
              &#039;  FROM eigenschaften&#039; +&lt;br /&gt;
              &#039;  LEFT JOIN perssta ON se_ref1 AND perssta.sys_uid = se_ref2&#039; +&lt;br /&gt;
              &#039;  WHERE se_nr = 0087&#039; + &lt;br /&gt;
              &#039;  AND ps_nr &amp;lt;&amp;gt; 100000&#039; +&lt;br /&gt;
              &#039;  UNION SELECT p_nr AS ps_nr,&#039; +&lt;br /&gt;
              &#039;  p_name1 AS ps_such,&#039; +&lt;br /&gt;
              &#039;  projekte.sys_uid AS ps_sys_uid,&#039; +&lt;br /&gt;
              &#039;  &amp;quot;PR&amp;quot; AS TYP&#039; +&lt;br /&gt;
              &#039;  FROM eigenschaften&#039; +&lt;br /&gt;
              &#039;  LEFT JOIN projekte ON se_ref1 = p_nr&#039; +&lt;br /&gt;
              &#039;  AND projekte.sys_uid = se_ref2 WHERE se_nr = 0087)&#039; +&lt;br /&gt;
              &#039;  A WHERE ps_nr IS NOT NULL&#039; +&lt;br /&gt;
              &#039;  AND Kunde = &#039; + DB_SQLVal(kunde) +&lt;br /&gt;
              &#039;  ORDER BY Kunde &#039;;&lt;br /&gt;
              &lt;br /&gt;
    if (DB_xSOpen(&#039;Y009LHNBTY&#039;, oDB, cSql, A_Query, False)) then begin&lt;br /&gt;
        while (not A_Query.EoF) do begin&lt;br /&gt;
            oArr.Add(A_Query.A2C(&#039;ps_nr&#039;));&lt;br /&gt;
            A_Query.Next();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(A_Query);&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
procedure _AddCHECK(oArr: TJSONArray);&lt;br /&gt;
var cSql        : String;&lt;br /&gt;
    A_Query     : TxFQuery;&lt;br /&gt;
begin&lt;br /&gt;
    cSql    :=  &#039;SELECT ze_starttime, zeiterfuser.sys_uid AS sysuid, zk_psnr, &#039; +&lt;br /&gt;
                &#039;za_text, ps_such, ze_startdate FROM &#039; +&lt;br /&gt;
                &#039;zeiterfuser LEFT JOIN zeiterfkomm ON &#039; +&lt;br /&gt;
                &#039;zk_messunguid = zeiterfuser.SYS_UID LEFT JOIN zeiterfart &#039; +&lt;br /&gt;
                &#039;ON zeiterfart.sys_uid = zeiterfuser.ze_uid AND &#039; +&lt;br /&gt;
                &#039;za_psnr = zeiterfkomm.zk_psnr LEFT JOIN perssta ON &#039; +&lt;br /&gt;
                &#039;ps_nr = zeiterfkomm.zk_psnr WHERE ze_status = 01 &#039;;&lt;br /&gt;
              &lt;br /&gt;
    if (DB_xSOpen(&#039;Y009LHNBTY&#039;, oDB, cSql, A_Query, False)) then begin&lt;br /&gt;
        while (not A_Query.EoF) do begin&lt;br /&gt;
            oArr.Add(A_Query.A2C(&#039;ze_starttime&#039;));&lt;br /&gt;
            oArr.Add(A_Query.A2C(&#039;zk_psnr&#039;));&lt;br /&gt;
            oArr.Add(A_Query.A2C(&#039;za_text&#039;));&lt;br /&gt;
            oArr.Add(A_Query.A2C(&#039;ps_such&#039;));&lt;br /&gt;
            oArr.Add(A_Query.A2C(&#039;sysuid&#039;));&lt;br /&gt;
            oArr.Add(A_Query.A2C(&#039;ze_startdate&#039;));&lt;br /&gt;
            A_Query.Next();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(A_Query);&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
procedure _getPauseTime(sysuid: String; oArr: TJSONArray);&lt;br /&gt;
var cSql        : String;&lt;br /&gt;
    A_Query     : TxFQuery;&lt;br /&gt;
begin&lt;br /&gt;
    cSql    :=  &#039;SELECT * FROM zeiterfuser WHERE sys_uid = &#039; + DB_SQLVal(sysuid);&lt;br /&gt;
    &lt;br /&gt;
    if (DB_xSOpen(&#039;Y009LHNBTY&#039;, oDB, cSql, A_Query, False)) then begin&lt;br /&gt;
        while (not A_Query.EoF) do begin&lt;br /&gt;
            oArr.Add(A_Query.A2C(&#039;ze_pausezeit&#039;));&lt;br /&gt;
            A_Query.Next();&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
    DB_Close(A_Query);&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//----------------Funktionen----------------------------------//&lt;br /&gt;
&lt;br /&gt;
function POST(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var &lt;br /&gt;
oArr1                                   : TJSONArray;&lt;br /&gt;
cSql                                    : String;&lt;br /&gt;
&lt;br /&gt;
begin&lt;br /&gt;
    if (oParams.values[&#039;choice&#039;]=&#039;job&#039;) then begin&lt;br /&gt;
        oArr1 := TJSONArray.Create();&lt;br /&gt;
        _AddJob(oArr1, oParams.values[&#039;id&#039;]);&lt;br /&gt;
        result := oArr1.ToJSON();&lt;br /&gt;
    end else if (oParams.values[&#039;choice&#039;]=&#039;customer&#039;) then begin&lt;br /&gt;
        oArr1 := TJSONArray.Create();&lt;br /&gt;
        _AddCustomer(oArr1);&lt;br /&gt;
        result := oArr1.ToJSON();&lt;br /&gt;
    end else if (oParams.values[&#039;choice&#039;]=&#039;id&#039;) then begin&lt;br /&gt;
        oArr1 := TJSONArray.Create();&lt;br /&gt;
        _AddID(oParams.values[&#039;selectedKunde&#039;], oArr1);&lt;br /&gt;
        result := oArr1.ToJSON();&lt;br /&gt;
    end else if (oParams.values[&#039;choice&#039;]=&#039;check&#039;) then begin&lt;br /&gt;
        oArr1 := TJSONArray.Create();&lt;br /&gt;
        _AddCHECK(oArr1);&lt;br /&gt;
        result := oArr1.ToJSON();&lt;br /&gt;
    end else if (oParams.values[&#039;choice&#039;]=&#039;pauseTime&#039;) then begin    &lt;br /&gt;
        oArr1 := TJSONArray.Create();&lt;br /&gt;
        _GetPauseTime(oParams.values[&#039;sysuid&#039;], oArr1);&lt;br /&gt;
        result := oArr1.ToJSON();&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
function PUT(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var &lt;br /&gt;
xZeit           : TqSQL;&lt;br /&gt;
cUSER_SYSUID    : String;&lt;br /&gt;
cKOMM_SYSUID    : String;&lt;br /&gt;
lNew            : Boolean;&lt;br /&gt;
cSql            : String;&lt;br /&gt;
dDBNow          : TDateTime;&lt;br /&gt;
begin  &lt;br /&gt;
    writeln(&#039;Choice: &#039; + oParams.values[&#039;choice&#039;]);&lt;br /&gt;
    if (oParams.values[&#039;choice&#039;]=&#039;start&#039;) then&lt;br /&gt;
    begin&lt;br /&gt;
        dDBNow  := DB_Now(oDB); &lt;br /&gt;
        lNew    := True;&lt;br /&gt;
        if (empty(cUSER_SYSUID)) then begin&lt;br /&gt;
            cUSER_SYSUID := GetNewId(oDB);&lt;br /&gt;
        end;&lt;br /&gt;
    &lt;br /&gt;
        xZeit           := qSqlInit(oDB, &#039;zeiterfuser&#039;);&lt;br /&gt;
        xZeit.lNoSysUID := True;&lt;br /&gt;
    &lt;br /&gt;
        writeln(&#039;SYSUID: &#039; + oParams.values[&#039;jobID&#039;]);&lt;br /&gt;
    &lt;br /&gt;
        xZeit.qSet(&#039;ze_user&#039;            , oParams.values[&#039;user&#039;]);&lt;br /&gt;
        xZeit.qSet(&#039;ze_starttime&#039;       , oParams.values[&#039;uhrzeit&#039;]);&lt;br /&gt;
        xZeit.qSet(&#039;ze_eigen&#039;           , &#039;0101&#039;);&lt;br /&gt;
        xZeit.qSet(&#039;ze_startdate&#039;       , oParams.values[&#039;datum&#039;]);&lt;br /&gt;
        xZeit.qSet(&#039;ze_endtime&#039;         , oParams.values[&#039;endTime&#039;]);&lt;br /&gt;
        xZeit.qSet(&#039;ze_enddate&#039;         , oParams.values[&#039;endDate&#039;]);&lt;br /&gt;
        xZeit.qSet(&#039;ze_zeit&#039;            , &#039;0&#039;);&lt;br /&gt;
        xZeit.qSet(&#039;ze_pausezeit&#039;       , &#039;0&#039;);&lt;br /&gt;
        xZeit.qSet(&#039;ze_status&#039;          , oParams.values[&#039;status&#039;]);&lt;br /&gt;
        xZeit.qSet(&#039;ze_berechnen&#039;       , 0);&lt;br /&gt;
        xZeit.qSet(&#039;ze_officestar&#039;      , 0);&lt;br /&gt;
        xZeit.qSet(&#039;ze_repa&#039;            , 0);&lt;br /&gt;
        xZeit.qSet(&#039;ze_vorbereitung&#039;    , 0);&lt;br /&gt;
        xZeit.qSet(&#039;ze_uid&#039;             , oParams.values[&#039;jobID&#039;]);&lt;br /&gt;
        xZeit.qSet(&#039;sys_uid&#039;            , cUSER_SYSUID);&lt;br /&gt;
    &lt;br /&gt;
        // SaveData meldet einen Fehler ueber den RUECKGABEWERT, nicht ueber eine&lt;br /&gt;
        // Exception. Ohne diese Pruefung antwortet der Endpunkt mit Erfolg,&lt;br /&gt;
        // obwohl nichts geschrieben wurde.&lt;br /&gt;
        if (not xZeit.SaveData(lNew)) then begin&lt;br /&gt;
            qSQLFree(xZeit);&lt;br /&gt;
            result := &#039;ERROR: Zeiterfassung konnte nicht gespeichert werden&#039;;&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
        qSQLFree(xZeit);&lt;br /&gt;
        &lt;br /&gt;
        //---------------------------------------------------------------------&lt;br /&gt;
        &lt;br /&gt;
        xZeit := qSqlInit(oDB,&#039;ZEITERFKOMM&#039;);&lt;br /&gt;
        xZeit.lNoSysUID := True;&lt;br /&gt;
        &lt;br /&gt;
        if (empty(cKOMM_SYSUID)) then begin&lt;br /&gt;
            cKOMM_SYSUID := GetNewId(oDB);&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        xZeit.qSet(&#039;zk_psnr&#039;       , oParams.values[&#039;psnr&#039;]);&lt;br /&gt;
        xZeit.qSet(&#039;zk_starttime&#039;  , oParams.values[&#039;uhrzeit&#039;]);&lt;br /&gt;
        xZeit.qSet(&#039;zk_startdate&#039;  , oParams.values[&#039;datum&#039;]);&lt;br /&gt;
        xZeit.qSet(&#039;zk_kommentar&#039;  , oParams.values[&#039;comm&#039;]);&lt;br /&gt;
        xZeit.qSet(&#039;zk_messunguid&#039; , cUSER_SYSUID);&lt;br /&gt;
        xZeit.qSet(&#039;sys_uid&#039;       , cKOMM_SYSUID);&lt;br /&gt;
&lt;br /&gt;
        if (not xZeit.SaveData(lNew)) then begin&lt;br /&gt;
            qSQLFree(xZeit);&lt;br /&gt;
            result := &#039;ERROR: Kommentar konnte nicht gespeichert werden&#039;;&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
        qSQLFree(xZeit);&lt;br /&gt;
        &lt;br /&gt;
        result  := cUser_SYSUID;&lt;br /&gt;
        &lt;br /&gt;
    end else if (oParams.values[&#039;choice&#039;]=&#039;stop&#039;) then&lt;br /&gt;
    begin&lt;br /&gt;
        &lt;br /&gt;
        // Jeder Wert aus oParams geht durch DB_SQLVal - auch die, die wie&lt;br /&gt;
        // Zahlen aussehen. Der Kommentar ist Freitext aus dem Browser, und&lt;br /&gt;
        // ohne Quoting beendet ein Apostroph darin das SQL-Statement.&lt;br /&gt;
        cSql := &#039;UPDATE zeiterfuser, zeiterfkomm &#039;   +&lt;br /&gt;
                &#039; SET zeiterfuser.ze_zeit = &#039;       + DB_SQLVal(oParams.values[&#039;elapsedTime&#039;]) + &lt;br /&gt;
                &#039;, zeiterfuser.ze_endtime = &#039;        + DB_SQLVal(oParams.values[&#039;vergZeit&#039;]) +&lt;br /&gt;
                &#039;, zeiterfuser.ze_enddate = &#039;        + DB_SQLVal(oParams.values[&#039;stopTimeValue&#039;]) +&lt;br /&gt;
                &#039;, zeiterfuser.ze_pausezeit = &#039;      + DB_SQLVal(oParams.values[&#039;elapsedPauseTime&#039;]) +&lt;br /&gt;
                &#039;, zeiterfuser.ze_status = &#039;         + DB_SQLVal(oParams.values[&#039;status&#039;]) +&lt;br /&gt;
                &#039;, zeiterfkomm.zk_kommentar = &#039;      + DB_SQLVal(oParams.values[&#039;comment&#039;]) +&lt;br /&gt;
                &#039; WHERE zeiterfuser.sys_uid = &#039;      + DB_SQLVal(oParams.values[&#039;uid&#039;]) +&lt;br /&gt;
                &#039; AND zeiterfkomm.zk_messunguid = &#039;  + DB_SQLVal(oParams.values[&#039;uid&#039;]);&lt;br /&gt;
&lt;br /&gt;
        if DB_SQLExec(oDB,cSql) then&lt;br /&gt;
        begin&lt;br /&gt;
            result := &#039;Die Daten wurden erfolgreich gespeichert!&#039;;&lt;br /&gt;
        end else begin&lt;br /&gt;
            result := &#039;Fehler!&#039;;&lt;br /&gt;
        end;&lt;br /&gt;
    end else if (oParams.values[&#039;choice&#039;]=&#039;pause&#039;) then&lt;br /&gt;
    begin     &lt;br /&gt;
        writeln(&#039;Pausezeit: &#039; + oParams.values[&#039;pauseTime&#039;]);&lt;br /&gt;
        cSql := &#039;UPDATE zeiterfuser SET ze_pausezeit &#039; + &lt;br /&gt;
                &#039;= ze_pausezeit + &#039;    + DB_SQLVal(oParams.values[&#039;pauseTime&#039;]) +&lt;br /&gt;
                &#039; WHERE sys_uid = &#039;    + DB_SQLVal(oParams.values[&#039;sysuid&#039;]);&lt;br /&gt;
&lt;br /&gt;
        if DB_SQLExec(oDB,cSql) then&lt;br /&gt;
        begin&lt;br /&gt;
            result := &#039;Die Daten wurden erfolgreich gespeichert!&#039;;&lt;br /&gt;
        end else begin&lt;br /&gt;
            result := &#039;Fehler!&#039;;&lt;br /&gt;
        end;&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
= HTML-Datei index.php =&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;html&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;!DOCTYPE html&amp;gt;&lt;br /&gt;
&amp;lt;html lang=&amp;quot;de-DE&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;head&amp;gt;&lt;br /&gt;
        &amp;lt;title&amp;gt;Ihr IT-Partner in Stade - Ernst Bergau GmbH&amp;lt;/title&amp;gt;&lt;br /&gt;
        &amp;lt;link rel=&amp;quot;stylesheet&amp;quot; type=&amp;quot;text/css&amp;quot; href=&amp;quot;timestyle.css&amp;quot;&amp;gt;&lt;br /&gt;
    &amp;lt;/head&amp;gt;&lt;br /&gt;
    &amp;lt;script&amp;gt;&lt;br /&gt;
        &amp;lt;?php&lt;br /&gt;
            include(&#039;timer.js&#039;);&lt;br /&gt;
        ?&amp;gt;&lt;br /&gt;
    &amp;lt;/script&amp;gt;&lt;br /&gt;
    &amp;lt;body&amp;gt;&lt;br /&gt;
        &amp;lt;!-- //--------------------------------------------------------------------------------------------------------------// --&amp;gt;&lt;br /&gt;
        &amp;lt;!-- //---------------------------------------------------HTML-------------------------------------------------------// --&amp;gt;&lt;br /&gt;
        &amp;lt;!-- //--------------------------------------------------------------------------------------------------------------// --&amp;gt;&lt;br /&gt;
&lt;br /&gt;
        &amp;lt;textarea id=&amp;quot;search&amp;quot; type=&amp;quot;text&amp;quot; rows=&amp;quot;1&amp;quot; cols=&amp;quot;50&amp;quot; oninput=&amp;quot;searchCustomers(this.value)&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
&lt;br /&gt;
        &amp;lt;div class=&amp;quot;kundenliste&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;ul id=&amp;quot;kundenListe&amp;quot;&amp;gt;&amp;lt;/ul&amp;gt;&lt;br /&gt;
        &amp;lt;/div&amp;gt;&lt;br /&gt;
        &amp;lt;table class=&amp;quot;timer&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;tbody id=&amp;quot;timerList&amp;quot;&amp;gt;&amp;lt;/tbody&amp;gt;&lt;br /&gt;
        &amp;lt;/table&amp;gt;&lt;br /&gt;
        &amp;lt;table class=&amp;quot;tabelleUeberschriften&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;thead&amp;gt;&lt;br /&gt;
                &amp;lt;tr&amp;gt;&lt;br /&gt;
                    &amp;lt;th class=&amp;quot;kunde&amp;quot;&amp;gt;Kunde&amp;lt;/th&amp;gt;&lt;br /&gt;
                    &amp;lt;th class=&amp;quot;konto&amp;quot;&amp;gt;Konto&amp;lt;/th&amp;gt;&lt;br /&gt;
                    &amp;lt;th class=&amp;quot;laufzeit&amp;quot;&amp;gt;Laufzeit&amp;lt;/th&amp;gt;&lt;br /&gt;
                    &amp;lt;th class=&amp;quot;status&amp;quot;&amp;gt;Status&amp;lt;/th&amp;gt;&lt;br /&gt;
                &amp;lt;/tr&amp;gt;&lt;br /&gt;
            &amp;lt;/thead&amp;gt;&lt;br /&gt;
        &amp;lt;/table&amp;gt;&lt;br /&gt;
        &lt;br /&gt;
&lt;br /&gt;
        &amp;lt;div class=&amp;quot;lightbox&amp;quot; id=&amp;quot;lightbox&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;label class=&amp;quot;commLabel&amp;quot; id=&amp;quot;commLabel&amp;quot; for=&amp;quot;comment&amp;quot;&amp;gt;Kommentar&amp;lt;/label&amp;gt;&lt;br /&gt;
            &amp;lt;textarea class=&amp;quot;comment&amp;quot; id=&amp;quot;comment&amp;quot; rows=&amp;quot;4&amp;quot; cols=&amp;quot;50&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;div class=&amp;quot;bezZeitkonto&amp;quot;&amp;gt;Zeitkonto&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;select class=&amp;quot;dropButtonsLightbox&amp;quot; id=&amp;quot;tätigkeit2Dropdown&amp;quot;&amp;gt;&amp;lt;/select&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;div class=&amp;quot;bezDatum&amp;quot;&amp;gt;Startdatum&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;date&amp;quot; class=&amp;quot;date&amp;quot; id=&amp;quot;date&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;textarea disabled class=&amp;quot;uhrzeit&amp;quot; id=&amp;quot;uhrzeit&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;div class=&amp;quot;bezPerson&amp;quot;&amp;gt;Person&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;textarea disabled class=&amp;quot;eingPerson&amp;quot; id=&amp;quot;eingPerson&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
            &amp;lt;textarea disabled class=&amp;quot;eingPersonZusatz&amp;quot; id=&amp;quot;eingPersonZusatz&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Std&amp;quot;&amp;gt;Std&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;number&amp;quot; class=&amp;quot;outStd&amp;quot; id=&amp;quot;outStd&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Min&amp;quot;&amp;gt;Min&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;number&amp;quot; class=&amp;quot;outMin&amp;quot; id=&amp;quot;outMin&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Sek&amp;quot;&amp;gt;Sek&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;number&amp;quot; class=&amp;quot;outSek&amp;quot; id=&amp;quot;outSek&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Pausenzeiten&amp;quot;&amp;gt;Davon Pausenzeiten&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Std2&amp;quot;&amp;gt;Std&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;number&amp;quot; class=&amp;quot;outStd2&amp;quot; id=&amp;quot;outStd2&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Min2&amp;quot;&amp;gt;Min&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;number&amp;quot; class=&amp;quot;outMin2&amp;quot; id=&amp;quot;outMin2&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;div class=&amp;quot;Sek2&amp;quot;&amp;gt;Sek&amp;lt;/div&amp;gt;&lt;br /&gt;
            &amp;lt;input type=&amp;quot;number&amp;quot; class=&amp;quot;outSek2&amp;quot; id=&amp;quot;outSek2&amp;quot;&amp;gt;&lt;br /&gt;
&lt;br /&gt;
            &amp;lt;button class=&amp;quot;saveButton&amp;quot; id=&amp;quot;saveButton&amp;quot;&amp;gt;Speichern&amp;lt;/button&amp;gt;&lt;br /&gt;
            &amp;lt;button class=&amp;quot;closeButton&amp;quot; id=&amp;quot;closeButton&amp;quot; onclick=&amp;quot;closeLightboxOnSchliessen()&amp;quot;&amp;gt;Schließen&amp;lt;/button&amp;gt; &lt;br /&gt;
        &amp;lt;/div&amp;gt;&lt;br /&gt;
        &lt;br /&gt;
        &amp;lt;div id=&amp;quot;zweiteListe&amp;quot;&amp;gt;&lt;br /&gt;
            &amp;lt;ul id=&amp;quot;taetigkeitListe&amp;quot;&amp;gt;&amp;lt;/ul&amp;gt;&lt;br /&gt;
        &amp;lt;/div&amp;gt;&lt;br /&gt;
    &amp;lt;/body&amp;gt;&lt;br /&gt;
&amp;lt;/html&amp;gt;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
= Javascript-Datei timer.js =&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;javascript&amp;quot;&amp;gt;&lt;br /&gt;
var api_key             = &#039;Y00C75PODJ&#039;;&lt;br /&gt;
var url                 = &#039;http://127.0.0.1:8099/Zeiterfa/zeiterfa/v1&#039;;&lt;br /&gt;
var zaehler             = -1;&lt;br /&gt;
var timerInterval;      &lt;br /&gt;
var pauseTimerInterval; &lt;br /&gt;
var startTimeValue;     &lt;br /&gt;
var selectedID          = 0;&lt;br /&gt;
var elapsedTime;        &lt;br /&gt;
var uid;                &lt;br /&gt;
var idStop; &lt;br /&gt;
var time;&lt;br /&gt;
var selID               = [];&lt;br /&gt;
var job                 = [];&lt;br /&gt;
var sys_uid             = [];&lt;br /&gt;
var timers              = [];    &lt;br /&gt;
var pauseTimers         = [];  &lt;br /&gt;
var pauseTime           = [];&lt;br /&gt;
var startdate           = [];&lt;br /&gt;
var uhrzeit             = [];&lt;br /&gt;
var datum               = [];&lt;br /&gt;
var statusTimer         = [];       &lt;br /&gt;
var selectedJob         = [];&lt;br /&gt;
var selectedKunde       = [];      &lt;br /&gt;
var tempSelectedKunde   = [];&lt;br /&gt;
var parameter           = [];&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//--------------------------------------------------Seitenaufruf---------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Bei Seitenaufruf werden die Funktionen----//&lt;br /&gt;
//----getCustomer und checkTimer aufgerufen und----//&lt;br /&gt;
//----die Kundenliste gefüllt und geprüft,----//&lt;br /&gt;
//----ob laufende timer vorhanden sind----//&lt;br /&gt;
&lt;br /&gt;
window.onload = function() {&lt;br /&gt;
&lt;br /&gt;
    getCustomer();&lt;br /&gt;
    checkTimer();&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//-------------------------------------------------Daten auslesen--------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Eine Liste der Kunden wird ausgelesenund gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function getCustomer() {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;POST&#039;;&lt;br /&gt;
    var choice = &#039;customer&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;getData.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            var responseData = JSON.parse(request.responseText);&lt;br /&gt;
            populateListeKunde(responseData);&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Bei Rechtsklick auf Kunden wird----//&lt;br /&gt;
//----seine ID ausgelesen und gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function getCustomerID(selectedKunde, callback) {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;POST&#039;;&lt;br /&gt;
    var choice = &#039;id&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;selectedKunde=&amp;quot;       + encodeURIComponent(selectedKunde);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;getData.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            selectedID = JSON.parse(request.responseText);&lt;br /&gt;
            callback(selectedID);&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Anhand der Kunden-ID wird eine Liste von Tätigkeiten----//&lt;br /&gt;
//----für jeden Kunden ausgelesen und gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function getJob(id, callback) {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;POST&#039;;&lt;br /&gt;
    var choice = &#039;job&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;id=&amp;quot;                  + encodeURIComponent(id);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;getData.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            var responseData = JSON.parse(request.responseText);&lt;br /&gt;
            var Data         = responseData.toString();&lt;br /&gt;
            var teile        = Data.split(&amp;quot;,&amp;quot;);&lt;br /&gt;
            var jobid = [];&lt;br /&gt;
&lt;br /&gt;
            for (i=0; i&amp;lt;teile.length; i++) {&lt;br /&gt;
                job[i]   = teile[i].split(&amp;quot;:&amp;quot;)[0];&lt;br /&gt;
                jobid[i] = teile[i].split(&amp;quot;:&amp;quot;)[1];&lt;br /&gt;
            }&lt;br /&gt;
            callback(job, jobid);&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Tätigkeitsliste wird nochmal zur Weiterverarbeitung gelesen----//&lt;br /&gt;
&lt;br /&gt;
function getJobs(id) {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;POST&#039;;&lt;br /&gt;
    var choice = &#039;job&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;id=&amp;quot;                  + encodeURIComponent(id);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;getData.php&amp;quot;, false);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            var responseData = JSON.parse(request.responseText);&lt;br /&gt;
            var data = responseData.toString();&lt;br /&gt;
            var teile = data.split(&amp;quot;,&amp;quot;);&lt;br /&gt;
            var jobid = [];&lt;br /&gt;
&lt;br /&gt;
            for (var i = 0; i &amp;lt; teile.length; i++) {&lt;br /&gt;
                job[i] = teile[i].split(&amp;quot;:&amp;quot;)[0];&lt;br /&gt;
                jobid[i] = teile[i].split(&amp;quot;:&amp;quot;)[1];&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
//----Gespeicherte Pausezeit wird ausgelesen----//&lt;br /&gt;
&lt;br /&gt;
function getPauseTime(sysuid, id) {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;POST&#039;;&lt;br /&gt;
    var choice = &#039;pauseTime&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params  =   &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;sysuid=&amp;quot;              + encodeURIComponent(sysuid);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;getData.php&amp;quot;, false);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            pauseTime[id].value = JSON.parse(request.responseText);&lt;br /&gt;
&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//---------------------------------------------Listen--------------------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Eine Liste wird mit Kundendaten gefüllt und angezeigt----//&lt;br /&gt;
&lt;br /&gt;
function populateListeKunde(data) {&lt;br /&gt;
&lt;br /&gt;
    data.forEach(function(item) {&lt;br /&gt;
        var listItem = document.createElement(&amp;quot;li&amp;quot;);&lt;br /&gt;
        listItem.textContent = item;&lt;br /&gt;
&lt;br /&gt;
        listItem.addEventListener(&amp;quot;contextmenu&amp;quot;, function(event) {&lt;br /&gt;
            event.preventDefault();&lt;br /&gt;
&lt;br /&gt;
            var selectedKunde = item;&lt;br /&gt;
            getCustomerID(selectedKunde, function(selectedID) {&lt;br /&gt;
                getJob(selectedID, function(job, jobid) {&lt;br /&gt;
                    showSecondList(job, jobid, item);&lt;br /&gt;
                });&lt;br /&gt;
            });&lt;br /&gt;
        });&lt;br /&gt;
&lt;br /&gt;
        kundenListe.appendChild(listItem);&lt;br /&gt;
    });&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Eine zweite Liste wird mit Jobs gefüllt,----//&lt;br /&gt;
//----angezeigt und bei Klick auf einen Eintrag---//&lt;br /&gt;
//-------die Funktion startTimer aufgerufen-------//&lt;br /&gt;
&lt;br /&gt;
function showSecondList(job, jobid, kundeItem) {&lt;br /&gt;
&lt;br /&gt;
    var zweiteListe = document.getElementById(&amp;quot;zweiteListe&amp;quot;);&lt;br /&gt;
    zweiteListe.innerHTML = &amp;quot;&amp;quot;;&lt;br /&gt;
&lt;br /&gt;
    job.forEach(function(item) {&lt;br /&gt;
        var listItem = document.createElement(&amp;quot;li&amp;quot;);&lt;br /&gt;
        listItem.textContent = item;&lt;br /&gt;
    &lt;br /&gt;
        (function(capturedItem) {&lt;br /&gt;
            listItem.addEventListener(&amp;quot;click&amp;quot;, function() {&lt;br /&gt;
                startTimer(job, jobid, capturedItem, kundeItem);&lt;br /&gt;
            });&lt;br /&gt;
        }) (item);&lt;br /&gt;
        zweiteListe.appendChild(listItem);&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    zweiteListe.style.display = &amp;quot;block&amp;quot;; &lt;br /&gt;
    document.addEventListener(&#039;keydown&#039;, closeSecondList);&lt;br /&gt;
    document.addEventListener(&#039;click&#039;, closeSecondList);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Nach Klick auf Eintrag wird die Liste geschlossen----//&lt;br /&gt;
function closeSecondList() {&lt;br /&gt;
&lt;br /&gt;
    var zweiteListe = document.getElementById(&amp;quot;zweiteListe&amp;quot;);&lt;br /&gt;
    zweiteListe.style.display = &amp;quot;none&amp;quot;; &lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//---------------------------------------------Daten speichern-----------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Startzeit, -datum, ID und Job werden gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function saveStartTime(user, uhrzeit, datum, endTime, endDate, status, psnr, Job, jobID) {&lt;br /&gt;
    &lt;br /&gt;
    var method = &#039;PUT&#039;;&lt;br /&gt;
    var choice = &#039;start&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;user=&amp;quot;                + encodeURIComponent(user) +&lt;br /&gt;
                    &amp;quot;&amp;amp;uhrzeit=&amp;quot;             + encodeURIComponent(uhrzeit) +&lt;br /&gt;
                    &amp;quot;&amp;amp;datum=&amp;quot;               + encodeURIComponent(datum) +&lt;br /&gt;
                    &amp;quot;&amp;amp;endTime=&amp;quot;             + encodeURIComponent(endTime) +&lt;br /&gt;
                    &amp;quot;&amp;amp;endDate=&amp;quot;             + encodeURIComponent(endDate) +&lt;br /&gt;
                    &amp;quot;&amp;amp;status=&amp;quot;              + encodeURIComponent(status)+&lt;br /&gt;
                    &amp;quot;&amp;amp;psnr=&amp;quot;                + encodeURIComponent(psnr)+&lt;br /&gt;
                    &amp;quot;&amp;amp;Job=&amp;quot;                 + encodeURIComponent(Job)+&lt;br /&gt;
                    &amp;quot;&amp;amp;jobID=&amp;quot;               + encodeURIComponent(jobID);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;saveTime.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            sys_uid[zaehler].value = request.responseText;&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
//----Pausezeit wird gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function savePauseTime(pauseTime, id) {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;PUT&#039;;&lt;br /&gt;
    var choice = &#039;pause&#039;;&lt;br /&gt;
    &lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;      + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;         + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;      + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;      + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;pauseTime=&amp;quot;   + encodeURIComponent(pauseTime) +&lt;br /&gt;
                    &amp;quot;&amp;amp;sysuid=&amp;quot;      + encodeURIComponent(sys_uid[id].value);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;saveTime.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200) {&lt;br /&gt;
            // alert(request.responseText);&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
//----Stopzeit und -datum wird gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function saveStopTime(stopTimeValue, elapsedTime, elapsedPauseTime, status, uid, vergZeit, comment) {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;PUT&#039;;&lt;br /&gt;
    var choice = &#039;stop&#039;;&lt;br /&gt;
    &lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice) +&lt;br /&gt;
                    &amp;quot;&amp;amp;stopTimeValue=&amp;quot;       + encodeURIComponent(stopTimeValue) +&lt;br /&gt;
                    &amp;quot;&amp;amp;elapsedTime=&amp;quot;         + encodeURIComponent(elapsedTime) +&lt;br /&gt;
                    &amp;quot;&amp;amp;elapsedPauseTime=&amp;quot;    + encodeURIComponent(elapsedPauseTime) +&lt;br /&gt;
                    &amp;quot;&amp;amp;status=&amp;quot;              + encodeURIComponent(status) +&lt;br /&gt;
                    &amp;quot;&amp;amp;uid=&amp;quot;                 + encodeURIComponent(uid) +&lt;br /&gt;
                    &amp;quot;&amp;amp;vergZeit=&amp;quot;            + encodeURIComponent(vergZeit)+&lt;br /&gt;
                    &amp;quot;&amp;amp;comment=&amp;quot;            + encodeURIComponent(comment);&lt;br /&gt;
    &lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;saveTime.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200 &amp;amp;&amp;amp; timers.length === 0) {&lt;br /&gt;
            location.reload();&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//---------------------------------------------Like-Suche----------------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Durchsucht die Liste der Kunden auf alle einegegebenen Zeichen----//&lt;br /&gt;
&lt;br /&gt;
function searchCustomers(text) {&lt;br /&gt;
&lt;br /&gt;
    text = text.toLowerCase();&lt;br /&gt;
    var kundenListe = document.getElementById(&amp;quot;kundenListe&amp;quot;);&lt;br /&gt;
    var listItems = kundenListe.getElementsByTagName(&amp;quot;li&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    for (i=0; i&amp;lt;listItems.length; i++) {&lt;br /&gt;
        var item = listItems[i].textContent.toLowerCase();&lt;br /&gt;
        if (item.includes(text)) {&lt;br /&gt;
            listItems[i].style.display = &amp;quot;block&amp;quot;;&lt;br /&gt;
        }&lt;br /&gt;
        else {&lt;br /&gt;
            listItems[i].style.display = &amp;quot;none&amp;quot;;&lt;br /&gt;
        }&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//------------------------------------------Vorhandenen Timer checken----------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Überprüft, ob es Einträge in Datenbank ohne----//&lt;br /&gt;
//----Endzeit gibt und startet entsprechend Timer----//&lt;br /&gt;
&lt;br /&gt;
function checkTimer() {&lt;br /&gt;
&lt;br /&gt;
    var method = &#039;POST&#039;;&lt;br /&gt;
    var choice = &#039;check&#039;;&lt;br /&gt;
&lt;br /&gt;
    var params =    &amp;quot;api_key=&amp;quot;              + encodeURIComponent(api_key) +&lt;br /&gt;
                    &amp;quot;&amp;amp;url=&amp;quot;                 + encodeURIComponent(url) +&lt;br /&gt;
                    &amp;quot;&amp;amp;method=&amp;quot;              + encodeURIComponent(method) +&lt;br /&gt;
                    &amp;quot;&amp;amp;choice=&amp;quot;              + encodeURIComponent(choice);&lt;br /&gt;
&lt;br /&gt;
    var request = new XMLHttpRequest();&lt;br /&gt;
&lt;br /&gt;
    request.open(&amp;quot;POST&amp;quot;, &amp;quot;getData.php&amp;quot;);&lt;br /&gt;
    request.setRequestHeader(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/x-www-form-urlencoded&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
    request.onreadystatechange = function() {&lt;br /&gt;
        if (this.readyState === 4 &amp;amp;&amp;amp; this.status === 200 &amp;amp;&amp;amp; timers.length === 0) {&lt;br /&gt;
            var responseData = JSON.parse(request.responseText);&lt;br /&gt;
            &lt;br /&gt;
            if (responseData.length !== 0) {&lt;br /&gt;
                startTimeValue = new Date();&lt;br /&gt;
                var minutes = startTimeValue.getMinutes();&lt;br /&gt;
                var seconds = startTimeValue.getSeconds();&lt;br /&gt;
                var month = startTimeValue.getMonth() + 1;&lt;br /&gt;
&lt;br /&gt;
                responseData = responseData.toString();&lt;br /&gt;
                var teile = responseData.split(&amp;quot;,&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
                for (var i=0; i&amp;lt;teile.length; i+=6) {&lt;br /&gt;
                    uhrzeit.push({value: startTimeValue.getHours() + &amp;quot;:&amp;quot; + (minutes &amp;lt; 10 ? &amp;quot;0&amp;quot; + minutes : minutes) + &amp;quot;:&amp;quot; + (seconds &amp;lt; 10 ? &amp;quot;0&amp;quot; + seconds : seconds)});&lt;br /&gt;
                    datum.push({value: startTimeValue.getFullYear() + &amp;quot;-&amp;quot; + (month &amp;lt; 10 ? &amp;quot;0&amp;quot; + month : month) + &amp;quot;-&amp;quot; + startTimeValue.getDate()});&lt;br /&gt;
&lt;br /&gt;
                    zaehler++;&lt;br /&gt;
&lt;br /&gt;
                    statusTimer.push({value: &#039;Running&#039;});&lt;br /&gt;
                    pauseTimers.push({value: 0});&lt;br /&gt;
                    pauseTime.push({value: 0});&lt;br /&gt;
                    timers.push({value: teile[i]});&lt;br /&gt;
                }&lt;br /&gt;
&lt;br /&gt;
                var j=0;&lt;br /&gt;
                for (var i=1; i&amp;lt;teile.length; i+=6) {&lt;br /&gt;
                    selID.push({value: teile[i]});&lt;br /&gt;
                    getJobs(selID[j].value);&lt;br /&gt;
                    j++;&lt;br /&gt;
                } &lt;br /&gt;
&lt;br /&gt;
                fillArray(2, selectedJob, teile);&lt;br /&gt;
                fillArray(3, selectedKunde, teile);&lt;br /&gt;
                fillArray(4, sys_uid, teile);&lt;br /&gt;
                fillArray(5, startdate, teile);&lt;br /&gt;
&lt;br /&gt;
                for (var i=0; i&amp;lt;timers.length; i++) {&lt;br /&gt;
                    var startZeitTeil = timers[i].value.split(&amp;quot;:&amp;quot;);&lt;br /&gt;
                    var startDatumTeil = startdate[i].value.split(&amp;quot;.&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
                    var diffMilli = new Date() - getStartdatum(startDatumTeil, startZeitTeil);&lt;br /&gt;
                    timers[i].value = Math.floor(diffMilli / 1000); &lt;br /&gt;
                }   &lt;br /&gt;
&lt;br /&gt;
                selecteID = 0;&lt;br /&gt;
                startInterval(job);&lt;br /&gt;
            }&lt;br /&gt;
        }&lt;br /&gt;
    };&lt;br /&gt;
    request.send(params);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Arrays werden an diese Funktion übergeben und gefüllt----//&lt;br /&gt;
&lt;br /&gt;
function fillArray(LZ, array, teile) {&lt;br /&gt;
&lt;br /&gt;
    var arrLZ = 0;&lt;br /&gt;
    for (var i=LZ; i&amp;lt;teile.length; i+=6) {&lt;br /&gt;
        array.push({value: teile[i]});&lt;br /&gt;
        arrLZ++;&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Startdatum wird errechnet----//&lt;br /&gt;
function getStartdatum(startDatumTeil, startZeitTeil) {&lt;br /&gt;
&lt;br /&gt;
    var startdatum = new Date(&lt;br /&gt;
        parseInt(startDatumTeil[2]),&lt;br /&gt;
        parseInt(startDatumTeil[1]) -1,&lt;br /&gt;
        parseInt(startDatumTeil[0]),&lt;br /&gt;
        parseInt(startZeitTeil[0]),&lt;br /&gt;
        parseInt(startZeitTeil[1]),&lt;br /&gt;
        parseInt(startZeitTeil[2])&lt;br /&gt;
    );&lt;br /&gt;
    return startdatum;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//-------------------------------------------------Interval--------------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Interval für Timer wird gestartet----//&lt;br /&gt;
&lt;br /&gt;
function startInterval(job, item, kundeItem, index) {&lt;br /&gt;
&lt;br /&gt;
    timerInterval = setInterval(function() {&lt;br /&gt;
&lt;br /&gt;
        for (i=0; i&amp;lt;timers.length; i++) {&lt;br /&gt;
&lt;br /&gt;
            timers[i].value += 1;&lt;br /&gt;
        }&lt;br /&gt;
        updateUI(job, item, kundeItem, index);&lt;br /&gt;
    }, 1000);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Interval für Pausentimer wird gestartet----// &lt;br /&gt;
&lt;br /&gt;
function pauseInterval(job, item, kundeItem, id) {&lt;br /&gt;
&lt;br /&gt;
    pauseTimerInterval = setInterval(function() {&lt;br /&gt;
&lt;br /&gt;
        for (i=0; i&amp;lt;pauseTimers.length; i++) {&lt;br /&gt;
&lt;br /&gt;
            pauseTimers[i].value += 1;&lt;br /&gt;
        }&lt;br /&gt;
        updateUI(job, item, kundeItem);&lt;br /&gt;
    }, 1000);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//------------------------------------------------Timer starten----------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----startTimer bereitet die nötigen Arrays vor,----//&lt;br /&gt;
//----speichert Startzeit und -datum,----//&lt;br /&gt;
//----beschreibt Arrays und Variablen entsprechend----//&lt;br /&gt;
//----und ruft die Funktion saveStartTime auf----//&lt;br /&gt;
&lt;br /&gt;
function startTimer(jobs, jobid, selJob, kundeItem) {&lt;br /&gt;
&lt;br /&gt;
    var user = 999;&lt;br /&gt;
    var endTime = &amp;quot;00:00:00&amp;quot;;&lt;br /&gt;
    var endDate = &amp;quot;0000-00-00&amp;quot;;&lt;br /&gt;
    var status = &#039;01&#039;;&lt;br /&gt;
    zaehler++;&lt;br /&gt;
    &lt;br /&gt;
    startTimeValue = new Date();&lt;br /&gt;
&lt;br /&gt;
    var minutes = startTimeValue.getMinutes();&lt;br /&gt;
    var seconds = startTimeValue.getSeconds();&lt;br /&gt;
    uhrzeit.push({value: startTimeValue.getHours() + &amp;quot;:&amp;quot; + (minutes &amp;lt; 10 ? &amp;quot;0&amp;quot; + minutes : minutes) + &amp;quot;:&amp;quot; + (seconds &amp;lt; 10 ? &amp;quot;0&amp;quot; + seconds : seconds)});&lt;br /&gt;
&lt;br /&gt;
    var month = startTimeValue.getMonth() + 1;&lt;br /&gt;
    datum.push({value: startTimeValue.getFullYear() + &amp;quot;-&amp;quot; + (month &amp;lt; 10 ? &amp;quot;0&amp;quot; + month : month) + &amp;quot;-&amp;quot; + startTimeValue.getDate()});&lt;br /&gt;
&lt;br /&gt;
    statusTimer.push({value: &#039;Running&#039;});&lt;br /&gt;
    pauseTimers.push({value: 0});&lt;br /&gt;
    pauseTime.push({value: 0});&lt;br /&gt;
    timers.push({value: 0});&lt;br /&gt;
    selectedKunde.push({value: kundeItem});&lt;br /&gt;
    selectedJob.push({value: selJob});&lt;br /&gt;
    sys_uid.push({value: 0});&lt;br /&gt;
&lt;br /&gt;
    var index = jobs.indexOf(selJob);&lt;br /&gt;
    &lt;br /&gt;
    saveStartTime(user, uhrzeit[zaehler].value, datum[zaehler].value, endTime, endDate, status, selectedID, selJob, jobid[index]);&lt;br /&gt;
    &lt;br /&gt;
    clearInterval(timerInterval);&lt;br /&gt;
    startInterval(jobs, selJob, kundeItem, index);&lt;br /&gt;
&lt;br /&gt;
    zweiteListe.style.display = &amp;quot;none&amp;quot;;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//------------------------------------------------Timer stoppen----------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Stopt den Timer, ändert den Status und ruft openLightbox auf----//&lt;br /&gt;
&lt;br /&gt;
function stopTimer(job, item, kundeItem, index, id) {&lt;br /&gt;
&lt;br /&gt;
    idStop = id;&lt;br /&gt;
    statusTimer[id].value = &#039;Stopped&#039;;&lt;br /&gt;
    openLightbox(job, item, kundeItem, index, id);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//------------------------------------------------Timer pausieren--------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Pausiert den Timer und ändert den Status----//&lt;br /&gt;
&lt;br /&gt;
function pauseTimer(job, item, kundeItem, id) {&lt;br /&gt;
&lt;br /&gt;
    pauseTimers[id].value = 0;&lt;br /&gt;
    clearInterval(pauseTimerInterval);&lt;br /&gt;
    pauseInterval(job, item, kundeItem, id);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//----------------------------------------------Button Pause-Start-------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Ruft die Funktion pauseTimer oder----//&lt;br /&gt;
//----savePauseTimer auf----//&lt;br /&gt;
&lt;br /&gt;
function toggleButton(job, item, kundeItem, id) {&lt;br /&gt;
&lt;br /&gt;
    var button = document.getElementById(id);&lt;br /&gt;
&lt;br /&gt;
    if (button.innerHTML === &amp;quot;Pause&amp;quot;) {&lt;br /&gt;
&lt;br /&gt;
        statusTimer[id].value = &#039;Paused&#039;;&lt;br /&gt;
        pauseTimer(job, item, kundeItem, id);&lt;br /&gt;
    } &lt;br /&gt;
    else {&lt;br /&gt;
&lt;br /&gt;
        statusTimer[id].value = &amp;quot;Running&amp;quot;;&lt;br /&gt;
        savePauseTime(pauseTimers[id].value, id);&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//------------------------------------------------Ausgabe des Timers-----------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Zeigt eine Liste mit einem oder mehreren----//&lt;br /&gt;
//----Timern und Buttons, verteilt dynamisch ID&#039;s----//&lt;br /&gt;
//----und ruft entsprechend der gedrückten----//&lt;br /&gt;
//----Buttons Funktionen auf----//&lt;br /&gt;
&lt;br /&gt;
function updateUI(job, item, kundeItem, index) {&lt;br /&gt;
    var timerList = document.getElementById(&amp;quot;timerList&amp;quot;);&lt;br /&gt;
    timerList.innerHTML = &#039;&#039;;&lt;br /&gt;
    &lt;br /&gt;
    for (i=0; i&amp;lt;timers.length; i++) {&lt;br /&gt;
        var neueReihe = document.createElement(&#039;tr&#039;)&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        //----Name des Kunden wird ausgegeben----//&lt;br /&gt;
&lt;br /&gt;
        var kundeEintrag = document.createElement(&#039;td&#039;);&lt;br /&gt;
        kundeEintrag.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
        kundeEintrag.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;;&lt;br /&gt;
        kundeEintrag.style.width = &amp;quot;30%&amp;quot;;&lt;br /&gt;
        kundeEintrag.style.left = &amp;quot;0%&amp;quot;; &lt;br /&gt;
        kundeEintrag.style.textAlign = &amp;quot;center&amp;quot;;&lt;br /&gt;
        kundeEintrag.classList.add(&#039;kunde_k&#039;);&lt;br /&gt;
        kundeEintrag.textContent = selectedKunde[i].value;&lt;br /&gt;
        timerList.appendChild(kundeEintrag);&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        //----Gewählter Job wird ausgegeben----//&lt;br /&gt;
&lt;br /&gt;
        var kontoEintrag = document.createElement(&#039;td&#039;);&lt;br /&gt;
        kontoEintrag.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
        kontoEintrag.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;; &lt;br /&gt;
        kontoEintrag.style.width = &amp;quot;30%&amp;quot;;&lt;br /&gt;
        kontoEintrag.style.left = &amp;quot;30%&amp;quot;; &lt;br /&gt;
        kontoEintrag.style.textAlign = &amp;quot;center&amp;quot;;&lt;br /&gt;
        kontoEintrag.classList.add(&#039;konto_k&#039;);&lt;br /&gt;
        kontoEintrag.textContent = selectedJob[i].value;&lt;br /&gt;
        timerList.appendChild(kontoEintrag);&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        //----Gestartete Timer werden in Liste----//&lt;br /&gt;
        //----ausgegeben, pausierte Timer werden----//&lt;br /&gt;
        //----Liste ausgegeben, sobald Timer gestoppt----//&lt;br /&gt;
        //----wird Ausgabe in Lightbox----//&lt;br /&gt;
&lt;br /&gt;
        if (statusTimer[i].value === &amp;quot;Running&amp;quot;) {&lt;br /&gt;
            var laufzeitElement = document.createElement(&#039;td&#039;);&lt;br /&gt;
            laufzeitElement.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
            laufzeitElement.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;; &lt;br /&gt;
            laufzeitElement.style.width = &amp;quot;15%&amp;quot;;&lt;br /&gt;
            laufzeitElement.style.left = &amp;quot;60%&amp;quot;; &lt;br /&gt;
            laufzeitElement.style.textAlign = &amp;quot;center&amp;quot;;&lt;br /&gt;
            laufzeitElement.classList.add(&#039;laufzeit_l&#039;);&lt;br /&gt;
            laufzeitElement.textContent = formatElapsedTime(timers[i].value);&lt;br /&gt;
            timerList.appendChild(laufzeitElement);&lt;br /&gt;
        }&lt;br /&gt;
        else if (statusTimer[i].value === &#039;Paused&#039;) {&lt;br /&gt;
            var laufzeitElement = document.createElement(&#039;td&#039;);&lt;br /&gt;
            laufzeitElement.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
            laufzeitElement.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;; &lt;br /&gt;
            laufzeitElement.style.width = &amp;quot;15%&amp;quot;;&lt;br /&gt;
            laufzeitElement.style.left = &amp;quot;60%&amp;quot;; &lt;br /&gt;
            laufzeitElement.style.textAlign = &amp;quot;center&amp;quot;;&lt;br /&gt;
            laufzeitElement.classList.add(&#039;laufzeit_l&#039;);&lt;br /&gt;
            laufzeitElement.textContent = formatElapsedTime(pauseTimers[i].value);&lt;br /&gt;
            timerList.appendChild(laufzeitElement);&lt;br /&gt;
        }&lt;br /&gt;
        else if (statusTimer[i].value === &#039;Stopped&#039;) {&lt;br /&gt;
            var laufzeitElement = document.createElement(&#039;td&#039;);&lt;br /&gt;
            laufzeitElement.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
            laufzeitElement.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;; &lt;br /&gt;
            laufzeitElement.style.width = &amp;quot;15%&amp;quot;;&lt;br /&gt;
            laufzeitElement.style.left = &amp;quot;60%&amp;quot;; &lt;br /&gt;
            laufzeitElement.style.textAlign = &amp;quot;center&amp;quot;;&lt;br /&gt;
            laufzeitElement.classList.add(&#039;laufzeit_l&#039;);&lt;br /&gt;
            laufzeitElement.textContent = formatElapsedTime(pauseTimers[i].value);&lt;br /&gt;
            timerList.appendChild(laufzeitElement);&lt;br /&gt;
&lt;br /&gt;
            var zeit = formatElapsedTime(timers[i].value);&lt;br /&gt;
            zeit = zeit.toString();&lt;br /&gt;
            var teile = zeit.split(&amp;quot;:&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
            var h = teile[0];&lt;br /&gt;
            var m = teile[1];&lt;br /&gt;
            var s = teile[2];&lt;br /&gt;
&lt;br /&gt;
            var outStdField = document.getElementById(&amp;quot;outStd&amp;quot;);&lt;br /&gt;
            var outMinField = document.getElementById(&amp;quot;outMin&amp;quot;);&lt;br /&gt;
            var outSekField = document.getElementById(&amp;quot;outSek&amp;quot;);&lt;br /&gt;
&lt;br /&gt;
            if (document.activeElement !== outStdField &amp;amp;&amp;amp; document.activeElement !== outMinField &amp;amp;&amp;amp; document.activeElement !== outSekField) {&lt;br /&gt;
                document.getElementById(&amp;quot;outStd&amp;quot;).value = h; &lt;br /&gt;
                document.getElementById(&amp;quot;outMin&amp;quot;).value = m;&lt;br /&gt;
                document.getElementById(&amp;quot;outSek&amp;quot;).value = s;&lt;br /&gt;
            }&lt;br /&gt;
&lt;br /&gt;
            var hEnd = document.getElementById(&amp;quot;outStd&amp;quot;).value;&lt;br /&gt;
            var mEnd = document.getElementById(&amp;quot;outMin&amp;quot;).value;&lt;br /&gt;
            var sEnd = document.getElementById(&amp;quot;outSek&amp;quot;).value;&lt;br /&gt;
            time = hEnd + &amp;quot;:&amp;quot; + mEnd + &amp;quot;:&amp;quot; + sEnd;&lt;br /&gt;
        }&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        //----Status (Running, Paused, Stopped) wird ausgegeben----//&lt;br /&gt;
&lt;br /&gt;
        var statusTimerElement = document.createElement(&#039;td&#039;);&lt;br /&gt;
        statusTimerElement.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
        statusTimerElement.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;; &lt;br /&gt;
        statusTimerElement.style.width = &amp;quot;15%&amp;quot;;&lt;br /&gt;
        statusTimerElement.style.left = &amp;quot;75%&amp;quot;; &lt;br /&gt;
        statusTimerElement.style.textAlign = &amp;quot;center&amp;quot;;&lt;br /&gt;
        statusTimerElement.classList.add(&#039;status_s&#039;);&lt;br /&gt;
        statusTimerElement.textContent = statusTimer[i].value;&lt;br /&gt;
        timerList.appendChild(statusTimerElement);&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        //----Pause Button mit dynamisch vergebener ID wird ausgegeben----//&lt;br /&gt;
&lt;br /&gt;
        if (statusTimer[i].value === &amp;quot;Running&amp;quot;) {&lt;br /&gt;
            var pauseButton = document.createElement(&#039;button&#039;)&lt;br /&gt;
            pauseButton.id = i;&lt;br /&gt;
            pauseButton.classList.add(&#039;pauseButton&#039;);&lt;br /&gt;
            pauseButton.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
            pauseButton.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;;&lt;br /&gt;
            pauseButton.style.left = &amp;quot;90%&amp;quot;; &lt;br /&gt;
            pauseButton.style.height = &amp;quot;9.4%&amp;quot;;&lt;br /&gt;
            pauseButton.style.minWidth = &amp;quot;3%&amp;quot;;&lt;br /&gt;
            pauseButton.style.fontSize = &amp;quot;80%&amp;quot;;&lt;br /&gt;
            pauseButton.textContent = &amp;quot;Pause&amp;quot;&lt;br /&gt;
            pauseButton.addEventListener(&#039;click&#039;, function() {toggleButton(job, item, kundeItem, this.id)});&lt;br /&gt;
            timerList.appendChild(pauseButton);&lt;br /&gt;
        }&lt;br /&gt;
        else if (statusTimer[i].value === &amp;quot;Paused&amp;quot;) {&lt;br /&gt;
            var pauseButton = document.createElement(&#039;button&#039;)&lt;br /&gt;
            pauseButton.id = i;&lt;br /&gt;
            pauseButton.classList.add(&#039;pauseButton&#039;);&lt;br /&gt;
            pauseButton.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
            pauseButton.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;;&lt;br /&gt;
            pauseButton.style.left = &amp;quot;90%&amp;quot;;&lt;br /&gt;
            pauseButton.style.height = &amp;quot;9.4%&amp;quot;;&lt;br /&gt;
            pauseButton.style.minWidth = &amp;quot;3%&amp;quot;;&lt;br /&gt;
            pauseButton.style.fontSize = &amp;quot;80%&amp;quot;;&lt;br /&gt;
            pauseButton.textContent = &amp;quot;Start&amp;quot;&lt;br /&gt;
            pauseButton.addEventListener(&#039;click&#039;, function() {toggleButton(job, item, kundeItem, this.id)});&lt;br /&gt;
            timerList.appendChild(pauseButton);&lt;br /&gt;
        }&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        //----Stop Button mit dynamisch vergebener ID wird ausgegeben----//&lt;br /&gt;
&lt;br /&gt;
        var stopButton = document.createElement(&#039;button&#039;)&lt;br /&gt;
        stopButton.id = i;&lt;br /&gt;
        stopButton.classList.add(&#039;stopButton&#039;);&lt;br /&gt;
        stopButton.style.position = &amp;quot;fixed&amp;quot;;&lt;br /&gt;
        stopButton.style.top = (12 + i * 14) + &amp;quot;%&amp;quot;;&lt;br /&gt;
        stopButton.style.left = &amp;quot;94%&amp;quot;;&lt;br /&gt;
        stopButton.style.height = &amp;quot;9.4%&amp;quot;;&lt;br /&gt;
        stopButton.style.width = &amp;quot;3%&amp;quot;;&lt;br /&gt;
        stopButton.style.fontSize = &amp;quot;80%&amp;quot;;&lt;br /&gt;
        stopButton.textContent = &amp;quot;Stop&amp;quot;&lt;br /&gt;
        stopButton.addEventListener(&#039;click&#039;, function() {stopTimer(job, item, kundeItem, index, this.id)});&lt;br /&gt;
        timerList.appendChild(stopButton);&lt;br /&gt;
        &lt;br /&gt;
        //----Für jeden aufgerufenen Timer wird----//&lt;br /&gt;
        //----eine neue Reihe ausgegeben----//&lt;br /&gt;
&lt;br /&gt;
        timerList.appendChild(neueReihe);&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------Sekunden in Uhrzeit umrechnen-------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Rechnet übergebene Sekunden in Stunden,----//&lt;br /&gt;
//----Minuten und Sekunden und gibt diese----//&lt;br /&gt;
//----an aufrufende Funktion zurück----//&lt;br /&gt;
&lt;br /&gt;
function formatElapsedTime(seconds) {&lt;br /&gt;
&lt;br /&gt;
    var minutes = Math.floor(seconds / 60);&lt;br /&gt;
    seconds %= 60;&lt;br /&gt;
    var hours = Math.floor(minutes / 60);&lt;br /&gt;
    minutes %= 60;&lt;br /&gt;
    function addLeadingZero(n) {&lt;br /&gt;
        return (n &amp;lt; 10 ? &#039;0&#039; : &#039;&#039;) + n;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    return addLeadingZero(hours) + &amp;quot;:&amp;quot; + addLeadingZero(minutes) + &amp;quot;:&amp;quot; + addLeadingZero(seconds);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//--------------------------------------------Uhrzeit in Sekunden umrechnen----------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Rechnet übergebene Stunden, Minuten----//&lt;br /&gt;
//----und Sekunden in Sekunden um und----//&lt;br /&gt;
//----gibt diese an aufrufende Funktion zurück----//&lt;br /&gt;
&lt;br /&gt;
function timeInSeconds(zeit) {&lt;br /&gt;
&lt;br /&gt;
    var teile = zeit.split(&#039;:&#039;);&lt;br /&gt;
    var Stunden = parseInt(teile[0], 10);&lt;br /&gt;
    var Minuten = parseInt(teile[1], 10);&lt;br /&gt;
    var Sekunden = parseInt(teile[2], 10);&lt;br /&gt;
    return Stunden * 3600 + Minuten * 60 + Sekunden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
//--------------------------------------------------Lightbox-------------------------------------------------------------//&lt;br /&gt;
//-----------------------------------------------------------------------------------------------------------------------//&lt;br /&gt;
&lt;br /&gt;
//----Öffnet eine Lightbox und zeigt Kunde----//&lt;br /&gt;
//----Job, vergangene Zeit und evtl Pausenzeit an----//&lt;br /&gt;
//----und gibt Möglichkeit alle Zeiten anzupassen----//&lt;br /&gt;
&lt;br /&gt;
function openLightbox(job, item, kundeItem, index, id) {&lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&#039;lightbox&#039;).style.display = &#039;block&#039;;&lt;br /&gt;
&lt;br /&gt;
    var dateField = document.getElementById(&amp;quot;date&amp;quot;);&lt;br /&gt;
    dateField.value = datum[id].value;&lt;br /&gt;
    document.getElementById(&amp;quot;uhrzeit&amp;quot;).innerHTML = uhrzeit[id].value;&lt;br /&gt;
&lt;br /&gt;
    var commentTextarea = document.getElementById(&amp;quot;comment&amp;quot;);&lt;br /&gt;
    commentTextarea.value = selectedJob[id].value;&lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&amp;quot;eingPersonZusatz&amp;quot;).innerHTML = selectedKunde[id].value;&lt;br /&gt;
&lt;br /&gt;
    if (selectedID.length !== undefined) {&lt;br /&gt;
        document.getElementById(&amp;quot;eingPerson&amp;quot;).innerHTML = selectedID;&lt;br /&gt;
    }&lt;br /&gt;
    else {&lt;br /&gt;
        document.getElementById(&amp;quot;eingPerson&amp;quot;).innerHTML = selID[id].value;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
    //----Ausgabe der vergangenen Zeit in Lightbox----//&lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&amp;quot;outStd&amp;quot;).value = 00;                      &lt;br /&gt;
    document.getElementById(&amp;quot;outMin&amp;quot;).value = 00;                      &lt;br /&gt;
    document.getElementById(&amp;quot;outSek&amp;quot;).value = 00;                      &lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
    //----Ausgabe der Pausenzeiten in Lightbox----//&lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&amp;quot;outStd2&amp;quot;).value = 00;                      &lt;br /&gt;
    document.getElementById(&amp;quot;outMin2&amp;quot;).value = 00;                      &lt;br /&gt;
    document.getElementById(&amp;quot;outSek2&amp;quot;).value = 00;                           &lt;br /&gt;
    getPauseTime(sys_uid[id].value, id);                                &lt;br /&gt;
                                                                        &lt;br /&gt;
    var secondsPause = pauseTime[id].value;                             &lt;br /&gt;
    secondsPause %= 60;  &lt;br /&gt;
&lt;br /&gt;
    var minutesPause = Math.floor(pauseTime[id].value / 60);            &lt;br /&gt;
    minutesPause %= 60;    &lt;br /&gt;
&lt;br /&gt;
    var hoursPause = Math.floor(minutesPause / 60);                     &lt;br /&gt;
    hoursPause %= 60;   &lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&amp;quot;outStd2&amp;quot;).value = hoursPause;              &lt;br /&gt;
    document.getElementById(&amp;quot;outMin2&amp;quot;).value = minutesPause;       &lt;br /&gt;
&lt;br /&gt;
    if (secondsPause &amp;gt; 0) {&lt;br /&gt;
        document.getElementById(&amp;quot;outSek2&amp;quot;).value = secondsPause;        &lt;br /&gt;
    }                                                                   &lt;br /&gt;
    else {&lt;br /&gt;
        document.getElementById(&amp;quot;outSek2&amp;quot;).value = 0;                   &lt;br /&gt;
    }                                                                   &lt;br /&gt;
&lt;br /&gt;
    var dropdown2 = document.getElementById(&amp;quot;tätigkeit2Dropdown&amp;quot;);&lt;br /&gt;
    &lt;br /&gt;
    job.forEach(function(item, index) {&lt;br /&gt;
        var option = document.createElement(&amp;quot;option&amp;quot;);&lt;br /&gt;
        option.textContent = item;&lt;br /&gt;
        dropdown2.add(option)&lt;br /&gt;
&lt;br /&gt;
        if (item === selectedJob[id].value) {&lt;br /&gt;
            option.selected = true;&lt;br /&gt;
        }&lt;br /&gt;
    });&lt;br /&gt;
&lt;br /&gt;
    document.addEventListener(&#039;keydown&#039;, closeLightboxOnEscape);&lt;br /&gt;
    document.addEventListener(&#039;keydown&#039;, closeLightboxOnF2);&lt;br /&gt;
}  &lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Lightbox schließen----//&lt;br /&gt;
&lt;br /&gt;
function closeLightbox() {&lt;br /&gt;
&lt;br /&gt;
    document.getElementById(&#039;lightbox&#039;).style.display = &#039;none&#039;;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Funktion closeLightbox wird aufgerufen,----//&lt;br /&gt;
//----wenn der Schließen-Button gedrückt wird----//&lt;br /&gt;
&lt;br /&gt;
function closeLightboxOnSchliessen() {&lt;br /&gt;
&lt;br /&gt;
    statusTimer[idStop].value = &#039;Running&#039;;&lt;br /&gt;
    closeLightbox();&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Funktion closeLightbox wird aufgerufen,----//&lt;br /&gt;
//----wenn Escape gedrückt wird----//&lt;br /&gt;
&lt;br /&gt;
function closeLightboxOnEscape() {&lt;br /&gt;
&lt;br /&gt;
    if (event.key === &#039;Escape&#039;) {&lt;br /&gt;
        statusTimer[idStop].value = &#039;Running&#039;;&lt;br /&gt;
        closeLightbox();&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Funktion closeLightbox wird aufgerufen,----//&lt;br /&gt;
//----wenn F2 gedrückt wird----//&lt;br /&gt;
&lt;br /&gt;
function closeLightboxOnF2(event) {&lt;br /&gt;
&lt;br /&gt;
    if (event.key === &#039;F2&#039;) {&lt;br /&gt;
        saveBox();&lt;br /&gt;
        closeLightbox();&lt;br /&gt;
    }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Alle Daten in Lightbox werden gespeichert----//&lt;br /&gt;
&lt;br /&gt;
function saveBox() {&lt;br /&gt;
&lt;br /&gt;
    var vergZeit = &#039;&amp;quot;&#039; + time + &#039;&amp;quot;&#039;;&lt;br /&gt;
&lt;br /&gt;
    var teile = time.split(&amp;quot;:&amp;quot;);&lt;br /&gt;
    var stunden = parseInt(teile[0]);&lt;br /&gt;
    var minuten = parseInt(teile[1]);&lt;br /&gt;
    var sekunden = parseInt(teile[2]);&lt;br /&gt;
    time = stunden * 3600 + minuten * 60 + sekunden;&lt;br /&gt;
&lt;br /&gt;
    elapsedTime = time;&lt;br /&gt;
&lt;br /&gt;
    var comment = &#039;&amp;quot;&#039; + document.getElementById(&amp;quot;comment&amp;quot;).value + &#039;&amp;quot;&#039;;&lt;br /&gt;
    &lt;br /&gt;
    var hPEnd = document.getElementById(&amp;quot;outStd2&amp;quot;).value;&lt;br /&gt;
    var mPEnd = document.getElementById(&amp;quot;outMin2&amp;quot;).value;&lt;br /&gt;
    var sPEnd = document.getElementById(&amp;quot;outSek2&amp;quot;).value;&lt;br /&gt;
    var pTime = hPEnd + &amp;quot;:&amp;quot; + mPEnd + &amp;quot;:&amp;quot; + sPEnd;&lt;br /&gt;
    pTime = timeInSeconds(pTime);&lt;br /&gt;
&lt;br /&gt;
    var uid = sys_uid[idStop].value;&lt;br /&gt;
    var month = startTimeValue.getMonth();&lt;br /&gt;
    var day = startTimeValue.getDate();&lt;br /&gt;
    month += 1;&lt;br /&gt;
    var stopTimeValue = &#039;&amp;quot;&#039; + startTimeValue.getFullYear() + &amp;quot;-&amp;quot; + (month &amp;lt; 10 ? &amp;quot;0&amp;quot; + month : month) + &amp;quot;-&amp;quot; + (day &amp;lt; 10 ? &amp;quot;0&amp;quot; + day : day) + &#039;&amp;quot;&#039;;&lt;br /&gt;
    &lt;br /&gt;
    var status = 21;&lt;br /&gt;
&lt;br /&gt;
    timers.splice(idStop, 1);&lt;br /&gt;
    pauseTimers.splice(idStop, 1);&lt;br /&gt;
    pauseTime.splice(idStop, 1);&lt;br /&gt;
    selectedKunde.splice(idStop, 1);&lt;br /&gt;
    selectedJob.splice(idStop, 1);&lt;br /&gt;
    statusTimer[idStop].value = &#039;Running&#039;;&lt;br /&gt;
    sys_uid.splice(idStop, 1);&lt;br /&gt;
&lt;br /&gt;
    if (zaehler &amp;gt; 0) {&lt;br /&gt;
        zaehler -= 1;&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    closeLightbox();&lt;br /&gt;
&lt;br /&gt;
    if (timers.length === 0) {&lt;br /&gt;
        clearInterval(timerInterval);&lt;br /&gt;
    }&lt;br /&gt;
&lt;br /&gt;
    saveStopTime(stopTimeValue, elapsedTime, pTime, status, uid, vergZeit, comment);&lt;br /&gt;
} &lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
//----Funktion saveBox wird aufgerufen,----//&lt;br /&gt;
//----wenn der Save-Button gedrückt wird----//&lt;br /&gt;
&lt;br /&gt;
document.addEventListener(&#039;DOMContentLoaded&#039;, function() {&lt;br /&gt;
        const saveButton = document.getElementById(&#039;saveButton&#039;);&lt;br /&gt;
        saveButton.addEventListener(&#039;click&#039;, function() {&lt;br /&gt;
            saveBox();&lt;br /&gt;
        })&lt;br /&gt;
});&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
= PHP-Script saveTime.php =&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;php&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
    $user               = 0;&lt;br /&gt;
    $uhrzeit            = 0;&lt;br /&gt;
    $datum              = 0;&lt;br /&gt;
    $endTime            = 0;&lt;br /&gt;
    $endDate            = 0;&lt;br /&gt;
    $status             = 0;&lt;br /&gt;
    $psnr               = 0;&lt;br /&gt;
    $comm               = 0;&lt;br /&gt;
    $jobID              = 0;&lt;br /&gt;
    $pauseTime          = 0;&lt;br /&gt;
    $sysuid             = 0;&lt;br /&gt;
    $stopTimeValue      = 0;&lt;br /&gt;
    $elapsedTime        = 0;&lt;br /&gt;
    $elapsedPauseTime   = 0;&lt;br /&gt;
    $status             = 0;&lt;br /&gt;
    $uid                = 0;&lt;br /&gt;
    $vergZeit           = 0;&lt;br /&gt;
&lt;br /&gt;
    if ($_SERVER[&#039;REQUEST_METHOD&#039;] === &#039;POST&#039;) &lt;br /&gt;
    {&lt;br /&gt;
        $api_key            = $_POST[&#039;api_key&#039;];&lt;br /&gt;
        $url                = $_POST[&#039;url&#039;];&lt;br /&gt;
        $method             = $_POST[&#039;method&#039;];&lt;br /&gt;
        $choice             = $_POST[&#039;choice&#039;];&lt;br /&gt;
&lt;br /&gt;
        if (isset($_POST[&#039;user&#039;])) {&lt;br /&gt;
            $user               = $_POST[&#039;user&#039;];&lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;uhrzeit&#039;])) {&lt;br /&gt;
            $uhrzeit            = $_POST[&#039;uhrzeit&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;datum&#039;])) {&lt;br /&gt;
            $datum              = $_POST[&#039;datum&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;endTime&#039;])) {&lt;br /&gt;
            $endTime            = $_POST[&#039;endTime&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;endDate&#039;])) {&lt;br /&gt;
            $endDate            = $_POST[&#039;endDate&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;status&#039;])) {&lt;br /&gt;
            $status             = $_POST[&#039;status&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;psnr&#039;])) {&lt;br /&gt;
            $psnr               = $_POST[&#039;psnr&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;Job&#039;])) {&lt;br /&gt;
            $comm               = $_POST[&#039;Job&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;jobID&#039;])) {&lt;br /&gt;
            $jobID              = $_POST[&#039;jobID&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;pauseTime&#039;])) {&lt;br /&gt;
            $pauseTime          = $_POST[&#039;pauseTime&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;sysuid&#039;])) {&lt;br /&gt;
            $sysuid             = $_POST[&#039;sysuid&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;stopTimeValue&#039;])) {&lt;br /&gt;
            $stopTimeValue      = $_POST[&#039;stopTimeValue&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;elapsedTime&#039;])) {&lt;br /&gt;
            $elapsedTime        = $_POST[&#039;elapsedTime&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;elapsedPauseTime&#039;])) {&lt;br /&gt;
            $elapsedPauseTime   = $_POST[&#039;elapsedPauseTime&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;status&#039;])) {&lt;br /&gt;
            $status             = $_POST[&#039;status&#039;];    &lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;uid&#039;])) {&lt;br /&gt;
            $uid                = $_POST[&#039;uid&#039;];&lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;vergZeit&#039;])) {&lt;br /&gt;
            $vergZeit           = $_POST[&#039;vergZeit&#039;];&lt;br /&gt;
        }&lt;br /&gt;
&lt;br /&gt;
        $params = array(    &#039;user&#039;              =&amp;gt; $user, &lt;br /&gt;
                            &#039;uhrzeit&#039;           =&amp;gt; $uhrzeit, &lt;br /&gt;
                            &#039;datum&#039;             =&amp;gt; $datum, &lt;br /&gt;
                            &#039;endTime&#039;           =&amp;gt; $endTime, &lt;br /&gt;
                            &#039;endDate&#039;           =&amp;gt; $endDate, &lt;br /&gt;
                            &#039;status&#039;            =&amp;gt; $status, &lt;br /&gt;
                            &#039;psnr&#039;              =&amp;gt; $psnr,&lt;br /&gt;
                            &#039;comm&#039;              =&amp;gt; $comm,&lt;br /&gt;
                            &#039;jobID&#039;             =&amp;gt; $jobID,&lt;br /&gt;
                            &#039;pauseTime&#039;         =&amp;gt; $pauseTime,&lt;br /&gt;
                            &#039;sysuid&#039;            =&amp;gt; $sysuid,&lt;br /&gt;
                            &#039;stopTimeValue&#039;     =&amp;gt; $stopTimeValue,&lt;br /&gt;
                            &#039;elapsedTime&#039;       =&amp;gt; $elapsedTime,&lt;br /&gt;
                            &#039;elapsedPauseTime&#039;  =&amp;gt; $elapsedPauseTime,&lt;br /&gt;
                            &#039;status&#039;            =&amp;gt; $status,&lt;br /&gt;
                            &#039;uid&#039;               =&amp;gt; $uid,&lt;br /&gt;
                            &#039;vergZeit&#039;          =&amp;gt; $vergZeit,&lt;br /&gt;
                            &#039;choice&#039;            =&amp;gt; $choice);&lt;br /&gt;
&lt;br /&gt;
        $curl = curl_init($url);&lt;br /&gt;
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);&lt;br /&gt;
        curl_setopt($curl, CURLOPT_HTTPHEADER, array(&#039;apikey: &#039;.$api_key));&lt;br /&gt;
        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);&lt;br /&gt;
        curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($params));&lt;br /&gt;
        $response = curl_exec($curl);&lt;br /&gt;
&lt;br /&gt;
        if ($response === false) {&lt;br /&gt;
            if($error) {&lt;br /&gt;
                echo &amp;quot;curl_Fehler: &amp;quot;.$error;&lt;br /&gt;
            }&lt;br /&gt;
        } &lt;br /&gt;
        else {&lt;br /&gt;
            echo $response;&lt;br /&gt;
        }&lt;br /&gt;
    }&lt;br /&gt;
?&amp;gt;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
= PHP-Script getData.php =&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;php&amp;quot;&amp;gt;&lt;br /&gt;
&amp;lt;?php&lt;br /&gt;
    $sysuid             = 0;&lt;br /&gt;
    $id                 = 0;&lt;br /&gt;
    $selectedKunde      = 0;&lt;br /&gt;
            &lt;br /&gt;
    if ($_SERVER[&#039;REQUEST_METHOD&#039;] === &#039;POST&#039;) &lt;br /&gt;
    {&lt;br /&gt;
        $api_key        = $_POST[&#039;api_key&#039;];&lt;br /&gt;
        $url            = $_POST[&#039;url&#039;];&lt;br /&gt;
        $method         = $_POST[&#039;method&#039;];&lt;br /&gt;
        $choice         = $_POST[&#039;choice&#039;];&lt;br /&gt;
        &lt;br /&gt;
        if (isset($_POST[&#039;id&#039;])) {&lt;br /&gt;
            $id = $_POST[&#039;id&#039;];&lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;sysuid&#039;])) {&lt;br /&gt;
            $sysuid = $_POST[&#039;sysuid&#039;];&lt;br /&gt;
        }&lt;br /&gt;
        if (isset($_POST[&#039;selectedKunde&#039;])) {&lt;br /&gt;
            $selectedKunde  = $_POST[&#039;selectedKunde&#039;];&lt;br /&gt;
        }&lt;br /&gt;
&lt;br /&gt;
        $params = array(    &#039;sysuid&#039;            =&amp;gt; $sysuid,&lt;br /&gt;
                            &#039;id&#039;                =&amp;gt; $id, &lt;br /&gt;
                            &#039;selectedKunde&#039;     =&amp;gt; $selectedKunde,&lt;br /&gt;
                            &#039;choice&#039;            =&amp;gt; $choice);&lt;br /&gt;
        &lt;br /&gt;
        $curl = curl_init($url);&lt;br /&gt;
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);&lt;br /&gt;
        curl_setopt($curl, CURLOPT_HTTPHEADER, array(&#039;apikey: &#039;.$api_key));&lt;br /&gt;
        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);&lt;br /&gt;
        curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($params));&lt;br /&gt;
        $response = curl_exec($curl);&lt;br /&gt;
&lt;br /&gt;
        if ($response === false) {&lt;br /&gt;
            if($error) {&lt;br /&gt;
                echo &amp;quot;curl_Fehler: &amp;quot;.$error;&lt;br /&gt;
            }&lt;br /&gt;
        } &lt;br /&gt;
        else {&lt;br /&gt;
            echo $response;&lt;br /&gt;
        }&lt;br /&gt;
    }&lt;br /&gt;
?&amp;gt;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
= CSS-Datei timestyle.css =&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;css&amp;quot;&amp;gt;&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
/*-----------------------------------------Allgemein-----------------------------------------------*/&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
&lt;br /&gt;
body &lt;br /&gt;
{&lt;br /&gt;
    font-family: &#039;Arial&#039;, sans-serif;&lt;br /&gt;
    display: flex;&lt;br /&gt;
    justify-content: right;&lt;br /&gt;
    align-items: flex-start;&lt;br /&gt;
    height: 100%;&lt;br /&gt;
    background-image: url(&#039;web_background_3.png&#039;);&lt;br /&gt;
    background-repeat: no-repeat;&lt;br /&gt;
    background-size: cover; &lt;br /&gt;
    background-attachment: fixed;&lt;br /&gt;
}&lt;br /&gt;
ul &lt;br /&gt;
{&lt;br /&gt;
    list-style-type: none;&lt;br /&gt;
}&lt;br /&gt;
li &lt;br /&gt;
{&lt;br /&gt;
    padding: 0.5em;&lt;br /&gt;
    cursor: pointer;&lt;br /&gt;
    display: flex; &lt;br /&gt;
    justify-content: space-between;&lt;br /&gt;
    align-items: center;&lt;br /&gt;
    font-size: 0.85em;&lt;br /&gt;
} &lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
/*-------------------------------------------Suche-------------------------------------------------*/&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
&lt;br /&gt;
#search&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 8%;&lt;br /&gt;
    left: 42%;&lt;br /&gt;
    width: 15%;&lt;br /&gt;
    height: 2.8%;&lt;br /&gt;
    resize: none;&lt;br /&gt;
    border: 0.04em solid #e4e4e4;&lt;br /&gt;
    border-radius: 0.1em;&lt;br /&gt;
    background-color: white;&lt;br /&gt;
    font-size: 2em;&lt;br /&gt;
    overflow: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
/*-----------------------------------------Timerausgabe--------------------------------------------*/&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
&lt;br /&gt;
.kundenliste&lt;br /&gt;
{&lt;br /&gt;
    width: 70%;&lt;br /&gt;
    height: 30%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 43.5%;&lt;br /&gt;
    left: 50%;&lt;br /&gt;
    transform: translate(-50%, -50%);&lt;br /&gt;
    border-top-left-radius: 0.2em;&lt;br /&gt;
    border-top-right-radius: 0.2em;&lt;br /&gt;
    overflow: auto;&lt;br /&gt;
    background-color: white;&lt;br /&gt;
    font-size: 0.85vw;&lt;br /&gt;
}&lt;br /&gt;
.tabelleUeberschriften&lt;br /&gt;
{&lt;br /&gt;
    width: 70%;&lt;br /&gt;
    min-height: 3%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 60%;&lt;br /&gt;
    left: 50%;&lt;br /&gt;
    transform: translate(-50%, -50%);&lt;br /&gt;
    /* border-top: 0.14em solid #ccc;&lt;br /&gt;
    border-bottom: 0.14em solid #ccc; */&lt;br /&gt;
    background-color: white;&lt;br /&gt;
    box-shadow: 0 0px 3px rgba(0, 0, 0, 0.5);&lt;br /&gt;
    font-size: 0.9vw;&lt;br /&gt;
}&lt;br /&gt;
#timerList&lt;br /&gt;
{&lt;br /&gt;
    width: 70%;&lt;br /&gt;
    height: 25%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 73.5%;&lt;br /&gt;
    left: 50%;&lt;br /&gt;
    transform: translate(-50%, -50%);&lt;br /&gt;
    overflow: auto;&lt;br /&gt;
    border-bottom-left-radius: 0.2em;&lt;br /&gt;
    border-bottom-right-radius: 0.2em;&lt;br /&gt;
    /* border: 0.14em solid red; */&lt;br /&gt;
    background-color: white;&lt;br /&gt;
    font-size: 0.85vw;&lt;br /&gt;
} &lt;br /&gt;
.kunde&lt;br /&gt;
{&lt;br /&gt;
    width: 30%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    left: 0%;&lt;br /&gt;
}&lt;br /&gt;
.konto&lt;br /&gt;
{&lt;br /&gt;
    width: 30%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    left: 30%;&lt;br /&gt;
}&lt;br /&gt;
.laufzeit&lt;br /&gt;
{&lt;br /&gt;
    width: 15%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    left: 60%;&lt;br /&gt;
}&lt;br /&gt;
.status&lt;br /&gt;
{&lt;br /&gt;
    width: 15%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    left: 75%;&lt;br /&gt;
}&lt;br /&gt;
.buttons&lt;br /&gt;
{&lt;br /&gt;
    width: 10%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    left: 90%;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
/*-------------------------------------------Lightbox----------------------------------------------*/&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
&lt;br /&gt;
.lightbox&lt;br /&gt;
{&lt;br /&gt;
    display: none;&lt;br /&gt;
    width: 52%;&lt;br /&gt;
    height: 62%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 61.5%;&lt;br /&gt;
    left: 67%;&lt;br /&gt;
    border-radius: 0.15em;&lt;br /&gt;
    background-color: white;&lt;br /&gt;
    transform: translate(-50%, -50%);&lt;br /&gt;
    flex-direction: column;&lt;br /&gt;
    align-items: center;&lt;br /&gt;
    box-shadow: 0 10px 2000px rgba(0, 0, 0, 0.5);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
/*-----------------------------------------Lightbox-Inhalt-----------------------------------------*/&lt;br /&gt;
&lt;br /&gt;
.commLabel&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 5%;&lt;br /&gt;
    left: 5%;   &lt;br /&gt;
    font-weight: bold;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
}&lt;br /&gt;
.comment&lt;br /&gt;
{&lt;br /&gt;
    width: 90%;&lt;br /&gt;
    height: 38%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 10%;&lt;br /&gt;
    left: 5%;&lt;br /&gt;
    resize: none;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    font-size: 0.9vw;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
.bezZeitkonto&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 55%;&lt;br /&gt;
    left: 5%;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
}&lt;br /&gt;
.dropButtonsLightbox&lt;br /&gt;
{&lt;br /&gt;
    width: 37%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 54%;&lt;br /&gt;
    left: 15%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.bezDatum&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 55%;&lt;br /&gt;
    left: 58%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.date&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 54%;&lt;br /&gt;
    left: 68%;&lt;br /&gt;
    width: 15%;&lt;br /&gt;
    height: 4%;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
}&lt;br /&gt;
.uhrzeit&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 54%;&lt;br /&gt;
    left: 85.5%;&lt;br /&gt;
    width: 10%;&lt;br /&gt;
    height: 3.9%;&lt;br /&gt;
    padding: 0.1em;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
    color: gray;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    resize: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
.bezPerson&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 68%;&lt;br /&gt;
    left: 5%;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
}&lt;br /&gt;
.eingPerson&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 68%;&lt;br /&gt;
    left: 15%;&lt;br /&gt;
    width: 10%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    resize: none;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
    color: gray;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
}&lt;br /&gt;
.eingPersonZusatz&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 68%;&lt;br /&gt;
    left: 26%;&lt;br /&gt;
    width: 25.7%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    overflow: auto;&lt;br /&gt;
    resize: none;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
    color: gray;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
}&lt;br /&gt;
.Std&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 63%;&lt;br /&gt;
    left: 66%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.Min&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 63%;&lt;br /&gt;
    left: 78%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.Sek&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 63%;&lt;br /&gt;
    left: 91%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.outStd&lt;br /&gt;
{&lt;br /&gt;
    width: 6%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 68%;&lt;br /&gt;
    left: 64%;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.outMin&lt;br /&gt;
{&lt;br /&gt;
    width: 6%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 68%;&lt;br /&gt;
    left: 76.5%;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.outSek&lt;br /&gt;
{&lt;br /&gt;
    width: 6%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 68%;&lt;br /&gt;
    left: 89%;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
.Pausenzeiten&lt;br /&gt;
{&lt;br /&gt;
    position: absolute;&lt;br /&gt;
    top: 79%;&lt;br /&gt;
    left: 71%;&lt;br /&gt;
    font-weight: bold;&lt;br /&gt;
    font-size: 0.8vw;&lt;br /&gt;
}&lt;br /&gt;
.Std2&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 84%;&lt;br /&gt;
    left: 66%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.Min2&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 84%;&lt;br /&gt;
    left: 78%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.Sek2&lt;br /&gt;
{&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 84%;&lt;br /&gt;
    left: 91%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.outStd2&lt;br /&gt;
{&lt;br /&gt;
    width: 6%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 89%;&lt;br /&gt;
    left: 64%;&lt;br /&gt;
    border: 0.1em solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.outMin2&lt;br /&gt;
{&lt;br /&gt;
    width: 6%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 89%;&lt;br /&gt;
    left: 76.5%;&lt;br /&gt;
    border: 0.1em  solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.outSek2&lt;br /&gt;
{&lt;br /&gt;
    width: 6%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 89%;&lt;br /&gt;
    left: 89%;&lt;br /&gt;
    border: 0.1em  solid black;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    text-align: center;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.saveButton&lt;br /&gt;
{&lt;br /&gt;
    min-width: 8%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 89%;&lt;br /&gt;
    left: 5%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
.closeButton&lt;br /&gt;
{&lt;br /&gt;
    min-width: 8%;&lt;br /&gt;
    height: 4.4%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 89%;&lt;br /&gt;
    left: 14%;&lt;br /&gt;
    font-size: 0.7vw;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
/*--------------------------------Tätigkeitenliste (zweite Liste)----------------------------------*/&lt;br /&gt;
/*-------------------------------------------------------------------------------------------------*/&lt;br /&gt;
&lt;br /&gt;
#zweiteListe &lt;br /&gt;
{&lt;br /&gt;
    display: none;&lt;br /&gt;
    width: 25%;&lt;br /&gt;
    max-height: 50%;&lt;br /&gt;
    position: fixed;&lt;br /&gt;
    top: 47%;&lt;br /&gt;
    left: 44%;&lt;br /&gt;
    padding: 1%;&lt;br /&gt;
    overflow: auto;&lt;br /&gt;
    border-radius: 0.2em;&lt;br /&gt;
    transform: translate(-50%, -50%);&lt;br /&gt;
    background-color: #FAFAFA;&lt;br /&gt;
    box-shadow: 0 10px 2000px rgba(0, 0, 0, 0.5);&lt;br /&gt;
    font-size: 0.85vw;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Scripting&amp;diff=64789</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Scripting</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Scripting&amp;diff=64789"/>
		<updated>2026-09-01T09:10:11Z</updated>

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

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Einrichtung eines REST-Endpunktes=&lt;br /&gt;
&lt;br /&gt;
Die folgende Reihenfolge stellt sicher, dass ein lauffähiger Endpunkt mit möglichst wenigen Korrektur-Schleifen entsteht.&lt;br /&gt;
&lt;br /&gt;
==Vorbereitung==&lt;br /&gt;
&lt;br /&gt;
# OBS-Support kontaktieren und das REST-Server-Modul aktivieren lassen.&lt;br /&gt;
# Prüfen, ob auf dem Zielrechner (OBS-Server oder dedizierter REST-Server) Port und IP-Adresse frei sind.&lt;br /&gt;
# Bei TLS-Profilen: TLS-Zertifikat (PEM, ggf. mit Chain), privaten Schlüssel (PEM) und - falls mTLS - das CA-Root-Zertifikat bereithalten.&lt;br /&gt;
&lt;br /&gt;
==Schritt 1: Server-Profil anlegen==&lt;br /&gt;
&lt;br /&gt;
In &#039;&#039;&#039;Stammdaten -&amp;gt; Z Weitere Stammdaten -&amp;gt; REST-Server&#039;&#039;&#039; den Punkt &#039;&#039;&#039;Server&#039;&#039;&#039; öffnen und mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; ein neues Profil anlegen. Pflichtfelder:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Nr&#039;&#039;&#039; - eindeutige Nummer (1-99), wird beim Anlegen automatisch vorgeschlagen.&lt;br /&gt;
* &#039;&#039;&#039;Name&#039;&#039;&#039; - sprechender Name (z.B. &#039;&#039;Public-API&#039;&#039;, &#039;&#039;Internal-mTLS&#039;&#039;, &#039;&#039;Debug-Local&#039;&#039;).&lt;br /&gt;
* Auswahl des Modus:&lt;br /&gt;
** &#039;&#039;&#039;Standard-TLS&#039;&#039;&#039; (Default): Zertifikat + Key + Root-Zertifikat hinterlegen.&lt;br /&gt;
** &#039;&#039;&#039;mTLS&#039;&#039;&#039; aktivieren: zusätzlich CA-Root-Zertifikat hinterlegen, jeder Client muss ein gültiges Cert vorlegen.&lt;br /&gt;
** &#039;&#039;&#039;Kein SSL&#039;&#039;&#039; aktivieren: ausschliesslich für lokale Tests, der Server akzeptiert dann nur Plain-HTTP.&lt;br /&gt;
&lt;br /&gt;
Optional lassen sich am Profil eigene &#039;&#039;&#039;Rate-Limit-Schwellen&#039;&#039;&#039; hinterlegen. Ohne&lt;br /&gt;
Angabe gelten die Standardwerte (60-Sekunden-Fenster, 120 erfolgreiche und 10&lt;br /&gt;
fehlgeschlagene Anfragen je Schlüssel, 600 Anfragen je Profil). Für Anwendungen&lt;br /&gt;
ohne eigenen API-Key - typisch mobile Apps - sollten die Werte bewusst gesetzt&lt;br /&gt;
werden, weil dann alle Benutzer hinter einer IP auf denselben Zähler laufen.&lt;br /&gt;
&lt;br /&gt;
Details siehe [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]].&lt;br /&gt;
&lt;br /&gt;
==Schritt 2: Bindung anlegen==&lt;br /&gt;
&lt;br /&gt;
Im Server-Profil mit &#039;&#039;&#039;F6&#039;&#039;&#039; die Bindungs-Liste öffnen und mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; eine neue Bindung anlegen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Host&#039;&#039;&#039; - IP-Adresse, auf der gelauscht werden soll (z.B. &#039;&#039;0.0.0.0&#039;&#039; für alle Adressen, &#039;&#039;127.0.0.1&#039;&#039; für lokal).&lt;br /&gt;
* &#039;&#039;&#039;Port&#039;&#039;&#039; - Port-Nummer (typisch 443 für HTTPS, 8099 für Debug, ggf. abweichend).&lt;br /&gt;
* &#039;&#039;&#039;Standard&#039;&#039;&#039; setzen, wenn diese Bindung die Standard-Bindung des Servers ist (pro Server-Profil nur eine).&lt;br /&gt;
&lt;br /&gt;
==Schritt 3: REST-Dienst starten / neustarten==&lt;br /&gt;
&lt;br /&gt;
Damit Änderungen an Server-Profilen oder Bindungen wirksam werden, muss der OBS REST-Server-Dienst (oder die Konsole) gestartet bzw. neugestartet werden. Das Profil wird beim Start aus der Datenbank gelesen.&lt;br /&gt;
&lt;br /&gt;
==Schritt 4: Zugang anlegen==&lt;br /&gt;
&lt;br /&gt;
Unter &#039;&#039;&#039;Zugänge&#039;&#039;&#039; mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; einen neuen Zugang anlegen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Name&#039;&#039;&#039; - erscheint im Protokoll und in der Statistik.&lt;br /&gt;
* &#039;&#039;&#039;API-Key&#039;&#039;&#039; - eindeutig, mit dem Schlüsselsymbol kann ein zufälliger Key generiert werden.&lt;br /&gt;
* &#039;&#039;&#039;Host&#039;&#039;&#039; - optional die IP oder der Hostname des Konsumenten (sollte, wenn möglich, immer gesetzt werden).&lt;br /&gt;
* Optional: &#039;&#039;&#039;CORS-Origins&#039;&#039;&#039; und &#039;&#039;&#039;JWT-Konfiguration&#039;&#039;&#039;, siehe [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
&lt;br /&gt;
==Schritt 5: Endpunkt anlegen==&lt;br /&gt;
&lt;br /&gt;
Unter &#039;&#039;&#039;Endpunkte&#039;&#039;&#039; mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; einen neuen Endpunkt anlegen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Endpunkt&#039;&#039;&#039; - Ressourcen-/Anzeigename (z.B. &#039;&#039;orders&#039;&#039;, &#039;&#039;kalender&#039;&#039;); dient nur der Übersicht, nicht dem Routing.&lt;br /&gt;
* &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; - der maßgebliche Routing-Pfad, ggf. mit Platzhaltern (z.B. &#039;&#039;/orders&#039;&#039;, &#039;&#039;/orders/{uid}&#039;&#039;, &#039;&#039;/orders/{uid}/modules/{code}&#039;&#039;).&lt;br /&gt;
* &#039;&#039;&#039;Server&#039;&#039;&#039; - die Server-Auswahl bestimmt, über welches Server-Profil der Endpunkt erreichbar ist.&lt;br /&gt;
* &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; - nur setzen, wenn der Endpunkt Dateien entgegennehmen soll; dann auch die &#039;&#039;&#039;Max. Upload-Grösse&#039;&#039;&#039; in MB festlegen (siehe [[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6]]).&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Die früheren Felder &#039;&#039;&#039;Sub-URL&#039;&#039;&#039; und &#039;&#039;&#039;Version&#039;&#039;&#039; werden nicht mehr ausgewertet. Eine Versionskennung wird ggf. als statisches Segment ins Pfad-Template aufgenommen (z.B. &#039;&#039;/orders/v1&#039;&#039;). Details: [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]].}}&lt;br /&gt;
&lt;br /&gt;
Anschliessend mit &#039;&#039;&#039;F7&#039;&#039;&#039; das Endpunkt-Skript pflegen (siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]]).&lt;br /&gt;
&lt;br /&gt;
==Schritt 6: Zugang für den Endpunkt freischalten==&lt;br /&gt;
&lt;br /&gt;
Aus der Endpunkt-Liste den neuen Endpunkt markieren und &#039;&#039;&#039;F6&#039;&#039;&#039; drücken. Es öffnet sich die Berechtigungs-Liste. Mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; kann ein oder mehrere Zugänge ausgewählt und mit &#039;&#039;&#039;F2&#039;&#039;&#039; übernommen werden. Erst nach dieser Freischaltung darf ein Zugang den Endpunkt aufrufen.&lt;br /&gt;
&lt;br /&gt;
==Schritt 7: Funktionsprüfung==&lt;br /&gt;
&lt;br /&gt;
Aufruf mit einem HTTP-Client (curl, Postman, Browser-Plug-in):&lt;br /&gt;
&lt;br /&gt;
 curl -H &amp;quot;apikey: [API-KEY]&amp;quot; https://api.meinserver.de/[Pfad-Template]&lt;br /&gt;
&lt;br /&gt;
Bei korrektem Setup erscheint die JSON-Antwort des Skripts. Fehlerfälle werden in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; protokolliert.&lt;br /&gt;
&lt;br /&gt;
==Schritt 8: Statistik und Protokoll prüfen==&lt;br /&gt;
&lt;br /&gt;
* In der Endpunkt-Liste &#039;&#039;&#039;F8&#039;&#039;&#039; für die Statistik des Endpunkts.&lt;br /&gt;
* In der Zugänge-Liste &#039;&#039;&#039;F8&#039;&#039;&#039; für die Statistik des Zugangs.&lt;br /&gt;
* In &#039;&#039;&#039;Stammdaten -&amp;gt; Z Weitere Stammdaten -&amp;gt; REST-Server -&amp;gt; Protokoll&#039;&#039;&#039; die Ereignisse prüfen.&lt;br /&gt;
&lt;br /&gt;
==Wartung der Server-Tabellen==&lt;br /&gt;
&lt;br /&gt;
Die Tabellen des REST-Servers pflegen sich selbst; ein regelmässiger Eingriff ist&lt;br /&gt;
nicht vorgesehen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Tabelle !! Aufräumen&lt;br /&gt;
|-&lt;br /&gt;
| RESTSRV_IDEMPOTENCY || Abgeschlossene Einträge werden nach 30 Tagen gelöscht. Offene Reservierungen bleiben absichtlich stehen und werden ab 24 Stunden im Protokoll gemeldet - sie sind ein Support-Fall, siehe Stolpersteine&lt;br /&gt;
|-&lt;br /&gt;
| RESTSRV_TOKEN || Zeilen werden gelöscht, sobald Access- &#039;&#039;&#039;und&#039;&#039;&#039; Refresh-Laufzeit vorbei sind. Läuft beim Serverstart und danach höchstens stündlich&lt;br /&gt;
|-&lt;br /&gt;
| RESTSRV_PROTO / RESTSRV_STATS || Wachsen mit dem Betrieb. Bei hohem Aufkommen empfiehlt sich ein turnusmässiges Archivieren&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==Häufige Stolpersteine==&lt;br /&gt;
&lt;br /&gt;
* Bindung angelegt, aber der Server wurde nicht neugestartet -&amp;gt; die Bindung ist noch nicht aktiv.&lt;br /&gt;
* Endpunkt einem falschen Server zugeordnet -&amp;gt; Aufruf liefert 404 &#039;&#039;Endpunkt nicht vorhanden oder inaktiv&#039;&#039;.&lt;br /&gt;
* Pfad-Template falsch geschrieben oder Segmentzahl passt nicht -&amp;gt; kein Template matcht, Aufruf liefert 404.&lt;br /&gt;
* Zugang für den Endpunkt nicht freigeschaltet -&amp;gt; Aufruf liefert 403 &#039;&#039;Keine Berechtigung für die Endpunkt-Nutzung&#039;&#039;.&lt;br /&gt;
* Falscher API-Key oder falscher Host beim Zugang -&amp;gt; Aufruf liefert 401 bzw. 403.&lt;br /&gt;
* mTLS aktiv, aber kein Client-Zertifikat installiert -&amp;gt; Verbindung wird während des TLS-Handshakes abgebrochen.&lt;br /&gt;
* Datei-Upload ohne gesetzten Haken &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; am Endpunkt -&amp;gt; Aufruf liefert 415, das Skript läuft nicht an.&lt;br /&gt;
* Skript geändert, aber der Aufruf verhält sich noch wie vorher -&amp;gt; Endpunkt-Cache abwarten (bis zu 60 Sekunden).&lt;br /&gt;
* Wiederholter Schreibaufruf liefert 422 &#039;&#039;IDEMPOTENCY_KEY_REUSED&#039;&#039; -&amp;gt; der Client sendet denselben &#039;&#039;Idempotency-Key&#039;&#039; mit geändertem Inhalt. Pro Vorgang genau ein Schlüssel, über alle Wiederholversuche hinweg unverändert.&lt;br /&gt;
* Wiederholter Schreibaufruf liefert 409 &#039;&#039;IDEMPOTENCY_UNRESOLVED&#039;&#039; -&amp;gt; ein früherer Versuch wurde hart abgebrochen (Dienst beendet). Ob gebucht wurde, ist offen: im Protokoll und in den Fachdaten prüfen. Der Aufräumlauf gibt den Eintrag nach 24 Stunden von selbst frei; wer schneller weitermachen will, entfernt ihn in &#039;&#039;&#039;RESTSRV_IDEMPOTENCY&#039;&#039;&#039;.&lt;br /&gt;
* Nach einem Update antworten &#039;&#039;&#039;alle&#039;&#039;&#039; bestehenden Token mit 401 &#039;&#039;AUTH_EXPIRED&#039;&#039; -&amp;gt; erwartetes Verhalten. Token, die vor der Einführung der Sitzungsprüfung ausgestellt wurden, gehören zu keiner Sitzung; die Konsumenten müssen sich einmalig neu anmelden.&lt;br /&gt;
* Anmeldung liefert 503 statt eines Tokens -&amp;gt; die Sitzungszeile konnte nicht geschrieben werden. Ursache im Protokoll unter &#039;&#039;TokenStore: ...&#039;&#039; nachsehen; typisch ist eine fehlende Tabelle &#039;&#039;&#039;RESTSRV_TOKEN&#039;&#039;&#039; nach einem Update ohne Datenbank-Abgleich.&lt;br /&gt;
* Abmelden liefert 503 -&amp;gt; die Sitzung liess sich nicht sperren. Der Client ist &#039;&#039;&#039;nicht&#039;&#039;&#039; abgemeldet und muss den Aufruf wiederholen; Details im Protokoll.&lt;br /&gt;
* Techniker wird beim Arbeiten unerwartet abgemeldet -&amp;gt; im Protokoll nach &#039;&#039;WIEDERVORLAGE&#039;&#039; suchen. Ein doppelt verwendetes Refresh-Token sperrt die Sitzung. Legt die App Token mehrfach parallel ab, gehört das clientseitig behoben.&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel6&amp;diff=64787</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel6</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel6&amp;diff=64787"/>
		<updated>2026-08-31T05:03:41Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 6: Datei-Upload und Ablage=&lt;br /&gt;
&lt;br /&gt;
Dieses Beispiel zeigt einen Endpunkt, der &#039;&#039;&#039;Dateien entgegennimmt&#039;&#039;&#039;: Ein&lt;br /&gt;
Aussendienst-Mitarbeiter fotografiert vor Ort ein Gerät, die App lädt das Foto zum&lt;br /&gt;
Auftrag hoch, der Server legt es ab und verknüpft es mit dem Vorgang.&lt;br /&gt;
&lt;br /&gt;
Der Ablauf ist derselbe für Belege aus einem Scanner, Unterschriften-Bilder oder&lt;br /&gt;
Prüfprotokolle als PDF. Behandelt werden beide Übertragungsarten - der einfache&lt;br /&gt;
Upload für kleine Dateien und der fortsetzbare Upload für grosse.&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; (Port 443)&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Mobile-App&#039;&#039;, API-Key zufällig generiert, JWT aktiv&lt;br /&gt;
* &#039;&#039;&#039;Endpunkt:&#039;&#039;&#039; &#039;&#039;/orders/{uid}/photos&#039;&#039;, Profil &#039;&#039;Public-API&#039;&#039;&lt;br /&gt;
** &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; auf &#039;&#039;&#039;Ja&#039;&#039;&#039; setzen (&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;)&lt;br /&gt;
** &#039;&#039;&#039;Max. Upload-Grösse&#039;&#039;&#039; auf &#039;&#039;20&#039;&#039; (MB) setzen&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Mobile-App&#039;&#039; für den Endpunkt freischalten&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ohne den Haken &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; lehnt der Server jeden Upload mit&lt;br /&gt;
&#039;&#039;&#039;415&#039;&#039;&#039; ab und das Skript läuft gar nicht erst an. Das ist die häufigste Ursache,&lt;br /&gt;
wenn ein Upload &amp;quot;ohne erkennbaren Grund&amp;quot; scheitert.}}&lt;br /&gt;
&lt;br /&gt;
==Was der Server erledigt, bevor das Skript läuft==&lt;br /&gt;
&lt;br /&gt;
Der Server nimmt die Datei entgegen, prüft die Grösse, legt sie in einem temporären&lt;br /&gt;
Verzeichnis ab und übergibt dem Skript drei Parameter:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Parameter !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_UPLOAD_PATH || Vollständiger Pfad zur temporären Datei&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_UPLOAD_NAME || Originaldateiname, wie ihn der Client gesendet hat&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_UPLOAD_CONTENTTYPE || Content-Type der Datei&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_UPLOAD_SHA256 || SHA-256 der gespeicherten Datei (hex, klein)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Zwei Dinge sind beim Schreiben des Skripts wichtig:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;&amp;lt;code&amp;gt;oBody&amp;lt;/code&amp;gt; ist bei Uploads immer &amp;lt;code&amp;gt;nil&amp;lt;/code&amp;gt;.&#039;&#039;&#039; Der Request-Body ist die Datei, kein JSON. Zusatzangaben (Kategorie, Bemerkung) kommen deshalb als &#039;&#039;&#039;Query-Parameter&#039;&#039;&#039; mit - oder, bei &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt;, als weitere &#039;&#039;&#039;Formularfelder&#039;&#039;&#039;; die stehen dem Skript als &amp;lt;code&amp;gt;_OBS_FORM_&amp;amp;lt;name&amp;amp;gt;&amp;lt;/code&amp;gt; zur Verfügung.&lt;br /&gt;
* &#039;&#039;&#039;Die temporäre Datei bleibt liegen, wenn das Skript sie nicht übernimmt.&#039;&#039;&#039; Der Server räumt sie nicht selbst weg.&lt;br /&gt;
&lt;br /&gt;
==Endpunkt-Skript &#039;&#039;/orders/{uid}/photos&#039;&#039;==&lt;br /&gt;
&lt;br /&gt;
Das Skript prüft den Dateityp, baut einen Zielpfad aus Auftrag und Zeitstempel,&lt;br /&gt;
verschiebt die Datei dorthin und schreibt einen Verweis in die Datenbank.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
const ZIEL_BASIS = &#039;D:\OBS\Daten\Auftragsfotos\&#039;;&lt;br /&gt;
var oRes, oErr, oHdr: TJSONObject;&lt;br /&gt;
    cUid     : string;&lt;br /&gt;
    cTmpPfad : string;&lt;br /&gt;
    cOrigName: string;&lt;br /&gt;
    cTyp     : string;&lt;br /&gt;
    cEndung  : string;&lt;br /&gt;
    cKategorie: string;&lt;br /&gt;
    cZielDir : string;&lt;br /&gt;
    cZielName: string;&lt;br /&gt;
    cZielPfad: string;&lt;br /&gt;
    qNeu     : TqSQL;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUid       := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
        cTmpPfad   := oParams.Values[&#039;_OBS_UPLOAD_PATH&#039;];&lt;br /&gt;
        cOrigName  := oParams.Values[&#039;_OBS_UPLOAD_NAME&#039;];&lt;br /&gt;
        cTyp       := oParams.Values[&#039;_OBS_UPLOAD_CONTENTTYPE&#039;];&lt;br /&gt;
        cKategorie := oParams.Values[&#039;kategorie&#039;];   // Query-Parameter, nicht Body&lt;br /&gt;
&lt;br /&gt;
        // 1) Gehoert der Auftrag zum Mandanten aus dem Token?&lt;br /&gt;
        if (not AuftragGehoertZuMandant(oParams.Values[&#039;_OBS_JWT_CLAIM_tenant&#039;], cUid)) then begin&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;NOT_FOUND&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;Auftrag nicht gefunden&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 404);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // 2) Dateityp gegen eine Whitelist pruefen - niemals gegen eine&lt;br /&gt;
        //    Blacklist, und niemals dem Content-Type allein vertrauen.&lt;br /&gt;
        cEndung := Lower(ExtractFileExt(cOrigName));&lt;br /&gt;
        if ((cEndung &amp;lt;&amp;gt; &#039;.jpg&#039;) and (cEndung &amp;lt;&amp;gt; &#039;.jpeg&#039;) and (cEndung &amp;lt;&amp;gt; &#039;.png&#039;) and (cEndung &amp;lt;&amp;gt; &#039;.pdf&#039;)) then begin&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;VALIDATION_FAILED&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;Nur JPG, PNG und PDF sind zulässig&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 422);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // 3) Zielname selbst bilden. Den Originalnamen NICHT als Pfad&lt;br /&gt;
        //    verwenden - er kommt vom Client und kann &#039;..\&#039; enthalten.&lt;br /&gt;
        cZielDir  := ZIEL_BASIS + DToS(Date()) + &#039;\&#039; + cUid + &#039;\&#039;;&lt;br /&gt;
        cZielName := DToSF(Now()) + &#039;_&#039; + GlobalUID() + cEndung;&lt;br /&gt;
        cZielPfad := cZielDir + cZielName;&lt;br /&gt;
&lt;br /&gt;
        MyForceDirectories(cZielDir);&lt;br /&gt;
&lt;br /&gt;
        if (not FMove(cTmpPfad, cZielPfad)) then begin&lt;br /&gt;
            // Fehlgeschlagenes Verschieben ist ein Serverproblem -&amp;gt; 500.&lt;br /&gt;
            // Der Idempotency-Key wird bei 5xx wieder freigegeben, der&lt;br /&gt;
            // Client darf denselben Schluessel erneut senden.&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;INTERNAL_ERROR&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;Datei konnte nicht abgelegt werden&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 500);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // 4) Verweis in der Datenbank ablegen&lt;br /&gt;
        qNeu := qSqlInit(oDB, &#039;AUFTRAG_DOKUMENT&#039;);&lt;br /&gt;
        qNeu.qSet(&#039;ad_auftrag&#039;  , cUid);&lt;br /&gt;
        qNeu.qSet(&#039;ad_pfad&#039;     , cZielPfad);&lt;br /&gt;
        qNeu.qSet(&#039;ad_dateiname&#039;, cOrigName);&lt;br /&gt;
        qNeu.qSet(&#039;ad_typ&#039;      , cTyp);&lt;br /&gt;
        qNeu.qSet(&#039;ad_kategorie&#039;, cKategorie);&lt;br /&gt;
        qNeu.qSet(&#039;ad_datum&#039;    , Now());&lt;br /&gt;
        qNeu.SaveData(NEW_RECORD);&lt;br /&gt;
        qSqlFree(qNeu);&lt;br /&gt;
&lt;br /&gt;
        // 5) 201 mit Location auf die abgelegte Datei&lt;br /&gt;
        oHdr := TJSONObject.Create();&lt;br /&gt;
        oHdr.AddPair(&#039;Location&#039;, &#039;/orders/&#039; + cUid + &#039;/photos/&#039; + cZielName);&lt;br /&gt;
&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 201);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
        oRes.AddPair(&#039;dateiname&#039;, cZielName);&lt;br /&gt;
        oRes.AddPair(&#039;groesse&#039;  , FSize(cZielPfad));&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Soll die Datei statt in ein Verzeichnis in das &#039;&#039;&#039;OBS-Dokumenten-System&#039;&#039;&#039;&lt;br /&gt;
wandern, ersetzt der DMS-Aufruf die Schritte 3 bis 5. Welche Funktion dafür&lt;br /&gt;
zuständig ist, hängt vom eingesetzten Dokumenttyp ab - bitte mit dem OBS-Support&lt;br /&gt;
klären.}}&lt;br /&gt;
&lt;br /&gt;
==Test mit curl==&lt;br /&gt;
&lt;br /&gt;
Einfacher Upload einer Datei (&#039;&#039;-F&#039;&#039; erzeugt &#039;&#039;multipart/form-data&#039;&#039;):&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -F &amp;quot;datei=@C:\Fotos\geraet.jpg&amp;quot; ^&lt;br /&gt;
      &amp;quot;https://api.meinserver.de/orders/4711/photos?kategorie=VORHER&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Antwort:&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 201 Created&lt;br /&gt;
 Location: /orders/4711/photos/20260817091233_A1B2C3.jpg&lt;br /&gt;
 X-Trace-Id: 20260817T091233123-00001A&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;dateiname&amp;quot;:&amp;quot;20260817091233_A1B2C3.jpg&amp;quot;,&amp;quot;groesse&amp;quot;:248113}&lt;br /&gt;
&lt;br /&gt;
Ein Upload an einen Endpunkt &#039;&#039;&#039;ohne&#039;&#039;&#039; Upload-Freigabe:&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 415 Unsupported Media Type&lt;br /&gt;
&lt;br /&gt;
Eine Datei über der eingestellten Grenze:&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 413 Payload Too Large&lt;br /&gt;
&lt;br /&gt;
==Grosse Dateien: fortsetzbarer Upload==&lt;br /&gt;
&lt;br /&gt;
Bei einem Foto aus dem Mobilfunknetz reisst die Verbindung schnell einmal ab. Für&lt;br /&gt;
solche Fälle überträgt der Client die Datei in Teilstücken und kann nach einem&lt;br /&gt;
Abbruch dort weitermachen, wo er aufgehört hat. &#039;&#039;&#039;Am Endpunkt-Skript ändert sich&lt;br /&gt;
dafür nichts&#039;&#039;&#039; - der Server sammelt die Teilstücke selbst ein und ruft das Skript&lt;br /&gt;
erst auf, wenn die Datei vollständig ist.&lt;br /&gt;
&lt;br /&gt;
Der erste Teil trägt den Dateinamen (Base64-kodiert im Header&lt;br /&gt;
&amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt;) und liefert eine &amp;lt;code&amp;gt;Upload-Id&amp;lt;/code&amp;gt; zurück:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Content-Range: bytes 0-1048575/5000000&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Upload-Metadata: filename Z2VyYWV0LmpwZw==,filetype aW1hZ2UvanBlZw==&amp;quot; ^&lt;br /&gt;
      --data-binary &amp;quot;@teil1.bin&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711/photos&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 308 Resume Incomplete&lt;br /&gt;
 Upload-Id: 7f2a9c84...&lt;br /&gt;
 Upload-Offset: 1048576&lt;br /&gt;
 Range: bytes=0-1048575&lt;br /&gt;
&lt;br /&gt;
Jeder weitere Teil sendet die &#039;&#039;Upload-Id&#039;&#039; mit. Der letzte Teil bekommt die&lt;br /&gt;
normale Antwort des Skripts (hier &#039;&#039;&#039;201&#039;&#039;&#039;). Nach einem Abbruch fragt der Client&lt;br /&gt;
den Stand ab und setzt dort auf:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Content-Range: bytes */5000000&amp;quot; -H &amp;quot;Upload-Id: 7f2a9c84...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711/photos&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 308 Resume Incomplete&lt;br /&gt;
 Upload-Offset: 3145728&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der Zwischenstand liegt im Arbeitsspeicher des Dienstes. Wird der&lt;br /&gt;
REST-Dienst neu gestartet, muss ein unvollständiger Upload von vorn beginnen.}}&lt;br /&gt;
&lt;br /&gt;
==Doppelte Uploads vermeiden==&lt;br /&gt;
&lt;br /&gt;
Bricht die Verbindung ab, nachdem der Server die Datei schon abgelegt hat, würde ein&lt;br /&gt;
Wiederholversuch dasselbe Foto ein zweites Mal einliefern. Dagegen sendet der Client&lt;br /&gt;
den Header &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; - für jedes Foto einen eigenen, über alle&lt;br /&gt;
Wiederholversuche hinweg denselben:&lt;br /&gt;
&lt;br /&gt;
 -H &amp;quot;Idempotency-Key: foto-4711-0003&amp;quot;&lt;br /&gt;
&lt;br /&gt;
Der Server erkennt die Wiederholung und liefert die &#039;&#039;&#039;gespeicherte 201-Antwort&#039;&#039;&#039;&lt;br /&gt;
samt Header &amp;lt;code&amp;gt;Idempotent-Replay: true&amp;lt;/code&amp;gt; zurück, ohne das Skript erneut zu&lt;br /&gt;
starten. Es entsteht also weder eine zweite Datei noch ein zweiter DB-Eintrag.&lt;br /&gt;
Details: [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]], Abschnitt&lt;br /&gt;
Idempotenz.&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Endpunkt mit &#039;&#039;&#039;Datei-Upload-Freigabe&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_upload&amp;lt;/code&amp;gt;) und eigener Grössenbegrenzung.&lt;br /&gt;
* Zugriff auf die hochgeladene Datei über &#039;&#039;_OBS_UPLOAD_PATH&#039;&#039;, &#039;&#039;_OBS_UPLOAD_NAME&#039;&#039; und &#039;&#039;_OBS_UPLOAD_CONTENTTYPE&#039;&#039;.&lt;br /&gt;
* Zusatzangaben als &#039;&#039;&#039;Query-Parameter&#039;&#039;&#039;, weil &#039;&#039;oBody&#039;&#039; bei Uploads &#039;&#039;nil&#039;&#039; ist.&lt;br /&gt;
* &#039;&#039;&#039;Whitelist&#039;&#039;&#039; für zulässige Dateitypen und ein &#039;&#039;&#039;selbst gebildeter Zielname&#039;&#039;&#039; - der Originalname landet nie im Pfad.&lt;br /&gt;
* Übernahme der temporären Datei mit &#039;&#039;FMove&#039;&#039;; ohne diesen Schritt bleibt sie liegen.&lt;br /&gt;
* Antwort mit &#039;&#039;&#039;201&#039;&#039;&#039; und &#039;&#039;Location&#039;&#039;-Header.&lt;br /&gt;
* Fortsetzbarer Upload ohne jede Änderung am Skript.&lt;br /&gt;
* Schutz gegen doppelte Einlieferung über den &#039;&#039;Idempotency-Key&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==Siehe auch==&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]] - Upload-Parameter und Protokoll im Detail&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]] - Freigabe, Grössenlimit, Idempotenz&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel 5]] - Schreibzugriff mit Statuscodes und ETag&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Scripting&amp;diff=64786</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Scripting</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Scripting&amp;diff=64786"/>
		<updated>2026-08-31T05:03:18Z</updated>

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

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
= Endpunkte =&lt;br /&gt;
&lt;br /&gt;
Ein &#039;&#039;&#039;Endpunkt&#039;&#039;&#039; stellt eine Adresse bereit, über die der REST-Server konkrete&lt;br /&gt;
Funktionen anbietet. Jeder Endpunkt wird durch ein OBS-Skript oder einen WebHook&lt;br /&gt;
realisiert und ist genau einem Server-Profil zugeordnet.&lt;br /&gt;
&lt;br /&gt;
== Routing über Pfad-Templates ==&lt;br /&gt;
&lt;br /&gt;
Das Routing erfolgt über die Spalte &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_pathtemplate&amp;lt;/code&amp;gt;).&lt;br /&gt;
Ein Template beschreibt den vollständigen Pfad nach dem Host und kann&lt;br /&gt;
&#039;&#039;&#039;Platzhalter&#039;&#039;&#039; enthalten:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Statische Segmente&#039;&#039;&#039; müssen exakt übereinstimmen (Groß-/Kleinschreibung wird ignoriert).&lt;br /&gt;
* &#039;&#039;&#039;Platzhalter&#039;&#039;&#039; in geschweiften Klammern – z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;{uid}&amp;lt;/code&amp;gt; – passen auf einen beliebigen Wert und werden als [[#Pfad-Parameter im Skript|Pfad-Parameter]] erfasst.&lt;br /&gt;
&lt;br /&gt;
Beispiele für gültige Templates:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Template !! Passt auf !! Pfad-Parameter&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; || –&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/4711&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;uid = 4711&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/4711/modules/A1&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;uid = 4711&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code = A1&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/kalender/v1&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/kalender/v1&amp;lt;/code&amp;gt; || –&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Präzedenz bei mehreren Treffern ===&lt;br /&gt;
&lt;br /&gt;
Passen mehrere Templates auf denselben Pfad, &#039;&#039;&#039;gewinnt das spezifischste&#039;&#039;&#039; –&lt;br /&gt;
also das mit den meisten statischen Segmenten. So schlägt &amp;lt;code&amp;gt;/orders/summary&amp;lt;/code&amp;gt;&lt;br /&gt;
das Template &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt;, während &amp;lt;code&amp;gt;/orders/4711&amp;lt;/code&amp;gt; auf&lt;br /&gt;
&amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; matcht.&lt;br /&gt;
&lt;br /&gt;
== Hauptfelder eines Endpunkts ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld !! Spalte !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_pathtemplate&amp;lt;/code&amp;gt; || &#039;&#039;&#039;Maßgeblich fürs Routing.&#039;&#039;&#039; Vollständiger Pfad mit Platzhaltern, z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Server&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_server&amp;lt;/code&amp;gt; || Zuordnung zum Server-Profil (erforderlich)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Aktiv&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_aktiv&amp;lt;/code&amp;gt; || Statusflag; inaktive Endpunkte liefern 404&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Skript&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_script&amp;lt;/code&amp;gt; || DwScript-Quelltext des Handlers (siehe [[/OBS/Kostenpflichtige_Module/RESTServer/Scripting|Scripting]])&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;WebHook&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_webhook&amp;lt;/code&amp;gt; || optional: Verweis auf einen Einmal-Endpunkt statt Skript&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Info&#039;&#039;&#039; || – || Dokumentations-Freitext&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_upload&amp;lt;/code&amp;gt; || Erlaubt Datei-Uploads an diesem Endpunkt (&amp;lt;code&amp;gt;0&amp;lt;/code&amp;gt; = aus, &amp;lt;code&amp;gt;1&amp;lt;/code&amp;gt; = an). Ohne Freigabe werden Upload-Anfragen mit 415 abgelehnt.&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Max. Upload-Grösse&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt; || Maximale Dateigrösse in MB. Leer/0 = Standard 25&amp;amp;nbsp;MB. Überschreitung wird mit 413 abgelehnt.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pfad-Parameter im Skript ==&lt;br /&gt;
&lt;br /&gt;
Die aus den Platzhaltern erfassten Werte stehen im Endpunkt-Skript als&lt;br /&gt;
reservierte Parameter mit Präfix &amp;lt;code&amp;gt;_OBS_PATH_&amp;lt;/code&amp;gt; zur Verfügung:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
function Get(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var cUid: string;&lt;br /&gt;
begin&lt;br /&gt;
    cUid := oParams.Values[&#039;_OBS_PATH_uid&#039;];   // aus /orders/{uid}&lt;br /&gt;
    // ...&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Da der Präfix &amp;lt;code&amp;gt;_OBS_&amp;lt;/code&amp;gt; für von außen gelieferte Header/Query-Parameter&lt;br /&gt;
gesperrt ist, können diese Werte nicht durch den Client überschrieben werden.&lt;br /&gt;
&lt;br /&gt;
== Datei-Uploads ==&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt nimmt Datei-Uploads nur entgegen, wenn er dafür freigeschaltet ist&lt;br /&gt;
(&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;). Andernfalls werden Upload-Anfragen mit&lt;br /&gt;
&#039;&#039;&#039;415 Unsupported Media Type&#039;&#039;&#039; abgelehnt und das Skript wird nicht ausgeführt.&lt;br /&gt;
&lt;br /&gt;
Die maximal zulässige Dateigrösse wird pro Endpunkt über &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;&lt;br /&gt;
(in MB) festgelegt; ohne Angabe gilt der Standard von &#039;&#039;&#039;25&amp;amp;nbsp;MB&#039;&#039;&#039;. Wird das&lt;br /&gt;
Limit überschritten, antwortet der Server mit &#039;&#039;&#039;413 Payload Too Large&#039;&#039;&#039;. Für&lt;br /&gt;
Uploads gilt nicht das JSON-Body-Limit (10&amp;amp;nbsp;MB), sondern dieses Endpunkt-Limit.&lt;br /&gt;
Der Originalname (beim resumable Upload über den Header &amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt;) ist &#039;&#039;&#039;Pflicht&#039;&#039;&#039;; fehlt er, wird der Upload mit 413/400 abgelehnt. Dem Skript stehen Pfad, Name, Content-Type und die SHA-256-Prüfsumme der gespeicherten Datei als &amp;lt;code&amp;gt;_OBS_UPLOAD_*&amp;lt;/code&amp;gt;-Parameter zur Verfügung; bei &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; zusätzlich die übrigen Formularfelder als &amp;lt;code&amp;gt;_OBS_FORM_*&amp;lt;/code&amp;gt; (Details: [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]]).&lt;br /&gt;
&lt;br /&gt;
Unterstützt werden zwei Übertragungsarten:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Einfacher Upload&#039;&#039;&#039; per &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; (eine Datei pro Request).&lt;br /&gt;
* &#039;&#039;&#039;Resumable/Chunked Upload&#039;&#039;&#039; per &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt; (grosse Dateien, fortsetzbar nach Abbruch).&lt;br /&gt;
&lt;br /&gt;
Der Server legt die hochgeladene Datei in einem temporären Verzeichnis ab und&lt;br /&gt;
übergibt dem Endpunkt-Skript den Pfad. Was mit der Datei geschieht (Ablage,&lt;br /&gt;
DMS-Verknüpfung, Weiterverarbeitung), entscheidet allein das Skript. Details und&lt;br /&gt;
Beispiele: [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
== HTTP-Methode ==&lt;br /&gt;
&lt;br /&gt;
Welche Funktion aufgerufen wird, ergibt sich aus dem HTTP-Verb: Der Server ruft&lt;br /&gt;
die gleichnamige Skript-Methode auf (&amp;lt;code&amp;gt;Get&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Post&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Put&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Delete&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Patch&amp;lt;/code&amp;gt;).&lt;br /&gt;
Ein Template entspricht damit &#039;&#039;&#039;einem&#039;&#039;&#039; Endpunkt-Skript; unterschiedliche&lt;br /&gt;
Pfad-Formen (z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; vs. &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt;) sind&lt;br /&gt;
&#039;&#039;&#039;eigene Endpunkt-Einträge&#039;&#039;&#039; mit eigenem Skript und eigener Berechtigung.&lt;br /&gt;
&lt;br /&gt;
== URL-Struktur ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
http://[Host][:Port][Pfad-Template]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Beispiele:&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders/4711&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders/4711/modules/A1&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Versionierung ==&lt;br /&gt;
&lt;br /&gt;
Da es kein eigenes Versions-Feld mehr gibt, wird die Version als statisches&lt;br /&gt;
Segment ins Template aufgenommen, z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders/v1&amp;lt;/code&amp;gt; oder&lt;br /&gt;
&amp;lt;code&amp;gt;/v1/orders/{uid}&amp;lt;/code&amp;gt;. Änderung ohne Breaking Change:&lt;br /&gt;
&lt;br /&gt;
# Bestehenden Endpunkt unverändert lassen.&lt;br /&gt;
# Neuen Endpunkt mit gleichem Ressourcennamen, aber neuem Versions-Segment im Template anlegen.&lt;br /&gt;
# Berechtigungen für neue Konsumenten setzen.&lt;br /&gt;
# Alten Endpunkt deaktivieren, wenn die Migration abgeschlossen ist.&lt;br /&gt;
&lt;br /&gt;
== Zugriffskontrolle ==&lt;br /&gt;
&lt;br /&gt;
Endpunkte sind Server-Profilen zugeordnet; ein Zugang benötigt eine explizite&lt;br /&gt;
Berechtigung (Tabelle &amp;lt;code&amp;gt;RESTSRV_ACCESS&amp;lt;/code&amp;gt;), um einen Endpunkt nutzen zu&lt;br /&gt;
dürfen. Weil jede Pfad-Form ein eigener Endpunkt-Eintrag ist, lässt sich der&lt;br /&gt;
Zugriff &#039;&#039;&#039;pro Pfad-Form&#039;&#039;&#039; granular vergeben (z.&amp;amp;nbsp;B. Lesen von&lt;br /&gt;
&amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; erlauben, aber das ändernde&lt;br /&gt;
&amp;lt;code&amp;gt;PUT /orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; nicht).&lt;br /&gt;
&lt;br /&gt;
== Caching ==&lt;br /&gt;
&lt;br /&gt;
Die Endpunkt-Definitionen werden pro Server-Profil zwischengespeichert&lt;br /&gt;
(TTL, Standard 60&amp;amp;nbsp;s). Neue oder geänderte Endpunkte/Templates werden daher&lt;br /&gt;
erst nach Ablauf des Caches (bzw. nach einer Cache-Invalidierung) wirksam – nicht&lt;br /&gt;
zwingend sofort.&lt;br /&gt;
&lt;br /&gt;
== HTTP-Fehlercodes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Code !! Ursache&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;404&#039;&#039;&#039; || Kein Template passt zum Pfad, Endpunkt inaktiv oder falsches Server-Profil&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;403&#039;&#039;&#039; || Zugang fehlt in der Berechtigungsliste&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;401&#039;&#039;&#039; || API-Key ungültig/fehlend, JWT abgelaufen oder Sitzung gesperrt (&#039;&#039;AUTH_EXPIRED&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;500&#039;&#039;&#039; || Skript- oder Syntax-Fehler (Details in &amp;lt;code&amp;gt;RESTSRV_PROTO&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;405&#039;&#039;&#039; || Das Endpunkt-Skript hat für die angefragte HTTP-Methode keine Funktion; der Header &amp;lt;code&amp;gt;Allow&amp;lt;/code&amp;gt; nennt die vorhandenen&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;415&#039;&#039;&#039; || Datei-Upload an einen Endpunkt, der dafür nicht freigeschaltet ist (&amp;lt;code&amp;gt;re_upload = 0&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;413&#039;&#039;&#039; || Hochgeladene Datei überschreitet die zulässige Maximalgrösse (&amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;429&#039;&#039;&#039; || Rate-Limit überschritten; die Antwort enthält den Header &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;503&#039;&#039;&#039; || Eine Anfrage mit demselben &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; wird gerade verarbeitet; die Antwort enthält &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;409&#039;&#039;&#039; || Ein früherer Aufruf mit demselben &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; ist ohne Ergebnis geblieben (&amp;lt;code&amp;gt;IDEMPOTENCY_UNRESOLVED&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;422&#039;&#039;&#039; || Derselbe &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; wurde mit &#039;&#039;&#039;anderem&#039;&#039;&#039; Inhalt gesendet (&amp;lt;code&amp;gt;IDEMPOTENCY_KEY_REUSED&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;400&#039;&#039;&#039; || Die Anfrage wurde unverschlüsselt an einen TLS-Port geschickt (&amp;lt;code&amp;gt;http&amp;lt;/code&amp;gt; statt &amp;lt;code&amp;gt;https&amp;lt;/code&amp;gt;). Die Abweisung erfolgt vor Authentifizierung und Endpunkt-Skript&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Jede Fehlerantwort besteht aus einem &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt;-Objekt mit&lt;br /&gt;
maschinenlesbarem Code - Aufbau siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer|Übersicht]].&lt;br /&gt;
&lt;br /&gt;
Daneben kann ein Endpunkt-Skript den Statuscode selbst setzen (z.B. &amp;lt;code&amp;gt;201&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;204&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;409&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;422&amp;lt;/code&amp;gt;) sowie Response-Header wie &amp;lt;code&amp;gt;ETag&amp;lt;/code&amp;gt; oder &amp;lt;code&amp;gt;Location&amp;lt;/code&amp;gt; - siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
== Idempotenz ==&lt;br /&gt;
&lt;br /&gt;
Schickt ein Konsument bei &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;PUT&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;PATCH&amp;lt;/code&amp;gt;&lt;br /&gt;
oder &amp;lt;code&amp;gt;DELETE&amp;lt;/code&amp;gt; den Header &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, sorgt der Server&lt;br /&gt;
selbst dafür, dass eine wiederholte Sendung &#039;&#039;&#039;keine Zweitwirkung&#039;&#039;&#039; hat. Das gilt&lt;br /&gt;
für &#039;&#039;&#039;jeden&#039;&#039;&#039; Endpunkt - es muss weder am Endpunkt etwas eingestellt noch im&lt;br /&gt;
Skript etwas programmiert werden.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Situation !! Antwort des Servers&lt;br /&gt;
|-&lt;br /&gt;
| Erster Aufruf || Endpunkt läuft normal, das Ergebnis wird zum Schlüssel gespeichert&lt;br /&gt;
|-&lt;br /&gt;
| Wiederholung, gleicher Inhalt || &#039;&#039;&#039;Gespeicherte Antwort&#039;&#039;&#039; (gleicher Statuscode, gleicher Body, gleiche Header) plus Header &amp;lt;code&amp;gt;Idempotent-Replay: true&amp;lt;/code&amp;gt;. Das Skript läuft &#039;&#039;&#039;nicht&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| Wiederholung, anderer Inhalt || &#039;&#039;&#039;422&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_KEY_REUSED&amp;lt;/code&amp;gt; - derselbe Schlüssel für einen anderen Inhalt ist ein Client-Fehler&lt;br /&gt;
|-&lt;br /&gt;
| Erster Aufruf läuft noch || &#039;&#039;&#039;503&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_IN_PROGRESS&amp;lt;/code&amp;gt; mit &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;. Beim nächsten Versuch liegt die gespeicherte Antwort vor&lt;br /&gt;
|-&lt;br /&gt;
| Früherer Aufruf ohne Ergebnis || &#039;&#039;&#039;409&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_UNRESOLVED&amp;lt;/code&amp;gt;. Nur noch nach einem &#039;&#039;&#039;harten Abbruch&#039;&#039;&#039; des Dienstes zwischen Verarbeitung und Festschreiben; ob die Buchung stattgefunden hat, ist unbekannt. Der Aufräumlauf gibt solche Einträge nach 24 Stunden frei und protokolliert sie&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Wichtig für die Auslegung eines Endpunkts:&lt;br /&gt;
&lt;br /&gt;
* Antwortet das Skript mit &#039;&#039;&#039;2xx&#039;&#039;&#039;, wird die Antwort gespeichert und bei einer Wiederholung erneut ausgeliefert.&lt;br /&gt;
* Antwortet das Skript mit &#039;&#039;&#039;4xx&#039;&#039;&#039; (fachliche Ablehnung), wird der Schlüssel wieder &#039;&#039;&#039;freigegeben&#039;&#039;&#039; - der Konsument darf ihn nach Korrektur erneut verwenden. Sonst würde ein einziger Validierungsfehler den Schlüssel dauerhaft blockieren.&lt;br /&gt;
* Endet die Verarbeitung mit &#039;&#039;&#039;5xx&#039;&#039;&#039; oder einem Skript-Fehler, wird der Schlüssel &#039;&#039;&#039;freigegeben&#039;&#039;&#039; und der Fall protokolliert. Der Konsument darf denselben Schlüssel erneut senden.&lt;br /&gt;
* Das ist eine bewusste Abwägung (geändert 2026-08-25): Bleibt der Schlüssel nach einem Serverfehler belegt, ist der Vorgang &#039;&#039;&#039;unwiederholbar&#039;&#039;&#039; - der Client bekommt dauerhaft 503 und müsste die erfasste Arbeit verwerfen. Garantierter Datenverlust wiegt schwerer als ein möglicher Doppelsatz.&lt;br /&gt;
* &#039;&#039;&#039;Folge für die Auslegung:&#039;&#039;&#039; ein schreibender Endpunkt sollte selbst wiederholbar sein, etwa über ein fachliches Merkmal des Clients. Wo reines Anhängen stattfindet (Positionen, Dateien), bleibt sonst ein Restrisiko.&lt;br /&gt;
&lt;br /&gt;
Der Schlüssel gilt je &#039;&#039;&#039;Zugang, Methode und Pfad&#039;&#039;&#039;. Derselbe&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; an einem anderen Endpunkt ist damit ein eigener&lt;br /&gt;
Vorgang - der Konsument muss ihn nicht global eindeutig vergeben, aber pro Vorgang&lt;br /&gt;
&#039;&#039;&#039;stabil wiederverwenden&#039;&#039;&#039; (also nicht bei jedem Wiederholversuch neu erzeugen).&lt;br /&gt;
&lt;br /&gt;
Die Einträge stehen in &amp;lt;code&amp;gt;RESTSRV_IDEMPOTENCY&amp;lt;/code&amp;gt; und werden nach 30 Tagen&lt;br /&gt;
automatisch aufgeräumt. Einträge ohne Ergebnis werden &#039;&#039;&#039;nicht&#039;&#039;&#039; automatisch&lt;br /&gt;
gelöscht, sondern im Protokoll gemeldet.&lt;br /&gt;
&lt;br /&gt;
== WebHooks ==&lt;br /&gt;
&lt;br /&gt;
WebHooks sind &#039;&#039;&#039;Einmal-Endpunkte&#039;&#039;&#039; für asynchrone Callbacks. Beim ersten&lt;br /&gt;
erfolgreichen Aufruf wird die Antwort in der Tabelle &amp;lt;code&amp;gt;REMOTE_HOOK_URL&amp;lt;/code&amp;gt;&lt;br /&gt;
gespeichert und der Endpunkt anschließend automatisch gelöscht.&lt;br /&gt;
&lt;br /&gt;
Typischer Einsatz: OBS stösst einen Vorgang bei einem Fremdsystem an (Bezahldienst,&lt;br /&gt;
Versanddienstleister) und gibt diesem eine Rückruf-Adresse mit, die nur &#039;&#039;&#039;ein&lt;br /&gt;
einziges Mal&#039;&#039;&#039; gültig ist. Damit kann die Adresse nicht später erneut - oder von&lt;br /&gt;
jemand anderem - benutzt werden.&lt;br /&gt;
&lt;br /&gt;
Ablauf:&lt;br /&gt;
&lt;br /&gt;
# OBS legt einen Eintrag in &amp;lt;code&amp;gt;REMOTE_HOOK_URL&amp;lt;/code&amp;gt; an und dazu einen Endpunkt, dessen Feld &#039;&#039;&#039;WebHook&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_webhook&amp;lt;/code&amp;gt;) auf diesen Eintrag zeigt. Ein Skript wird für diesen Endpunkt nicht gepflegt.&lt;br /&gt;
# Die Adresse dieses Endpunkts wird dem Fremdsystem als Callback-URL übergeben.&lt;br /&gt;
# Das Fremdsystem ruft die Adresse auf. Der Server legt den übergebenen Inhalt in &amp;lt;code&amp;gt;REMOTE_HOOK_URL.hu_response&amp;lt;/code&amp;gt; ab, antwortet mit &amp;lt;code&amp;gt;{&amp;quot;status&amp;quot;: &amp;quot;ok&amp;quot;}&amp;lt;/code&amp;gt; und &#039;&#039;&#039;löscht den Endpunkt&#039;&#039;&#039;.&lt;br /&gt;
# Der weiterverarbeitende OBS-Prozess findet die Rückmeldung in &amp;lt;code&amp;gt;hu_response&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein zweiter Aufruf derselben Adresse läuft ins Leere: Der Endpunkt&lt;br /&gt;
existiert nicht mehr, die Antwort ist 404. Das ist gewollt - ein WebHook ist keine&lt;br /&gt;
dauerhafte Schnittstelle. Für eine dauerhaft erreichbare Rückmelde-Adresse einen&lt;br /&gt;
normalen Endpunkt mit Skript anlegen.}}&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Stammdaten/Schnittstellen/Lagerlieferanten/OEX/Assmann&amp;diff=64779</id>
		<title>OBS/Stammdaten/Schnittstellen/Lagerlieferanten/OEX/Assmann</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Stammdaten/Schnittstellen/Lagerlieferanten/OEX/Assmann&amp;diff=64779"/>
		<updated>2026-08-27T08:54:21Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: Die Seite wurde neu angelegt: „= OEX-Schnittstelle: Assmann =  Herstellerspezifische Besonderheiten der OEX-Anbindung an &amp;#039;&amp;#039;&amp;#039;Assmann&amp;#039;&amp;#039;&amp;#039;. Die allgemeine Beschreibung von Umfang, Voraussetzungen und Ablauf steht auf der Hauptseite.  {| class=&amp;quot;wikitable&amp;quot; ! Merkmal !! Wert |- | Standardversion || 3.0 |- | Status || &amp;#039;&amp;#039;&amp;#039;vorbereitet&amp;#039;&amp;#039;&amp;#039; – die Anbindung geht demnächst produktiv |- | Übertragungsart in OBS || „OEX Assmann&amp;quot; |}  == 1 Aktueller Stand ==  Die Anbindung ist…“&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= OEX-Schnittstelle: Assmann =&lt;br /&gt;
&lt;br /&gt;
Herstellerspezifische Besonderheiten der OEX-Anbindung an &#039;&#039;&#039;Assmann&#039;&#039;&#039;. Die allgemeine Beschreibung von Umfang, Voraussetzungen und Ablauf steht auf der [[OEX-Schnittstelle|Hauptseite]].&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Merkmal !! Wert&lt;br /&gt;
|-&lt;br /&gt;
| Standardversion || 3.0&lt;br /&gt;
|-&lt;br /&gt;
| Status || &#039;&#039;&#039;vorbereitet&#039;&#039;&#039; – die Anbindung geht demnächst produktiv&lt;br /&gt;
|-&lt;br /&gt;
| Übertragungsart in OBS || „OEX Assmann&amp;quot;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== 1 Aktueller Stand ==&lt;br /&gt;
&lt;br /&gt;
Die Anbindung ist in OBS vollständig umgesetzt: Bestellungen an Assmann werden nach Standardversion 3.0 erzeugt, geprüft und im Ablageverzeichnis hinterlegt.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Noch nicht aktiv ist die automatische Übertragung.&#039;&#039;&#039; Bis zum Produktivstart wird die Bestelldatei erzeugt und abgelegt, aber nicht selbsttätig an Assmann übermittelt; der Anwender erhält beim Export einen entsprechenden Hinweis. Der Übertragungsweg wird zum Produktivstart eingerichtet.&lt;br /&gt;
&lt;br /&gt;
Vor dem ersten produktiven Einsatz bitte den OBS-Support ansprechen.&lt;br /&gt;
&lt;br /&gt;
== 2 Ablage und Protokoll ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Was !! Ort&lt;br /&gt;
|-&lt;br /&gt;
| Erzeugte Dateien || &amp;lt;code&amp;gt;&amp;amp;lt;OBS-Verzeichnis&amp;amp;gt;\data\oex\&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Protokolldatei || &amp;lt;code&amp;gt;&amp;amp;lt;OBS-Verzeichnis&amp;amp;gt;\data\oex\oex_proto.txt&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Siehe auch ==&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Stammdaten/Schnittstellen/Lagerlieferanten/OEX|OEX-Schnittstelle]] – allgemeine Beschreibung der Schnittstelle&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Stammdaten/Schnittstellen/Lagerlieferanten/OEX/Steelcase&amp;diff=64778</id>
		<title>OBS/Stammdaten/Schnittstellen/Lagerlieferanten/OEX/Steelcase</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Stammdaten/Schnittstellen/Lagerlieferanten/OEX/Steelcase&amp;diff=64778"/>
		<updated>2026-08-27T08:53:30Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: Die Seite wurde neu angelegt: „= OEX-Schnittstelle: Steelcase =  Herstellerspezifische Besonderheiten der OEX-Anbindung an &amp;#039;&amp;#039;&amp;#039;Steelcase&amp;#039;&amp;#039;&amp;#039;. Die allgemeine Beschreibung von Umfang, Voraussetzungen und Ablauf steht auf der Hauptseite.  {| class=&amp;quot;wikitable&amp;quot; ! Merkmal !! Wert |- | Standardversion || 2.1 |- | Übertragungsweg || Direktübertragung per Web-Service in das Steelcase-SAP |- | Status || produktiv im Einsatz |- | Übertragungsart in OBS || „OEX Steelcase&amp;quot;…“&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= OEX-Schnittstelle: Steelcase =&lt;br /&gt;
&lt;br /&gt;
Herstellerspezifische Besonderheiten der OEX-Anbindung an &#039;&#039;&#039;Steelcase&#039;&#039;&#039;. Die allgemeine Beschreibung von Umfang, Voraussetzungen und Ablauf steht auf der [[OEX-Schnittstelle|Hauptseite]].&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Merkmal !! Wert&lt;br /&gt;
|-&lt;br /&gt;
| Standardversion || 2.1&lt;br /&gt;
|-&lt;br /&gt;
| Übertragungsweg || Direktübertragung per Web-Service in das Steelcase-SAP&lt;br /&gt;
|-&lt;br /&gt;
| Status || produktiv im Einsatz&lt;br /&gt;
|-&lt;br /&gt;
| Übertragungsart in OBS || „OEX Steelcase&amp;quot;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== 1 Übertragungsweg ==&lt;br /&gt;
&lt;br /&gt;
Steelcase nutzt nicht den im Standard vorgesehenen Mailversand, sondern einen &#039;&#039;&#039;gesicherten Web-Service&#039;&#039;&#039;. OBS übergibt die Bestellung unmittelbar an das Steelcase-SAP.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Merkmal !! Umsetzung&lt;br /&gt;
|-&lt;br /&gt;
| Protokoll || HTTPS, verschlüsselte Verbindung&lt;br /&gt;
|-&lt;br /&gt;
| Übergabe || die erzeugte XML-Datei wird an den konfigurierten Endpunkt übergeben&lt;br /&gt;
|-&lt;br /&gt;
| Authentifizierung || Signaturverfahren über Zeitstempel, Einmalkennung, Anfrage-Kennung und Partnerkennung&lt;br /&gt;
|-&lt;br /&gt;
| Dienst || Bestellung&lt;br /&gt;
|-&lt;br /&gt;
| Rückmeldung || die Antwort des Steelcase-Systems wird im OEX-Protokoll festgehalten&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Bestellung ist damit unmittelbar nach dem Export im Herstellersystem – es gibt keinen Mailversand und keine Wartezeit im Postausgang.&lt;br /&gt;
&lt;br /&gt;
=== 1.1 Zugangsdaten ===&lt;br /&gt;
&lt;br /&gt;
In der Konfiguration des Lagerlieferanten sind drei Werte zu hinterlegen. Alle drei kommen von Steelcase.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Eintrag !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| Endpunkt || Adresse des Web-Service bei Steelcase&lt;br /&gt;
|-&lt;br /&gt;
| Key || eindeutige Identifikation für den Zugang (Partnerkennung)&lt;br /&gt;
|-&lt;br /&gt;
| Secret Key || Authentifizierungs-Token; wird verschlüsselt gespeichert und nicht im Klartext angezeigt&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Sind die Werte nicht vollständig hinterlegt, wird die Bestelldatei erzeugt und abgelegt, aber &#039;&#039;&#039;nicht übertragen&#039;&#039;&#039;. Im OEX-Protokoll erscheint dann ein Hinweis auf die unvollständige Konfiguration. Das ist der häufigste Grund dafür, dass eine Bestellung beim Hersteller nicht ankommt, obwohl OBS keinen Fehler meldet.&lt;br /&gt;
&lt;br /&gt;
== 2 Was der Anwender selbst beachten muss ==&lt;br /&gt;
&lt;br /&gt;
=== 2.1 Nur ein Vertriebsgebiet pro Bestellung ===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Artikel aus unterschiedlichen Vertriebsgebieten – DACH und EMEA – dürfen nicht in derselben Bestellung gemischt werden.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Die Schnittstelle prüft das &#039;&#039;&#039;nicht&#039;&#039;&#039;. Eine gemischte Bestellung wird technisch fehlerfrei übertragen und schlägt erst bei der Verarbeitung im Steelcase-SAP auf. Der Anwender muss die Bestellung daher vor dem Export prüfen und gegebenenfalls nach Vertriebsgebiet in zwei Bestellungen aufteilen.&lt;br /&gt;
&lt;br /&gt;
=== 2.2 Zahlungs- und Lieferbedingungen ===&lt;br /&gt;
&lt;br /&gt;
OBS überträgt die Zahlungsbedingungen aus dem Stamm mit, &#039;&#039;&#039;Steelcase ignoriert sie jedoch&#039;&#039;&#039;. Führend sind ausschließlich die im Steelcase-SAP hinterlegten Konditionen. Die Angaben in der Datei dienen nur der Information.&lt;br /&gt;
&lt;br /&gt;
Abweichende Konditionen für eine einzelne Bestellung lassen sich daher &#039;&#039;&#039;nicht&#039;&#039;&#039; über die Schnittstelle vereinbaren – das muss vorab direkt mit Steelcase geklärt und dort hinterlegt werden.&lt;br /&gt;
&lt;br /&gt;
== 3 Vorgaben von Steelcase ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Thema !! Vorgabe&lt;br /&gt;
|-&lt;br /&gt;
| Absenderkennung im Dateinamen || Aufbau von Steelcase vorgegeben: Kundennummer, Mandant und Bestellnummer&lt;br /&gt;
|-&lt;br /&gt;
| Applikationskennung || OBS meldet sich mit der von Steelcase vorgegebenen Kennung an&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== 4 Ablage und Protokoll ==&lt;br /&gt;
&lt;br /&gt;
Wie bei allen OEX-Bestellungen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Was !! Ort&lt;br /&gt;
|-&lt;br /&gt;
| Erzeugte Dateien || &amp;lt;code&amp;gt;&amp;amp;lt;OBS-Verzeichnis&amp;amp;gt;\data\oex\&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Protokolldatei || &amp;lt;code&amp;gt;&amp;amp;lt;OBS-Verzeichnis&amp;amp;gt;\data\oex\oex_proto.txt&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Bei Rückfragen zu einer Bestellung enthält das Protokoll neben dem Dateinamen auch die Anfrage-Kennungen der Übertragung und die Antwort des Steelcase-Systems. Diese Kennungen sind bei Rückfragen an Steelcase anzugeben.&lt;br /&gt;
&lt;br /&gt;
== Siehe auch ==&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Stammdaten/Schnittstellen/Lagerlieferanten/OEX|OEX-Schnittstelle]] – allgemeine Beschreibung der Schnittstelle&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Stammdaten/Schnittstellen/Lagerlieferanten/OEX&amp;diff=64777</id>
		<title>OBS/Stammdaten/Schnittstellen/Lagerlieferanten/OEX</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Stammdaten/Schnittstellen/Lagerlieferanten/OEX&amp;diff=64777"/>
		<updated>2026-08-27T08:36:58Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: Die Seite wurde neu angelegt: „= OEX-Schnittstelle (OFML Business Data Exchange) =  Die OEX-Schnittstelle überträgt Möbelbestellungen aus OBS elektronisch an den Hersteller – inklusive der vollständigen OFML-Konfiguration jeder Position. Sie ersetzt den manuellen Bestellweg per PDF und Mail für Hersteller der Büro- und Objektmöbelbranche.  OEX ist ein offener Branchenstandard des &amp;#039;&amp;#039;&amp;#039;Industrieverbands Büro und Arbeitswelt e. V. (IBA)&amp;#039;&amp;#039;&amp;#039; und Teil der OFML-Standardfamilie.  {| c…“&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;= OEX-Schnittstelle (OFML Business Data Exchange) =&lt;br /&gt;
&lt;br /&gt;
Die OEX-Schnittstelle überträgt Möbelbestellungen aus OBS elektronisch an den Hersteller – inklusive der vollständigen OFML-Konfiguration jeder Position. Sie ersetzt den manuellen Bestellweg per PDF und Mail für Hersteller der Büro- und Objektmöbelbranche.&lt;br /&gt;
&lt;br /&gt;
OEX ist ein offener Branchenstandard des &#039;&#039;&#039;Industrieverbands Büro und Arbeitswelt e. V. (IBA)&#039;&#039;&#039; und Teil der OFML-Standardfamilie.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Merkmal !! Wert&lt;br /&gt;
|-&lt;br /&gt;
| Standard || OEX – OFML Business Data Exchange&lt;br /&gt;
|-&lt;br /&gt;
| Herausgeber || Industrieverband Büro und Arbeitswelt e. V. (IBA)&lt;br /&gt;
|-&lt;br /&gt;
| Datenformat || XML&lt;br /&gt;
|-&lt;br /&gt;
| Zeichensatz || UTF-8&lt;br /&gt;
|-&lt;br /&gt;
| Umgesetzte Standardversionen || 2.1 und 3.0&lt;br /&gt;
|-&lt;br /&gt;
| Umgesetzter Beleg || Bestellung&lt;br /&gt;
|-&lt;br /&gt;
| OBS-Modul || OEX (separat freizuschalten)&lt;br /&gt;
|-&lt;br /&gt;
| Einordnung in OBS || Lagerlieferanten-Übertragungsart&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== 1 Funktionsumfang ==&lt;br /&gt;
&lt;br /&gt;
=== 1.1 Umgesetzter Beleg ===&lt;br /&gt;
&lt;br /&gt;
Umgesetzt ist die &#039;&#039;&#039;Bestellung&#039;&#039;&#039; in Richtung Hersteller. Sie geht vollständig elektronisch heraus, mit allen Positionen, Konfigurationsmerkmalen, Texten und Adressen.&lt;br /&gt;
&lt;br /&gt;
Rückmeldungen des Herstellers – Bestellbestätigung, Lieferavis, Rechnung – werden weiterhin auf dem bisherigen Weg verarbeitet (Mail, Papier, Portal). Auch Änderungen oder Stornos zu einer bereits übertragenen Bestellung werden nicht elektronisch nachgeschickt, sondern direkt mit dem Hersteller abgestimmt.&lt;br /&gt;
&lt;br /&gt;
=== 1.2 Umgesetzte Standardversionen ===&lt;br /&gt;
&lt;br /&gt;
Der OEX-Standard liegt in mehreren Versionen vor. OBS beherrscht die beiden in der Branche verbreiteten Versionen &#039;&#039;&#039;2.1&#039;&#039;&#039; und &#039;&#039;&#039;3.0&#039;&#039;&#039; und erzeugt aus derselben Programmlogik die jeweils passende Datei.&lt;br /&gt;
&lt;br /&gt;
Welche Version zum Einsatz kommt, gibt der Hersteller vor – sie ist nicht frei wählbar und wird bei der Einrichtung der Anbindung festgelegt. Für den Anwender macht das im Arbeitsablauf keinen Unterschied; die Unterschiede liegen im Umfang der übertragenen Daten (siehe Abschnitt 8).&lt;br /&gt;
&lt;br /&gt;
Damit ist OBS unabhängig davon anschlussfähig, ob ein Hersteller noch auf der älteren Version arbeitet oder bereits die aktuelle einsetzt.&lt;br /&gt;
&lt;br /&gt;
== 2 Voraussetzungen ==&lt;br /&gt;
&lt;br /&gt;
=== 2.1 Modul und Lizenz ===&lt;br /&gt;
&lt;br /&gt;
Die Schnittstelle erfordert das freigeschaltete &#039;&#039;&#039;OBS-Modul „OEX&amp;quot;&#039;&#039;&#039;. Ist es nicht aktiv, bricht der Export mit einem entsprechenden Hinweis ab. Die Nutzung wird protokolliert und gezählt.&lt;br /&gt;
&lt;br /&gt;
=== 2.2 OFML-Datenbasis (zwingend) ===&lt;br /&gt;
&lt;br /&gt;
Dies ist die wichtigste Voraussetzung und der häufigste Grund für Rückfragen:&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Eine OEX-Bestellung lässt sich nur aus Positionen erzeugen, die aus einer OFML-Planung stammen.&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Der Hersteller braucht nicht nur die Artikelnummer, sondern die vollständige Konfiguration – Serie, Variantencode, alle Merkmale mit ihren Werten. Diese Informationen entstehen im Planungswerkzeug, typischerweise &#039;&#039;&#039;pCon.planner&#039;&#039;&#039; oder &#039;&#039;&#039;pCon.basket&#039;&#039;&#039;, und gelangen über den &#039;&#039;&#039;OBX-Import&#039;&#039;&#039; in den OBS-Vorgang.&lt;br /&gt;
&lt;br /&gt;
Datenfluss:&lt;br /&gt;
&lt;br /&gt;
  Planung (pCon)  →  OBX-Datei  →  OBX-Import in Angebot/Auftrag  →  Bestellung  →  OEX-Export  →  Hersteller&lt;br /&gt;
&lt;br /&gt;
Der OBX-Import übernimmt je Position:&lt;br /&gt;
&lt;br /&gt;
* Hersteller-Kennung und Serie&lt;br /&gt;
* Artikelnummer in den Varianten Basis-, Final- und Variantencode&lt;br /&gt;
* Kurztext, Langtext und Ausstattungstext&lt;br /&gt;
* alle Konfigurationsmerkmale mit Bezeichnung und Wert&lt;br /&gt;
* Menge&lt;br /&gt;
* EK- und VK-Preis (Währung EUR), Positions- und Lieferantenrabatt&lt;br /&gt;
* Ordnerstruktur und Unterpositionen der Planung&lt;br /&gt;
* Zusatztexte aus der Planung&lt;br /&gt;
* Bilder der Planung (optional)&lt;br /&gt;
&lt;br /&gt;
Zusätzlich wird die komplette Original-Konfiguration als Rückverweis gespeichert. In Version 3.0 wird dieser Rückverweis mit der Bestellung übertragen, sodass der Hersteller die Planung auf seiner Seite wieder öffnen kann.&lt;br /&gt;
&lt;br /&gt;
Manuell in OBS angelegte Positionen und Positionen aus dem normalen Artikelstamm haben keine OFML-Daten und können nicht per OEX bestellt werden.&lt;br /&gt;
&lt;br /&gt;
=== 2.3 Stammdaten ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Was !! Wo !! Bemerkung&lt;br /&gt;
|-&lt;br /&gt;
| Übertragungsart des Lieferanten || Lagerlieferant || die OEX-Übertragungsart des jeweiligen Herstellers&lt;br /&gt;
|-&lt;br /&gt;
| Eigene Kundennummer beim Lieferanten || Bestellung / Lieferantenstamm || &#039;&#039;&#039;Pflicht&#039;&#039;&#039; – ohne diese Nummer bricht der Export ab&lt;br /&gt;
|-&lt;br /&gt;
| Hersteller-Zuordnung der OFML-Kennung || wird beim OBX-Import einmalig abgefragt || ordnet die OFML-Hersteller-Kennung dem OBS-Lieferanten zu&lt;br /&gt;
|-&lt;br /&gt;
| Zugangsdaten des Übertragungswegs || Konfiguration des Lagerlieferanten || nur bei Herstellern mit Direktübertragung, siehe Abschnitt 6&lt;br /&gt;
|-&lt;br /&gt;
| Währung, Land, Zahlungsbedingung || Standard-Stammdaten || für Belegwährung, ISO-Länderkennung und Zahlungsbedingungen&lt;br /&gt;
|-&lt;br /&gt;
| Mandantenadresse || Konstanten des Mandanten || wird als Auftraggeber-Adresse übertragen&lt;br /&gt;
|-&lt;br /&gt;
| Sachbearbeiter mit Telefon und E-Mail || Verkäuferstamm || wird als Ansprechpartner übertragen&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== 3 Einrichtung ==&lt;br /&gt;
&lt;br /&gt;
# &#039;&#039;&#039;Modul freischalten&#039;&#039;&#039; – über den OBS-Support.&lt;br /&gt;
# &#039;&#039;&#039;Lieferant als Lagerlieferant einrichten&#039;&#039;&#039; und die OEX-Übertragungsart des Herstellers setzen.&lt;br /&gt;
# &#039;&#039;&#039;Eigene Kundennummer beim Lieferanten&#039;&#039;&#039; eintragen.&lt;br /&gt;
# &#039;&#039;&#039;Übertragungsweg einrichten.&#039;&#039;&#039; Wie die Datei zum Hersteller kommt und welche Zugangsdaten dafür nötig sind, gibt der Hersteller vor – Details auf der jeweiligen Unterseite (siehe Abschnitt 10).&lt;br /&gt;
# &#039;&#039;&#039;Testbestellung&#039;&#039;&#039; mit dem Hersteller abstimmen und durchführen.&lt;br /&gt;
&lt;br /&gt;
== 4 Ablauf im Tagesgeschäft ==&lt;br /&gt;
&lt;br /&gt;
# Planung in pCon erstellen und als OBX-Datei exportieren.&lt;br /&gt;
# OBX-Datei in das OBS-Angebot bzw. den Auftrag importieren. Die OFML-Daten bleiben an den Positionen hängen und werden bei der Übernahme in Folgevorgänge mitgeführt.&lt;br /&gt;
# Bestellung zum Lieferanten erzeugen.&lt;br /&gt;
# Bestellung exportieren. Es erscheint die Rückfrage, ob die Bestellung wirklich exportiert werden soll.&lt;br /&gt;
# OBS prüft die Positionen auf Vollständigkeit, erzeugt die XML-Datei und überträgt sie an den Hersteller.&lt;br /&gt;
# Die Bestellung wird auf die Übertragungsart „Export&amp;quot; gesetzt. Der Vorgang wird im OEX-Protokoll festgehalten.&lt;br /&gt;
&lt;br /&gt;
=== 4.1 Vollständigkeitsprüfung vor dem Export ===&lt;br /&gt;
&lt;br /&gt;
Vor dem Erzeugen der Datei prüft OBS &#039;&#039;&#039;jede&#039;&#039;&#039; Position. Fehlt eine der folgenden Angaben, wird der Export abgebrochen und alle betroffenen Positionen werden mit Positionsnummer, Bezeichnung und Fehlergrund aufgelistet:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Meldung !! Bedeutung / Abhilfe&lt;br /&gt;
|-&lt;br /&gt;
| keine OFML-Daten vorhanden || Die Position stammt nicht aus einer OFML-Planung. Position über OBX-Import einfügen oder aus der Bestellung entfernen.&lt;br /&gt;
|-&lt;br /&gt;
| OFML-Bestellnummer fehlt || Die Planungsdaten enthalten keine Bestellartikelnummer. Planung im Planungswerkzeug prüfen.&lt;br /&gt;
|-&lt;br /&gt;
| OFML-Hersteller fehlt || Die Hersteller-Kennung fehlt in den Planungsdaten.&lt;br /&gt;
|-&lt;br /&gt;
| OFML-Serie fehlt || Die Serienangabe fehlt in den Planungsdaten.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Zusätzlich wird geprüft, ob die eigene Kundennummer beim Lieferanten eingetragen ist. Fehlt sie, erscheint der Hinweis, dass eine OEX-Bestellung nur mit eingetragener Kundennummer möglich ist.&lt;br /&gt;
&lt;br /&gt;
=== 4.2 Erneutes Versenden ===&lt;br /&gt;
&lt;br /&gt;
Ein zweiter Export derselben Bestellung erzeugt beim Hersteller eine zweite Bestellung – jede Datei ist inhaltlich eine Neuanlage. Ein erneutes Versenden muss deshalb immer mit dem Hersteller abgesprochen sein.&lt;br /&gt;
&lt;br /&gt;
== 5 Übertragener Inhalt ==&lt;br /&gt;
&lt;br /&gt;
Die Bestellung wird &#039;&#039;&#039;vollständig&#039;&#039;&#039; übertragen, also mit allen Positionen. Belegkopf und Positionen sind als Neuanlage gekennzeichnet.&lt;br /&gt;
&lt;br /&gt;
=== 5.1 Belegkopf ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Inhalt !! Herkunft in OBS&lt;br /&gt;
|-&lt;br /&gt;
| Bestellnummer || Bestellung&lt;br /&gt;
|-&lt;br /&gt;
| Kundennummer beim Lieferanten || Bestellung&lt;br /&gt;
|-&lt;br /&gt;
| Lieferantennummer || Bestellung&lt;br /&gt;
|-&lt;br /&gt;
| Belegwährung || Währungsstamm&lt;br /&gt;
|-&lt;br /&gt;
| Teillieferung erlaubt || fest „nein&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
| Belegsprache || fest „deutsch&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
| Auftragsart || fest „Standardauftrag&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
| Belegdatum || Bestelldatum&lt;br /&gt;
|-&lt;br /&gt;
| Wunschliefertermin || Lieferdatum der Bestellung&lt;br /&gt;
|-&lt;br /&gt;
| Rahmenvertragsnummer || OFML-Text „Sonderkondition&amp;quot; des Vorgangs&lt;br /&gt;
|-&lt;br /&gt;
| Kommission || Auftragsnummer des Endkunden&lt;br /&gt;
|-&lt;br /&gt;
| Verkaufsorganisation || Mandant&lt;br /&gt;
|-&lt;br /&gt;
| Abwicklungsmodalitäten (Text) || OFML-Text „Lieferbedingung&amp;quot; des Vorgangs&lt;br /&gt;
|-&lt;br /&gt;
| Verweis auf Anhang || Bestell-PDF (&amp;lt;code&amp;gt;BE&amp;amp;lt;Bestellnummer&amp;amp;gt;.pdf&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| Gesamtnetto || Nettobetrag der Bestellung, als Information&lt;br /&gt;
|-&lt;br /&gt;
| Zahlungsbedingungen || Zahlungsbedingungsstamm: Skontotage und -satz, zweite Skontostufe, Nettoziel (bis zu drei Staffeln)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Adressen&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Adressart !! Inhalt !! Ansprechpartner&lt;br /&gt;
|-&lt;br /&gt;
| Auftraggeber || Adresse des eigenen Mandanten, mit der Kundennummer beim Lieferanten || Sachbearbeiter aus dem Verkäuferstamm mit Telefon und E-Mail&lt;br /&gt;
|-&lt;br /&gt;
| Lieferant || Bestelladresse des Lieferanten || Ansprechpartner mit Telefonnummer&lt;br /&gt;
|-&lt;br /&gt;
| Anlieferadresse || Versandadresse der Bestellung; ist keine hinterlegt, die Adresse des Mandanten (eigenes Lager) ||&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Länderkennung wird als ISO-Länderkennung mit zwei Zeichen übertragen; ist im Land keine hinterlegt, wird „DE&amp;quot; verwendet.&lt;br /&gt;
&lt;br /&gt;
=== 5.2 Positionen ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Inhalt !! Herkunft in OBS&lt;br /&gt;
|-&lt;br /&gt;
| Positionsnummer || Bestellposition&lt;br /&gt;
|-&lt;br /&gt;
| übergeordnete Position || OFML-Struktur (Unterpositionen, Stücklisten)&lt;br /&gt;
|-&lt;br /&gt;
| Kundenartikelnummer || Artikelnummer der Bestellposition&lt;br /&gt;
|-&lt;br /&gt;
| Lieferantenartikelnummer || OFML-Bestellnummer, mit Artikelstatus (Original / modifiziert)&lt;br /&gt;
|-&lt;br /&gt;
| Lieferantenkennung || OFML-Hersteller&lt;br /&gt;
|-&lt;br /&gt;
| Serie || OFML-Serie&lt;br /&gt;
|-&lt;br /&gt;
| Bestellmenge || Menge der Bestellposition&lt;br /&gt;
|-&lt;br /&gt;
| Mengeneinheit || fest „Stück&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
| Konfigurationsmerkmale || alle OFML-Merkmale der Position mit Bezeichnung und Wert&lt;br /&gt;
|-&lt;br /&gt;
| Positions-ID || formatierte Positionsnummer&lt;br /&gt;
|-&lt;br /&gt;
| Konfigurations-ID || OFML-Variantencode&lt;br /&gt;
|-&lt;br /&gt;
| Abladeangabe || OFML-Text „Abladeangabe&amp;quot; – z. B. „1. OG, Herr Huber&amp;quot;, wird beim Hersteller auf die Packstücke gedruckt&lt;br /&gt;
|-&lt;br /&gt;
| Artikelkurztext || Bezeichnung 1 und 2 der Bestellposition&lt;br /&gt;
|-&lt;br /&gt;
| Artikellangtext || OFML-Langtext und Ausstattungstext&lt;br /&gt;
|-&lt;br /&gt;
| modifizierter Artikeltext || OFML-Zusatztext, sofern vorhanden&lt;br /&gt;
|-&lt;br /&gt;
| Nettogesamtpreis || Gesamtpreis der Bestellposition&lt;br /&gt;
|-&lt;br /&gt;
| Rückverweis auf die Konfiguration || Original-Planungsdaten (nur Version 3.0)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
In Version 3.0 wird zusätzlich die &#039;&#039;&#039;Ordnerstruktur&#039;&#039;&#039; der Planung als eigene Ordnerpositionen übertragen, sodass die Gliederung beim Hersteller erhalten bleibt. Version 2.1 kennt keine Ordnerpositionen.&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Artikelstatus&#039;&#039;&#039;: Positionen mit modifiziertem Artikeltext werden als „modifiziert&amp;quot; gekennzeichnet, alle anderen als „Original-Artikel des Lieferanten&amp;quot;. Der Hersteller erkennt daran, ob er den Artikel unverändert produzieren kann oder ob eine Sonderbearbeitung anfällt.&lt;br /&gt;
&lt;br /&gt;
=== 5.3 Preise ===&lt;br /&gt;
&lt;br /&gt;
Übertragen wird das &#039;&#039;&#039;Gesamtnetto&#039;&#039;&#039; – im Kopf für die Bestellung, je Position der Nettogesamtpreis – jeweils als Einkaufskondition. Rabatte, Zuschläge, Zwischensummen und Steuersätze werden &#039;&#039;&#039;nicht&#039;&#039;&#039; einzeln übertragen. Die Preise dienen dem Abgleich; führend für die Fakturierung ist das System des Herstellers.&lt;br /&gt;
&lt;br /&gt;
== 6 Übermittlung ==&lt;br /&gt;
&lt;br /&gt;
Der OEX-Standard sieht als Übermittlungsweg den &#039;&#039;&#039;E-Mail-Anhang&#039;&#039;&#039; zwischen zwei vereinbarten Adressen vor. Zusätzliche Anhänge wie das Bestell-PDF werden im Dokument als Verweis aufgeführt.&lt;br /&gt;
&lt;br /&gt;
Einzelne Hersteller stellen stattdessen einen eigenen Web-Service für die &#039;&#039;&#039;Direktübertragung&#039;&#039;&#039; bereit. OBS übergibt die Bestellung dann unmittelbar und gesichert an das System des Herstellers – ohne Mailversand, ohne manuellen Zwischenschritt und ohne Wartezeit im Postausgang. Die Antwort des Herstellersystems wird im OEX-Protokoll festgehalten.&lt;br /&gt;
&lt;br /&gt;
Für die Direktübertragung sind Endpunkt und Zugangsdaten in der Konfiguration des Lagerlieferanten zu hinterlegen; der Authentifizierungs-Token wird verschlüsselt gespeichert und nicht im Klartext angezeigt. Welche Werte einzutragen sind, gibt der Hersteller vor – siehe die jeweilige Unterseite (Abschnitt 10).&lt;br /&gt;
&lt;br /&gt;
Ist für einen Hersteller keine Direktübertragung eingerichtet, wird die Datei erzeugt und abgelegt, aber nicht automatisch übertragen; der Anwender erhält einen entsprechenden Hinweis.&lt;br /&gt;
&lt;br /&gt;
== 7 Dateien, Pfade und Protokoll ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Was !! Ort&lt;br /&gt;
|-&lt;br /&gt;
| Erzeugte OEX-Dateien || &amp;lt;code&amp;gt;&amp;amp;lt;OBS-Verzeichnis&amp;amp;gt;\data\oex\&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Protokolldatei || &amp;lt;code&amp;gt;&amp;amp;lt;OBS-Verzeichnis&amp;amp;gt;\data\oex\oex_proto.txt&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Dateinamen&#039;&#039;&#039; folgen dem Muster des Standards:&lt;br /&gt;
&lt;br /&gt;
  oex-orders_&amp;lt;Absenderkennung&amp;gt;_jjjjmmtt-hhmmss.xml&lt;br /&gt;
&lt;br /&gt;
Als Absenderkennung verwendet OBS die Kombination aus Kundennummer beim Lieferanten, Mandant und Bestellnummer. Einzelne Hersteller geben den Aufbau der Absenderkennung ausdrücklich vor.&lt;br /&gt;
&lt;br /&gt;
Das &#039;&#039;&#039;Protokoll&#039;&#039;&#039; hält je Vorgang mit Zeitstempel und Benutzerkennung fest: Start des Exports, erzeugter Dateiname, Beginn der Übertragung, Anfrage-Kennungen, die Antwort des Herstellersystems sowie jede aufgetretene Fehlermeldung. Es ist die erste Anlaufstelle bei Rückfragen zu einer Bestellung.&lt;br /&gt;
&lt;br /&gt;
== 8 Unterschiede der Versionen 2.1 und 3.0 ==&lt;br /&gt;
&lt;br /&gt;
Beide Versionen übertragen denselben Kern: Bestellkopf, Adressen, Positionen mit vollständiger Konfiguration, Texte und Preise. Version 3.0 geht darüber hinaus:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Thema !! Version 2.1 !! Version 3.0&lt;br /&gt;
|-&lt;br /&gt;
| Ordnerstruktur der Planung || nicht vorgesehen || wird als eigene Ordnerpositionen übertragen&lt;br /&gt;
|-&lt;br /&gt;
| Rückverweis auf die Planungsdaten || nicht vorgesehen || wird mitgeliefert, der Hersteller kann die Planung wieder öffnen&lt;br /&gt;
|-&lt;br /&gt;
| Set-Artikel und reine Textpositionen || nicht vorgesehen || eigene Positionsarten vorhanden&lt;br /&gt;
|-&lt;br /&gt;
| Identifikation der Partner || über die internationale Lokationsnummer (ILN) || über Kunden- und Lieferanten-ID mit Klassifizierung&lt;br /&gt;
|-&lt;br /&gt;
| Vorgängerbeleg (z. B. Angebot) || nicht im Kopf abbildbar || eigene Felder im Kopf und je Position&lt;br /&gt;
|-&lt;br /&gt;
| Katalog-Kennung, Variantencode, Unterartikel || nicht vorgesehen || vorhanden&lt;br /&gt;
|-&lt;br /&gt;
| Eindeutige Kennung je Position || nicht vorgesehen || jede Position trägt eine eindeutige Kennung&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Praktisch heißt das: Bei einem Hersteller auf Version 3.0 bleibt die Gliederung der Planung erhalten und der Hersteller kann die Konfiguration auf seiner Seite nachvollziehen. Bei Version 2.1 wird die Bestellung als flache Positionsliste übertragen – inhaltlich vollständig, aber ohne Gliederung und ohne Rückverweis.&lt;br /&gt;
&lt;br /&gt;
== 9 Grenzen und bewusste Festlegungen ==&lt;br /&gt;
&lt;br /&gt;
Die Schnittstelle ist auf den Bestellprozess ausgelegt. Folgendes ist nicht enthalten:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Nur die Bestellung, nur ausgehend&#039;&#039;&#039; (siehe 1.1).&lt;br /&gt;
* &#039;&#039;&#039;Auftragsart fest Standardauftrag.&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Mengeneinheit fest Stück.&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Keine Teillieferungen&#039;&#039;&#039; – es wird immer „Teillieferung nicht erlaubt&amp;quot; übertragen.&lt;br /&gt;
* &#039;&#039;&#039;Belegsprache fest deutsch.&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Keine Gewichts- und Volumenangaben, keine Lieferbedingungen nach Inco-Terms, keine EAN, keine Bankdaten, kein separater Rechnungsempfänger.&#039;&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Rabatte, Zuschläge und Steuersätze&#039;&#039;&#039; werden nicht einzeln übertragen (siehe 5.3).&lt;br /&gt;
* &#039;&#039;&#039;Ein Beleg pro Datei&#039;&#039;&#039; – jede Bestellung wird als eigene Datei übertragen, es werden keine Bestellungen gebündelt.&lt;br /&gt;
&lt;br /&gt;
== 10 Herstellerspezifische Besonderheiten ==&lt;br /&gt;
&lt;br /&gt;
Jeder Hersteller legt die eingesetzte Standardversion, den Übertragungsweg, die Zugangsdaten und einzelne Detailvorgaben selbst fest. Diese Besonderheiten sind je Hersteller auf einer eigenen Unterseite dieser Seite dokumentiert.&lt;br /&gt;
&lt;br /&gt;
Vor der ersten Bestellung an einen Hersteller sollte die zugehörige Unterseite gelesen werden – dort stehen die Punkte, die der Anwender selbst beachten muss und die die Schnittstelle nicht prüfen kann.&lt;br /&gt;
&lt;br /&gt;
== 11 Nutzen im Überblick ==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Keine Doppeleingabe.&#039;&#039;&#039; Die Planung wandert vom Planungswerkzeug über Angebot und Auftrag bis in die Bestellung des Herstellers, ohne dass Konfigurationen erneut erfasst werden.&lt;br /&gt;
* &#039;&#039;&#039;Keine Übertragungsfehler.&#039;&#039;&#039; Serie, Variantencode und alle Merkmale gehen maschinenlesbar heraus statt als Freitext im PDF.&lt;br /&gt;
* &#039;&#039;&#039;Vollständigkeitsprüfung vorab.&#039;&#039;&#039; Unvollständige Positionen fallen in OBS auf, nicht erst beim Hersteller.&lt;br /&gt;
* &#039;&#039;&#039;Schnellere Auftragsbestätigung&#039;&#039;&#039;, weil beim Hersteller keine manuelle Erfassung dazwischen liegt.&lt;br /&gt;
* &#039;&#039;&#039;Nachvollziehbarkeit.&#039;&#039;&#039; Datei und Protokoll dokumentieren, was wann an wen übertragen wurde.&lt;br /&gt;
* &#039;&#039;&#039;Rauminformationen bis auf das Packstück.&#039;&#039;&#039; Abladeangaben aus der Planung werden mitgegeben und beim Hersteller auf die Packstücke gedruckt.&lt;br /&gt;
* &#039;&#039;&#039;Breite Anschlussfähigkeit.&#039;&#039;&#039; Mit den Versionen 2.1 und 3.0 sind beide in der Branche verbreiteten Stände umgesetzt; weitere Hersteller lassen sich mit begrenztem Aufwand anbinden.&lt;br /&gt;
&lt;br /&gt;
== 12 Begriffe ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Begriff !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| OFML || Datenstandard der Möbelbranche für Artikel-, Konfigurations- und Preisdaten&lt;br /&gt;
|-&lt;br /&gt;
| OEX || Standard für den elektronischen Austausch von Geschäftsdokumenten innerhalb der OFML-Familie&lt;br /&gt;
|-&lt;br /&gt;
| OBX || Austauschformat der Planungswerkzeuge für Planungsinhalte („Warenkorb&amp;quot;); Eingangsformat für OBS&lt;br /&gt;
|-&lt;br /&gt;
| pCon.planner / pCon.basket || verbreitete Planungswerkzeuge der Branche, liefern die OBX-Dateien&lt;br /&gt;
|-&lt;br /&gt;
| IBA || Industrieverband Büro und Arbeitswelt e. V., Herausgeber des Standards&lt;br /&gt;
|-&lt;br /&gt;
| Variantencode || Kurzschlüssel, der eine konkrete Artikelkonfiguration eindeutig beschreibt&lt;br /&gt;
|-&lt;br /&gt;
| Abladeangabe || Zusatzinformation zum Anlieferort einer Position, z. B. Etage und Raum&lt;br /&gt;
|-&lt;br /&gt;
| ILN || Internationale Lokationsnummer zur Identifikation von Geschäftspartnern (Version 2.1)&lt;br /&gt;
|}&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Zugaenge&amp;diff=64702</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Zugaenge</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Zugaenge&amp;diff=64702"/>
		<updated>2026-08-20T07:30:52Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Zugänge=&lt;br /&gt;
&lt;br /&gt;
Ein &#039;&#039;&#039;Zugang&#039;&#039;&#039; (Account) ist der Kanal, über den ein externer Konsument den REST-Server anspricht. Er bündelt API-Key, Host-Beschränkung, CORS-Konfiguration, optionale JWT-Authentifizierung und optionale mTLS-Subject-Prüfung.&lt;br /&gt;
&lt;br /&gt;
==Aufruf==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Stammdaten -&amp;gt; Z Weitere Stammdaten -&amp;gt; REST-Server -&amp;gt; Zugänge&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==Felder==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld          !! Beschreibung&lt;br /&gt;
|-&lt;br /&gt;
| Name          || Sprechender Name, erscheint im Protokoll und der Statistik&lt;br /&gt;
|-&lt;br /&gt;
| Aktiv         || Inaktive Zugänge werden bei der Authentifizierung mit 403 abgewiesen&lt;br /&gt;
|-&lt;br /&gt;
| API-Key       || Eindeutiger Schlüssel, den der Konsument im HTTP-Header &#039;&#039;apikey&#039;&#039; senden muss. Per Schlüsselsymbol generierbar&lt;br /&gt;
|-&lt;br /&gt;
| Host          || Optionaler Hostname oder IP-Adresse des Konsumenten. Stimmt die anfragende IP nicht überein, wird mit 403 abgelehnt&lt;br /&gt;
|-&lt;br /&gt;
| CORS-Origins  || Liste erlaubter Origins für Browser-Aufrufe, mehrere durch &#039;&#039;;&#039;&#039; getrennt. &#039;&#039;&#039;Leer heisst nicht „keine Einschränkung&amp;quot;, sondern „kein Browser-Zugriff&amp;quot;&#039;&#039;&#039; - siehe unten&lt;br /&gt;
|-&lt;br /&gt;
| mTLS-Subject  || Erwartete RDN-Komponente(n) im Client-Zertifikat-Subject, Komma-/Semikolon-getrennt (alle müssen vorkommen; nur sinnvoll bei mTLS-Servern)&lt;br /&gt;
|-&lt;br /&gt;
| JWT           || Schalter, ob für diesen Zugang ein JWT-Token nötig ist&lt;br /&gt;
|-&lt;br /&gt;
| JWT-Endpunkt  || Pfad, über den &#039;&#039;&#039;alle Token-Vorgänge&#039;&#039;&#039; laufen: Anmelden, Erneuern und Abmelden (z.B. &#039;&#039;oauth&#039;&#039; oder &#039;&#039;meineapp/dev/auth&#039;&#039;). Verglichen wird der &#039;&#039;&#039;vollständige Pfad&#039;&#039;&#039;, mehrstufige Angaben mit &#039;&#039;/&#039;&#039; sind also erlaubt&lt;br /&gt;
|-&lt;br /&gt;
| JWT-Key       || Geheimer Schlüssel zur Signierung/Verifikation, per Schlüsselsymbol generierbar&lt;br /&gt;
|-&lt;br /&gt;
| JWT-Exp       || Gültigkeitsdauer eines ausgestellten Access-Tokens in &#039;&#039;&#039;Minuten&#039;&#039;&#039;. Derselbe Wert wird dem Konsumenten in der Token-Antwort als &#039;&#039;expiresIn&#039;&#039; in &#039;&#039;&#039;Sekunden&#039;&#039;&#039; mitgeteilt und steht dem Endpunkt-Skript als &#039;&#039;_OBS_JWT_EXP_SEC&#039;&#039; zur Verfügung&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==API-Key==&lt;br /&gt;
&lt;br /&gt;
Der API-Key ist die Basis-Authentifizierung. Der Client übergibt ihn im HTTP-Header:&lt;br /&gt;
&lt;br /&gt;
 apikey: abcd-efgh-ijkl-mnop-qrstuvwxyz12&lt;br /&gt;
&lt;br /&gt;
Wird ein Aufruf ohne &#039;&#039;apikey&#039;&#039;-Header gestellt, sucht der Server intern nach einem Zugang mit API-Key &#039;&#039;&#039;*&#039;&#039;&#039; - damit lassen sich (mit Vorsicht!) auch öffentliche Endpunkte ohne Authentifizierung realisieren.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein Zugang mit API-Key &#039;&#039;*&#039;&#039; sollte nur für bewusst freigegebene Endpunkte verwendet werden und immer in Kombination mit einer Host-Beschränkung oder CORS-Beschränkung betrieben werden.}}&lt;br /&gt;
&lt;br /&gt;
==Host-Beschränkung==&lt;br /&gt;
&lt;br /&gt;
Ist im Feld &#039;&#039;&#039;Host&#039;&#039;&#039; ein Wert hinterlegt, akzeptiert der Server Anfragen unter diesem Zugang nur dann, wenn die Remote-IP exakt diesem Host entspricht oder durch DNS-Auflösung auf diese IP zeigt. DNS-Auflösungen werden 5 Minuten gecached.&lt;br /&gt;
&lt;br /&gt;
==CORS==&lt;br /&gt;
&lt;br /&gt;
Soll ein Endpunkt aus einer Browser-Anwendung (Single-Page-App, JavaScript) heraus aufgerufen werden, muss das Origin der Anwendung in der CORS-Liste des Zugangs eingetragen sein.&lt;br /&gt;
&lt;br /&gt;
Format der Liste:&lt;br /&gt;
&lt;br /&gt;
 https://app.kunde.de;https://test.kunde.de;https://localhost:8080&lt;br /&gt;
&lt;br /&gt;
Ablauf:&lt;br /&gt;
&lt;br /&gt;
# Browser sendet einen Preflight-Request (&#039;&#039;OPTIONS&#039;&#039;). Der REST-Server prüft das Origin gegen die globale Origin-Liste &#039;&#039;&#039;aller&#039;&#039;&#039; Accounts (Cache, TTL 60s) und antwortet mit 204 + CORS-Header, falls erlaubt.&lt;br /&gt;
# Der eigentliche Request wird gegen die Origin-Liste &#039;&#039;&#039;des aktuell verwendeten Zugangs&#039;&#039;&#039; geprüft.&lt;br /&gt;
# Stimmt das Origin nicht, wird mit 403 &#039;&#039;Origin nicht erlaubt für Account ...&#039;&#039; abgelehnt.&lt;br /&gt;
&lt;br /&gt;
Der Preflight kann nicht zugangsbezogen prüfen: der Browser schickt dabei keinen&lt;br /&gt;
&#039;&#039;apikey&#039;&#039; mit, der Zugang ist zu diesem Zeitpunkt unbekannt. &#039;&#039;&#039;Die Durchsetzung&lt;br /&gt;
liegt deshalb auf Schritt 2&#039;&#039;&#039;, nicht auf dem Preflight. Praktische Folge: trägt&lt;br /&gt;
&#039;&#039;&#039;ein&#039;&#039;&#039; Zugang auf dem Server ein &#039;&#039;*&#039;&#039; in seiner Liste, antwortet jeder&lt;br /&gt;
Preflight mit &#039;&#039;Access-Control-Allow-Origin: *&#039;&#039; - auch für Zugänge mit engerer&lt;br /&gt;
Liste. Deren eigentliche Anfrage wird trotzdem gegen die eigene Liste geprüft.&lt;br /&gt;
&lt;br /&gt;
Erlaubte Methoden: &#039;&#039;GET, POST, PUT, DELETE, PATCH&#039;&#039; (systemseitig festgelegt).&lt;br /&gt;
&lt;br /&gt;
Bei den &#039;&#039;&#039;Anfrage-Headern&#039;&#039;&#039; spiegelt der Server im Preflight zurück, was der&lt;br /&gt;
Browser in &#039;&#039;Access-Control-Request-Headers&#039;&#039; angefragt hat. Damit sind eigene&lt;br /&gt;
Header eines Projekts - &#039;&#039;Idempotency-Key&#039;&#039;, &#039;&#039;If-Match&#039;&#039;, später&lt;br /&gt;
&#039;&#039;Upload-Offset&#039;&#039;/&#039;&#039;Upload-Length&#039;&#039; - ohne Änderung am Server nutzbar. Fragt der&lt;br /&gt;
Preflight keine Header an, antwortet der Server mit der Liste&lt;br /&gt;
&#039;&#039;Content-Type, apikey, Authorization, Accept&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Die Header-Liste ist &#039;&#039;&#039;keine Schutzgrenze&#039;&#039;&#039; - das ist das Origin. Sie&lt;br /&gt;
sagt dem Browser nur, welche Header der Server versteht. Dazu kommt, dass die&lt;br /&gt;
Zugangsdaten dieser Schnittstelle in Headern stehen (&#039;&#039;apikey&#039;&#039;,&lt;br /&gt;
&#039;&#039;Authorization&#039;&#039;) und nicht in Cookies: es gibt also keine automatisch&lt;br /&gt;
mitgeschickte Berechtigung, die eine fremde Seite ausnutzen könnte.}}&lt;br /&gt;
&lt;br /&gt;
===Was „leer&amp;quot; bedeutet===&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Ein &#039;&#039;&#039;leeres&#039;&#039;&#039; Feld CORS-Origins heisst &#039;&#039;&#039;nicht&#039;&#039;&#039; „keine&lt;br /&gt;
Einschränkung&amp;quot;. Es heisst: &#039;&#039;&#039;jeder Aufruf mit &#039;&#039;Origin&#039;&#039;-Header wird mit 403&lt;br /&gt;
abgelehnt&#039;&#039;&#039;. Für Clients &#039;&#039;&#039;ohne&#039;&#039;&#039; &#039;&#039;Origin&#039;&#039;-Header - mobile Apps, Server,&lt;br /&gt;
curl - ändert das Feld gar nichts, sie werden nie gegen CORS geprüft.}}&lt;br /&gt;
&lt;br /&gt;
Daraus ergeben sich drei sinnvolle Einstellungen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld !! Wirkung&lt;br /&gt;
|-&lt;br /&gt;
| leer || Kein Browser-Zugriff. Richtig für Zugänge, die nur von Apps oder Servern benutzt werden&lt;br /&gt;
|-&lt;br /&gt;
| konkrete Origins || Browser-Zugriff nur von diesen Seiten. Der Regelfall für Web-Anwendungen&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;*&#039;&#039; || Browser-Zugriff von überall. Wirkt zusätzlich auf die Preflight-Antwort aller anderen Zugänge - bewusst und sparsam verwenden&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Für eine Browser-Anwendung ist dieses Feld besonders wichtig, weil ihr API-Key&lt;br /&gt;
zwangsläufig im JavaScript liegt und damit öffentlich ist. Die Origin-Liste&lt;br /&gt;
verhindert dann, dass jemand den Key nimmt und von seiner eigenen Seite benutzt.&lt;br /&gt;
Sie ist allerdings kein Ersatz für eine Zugriffskontrolle: wer den Key hat,&lt;br /&gt;
umgeht CORS mit einem einzigen Aufruf ausserhalb des Browsers.&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierung==&lt;br /&gt;
&lt;br /&gt;
Wird ein Zugang mit aktiver JWT-Pflicht aufgerufen, sind die folgenden Phasen zu durchlaufen (Phase 3 nur bei genutztem Refresh).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Anmelden, Erneuern und Abmelden laufen über denselben Endpunkt&#039;&#039;&#039; - den im&lt;br /&gt;
Feld JWT-Endpunkt hinterlegten Pfad. Ein eigener Endpunkt fürs Abmelden ist&lt;br /&gt;
nicht nötig und wäre auch umständlicher: der JWT-Endpunkt greift &#039;&#039;&#039;vor&#039;&#039;&#039; der&lt;br /&gt;
Adressauflösung und braucht deshalb weder eine Zeile in &#039;&#039;RESTSRV_ENDPOINTS&#039;&#039;&lt;br /&gt;
noch eine Berechtigung in &#039;&#039;RESTSRV_ACCESS&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Unterschieden wird nach Verb und Token-Art:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Ablauf&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;POST&#039;&#039; ohne &#039;&#039;Authorization&#039;&#039; || Anmelden (Skript-Methode &#039;&#039;Authenticate&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;POST&#039;&#039; mit Bearer, Refresh-Token || Erneuern (Skript-Methode &#039;&#039;Refresh&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;DELETE&#039;&#039; mit Bearer, Access-Token || Abmelden (kein Skript nötig)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;POST&#039;&#039; mit Bearer, Access-Token || &#039;&#039;&#039;401&#039;&#039;&#039; - unverändert&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Dass das Abmelden am Verb hängt und nicht allein an der Token-Art, ist&lt;br /&gt;
Absicht. Würde ein vorgelegtes Access-Token beim &#039;&#039;POST&#039;&#039; als Abmeldung gedeutet,&lt;br /&gt;
würde ein Client, der seine beiden Token verwechselt, den Anwender still&lt;br /&gt;
abmelden - statt das klare 401 zu bekommen, an dem der Fehler auffällt.}}&lt;br /&gt;
&lt;br /&gt;
===Phase 1: Token-Bezug===&lt;br /&gt;
&lt;br /&gt;
Der Konsument ruft den JWT-Endpunkt des Zugangs auf, z.B.:&lt;br /&gt;
&lt;br /&gt;
 POST https://api.meinserver.de/oauth/&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
 Body:   {&amp;quot;username&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;password&amp;quot;:&amp;quot;...&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Der Server führt das im Zugang hinterlegte &#039;&#039;&#039;Authentifizierungs-Skript&#039;&#039;&#039; aus (F7 in der Zugangs-Liste). Das Skript muss eine Funktion &#039;&#039;Authenticate&#039;&#039; bereitstellen, die ein JSON-Objekt mit:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;status&#039;&#039; = &#039;&#039;1&#039;&#039; bei Erfolg, &#039;&#039;9&#039;&#039; bei Misserfolg&lt;br /&gt;
* &#039;&#039;error&#039;&#039; = Fehlertext (bei status=9)&lt;br /&gt;
* &#039;&#039;_OBS_JWT_ID&#039;&#039;, &#039;&#039;_OBS_JWT_SUBJECT&#039;&#039;, &#039;&#039;_OBS_JWT_AUDIENCE&#039;&#039; für die JWT-Claims (optional)&lt;br /&gt;
* &#039;&#039;_OBS_JWT_CLAIM_&amp;amp;lt;name&amp;amp;gt;&#039;&#039; für beliebige Custom-Claims (z.B. Mandant &#039;&#039;tenant&#039;&#039;, Rollen &#039;&#039;roles&#039;&#039;); im Folge-Skript lesbar&lt;br /&gt;
* &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; (optional) löst die Ausstellung eines Refresh-Tokens aus; &#039;&#039;_OBS_JWT_REFRESH_EXP&#039;&#039; setzt dessen Lebensdauer in Minuten (Default 90 Tage)&lt;br /&gt;
&lt;br /&gt;
zurückliefert. Bei Erfolg signiert der Server einen Token (HS256, Issuer &#039;&#039;OBS REST-Server&#039;&#039;, Lebenszeit gemäß JWT-Exp) und antwortet:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:        &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;accessToken&amp;quot;:  &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:    28800,&lt;br /&gt;
  &amp;quot;serverTime&amp;quot;:   &amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Zum Aufbau der Antwort:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;token&#039;&#039; und &#039;&#039;accessToken&#039;&#039; enthalten &#039;&#039;&#039;denselben&#039;&#039;&#039; Token. &#039;&#039;accessToken&#039;&#039; ist der Name, den die meisten Client-Bibliotheken erwarten; &#039;&#039;token&#039;&#039; bleibt für bestehende Konsumenten erhalten.&lt;br /&gt;
* &#039;&#039;expiresIn&#039;&#039; ist die Gültigkeit in &#039;&#039;&#039;Sekunden&#039;&#039;&#039; (JWT-Exp × 60).&lt;br /&gt;
* &#039;&#039;&#039;Alle weiteren Felder, die das Authenticate-Skript zurückgibt, werden mit ausgeliefert&#039;&#039;&#039; - mit Ausnahme von &#039;&#039;status&#039;&#039; und den &#039;&#039;_OBS_&#039;&#039;-Steuerfeldern. So kann das Skript z.B. eine Benutzer-Id, den Mandanten oder Rollen direkt in die Anmelde-Antwort legen, ohne dass der Client dafür einen zweiten Aufruf braucht.&lt;br /&gt;
&lt;br /&gt;
Beispiel mit Zusatzfeldern aus dem Skript:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;benutzerId&amp;quot;: &amp;quot;4711&amp;quot;, &amp;quot;tenant&amp;quot;: &amp;quot;nord&amp;quot;, &amp;quot;roles&amp;quot;: [&amp;quot;TECHNIKER&amp;quot;],&lt;br /&gt;
  &amp;quot;token&amp;quot;: &amp;quot;eyJ...&amp;quot;, &amp;quot;accessToken&amp;quot;: &amp;quot;eyJ...&amp;quot;, &amp;quot;refreshToken&amp;quot;: &amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;: 28800, &amp;quot;serverTime&amp;quot;: &amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Mit der Ausstellung legt der Server zu dem Token-Paar eine &#039;&#039;&#039;Sitzungszeile&#039;&#039;&#039; in&lt;br /&gt;
&#039;&#039;RESTSRV_TOKEN&#039;&#039; an. Lässt sich diese Zeile nicht schreiben (Tabelle fehlt,&lt;br /&gt;
Datenbankproblem), wird &#039;&#039;&#039;kein Token ausgeliefert&#039;&#039;&#039;: der Server antwortet mit&lt;br /&gt;
&#039;&#039;&#039;503&#039;&#039;&#039; und &#039;&#039;Retry-After&#039;&#039;. Das ist bewusst von der abgelehnten Anmeldung&lt;br /&gt;
unterschieden - die Zugangsdaten waren in Ordnung, der Versuch darf wiederholt&lt;br /&gt;
werden.&lt;br /&gt;
&lt;br /&gt;
Wird die Anmeldung abgelehnt (&#039;&#039;status&#039;&#039; = 9), antwortet der Server mit&lt;br /&gt;
&#039;&#039;&#039;401 Unauthorized&#039;&#039;&#039; und reicht den Text aus dem Feld &#039;&#039;error&#039;&#039; als&lt;br /&gt;
&#039;&#039;error.message&#039;&#039; an den Client durch - der Konsument kann ihn also direkt&lt;br /&gt;
anzeigen. Die Formulierung liegt damit beim Skript.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der durchgereichte Text ist für den Endanwender gedacht und sollte&lt;br /&gt;
keine internen Details verraten. „Benutzer oder Passwort ist falsch&amp;quot; ist eine gute&lt;br /&gt;
Meldung, „Kein Datensatz in BENUTZ mit b_name=meier&amp;quot; nicht.}}&lt;br /&gt;
&lt;br /&gt;
===Phase 2: Token-Verwendung===&lt;br /&gt;
&lt;br /&gt;
Bei allen folgenden Aufrufen sendet der Client den Token im &#039;&#039;Authorization&#039;&#039;-Header:&lt;br /&gt;
&lt;br /&gt;
 GET https://api.meinserver.de/kalender/v1&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
         Authorization: Bearer eyJhbGciOi...&lt;br /&gt;
&lt;br /&gt;
Der Server verifiziert Signatur, Issuer, Ausstellzeitpunkt und Ablauf (Toleranz: 20 Sekunden Clock-Skew) und prüft anschliessend die &#039;&#039;&#039;Sitzung&#039;&#039;&#039; in &#039;&#039;RESTSRV_TOKEN&#039;&#039;. Im Skript stehen die Claims als Parameter &#039;&#039;_OBS_JWT_ID&#039;&#039;, &#039;&#039;_OBS_JWT_SUBJECT&#039;&#039; und &#039;&#039;_OBS_JWT_AUDIENCE&#039;&#039; zur Verfügung.&lt;br /&gt;
&lt;br /&gt;
Die Sitzungsprüfung weist ein Token ab, das zu keiner Zeile gehört oder dessen&lt;br /&gt;
Sitzung gesperrt wurde - beides mit &#039;&#039;&#039;401&#039;&#039;&#039; und dem Code &#039;&#039;AUTH_EXPIRED&#039;&#039;. Der&lt;br /&gt;
Grund steht jeweils im Protokoll &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; unter &#039;&#039;TokenStore: ...&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein Token, das &#039;&#039;&#039;vor&#039;&#039;&#039; der Einführung dieser Prüfung ausgestellt&lt;br /&gt;
wurde, gehört zu keiner Sitzung und wird abgewiesen. Nach dem Update müssen sich&lt;br /&gt;
angemeldete Konsumenten also einmalig neu anmelden.}}&lt;br /&gt;
&lt;br /&gt;
===Phase 3: Token erneuern (Refresh)===&lt;br /&gt;
&lt;br /&gt;
Soll die App lange ohne Neuanmeldung arbeiten, stellt das Authenticate-Skript zusätzlich ein Refresh-Token aus (Feld &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039;). Zum Erneuern ruft der Client &#039;&#039;&#039;denselben JWT-Endpunkt&#039;&#039;&#039; auf, aber mit dem Refresh-Token im &#039;&#039;Authorization&#039;&#039;-Header:&lt;br /&gt;
&lt;br /&gt;
 POST https://api.meinserver.de/oauth/&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
         Authorization: Bearer eyJhbGciOi... (Refresh-Token)&lt;br /&gt;
&lt;br /&gt;
Der Server erkennt am Bearer-Token den Refresh-Fall, verifiziert das Token (Signatur, Ablauf, &#039;&#039;token_use=refresh&#039;&#039;), &#039;&#039;&#039;entwertet es in der Sitzung&#039;&#039;&#039; und ruft erst dann im selben Skript die Methode &#039;&#039;&#039;Refresh&#039;&#039;&#039; auf. Das Skript liefert nur noch die aktuellen Claims zurück - Rotation, Einmalgebrauch und Sperrliste erledigt der Server. Die Antwort ist ein rotiertes Paar &#039;&#039;{&amp;quot;token&amp;quot;,&amp;quot;accessToken&amp;quot;,&amp;quot;refreshToken&amp;quot;,&amp;quot;expiresIn&amp;quot;,&amp;quot;serverTime&amp;quot;}&#039;&#039; in &#039;&#039;&#039;derselben Sitzung&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Eine &#039;&#039;&#039;eigene Sperrtabelle im Skript ist nicht mehr nötig&#039;&#039;&#039; und sollte&lt;br /&gt;
entfernt werden. Wer sie weiterführt, rotiert zweimal: einmal im Skript, einmal im&lt;br /&gt;
Server. Der Server sieht die Skript-Tabelle nicht, und das Skript sieht die&lt;br /&gt;
Sitzung nicht.}}&lt;br /&gt;
&lt;br /&gt;
Verhalten bei einem bereits benutzten Refresh-Token:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Lage !! Antwort !! Wirkung auf die Sitzung&lt;br /&gt;
|-&lt;br /&gt;
| Erneute Vorlage &#039;&#039;&#039;innerhalb von 60 Sekunden&#039;&#039;&#039; || 401 &#039;&#039;AUTH_EXPIRED&#039;&#039; || Keine. Das ist der Normalfall einer App, deren parallele Anfragen gleichzeitig in den Refresh laufen - eine gewinnt, die anderen sollen die vom Gewinner gespeicherten Token benutzen&lt;br /&gt;
|-&lt;br /&gt;
| Erneute Vorlage &#039;&#039;&#039;später&#039;&#039;&#039; || 401 &#039;&#039;AUTH_EXPIRED&#039;&#039; || Die &#039;&#039;&#039;gesamte Sitzung wird gesperrt&#039;&#039;&#039;. Das Token wurde kopiert oder wiederholt, ohne dass ein Gewinner es noch brauchen könnte; der Vorfall wird mit IP protokolliert&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Details und ein vollständiges Beispiel siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
===Phase 4: Abmelden (Logout)===&lt;br /&gt;
&lt;br /&gt;
Abgemeldet wird mit einem &#039;&#039;&#039;DELETE&#039;&#039;&#039; auf denselben JWT-Endpunkt, das&lt;br /&gt;
Access-Token im &#039;&#039;Authorization&#039;&#039;-Header:&lt;br /&gt;
&lt;br /&gt;
 DELETE https://api.meinserver.de/oauth/&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
         Authorization: Bearer eyJhbGciOi... (Access-Token)&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 204 No Content&lt;br /&gt;
&lt;br /&gt;
Der Server sperrt die &#039;&#039;&#039;ganze Sitzung&#039;&#039;&#039; - also auch die noch nicht&lt;br /&gt;
abgelaufenen Access-Token aller vorherigen Erneuerungen. &#039;&#039;&#039;Ein Skript ist dafür&lt;br /&gt;
nicht nötig&#039;&#039;&#039;: es gibt keine Methode &#039;&#039;Logout&#039;&#039;, der Vorgang läuft komplett in&lt;br /&gt;
der Engine. Damit funktioniert das Abmelden für jeden Zugang mit aktiver&lt;br /&gt;
JWT-Pflicht, ohne dass am Authentifizierungs-Skript etwas geändert wird.&lt;br /&gt;
&lt;br /&gt;
* Ein wiederholter Aufruf ist unschädlich und bleibt &#039;&#039;&#039;204&#039;&#039;&#039; - auch wenn die Sitzung längst gesperrt ist.&lt;br /&gt;
* Ein abgelaufenes oder ungültiges Token ergibt &#039;&#039;&#039;401&#039;&#039;&#039; (es wird schon bei der Prüfung abgewiesen).&lt;br /&gt;
* Ein Refresh-Token statt des Access-Tokens ergibt &#039;&#039;&#039;401&#039;&#039;&#039;.&lt;br /&gt;
* Lässt sich die Sitzung nicht sperren, antwortet der Server &#039;&#039;&#039;503&#039;&#039;&#039; mit &#039;&#039;Retry-After&#039;&#039; - ein Client darf nicht glauben, er sei abgemeldet, während sein Token weiterläuft.&lt;br /&gt;
&lt;br /&gt;
====Alle Geräte abmelden====&lt;br /&gt;
&lt;br /&gt;
Der Fall „Gerät verloren&amp;quot; ist bewusst &#039;&#039;&#039;nicht&#039;&#039;&#039; am JWT-Endpunkt untergebracht:&lt;br /&gt;
er braucht eine fachliche Entscheidung darüber, wer das darf. Dafür gibt es das&lt;br /&gt;
Antwortfeld &#039;&#039;_OBS_JWT_REVOKE&#039;&#039;, das jeder gewöhnliche Endpunkt setzen kann:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes: TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        // &#039;session&#039; = nur diese Sitzung, &#039;all&#039; = alle Sitzungen des Subjects&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_REVOKE&#039; , &#039;all&#039;);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 204);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;all&#039;&#039; setzt voraus, dass das Authenticate-Skript &#039;&#039;_OBS_JWT_SUBJECT&#039;&#039;&lt;br /&gt;
gesetzt hat. Ohne Subject lässt sich die Menge der Sitzungen nicht bestimmen, und&lt;br /&gt;
der Aufruf antwortet mit 503.}}&lt;br /&gt;
&lt;br /&gt;
==Sitzungen (RESTSRV_TOKEN)==&lt;br /&gt;
&lt;br /&gt;
Je ausgestelltem Token-Paar entsteht eine Zeile. Sie enthält die Sitzung, die&lt;br /&gt;
Zeilenkennung, das Subject, die beiden Laufzeiten, IP und Trace-ID der Ausstellung&lt;br /&gt;
sowie die Kennzeichen „verbraucht&amp;quot; und „gesperrt&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Rotation und Widerruf sind getrennt.&#039;&#039;&#039; Ein Refresh entwertet nur das Refresh-Token; der zugehörige Access-Token bleibt bis zu seinem Ablauf gültig. Sonst bekämen parallel laufende Anfragen ein 401, obwohl niemand abgemeldet ist.&lt;br /&gt;
* &#039;&#039;&#039;Die Sitzung überlebt die Rotation.&#039;&#039;&#039; Alle Paare einer Anmeldung tragen dieselbe Sitzungskennung - deshalb kann ein Logout sie gemeinsam sperren.&lt;br /&gt;
* &#039;&#039;&#039;Aufräumen läuft automatisch&#039;&#039;&#039; beim Serverstart und danach höchstens stündlich. Gelöscht wird nur, was nichts mehr entscheiden kann: Zeilen, deren Access- &#039;&#039;&#039;und&#039;&#039;&#039; Refresh-Laufzeit vorbei sind. Eine verbrauchte Zeile bleibt bis dahin stehen, weil sonst die Erkennung eines später vorgelegten gestohlenen Refresh-Tokens verloren ginge.&lt;br /&gt;
* &#039;&#039;&#039;Kein Eingriff nötig.&#039;&#039;&#039; Die Tabelle wird nicht gepflegt; sie ist Diagnose-Material. Alle Abweisungen stehen als &#039;&#039;TokenStore: ...&#039;&#039; in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==mTLS-Subject-Prüfung==&lt;br /&gt;
&lt;br /&gt;
Wird im Feld &#039;&#039;&#039;mTLS-Subject&#039;&#039;&#039; ein Wert hinterlegt, prüft der Server zusätzlich zur Standard-mTLS-Validierung, ob der Subject-DN des (CA-validierten) Client-Zertifikats die hinterlegten &#039;&#039;&#039;RDN-Komponenten&#039;&#039;&#039; enthält. Verglichen wird jede Komponente &#039;&#039;&#039;vollständig&#039;&#039;&#039; (kein Substring-Match) und case-insensitiv; mehrere Komponenten werden durch Komma oder Semikolon getrennt und müssen &#039;&#039;&#039;alle&#039;&#039;&#039; im Subject vorkommen.&lt;br /&gt;
&lt;br /&gt;
Beispiele für Subject-Strings im Cert (OneLine-DN):&lt;br /&gt;
&lt;br /&gt;
 /C=DE/O=Kunde GmbH/CN=client-prod.kunde.de&lt;br /&gt;
 /C=DE/O=Kunde GmbH/CN=client-test.kunde.de&lt;br /&gt;
&lt;br /&gt;
Mit &#039;&#039;&#039;CN=client-prod.kunde.de&#039;&#039;&#039; im Feld wird genau das prod-Cert akzeptiert, das test-Cert mit 401 abgewiesen. Da jede RDN-Komponente vollständig verglichen wird, matcht z.B. &#039;&#039;&#039;CN=client1&#039;&#039;&#039; nicht fälschlich auch &#039;&#039;&#039;CN=client10&#039;&#039;&#039;. Mehrere Bedingungen lassen sich mit Komma/Semikolon kombinieren, z.B. &#039;&#039;O=Kunde GmbH;CN=client-prod.kunde.de&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==Listen-Funktionen==&lt;br /&gt;
&lt;br /&gt;
In der Zugänge-Liste (allein):&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Einfg&#039;&#039;&#039; - neuer Zugang&lt;br /&gt;
* &#039;&#039;&#039;Return&#039;&#039;&#039; - Zugang bearbeiten&lt;br /&gt;
* &#039;&#039;&#039;F4&#039;&#039;&#039; - Sortierung&lt;br /&gt;
* &#039;&#039;&#039;F7&#039;&#039;&#039; - JWT-Authentifizierungs-Skript bearbeiten&lt;br /&gt;
* &#039;&#039;&#039;F8&#039;&#039;&#039; - Statistik für den Zugang&lt;br /&gt;
&lt;br /&gt;
Aus der Endpunkt-Liste via F6 geöffnet (Auswahl für Berechtigung):&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;F2&#039;&#039;&#039; - markierte Zugänge dem Endpunkt zuordnen&lt;br /&gt;
* &#039;&#039;&#039;F5&#039;&#039;&#039; - Zugang in der Liste markieren / Markierung aufheben&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel5&amp;diff=64689</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel5</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel5&amp;diff=64689"/>
		<updated>2026-08-19T12:01:01Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz=&lt;br /&gt;
&lt;br /&gt;
Dieses Beispiel zeigt einen schreibenden Endpunkt für eine mobile App: Aufträge werden mit echten HTTP-Statuscodes aktualisiert, konkurrierende Änderungen über ETag/If-Match abgesichert (Optimistic Concurrency) und doppelte Sendungen über einen Idempotency-Key abgefangen. Die Anmeldung nutzt JWT mit Custom-Claims (Mandant, Rollen) und einem Refresh-Token.&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; (Port 443)&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Mobile-App&#039;&#039;&lt;br /&gt;
** API-Key: zufällig generiert&lt;br /&gt;
** JWT aktiv, JWT-Endpunkt &#039;&#039;auth&#039;&#039;, JWT-Key zufällig, JWT-Exp 60 (Minuten)&lt;br /&gt;
* &#039;&#039;&#039;Endpunkte&#039;&#039;&#039; (beide dem Profil &#039;&#039;Public-API&#039;&#039; zugeordnet):&lt;br /&gt;
** &#039;&#039;orders/{uid}&#039;&#039; - Auftrag lesen/Ändern&lt;br /&gt;
** &#039;&#039;orders/{uid}/material&#039;&#039; - Material erfassen&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Mobile-App&#039;&#039; für beide Endpunkte freigeschaltet&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Für das Abmelden gibt es &#039;&#039;&#039;keinen eigenen Endpunkt&#039;&#039;&#039;. Es läuft über&lt;br /&gt;
ein &#039;&#039;DELETE&#039;&#039; auf den JWT-Endpunkt &#039;&#039;auth&#039;&#039; und braucht daher weder eine Zeile&lt;br /&gt;
in &#039;&#039;RESTSRV_ENDPOINTS&#039;&#039; noch eine Berechtigung.}}&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierungs-Skript (Zugang)==&lt;br /&gt;
&lt;br /&gt;
Stellt Mandant und Rollen als Custom-Claims aus und löst über &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; ein Refresh-Token aus. Bei einem Refresh ruft der Server im selben Skript die Methode &#039;&#039;Refresh&#039;&#039; auf. Die Hilfsfunktionen (&#039;&#039;PasswortPasst&#039;&#039;, &#039;&#039;TechnikerMandant&#039;&#039;, &#039;&#039;TechnikerRollen&#039;&#039;) sind illustrativ und projektabhängig.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Eine &#039;&#039;&#039;eigene Sperrtabelle für Refresh-jti ist nicht nötig&#039;&#039;&#039;.&lt;br /&gt;
Einmalgebrauch, Rotation und Widerruf führt der Server in &#039;&#039;RESTSRV_TOKEN&#039;&#039; -&lt;br /&gt;
das Skript entscheidet nur, &#039;&#039;&#039;ob&#039;&#039;&#039; und &#039;&#039;&#039;mit welchen Rechten&#039;&#039;&#039; die Sitzung&lt;br /&gt;
fortgesetzt wird. Frühere Fassungen dieses Beispiels zeigten eine Skript-Tabelle;&lt;br /&gt;
wer sie übernommen hat, kann sie entfernen.}}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Authenticate(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes : TJSONObject;&lt;br /&gt;
    oVal : TJSONValue;&lt;br /&gt;
    cUser, cPass: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUser := &#039;&#039;; cPass := &#039;&#039;;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;username&#039;); if (Assigned(oVal)) then cUser := oVal.Value;&lt;br /&gt;
            oVal := oBody.GetValue(&#039;password&#039;); if (Assigned(oVal)) then cPass := oVal.Value;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        if (PasswortPasst(cUser, cPass)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;               , 1);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));   // z.B. &amp;quot;tech,lead&amp;quot;&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());   // loest das Refresh-Token aus&lt;br /&gt;
            // _OBS_JWT_REFRESH_EXP weggelassen -&amp;gt; Default 90 Tage&lt;br /&gt;
        end else begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;, 9);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039; , &#039;Login fehlgeschlagen&#039;);&lt;br /&gt;
        end;&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function Refresh(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes : TJSONObject;&lt;br /&gt;
    cUser: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUser := oParams.Values[&#039;_OBS_JWT_SUBJECT&#039;];&lt;br /&gt;
&lt;br /&gt;
        // Das vorgelegte Refresh-Token hat der Server bereits geprueft und&lt;br /&gt;
        // entwertet. Hier wird nur entschieden, ob die Sitzung fortgesetzt&lt;br /&gt;
        // werden darf - und mit welchen Rechten.&lt;br /&gt;
        if (not TechnikerAktiv(cUser)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;, 9);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039; , &#039;Konto ist nicht mehr aktiv, bitte neu anmelden&#039;);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // Mandant und Rollen NEU lesen, nicht aus dem alten Token uebernehmen -&lt;br /&gt;
        // sonst wirkt ein Rechteentzug erst beim naechsten Login.&lt;br /&gt;
        oRes.AddPair(&#039;status&#039;               , 1);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Endpunkt &#039;&#039;orders/{uid}&#039;&#039; - ändern mit ETag / If-Match==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;GET&#039;&#039; liefert den Auftrag samt &#039;&#039;ETag&#039;&#039; (Version), &#039;&#039;PUT&#039;&#039; prüft Rolle und &#039;&#039;If-Match&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Get(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oHdr: TJSONObject;&lt;br /&gt;
    cUid: string;&lt;br /&gt;
    nVer: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUid := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
        if (not AuftragLesen(oParams.Values[&#039;_OBS_JWT_CLAIM_tenant&#039;], cUid, oRes, nVer)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 404);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;NOT_FOUND&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
        oHdr := TJSONObject.Create();&lt;br /&gt;
        oHdr.AddPair(&#039;ETag&#039;, xStr(nVer));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function Put(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oHdr: TJSONObject;&lt;br /&gt;
    cUid: string;&lt;br /&gt;
    nAktuell, nIfMatch: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        // nur Rolle &amp;quot;lead&amp;quot; darf ändern - Rolle kommt aus dem Token, nicht aus dem Body.&lt;br /&gt;
        // Mit Trennzeichen suchen: ein blosses Pos(&#039;lead&#039;, ...) wuerde auch in&lt;br /&gt;
        // &amp;quot;leadless&amp;quot; oder &amp;quot;teamlead&amp;quot; treffen.&lt;br /&gt;
        if (Pos(&#039;,lead,&#039;, &#039;,&#039; + oParams.Values[&#039;_OBS_JWT_CLAIM_roles&#039;] + &#039;,&#039;) = 0) then begin&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 403);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;FORBIDDEN_ROLE&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        cUid     := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
        nAktuell := AuftragVersion(cUid);&lt;br /&gt;
        nIfMatch := iVal(oParams.Values[&#039;if-match&#039;]);&lt;br /&gt;
&lt;br /&gt;
        if (nIfMatch &amp;lt;&amp;gt; nAktuell) then begin&lt;br /&gt;
            oHdr := TJSONObject.Create();&lt;br /&gt;
            oHdr.AddPair(&#039;ETag&#039;, xStr(nAktuell));&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 409);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;VERSION_CONFLICT&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        AuftragSpeichern(cUid, oBody);   // setzt Version auf nAktuell + 1&lt;br /&gt;
        oHdr := TJSONObject.Create();&lt;br /&gt;
        oHdr.AddPair(&#039;ETag&#039;, xStr(nAktuell + 1));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Endpunkt &#039;&#039;orders/{uid}/material&#039;&#039; - idempotentes Anlegen==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;POST&#039;&#039; erfasst eine Materialposition: Validierungsfehler -&amp;gt; 422, erfolgreiches Anlegen -&amp;gt; 201.&lt;br /&gt;
&lt;br /&gt;
Gegen doppelte Sendungen schickt der Client den Header &#039;&#039;Idempotency-Key&#039;&#039;. &#039;&#039;&#039;Das&lt;br /&gt;
Skript muss dafür nichts tun&#039;&#039;&#039; - der Server erkennt den Header, merkt sich das&lt;br /&gt;
Ergebnis und liefert bei einer Wiederholung mit demselben Schlüssel die&lt;br /&gt;
gespeicherte Antwort zurück, ohne das Skript erneut zu starten (siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]], Abschnitt&lt;br /&gt;
Idempotenz). Das Skript kümmert sich nur um seine Fachlogik:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oErr: TJSONObject;&lt;br /&gt;
    oVal: TJSONValue;&lt;br /&gt;
    cUid, cArtikel, cNeueUid: string;&lt;br /&gt;
    nMenge: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUid := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
&lt;br /&gt;
        // 1) Validierung -&amp;gt; 422 mit traceId.&lt;br /&gt;
        //    Wichtig: fachliche Ablehnung als 4xx melden, nicht als 200 mit&lt;br /&gt;
        //    Fehlertext - nur dann gibt der Server den Idempotency-Key wieder&lt;br /&gt;
        //    frei und der Client darf ihn nach Korrektur erneut verwenden.&lt;br /&gt;
        cArtikel := &#039;&#039;;&lt;br /&gt;
        nMenge   := 0;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;artikel&#039;); if (Assigned(oVal)) then cArtikel := oVal.Value;&lt;br /&gt;
            oVal := oBody.GetValue(&#039;menge&#039;);   if (Assigned(oVal)) then nMenge   := iVal(oVal.Value);&lt;br /&gt;
        end;&lt;br /&gt;
        if ((Empty(cArtikel)) or (nMenge &amp;lt;= 0)) then begin&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;VALIDATION_FAILED&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;artikel und menge sind Pflicht&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 422);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // 2) Buchen. Die Transaktionssteuerung bleibt beim Skript; der&lt;br /&gt;
        //    Idempotenz-Speicher des Servers läuft getrennt davon und&lt;br /&gt;
        //    umschliesst diesen Block nicht.&lt;br /&gt;
        cNeueUid := MaterialAnlegen(cUid, cArtikel, nMenge);&lt;br /&gt;
&lt;br /&gt;
        // 3) Antwort vollständig aufbauen - genau sie wird eingefroren und bei&lt;br /&gt;
        //    einer Wiederholung erneut ausgeliefert.&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 201);&lt;br /&gt;
        oRes.AddPair(&#039;uid&#039;, cNeueUid);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Abmelden - ohne Endpunkt und ohne Skript==&lt;br /&gt;
&lt;br /&gt;
Beim Abmelden soll das Gerät seine Sitzung wirklich verlieren, nicht erst mit dem&lt;br /&gt;
Ablauf des Tokens. Dafür ist &#039;&#039;&#039;nichts einzurichten&#039;&#039;&#039;: ein &#039;&#039;DELETE&#039;&#039; auf den&lt;br /&gt;
JWT-Endpunkt mit dem Access-Token genügt, der Server sperrt die Sitzung in&lt;br /&gt;
&#039;&#039;RESTSRV_TOKEN&#039;&#039; und antwortet 204.&lt;br /&gt;
&lt;br /&gt;
Gesperrt wird die ganze Sitzung, also auch die Access-Token vorheriger&lt;br /&gt;
Erneuerungen. Ein zweiter Aufruf ist unschädlich und bleibt 204. Kann der Server&lt;br /&gt;
die Sitzung nicht sperren, antwortet er &#039;&#039;&#039;503&#039;&#039;&#039; mit &#039;&#039;Retry-After&#039;&#039; - die App&lt;br /&gt;
wiederholt den Abmeldevorgang dann.&lt;br /&gt;
&lt;br /&gt;
Nur wenn beim Abmelden zusätzlich etwas passieren soll - hier: die&lt;br /&gt;
Geräteregistrierung für Push löschen -, braucht es einen eigenen Endpunkt. Er&lt;br /&gt;
erledigt seine Fachlogik und setzt &#039;&#039;_OBS_JWT_REVOKE&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes : TJSONObject;&lt;br /&gt;
    oVal : TJSONValue;&lt;br /&gt;
    cAlle: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        PushRegistrierungLoeschen(oParams.Values[&#039;_OBS_JWT_CLAIM_technikerUid&#039;]);&lt;br /&gt;
&lt;br /&gt;
        // Optional: {&amp;quot;alleGeraete&amp;quot;: true} meldet den Techniker ueberall ab.&lt;br /&gt;
        // Diese Entscheidung gehoert bewusst in ein Skript - sie braucht eine&lt;br /&gt;
        // Berechtigungspruefung, die der JWT-Endpunkt nicht leisten kann.&lt;br /&gt;
        cAlle := &#039;session&#039;;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;alleGeraete&#039;);&lt;br /&gt;
            if (Assigned(oVal)) and (Lower(oVal.Value) = &#039;true&#039;) then begin&lt;br /&gt;
                cAlle := &#039;all&#039;;&lt;br /&gt;
            end;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_REVOKE&#039; , cAlle);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 204);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Test mit curl==&lt;br /&gt;
&lt;br /&gt;
Token holen:&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;username\&amp;quot;:\&amp;quot;tech1\&amp;quot;,\&amp;quot;password\&amp;quot;:\&amp;quot;geheim\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:&amp;quot;eyJ...&amp;quot;,&amp;quot;accessToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&amp;quot;refreshToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:3600,&amp;quot;serverTime&amp;quot;:&amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Bei falschen Zugangsdaten antwortet der Server mit &#039;&#039;&#039;401&#039;&#039;&#039; und dem Text aus dem&lt;br /&gt;
Authenticate-Skript als &#039;&#039;error.message&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Auftrag lesen (liefert den ETag-Header):&lt;br /&gt;
&lt;br /&gt;
 curl -i -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
 ... ETag: 7&lt;br /&gt;
&lt;br /&gt;
ändern mit korrektem If-Match -&amp;gt; 200, mit veraltetem If-Match -&amp;gt; 409:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X PUT -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;If-Match: 7&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;status\&amp;quot;:\&amp;quot;erledigt\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
&lt;br /&gt;
Material idempotent erfassen (zweiter Aufruf mit gleichem Key -&amp;gt; gleiche Antwort, keine Doppelbuchung):&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Idempotency-Key: 9c84-7f2a-...&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;artikel\&amp;quot;:\&amp;quot;A100\&amp;quot;,\&amp;quot;menge\&amp;quot;:3}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711/material&lt;br /&gt;
&lt;br /&gt;
Die Antwort des zweiten Aufrufs trägt zusätzlich den Header&lt;br /&gt;
&#039;&#039;Idempotent-Replay: true&#039;&#039; - daran ist erkennbar, dass sie aus dem Speicher kam&lt;br /&gt;
und nichts erneut gebucht wurde.&lt;br /&gt;
&lt;br /&gt;
Token erneuern (Refresh-Token im Authorization-Header an denselben JWT-Endpunkt):&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer &amp;lt;refreshToken&amp;gt;&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
Die Antwort enthält ein neues Paar. Das alte Refresh-Token ist damit verbraucht:&lt;br /&gt;
ein zweiter Aufruf mit demselben Token antwortet &#039;&#039;&#039;401&#039;&#039;&#039;. Erfolgt er innerhalb&lt;br /&gt;
von 60 Sekunden, bleibt die Sitzung bestehen (paralleler Refresh der App); später&lt;br /&gt;
gilt er als Wiedervorlage und die &#039;&#039;&#039;ganze Sitzung wird gesperrt&#039;&#039;&#039; - der&lt;br /&gt;
Techniker muss sich neu anmelden, und der Vorfall steht mit IP im Protokoll.&lt;br /&gt;
&lt;br /&gt;
Abmelden - DELETE auf denselben JWT-Endpunkt, Access-Token im Header:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X DELETE -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 204 No Content&lt;br /&gt;
&lt;br /&gt;
Derselbe Token danach noch einmal verwendet:&lt;br /&gt;
&lt;br /&gt;
 curl -i -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 401 Unauthorized&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;error&amp;quot;: {&lt;br /&gt;
    &amp;quot;code&amp;quot;:    &amp;quot;AUTH_EXPIRED&amp;quot;,&lt;br /&gt;
    &amp;quot;uid&amp;quot;:     &amp;quot;Y00G9TOK09&amp;quot;,&lt;br /&gt;
    &amp;quot;message&amp;quot;: &amp;quot;Die Sitzung ist nicht mehr gültig, bitte neu anmelden&amp;quot;,&lt;br /&gt;
    &amp;quot;traceId&amp;quot;: &amp;quot;20260819T091233123-00001A&amp;quot;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Echte HTTP-Statuscodes aus dem Skript: 201, 403, 404, 409, 422.&lt;br /&gt;
* Optimistic Concurrency über &#039;&#039;ETag&#039;&#039; (GET) und &#039;&#039;If-Match&#039;&#039; (PUT) -&amp;gt; 409 VERSION_CONFLICT.&lt;br /&gt;
* Idempotente Schreibzugriffe über den &#039;&#039;Idempotency-Key&#039;&#039; - &#039;&#039;&#039;vom Server erledigt&#039;&#039;&#039;, das Skript enthält dafür keine Zeile Code.&lt;br /&gt;
* Rollenprüfung aus dem Token-Claim &#039;&#039;_OBS_JWT_CLAIM_roles&#039;&#039;, nicht aus dem Body.&lt;br /&gt;
* JWT mit Custom-Claims (&#039;&#039;tenant&#039;&#039;/&#039;&#039;roles&#039;&#039;) und Refresh-Token mit Rotation - &#039;&#039;&#039;vom Server erledigt&#039;&#039;&#039;, das Skript führt keine Sperrtabelle.&lt;br /&gt;
* Anmelden, Erneuern und Abmelden über &#039;&#039;&#039;einen&#039;&#039;&#039; Endpunkt - Abmelden als &#039;&#039;DELETE&#039;&#039;, ohne eigene Endpunkt-Zeile und ohne Skript.&lt;br /&gt;
* &#039;&#039;_OBS_JWT_REVOKE&#039;&#039; für den Fall, dass beim Abmelden zusätzlich Fachlogik laufen soll oder alle Geräte gemeint sind.&lt;br /&gt;
* &#039;&#039;traceId&#039;&#039; im Fehler-Body für die Support-Nachverfolgung (auch als Header &#039;&#039;X-Trace-Id&#039;&#039;).&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer&amp;diff=64688</id>
		<title>OBS/Kostenpflichtige Module/RESTServer</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer&amp;diff=64688"/>
		<updated>2026-08-19T12:00:36Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
=REST-Server=&lt;br /&gt;
{{Hinweis|Es handelt sich um ein kostenpflichtiges Modul. Die Schnittstelle muss über den OBS-Support aktiviert werden.}}&lt;br /&gt;
&lt;br /&gt;
==Was leistet der REST-Server?==&lt;br /&gt;
&lt;br /&gt;
Der REST-Server ist die &#039;&#039;&#039;universelle Schnittstelle&#039;&#039;&#039; Ihrer OBS-Installation nach außen. Er macht aus Ihrem OBS einen Dienst, mit dem andere Programme - Webseiten, Apps, Geräte, Kundensysteme, Cloud-Dienste - direkt sprechen können. Statt Daten manuell zu exportieren, zu mailen oder über Umwege bereitzustellen, holen sich angebundene Systeme genau die Information, die sie brauchen, in dem Moment, in dem sie sie brauchen - oder liefern neue Daten direkt in OBS ab.&lt;br /&gt;
&lt;br /&gt;
Für Anwender, die nicht selbst programmieren, bedeutet das: &#039;&#039;&#039;Was bisher nur manuell, per Datei-Import oder über Spezialschnittstellen ging, lässt sich jetzt automatisieren.&#039;&#039;&#039; Eine Webseite zeigt live aktuelle Lagerbestände. Mitarbeiter erfassen Zeiten über das Handy, ohne dass jemand Excel-Listen einpflegen muss. Ein Kunde stößt per Bestelltaste in seinem System einen Vorgang in Ihrem OBS an. Ein Lieferant meldet Wareneingänge automatisch zurück. Jeder dieser Anwendungsfälle wird einmal eingerichtet und läuft danach automatisch.&lt;br /&gt;
&lt;br /&gt;
Technisch gesehen ist der REST-Server ein in OBS integrierter, voll konfigurierbarer &#039;&#039;&#039;HTTP/HTTPS-Dienst&#039;&#039;&#039;, dessen Endpunkte über in OBS gepflegte Pascal-Skripte realisiert werden. Jeder Endpunkt bekommt eine eigene Adresse, eine eigene Logik und eine eigene Berechtigungsstruktur. Rückgaben erfolgen ausschliesslich im JSON-Format. Damit lassen sich beliebige Lese- und Schreibvorgänge auf der OBS-Datenbank realisieren, ohne dass externe Systeme direkten Datenbank-Zugriff erhalten - das ganze OBS-Regelwerk (Rechte, Validierung, Geschäftslogik) bleibt aktiv.&lt;br /&gt;
&lt;br /&gt;
Im Ergebnis ist der REST-Server kein einzelnes Feature, sondern ein &#039;&#039;&#039;Werkzeugkasten&#039;&#039;&#039;: Was an Daten oder Funktionen in OBS verfügbar ist, kann über den REST-Server auch nach außen angeboten - oder von außen entgegengenommen - werden. Die Bandbreite reicht vom einfachen Lese-Endpunkt für ein Webseiten-Widget bis hin zu kompletten B2B-Integrationen mit Mandanten- und Rollen-Trennung.&lt;br /&gt;
&lt;br /&gt;
Aufruf des Moduls in OBS:&lt;br /&gt;
&#039;&#039;&#039;Stammdaten&#039;&#039;&#039; -&amp;gt; &#039;&#039;&#039;Z Weitere Stammdaten&#039;&#039;&#039; -&amp;gt; &#039;&#039;&#039;REST-Server&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==Anwendungsbereiche==&lt;br /&gt;
&lt;br /&gt;
Die folgenden Einsatzfälle sind typische Beispiele, an denen sich der praktische Nutzen ablesen lässt. Es ist keine abschliessende Liste - alles, was sich in OBS-Skripten abbilden lässt, kann auch über einen Endpunkt bereitgestellt werden.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Dies sind Beispiele. Für die konkrete Entwicklung können, je nach Komplexität, weitere Kosten anfallen.}}&lt;br /&gt;
&lt;br /&gt;
===Webseiten und Kundenportale===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Live-Daten auf der eigenen Webseite:&#039;&#039;&#039; Lagerbestände, Verfügbarkeiten, Preise, Auftragsstatus aus OBS direkt anzeigen, ohne tägliche Datei-Exports.&lt;br /&gt;
* &#039;&#039;&#039;Kundenportale:&#039;&#039;&#039; Kunden sehen ihre offenen Posten, Auftragshistorie, Lieferscheine. Die Seite holt sich die Daten zur Anzeigezeit direkt aus OBS, jeder Kunde sieht nur seine eigenen Daten.&lt;br /&gt;
* &#039;&#039;&#039;Trackingseiten:&#039;&#039;&#039; Status einer Bestellung, eines Auftrags oder einer Reparatur für Endkunden, abrufbar über eine Sendungsnummer.&lt;br /&gt;
&lt;br /&gt;
===Mobile Anwendungen und Außendienst===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Mobile Zeiterfassung:&#039;&#039;&#039; Mitarbeiter buchen Anfang, Pause und Ende über Smartphone oder Tablet, die Daten landen direkt in OBS - ohne Zettelwirtschaft.&lt;br /&gt;
* &#039;&#039;&#039;QR-/Barcode-Scanner und Lagergeräte:&#039;&#039;&#039; Wareneingang, Inventur, Kommissionierung direkt in OBS abbilden, ohne zwischengeschaltete Software.&lt;br /&gt;
* &#039;&#039;&#039;Außendienst-Apps:&#039;&#039;&#039; Techniker sehen ihre Aufträge, dokumentieren Einsätze, erfassen Material - synchronisiert mit OBS.&lt;br /&gt;
&lt;br /&gt;
===Anbindung von Drittsystemen===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Webshops:&#039;&#039;&#039; Bestellungen werden direkt vom Shop in OBS gemeldet, Lagerbestände und Preise vom Shop bei OBS abgefragt.&lt;br /&gt;
* &#039;&#039;&#039;Buchhaltungs- und ERP-Systeme:&#039;&#039;&#039; Bidirektionaler Austausch von Belegen, Stammdaten und Buchungen.&lt;br /&gt;
* &#039;&#039;&#039;CRM- und Marketing-Tools:&#039;&#039;&#039; Personendaten, Aktivitäten oder Mailing-Empfänger werden synchron gehalten.&lt;br /&gt;
* &#039;&#039;&#039;Logistikdienstleister:&#039;&#039;&#039; Versandaufträge werden übergeben, Tracking-Informationen zurückgeliefert.&lt;br /&gt;
&lt;br /&gt;
===Automatisierte Rückmeldungen (WebHooks)===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Zahlungs-Callbacks:&#039;&#039;&#039; Bezahldienste (z.B. PayPal, Klarna, Stripe) melden den Zahlungseingang an einen WebHook-Endpunkt - OBS bucht automatisch.&lt;br /&gt;
* &#039;&#039;&#039;Versanddienste:&#039;&#039;&#039; Statusmeldungen (verschickt, zugestellt, retour) werden direkt im passenden Vorgang dokumentiert.&lt;br /&gt;
* &#039;&#039;&#039;Cloud-Workflows:&#039;&#039;&#039; Externe Automatisierungs-Plattformen (Make, Zapier, n8n, ...) stoßen OBS-Vorgänge an oder werden von OBS aus angesprochen.&lt;br /&gt;
&lt;br /&gt;
===Geschäftspartner-Kommunikation (B2B)===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Lieferanten-Schnittstelle:&#039;&#039;&#039; Bestellungen werden automatisch übergeben, Auftragsbestätigungen und Lieferavise zurückgespielt.&lt;br /&gt;
* &#039;&#039;&#039;Kunden-Schnittstelle:&#039;&#039;&#039; Grosskunden bestellen direkt aus ihrem ERP heraus, Stammdaten und Konditionen werden zentral gepflegt.&lt;br /&gt;
* &#039;&#039;&#039;Sichere Maschine-zu-Maschine-Kommunikation:&#039;&#039;&#039; Mit Mutual-TLS (mTLS) wird sichergestellt, dass nur die freigeschalteten Partnersysteme - und niemand sonst - mit OBS sprechen können.&lt;br /&gt;
&lt;br /&gt;
===Interne Microservices und Automatisierungen===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Eigene kleine Hilfs-Tools:&#039;&#039;&#039; Zeitschaltautomatik, Reporting-Generator, Sammelaktionen, die aus mehreren Quellen Daten in OBS verarbeiten.&lt;br /&gt;
* &#039;&#039;&#039;Verbindung mehrerer Standorte:&#039;&#039;&#039; Verteilte Systeme tauschen Daten über definierte Endpunkte aus, ohne offene Datenbankverbindungen.&lt;br /&gt;
* &#039;&#039;&#039;Datenbereitstellung für Dashboards und Auswertungen:&#039;&#039;&#039; BI-Tools (Power BI, Grafana, ...) lesen aufbereitete Kennzahlen direkt aus OBS.&lt;br /&gt;
&lt;br /&gt;
===Was bedeutet das wirtschaftlich?===&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Manuelle Arbeit fällt weg.&#039;&#039;&#039; Daten werden nicht mehr aus OBS exportiert, per Mail geschickt und woanders eingelesen - sie fliessen direkt.&lt;br /&gt;
* &#039;&#039;&#039;Fehler werden weniger.&#039;&#039;&#039; Jede manuelle Stelle ist eine mögliche Fehlerquelle; jede Automatisierung eine Stelle, an der nichts mehr schiefgehen kann.&lt;br /&gt;
* &#039;&#039;&#039;Neue Geschäftsmodelle werden möglich.&#039;&#039;&#039; Kunden- und Lieferantenportale, Self-Service-Funktionen, App-Anbindungen lassen sich aufbauen, ohne ein zweites System pflegen zu müssen.&lt;br /&gt;
* &#039;&#039;&#039;Bestehende Software bleibt verbunden.&#039;&#039;&#039; Statt eine vorhandene Anwendung ablösen zu müssen, kann sie über den REST-Server an OBS angedockt werden - in beiden Richtungen.&lt;br /&gt;
* &#039;&#039;&#039;Skaliert mit:&#039;&#039;&#039; Was klein anfängt (ein einzelner Endpunkt für eine Webseite) kann zu einer kompletten Integrations-Plattform ausgebaut werden, ohne dass die Grundlage gewechselt werden muss.&lt;br /&gt;
&lt;br /&gt;
==Architektur im Überblick==&lt;br /&gt;
&lt;br /&gt;
Der REST-Server besteht aus mehreren aufeinander aufbauenden Bausteinen, die alle in OBS gepflegt werden:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Baustein     !! Tabelle              !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| Server       || RESTSRV_SERVER       || TLS-Profil + eigene HTTP-Server-Instanz, kann mehrfach existieren&lt;br /&gt;
|-&lt;br /&gt;
| Bindung      || RESTSRV_BINDINGS     || IP-Adresse + Port pro Server&lt;br /&gt;
|-&lt;br /&gt;
| Zugang       || RESTSRV_ACCOUNT      || API-Key, optionale Host- und CORS-Beschränkung, optionale JWT-Konfiguration&lt;br /&gt;
|-&lt;br /&gt;
| Endpunkt     || RESTSRV_ENDPOINTS    || Skript, das die Anfrage bearbeitet, einem Server fest zugeordnet&lt;br /&gt;
|-&lt;br /&gt;
| Berechtigung || RESTSRV_ACCESS       || Verbindet Zugang und Endpunkt&lt;br /&gt;
|-&lt;br /&gt;
| Idempotenz   || RESTSRV_IDEMPOTENCY  || Speicher für Idempotency-Keys und die dazu gelieferte Antwort&lt;br /&gt;
|-&lt;br /&gt;
| Sitzung      || RESTSRV_TOKEN        || Ausgestellte Token-Paare je Sitzung; Grundlage für Rotation und Widerruf&lt;br /&gt;
|-&lt;br /&gt;
| Protokoll    || RESTSRV_PROTO        || Laufende Ereignisse, Fehler, Audit-Einträge&lt;br /&gt;
|-&lt;br /&gt;
| Statistik    || RESTSRV_STATS        || Aufrufzähler, Antwortzeiten und HTTP-Status pro Endpunkt&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Eine Anfrage durchläuft folgende Stationen:&lt;br /&gt;
&lt;br /&gt;
# Rate-Limit-Prüfung (pro API-Key bzw. IP und global, Schwellen je Server-Profil)&lt;br /&gt;
# Authentifizierung über den API-Key (Header &#039;&#039;apikey&#039;&#039;)&lt;br /&gt;
# CORS-Prüfung (sofern &#039;&#039;Origin&#039;&#039;-Header gesetzt)&lt;br /&gt;
# Optional: JWT-Prüfung - Signatur, Ablauf und Sitzung (sofern für den Zugang aktiviert)&lt;br /&gt;
# Routing: Auflösung des Pfads über die Pfad-Templates der Endpunkte&lt;br /&gt;
# Prüfung der Berechtigung (Zugang -&amp;gt; Endpunkt)&lt;br /&gt;
# Idempotenz-Prüfung (nur bei POST/PUT/PATCH/DELETE &#039;&#039;&#039;mit&#039;&#039;&#039; &#039;&#039;Idempotency-Key&#039;&#039;-Header)&lt;br /&gt;
# Ausführung des Endpunkt-Skripts (oder WebHook-Antwort)&lt;br /&gt;
# Festschreiben des Ergebnisses im Idempotenz-Speicher (sofern in Schritt 7 reserviert)&lt;br /&gt;
# JSON-Antwort, Statistik-Eintrag, Protokoll-Eintrag&lt;br /&gt;
&lt;br /&gt;
==Adressierung==&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt wird über ein &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; angesprochen, das den Pfad nach dem Host beschreibt und Platzhalter enthalten kann:&lt;br /&gt;
&lt;br /&gt;
 http://[Hostadresse][:Port][Pfad-Template]&lt;br /&gt;
&lt;br /&gt;
Beispiele:&lt;br /&gt;
&lt;br /&gt;
 https://api.meinserver.de/kalender/v1&lt;br /&gt;
 https://api.meinserver.de/orders/{uid}&lt;br /&gt;
 https://api.meinserver.de/orders/{uid}/modules/{code}&lt;br /&gt;
&lt;br /&gt;
Statische Segmente müssen exakt passen; Platzhalter in geschweiften Klammern (z.B. {uid}) matchen einen beliebigen Wert und stehen im Skript als Pfad-Parameter zur Verfügung. Eine Versionskennung wird als statisches Segment ins Template aufgenommen (z.B. /kalender/v1), um eine Funktionalität zu verändern, ohne bestehende Konsumenten zu brechen. Details: [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]].&lt;br /&gt;
&lt;br /&gt;
==Unterstützte HTTP-Methoden==&lt;br /&gt;
&lt;br /&gt;
Der Server akzeptiert die Methoden &#039;&#039;&#039;GET&#039;&#039;&#039;, &#039;&#039;&#039;POST&#039;&#039;&#039;, &#039;&#039;&#039;PUT&#039;&#039;&#039;, &#039;&#039;&#039;DELETE&#039;&#039;&#039; und &#039;&#039;&#039;PATCH&#039;&#039;&#039; sowie &#039;&#039;&#039;OPTIONS&#039;&#039;&#039; (CORS-Preflight, ohne Skript-Aufruf). Jede Methode wird im Endpunkt-Skript als gleichnamige Funktion implementiert. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
==Datei-Uploads==&lt;br /&gt;
&lt;br /&gt;
Endpunkte, die dafür freigeschaltet sind (&amp;lt;code&amp;gt;re_upload&amp;lt;/code&amp;gt;), nehmen Dateien&lt;br /&gt;
per &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; oder als fortsetzbaren &#039;&#039;&#039;Resumable-Upload&#039;&#039;&#039;&lt;br /&gt;
(&amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;) entgegen. Die maximale Grösse wird pro Endpunkt&lt;br /&gt;
festgelegt (&amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;, Standard 25&amp;amp;nbsp;MB); Überschreitung&lt;br /&gt;
ergibt 413, ein Upload an einen nicht freigeschalteten Endpunkt 415. Der Server&lt;br /&gt;
legt die Datei temporär ab und übergibt dem Skript den Pfad; die weitere&lt;br /&gt;
Verarbeitung (z.B. DMS-Ablage) übernimmt das Skript. Details:&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
==Doppelte Sendungen abfangen (Idempotenz)==&lt;br /&gt;
&lt;br /&gt;
Ein Client, der nach einem Timeout dieselbe Anfrage erneut schickt, darf keine&lt;br /&gt;
Zweitbuchung auslösen. Der Server erledigt das selbst: Sendet ein Aufruf mit&lt;br /&gt;
&#039;&#039;&#039;POST&#039;&#039;&#039;, &#039;&#039;&#039;PUT&#039;&#039;&#039;, &#039;&#039;&#039;PATCH&#039;&#039;&#039; oder &#039;&#039;&#039;DELETE&#039;&#039;&#039; den Header&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, merkt sich der Server Schlüssel und Ergebnis in&lt;br /&gt;
&#039;&#039;&#039;RESTSRV_IDEMPOTENCY&#039;&#039;&#039;. Eine Wiederholung mit demselben Schlüssel und demselben&lt;br /&gt;
Inhalt bekommt die &#039;&#039;&#039;gespeicherte Antwort&#039;&#039;&#039; zurück - das Endpunkt-Skript läuft&lt;br /&gt;
gar nicht erst an.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Ohne den Header ändert sich nichts.&#039;&#039;&#039; Bestehende Endpunkte verhalten sich unverändert.&lt;br /&gt;
* &#039;&#039;&#039;Kein Schalter am Endpunkt.&#039;&#039;&#039; Die Mechanik greift automatisch, sobald der Header anliegt.&lt;br /&gt;
* &#039;&#039;&#039;Das Skript muss nichts tun.&#039;&#039;&#039; Eine eigene Idempotenz-Logik im Skript ist nicht mehr nötig.&lt;br /&gt;
&lt;br /&gt;
Details und die Statuscodes: [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
==Sitzungen und Abmelden==&lt;br /&gt;
&lt;br /&gt;
Ein JWT trägt seine Gültigkeit in sich: der Server prüft Signatur und Ablauf und&lt;br /&gt;
braucht dafür keine Datenbank. Genau deshalb liesse sich ein ausgegebener Token&lt;br /&gt;
von sich aus auch nicht mehr zurückziehen - ein verlorenes Gerät wäre bis zum&lt;br /&gt;
Ablauf des Tokens weiter nutzbar.&lt;br /&gt;
&lt;br /&gt;
Der Server führt deshalb zu jedem ausgestellten Token-Paar eine Zeile in&lt;br /&gt;
&#039;&#039;&#039;RESTSRV_TOKEN&#039;&#039;&#039; und prüft jeden Zugriff dagegen. Daraus ergeben sich drei&lt;br /&gt;
Eigenschaften:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Abmelden wirkt sofort.&#039;&#039;&#039; Ein &#039;&#039;DELETE&#039;&#039; auf den JWT-Endpunkt sperrt die Sitzung; alle Token dieser Sitzung sind damit unmittelbar ungültig, auch die noch nicht abgelaufenen. Anmelden, Erneuern und Abmelden laufen also über einen einzigen Endpunkt, und für das Abmelden ist kein Skript nötig.&lt;br /&gt;
* &#039;&#039;&#039;Ein Refresh-Token gilt genau einmal.&#039;&#039;&#039; Beim Erneuern wird es entwertet. Wird es später erneut vorgelegt, gilt das als Diebstahl oder Fehlfunktion: die betroffene Sitzung wird komplett gesperrt und der Vorfall protokolliert.&lt;br /&gt;
* &#039;&#039;&#039;Alle Geräte abmelden.&#039;&#039;&#039; Ein gewöhnlicher Endpunkt kann über das Antwortfeld &#039;&#039;_OBS_JWT_REVOKE&#039;&#039; auch alle Sitzungen eines Benutzers sperren - der Support-Fall „Gerät verloren&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Mit der Einführung dieser Prüfung verlieren &#039;&#039;&#039;alle vorher&lt;br /&gt;
ausgestellten Token&#039;&#039;&#039; ihre Gültigkeit, weil sie zu keiner Sitzung gehören.&lt;br /&gt;
Angemeldete Konsumenten müssen sich nach dem Update einmalig neu anmelden.}}&lt;br /&gt;
&lt;br /&gt;
Der Speicher pflegt sich selbst: Zeilen werden gelöscht, sobald sowohl das&lt;br /&gt;
Access- als auch das Refresh-Token abgelaufen sind. Details und die Skript-Seite:&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
&lt;br /&gt;
==Fehlerformat==&lt;br /&gt;
&lt;br /&gt;
Jede Fehlerantwort des Servers hat dieselbe Form: ein einziges &#039;&#039;error&#039;&#039;-Objekt&lt;br /&gt;
mit maschinenlesbarem Code, Support-Kennung, Meldung und Korrelations-ID:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;error&amp;quot;: {&lt;br /&gt;
    &amp;quot;code&amp;quot;:    &amp;quot;AUTH_EXPIRED&amp;quot;,&lt;br /&gt;
    &amp;quot;uid&amp;quot;:     &amp;quot;Y00E2RTPDY&amp;quot;,&lt;br /&gt;
    &amp;quot;message&amp;quot;: &amp;quot;Ungültiges oder abgelaufenes Token&amp;quot;,&lt;br /&gt;
    &amp;quot;traceId&amp;quot;: &amp;quot;20260817T091233123-00001A&amp;quot;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;code&#039;&#039;&#039; - sprechender Bezeichner (z.B. &#039;&#039;AUTH_EXPIRED&#039;&#039;, &#039;&#039;NOT_FOUND&#039;&#039;, &#039;&#039;RATE_LIMITED&#039;&#039;, &#039;&#039;INTERNAL_ERROR&#039;&#039;), an dem ein Client seine Reaktion festmachen kann, ohne Texte auszuwerten.&lt;br /&gt;
* &#039;&#039;&#039;uid&#039;&#039;&#039; - OBS-Kennung der Codestelle. Für die Support-Recherche gedacht, nicht zur Auswertung im Client.&lt;br /&gt;
* &#039;&#039;&#039;message&#039;&#039;&#039; - Text für den Client. Interne Fehlerdetails stehen nie darin, sondern ausschliesslich im Protokoll &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039;.&lt;br /&gt;
* &#039;&#039;&#039;traceId&#039;&#039;&#039; - Korrelations-ID. Steht zusätzlich im Header &#039;&#039;X-Trace-Id&#039;&#039; und in jeder zugehörigen Logzeile.&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt-Skript kann eigene Codes setzen; siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Früher standen &#039;&#039;Fehlercode&#039;&#039;, &#039;&#039;Nachricht&#039;&#039; und &#039;&#039;traceId&#039;&#039; zusätzlich&lt;br /&gt;
flach neben dem &#039;&#039;error&#039;&#039;-Objekt. Diese Felder sind entfallen - der Wert von&lt;br /&gt;
&#039;&#039;Fehlercode&#039;&#039; heisst jetzt &#039;&#039;error.uid&#039;&#039;, &#039;&#039;Nachricht&#039;&#039; entspricht&lt;br /&gt;
&#039;&#039;error.message&#039;&#039;. Clients, die die flachen Felder auswerten, müssen angepasst&lt;br /&gt;
werden.}}&lt;br /&gt;
&lt;br /&gt;
==Sicherheitsmerkmale==&lt;br /&gt;
&lt;br /&gt;
Der REST-Server enthält eine Reihe von Schutzmechanismen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Rate-Limit:&#039;&#039;&#039; Standardmässig max. 10 fehlgeschlagene und 120 erfolgreiche Anfragen pro API-Key (Account) bzw. IP und 60 Sekunden, max. 600 Anfragen global pro 60 Sekunden. Beim Überschreiten wird HTTP 429 mit &#039;&#039;Retry-After&#039;&#039;-Header zurückgegeben. Die vier Schwellen lassen sich &#039;&#039;&#039;pro Server-Profil&#039;&#039;&#039; hinterlegen; die Zähler laufen ebenfalls getrennt je Profil, ein überlastetes Profil bremst das andere also nicht mit aus. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]].&lt;br /&gt;
* &#039;&#039;&#039;Body-Limit:&#039;&#039;&#039; max. 10 MB Request-Body (JSON), max. 1024 Zeichen pro Header- oder Query-Parameter. Datei-Uploads laufen über einen separaten Pfad und sind pro Endpunkt auf &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt; MB begrenzt (Standard 25&amp;amp;nbsp;MB).&lt;br /&gt;
* &#039;&#039;&#039;Sensible Felder&#039;&#039;&#039; (&#039;&#039;password&#039;&#039;, &#039;&#039;token&#039;&#039;, &#039;&#039;secret&#039;&#039;, &#039;&#039;apikey&#039;&#039;, &#039;&#039;authorization&#039;&#039;, &#039;&#039;cookie&#039;&#039;, ...) werden in Protokoll-Ausgaben automatisch maskiert.&lt;br /&gt;
* &#039;&#039;&#039;Widerrufbare Token:&#039;&#039;&#039; Jedes ausgestellte Token-Paar gehört zu einer Sitzung in &#039;&#039;&#039;RESTSRV_TOKEN&#039;&#039;&#039; und wird bei &#039;&#039;&#039;jedem&#039;&#039;&#039; Zugriff dagegen geprüft. Eine Abmeldung wirkt damit sofort und nicht erst mit dem Ablauf des Tokens; ein Refresh-Token gilt genau einmal. Siehe [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
* &#039;&#039;&#039;Reserviertes Präfix:&#039;&#039;&#039; Parameter-Namen mit Präfix &#039;&#039;_OBS_&#039;&#039; können von aussen nicht gesetzt werden, sie sind für den Server reserviert (z.B. JWT-Claims).&lt;br /&gt;
* &#039;&#039;&#039;Geblockte Header:&#039;&#039;&#039; &#039;&#039;authorization&#039;&#039;, &#039;&#039;cookie&#039;&#039;, &#039;&#039;proxy-authorization&#039;&#039;, &#039;&#039;x-forwarded-for&#039;&#039;, &#039;&#039;x-real-ip&#039;&#039;, &#039;&#039;apikey&#039;&#039;, &#039;&#039;api_key&#039;&#039; werden nicht an das Skript durchgereicht.&lt;br /&gt;
* &#039;&#039;&#039;TLS:&#039;&#039;&#039; Bei Profilen mit aktivem TLS fährt der Server ohne Zertifikat und Key nicht hoch. Plain-HTTP ist nur für ein dediziertes Debug-Profil möglich.&lt;br /&gt;
* &#039;&#039;&#039;mTLS&#039;&#039;&#039; (Mutual TLS): erzwingt, dass jeder Client ein gültiges Client-Zertifikat vorlegt. Optional kann pro Zugang ein erwarteter Subject-DN hinterlegt werden.&lt;br /&gt;
* &#039;&#039;&#039;DNS-Cache:&#039;&#039;&#039; Host-zu-IP-Auflösungen für die Zugangs-Host-Prüfung werden 5 Minuten gecached.&lt;br /&gt;
&lt;br /&gt;
==Protokoll==&lt;br /&gt;
&lt;br /&gt;
Die Tabelle &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; enthält alle relevanten Ereignisse: erfolgreiche und abgelehnte Anfragen, TLS-/JWT-Fehler, Bindungs-Ereignisse, Skript-Fehler. Einträge sind nach Datum, Uhrzeit und Host (IP oder Account-Name) sortiert. Das Protokoll ist die erste Anlaufstelle bei Störungen. Jede Antwort trägt zudem eine Korrelations-ID (Response-Header &#039;&#039;X-Trace-Id&#039;&#039;), die in den Protokoll-Einträgen wiederzufinden ist.&lt;br /&gt;
&lt;br /&gt;
==Statistik==&lt;br /&gt;
&lt;br /&gt;
Die Tabelle &#039;&#039;&#039;RESTSRV_STATS&#039;&#039;&#039; enthält eine Statistik aller bearbeiteten Anfragen mit Zugang, Endpunkt, Methode, HTTP-Status und Laufzeit in Millisekunden. Aufruf der Statistik:&lt;br /&gt;
&lt;br /&gt;
* Aus der Endpunkt-Liste: F8 zeigt die Statistik des markierten Endpunkts.&lt;br /&gt;
* Aus der Zugänge-Liste: F8 zeigt die Statistik des markierten Zugangs.&lt;br /&gt;
&lt;br /&gt;
==Konsole==&lt;br /&gt;
&lt;br /&gt;
Wird der REST-Server nicht als Dienst, sondern interaktiv gestartet, erscheint eine farbige Konsole mit Live-Log. Die wichtigsten Befehle:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Befehl       !! Wirkung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;show &amp;lt;n&amp;gt;&#039;&#039; || Zeigt die Details zum Debug-Eintrag &#039;&#039;#n&#039;&#039; (z.B. Request-Header, Response-Body), JSON wird farbig formatiert&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;exit&#039;&#039; / &#039;&#039;quit&#039;&#039; || Beendet den Server geordnet&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Im Service-Modus (Windows-Dienst) ist die Konsole nicht sichtbar. Alle Einträge landen dort ausschliesslich in der Tabelle RESTSRV_PROTO.&lt;br /&gt;
&lt;br /&gt;
==Weiterführende Seiten==&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Einrichtung|Einrichtung]] - Schritt-für-Schritt-Anleitung&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]] - TLS, mTLS und Bindungen&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]] - API-Keys, JWT, CORS, Host-Beschränkung&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]] - Verwaltung, Berechtigungen, WebHooks&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]] - Aufbau der Endpunkt-Skripte&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel1|Beispiel 1: Daten-Abruf mit JWT]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel2|Beispiel 2: Zeiterfassung als komplette Webanwendung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel3|Beispiel 3: Datensatz anlegen mit JSON-Body (CRUD)]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4: Pfad-Parameter im Routing]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6: Datei-Upload und Ablage im DMS]]&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Scripting&amp;diff=64687</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Scripting</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Scripting&amp;diff=64687"/>
		<updated>2026-08-19T12:00:25Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Anleitung für Endpunkt-Skripte=&lt;br /&gt;
&lt;br /&gt;
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.&lt;br /&gt;
&lt;br /&gt;
==Sicherheitshinweise==&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Endpunkt-Skripte verarbeiten Daten aus dem Internet. Übergabeparameter dürfen niemals ungeprüft in SQL-Anweisungen eingebaut werden. Werte immer über &#039;&#039;DB_SQLVal&#039;&#039; bzw. Parameter-Bindings absichern, Eingabewerte gegen Whitelists prüfen.}}&lt;br /&gt;
&lt;br /&gt;
* Eingaben gegen erwartete Werte prüfen (z.B. Pflichtfelder, erlaubte Typen, Wertebereiche).&lt;br /&gt;
* Nur das zurückgeben, was der Konsument wirklich braucht - keine internen IDs, keine Sys-Felder, keine Passwörter.&lt;br /&gt;
* Bei Fehlern keine internen Details an den Konsumenten zurückgeben; stattdessen schreibt der Server ohnehin Detail-Einträge in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==Methoden-Signatur==&lt;br /&gt;
&lt;br /&gt;
Pro HTTP-Methode wird im Skript eine gleichnamige Funktion implementiert. Der Server ruft genau die Funktion auf, die zur Methode des eingehenden Requests passt.&lt;br /&gt;
&lt;br /&gt;
 function Get   (oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
 function Post  (oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
 function Put   (oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
 function Delete(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
 function Patch (oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
&lt;br /&gt;
Nicht implementierte Methoden liefern automatisch &#039;&#039;405&#039;&#039;-ähnliche Fehler über die generische Skript-Antwort.&lt;br /&gt;
&lt;br /&gt;
==Parameter==&lt;br /&gt;
&lt;br /&gt;
===oParams (TStrings)===&lt;br /&gt;
&lt;br /&gt;
Enthält alle Query-Parameter, POST-Parameter (form-urlencoded) sowie die durchgereichten HTTP-Header. Werte sind immer Strings.&lt;br /&gt;
&lt;br /&gt;
Filterung durch den Server:&lt;br /&gt;
&lt;br /&gt;
* Geblockte Header werden nicht durchgereicht: &#039;&#039;authorization&#039;&#039;, &#039;&#039;cookie&#039;&#039;, &#039;&#039;proxy-authorization&#039;&#039;, &#039;&#039;x-forwarded-for&#039;&#039;, &#039;&#039;x-real-ip&#039;&#039;, &#039;&#039;apikey&#039;&#039;, &#039;&#039;api_key&#039;&#039;.&lt;br /&gt;
* Parameter mit Präfix &#039;&#039;&#039;_OBS_&#039;&#039;&#039; können von aussen nicht gesetzt werden; sie sind für interne Werte reserviert.&lt;br /&gt;
* Werte werden auf max. 1024 Zeichen begrenzt.&lt;br /&gt;
* Null-Bytes und Steuerzeichen (ausser Tab, CR, LF) werden entfernt.&lt;br /&gt;
&lt;br /&gt;
Zugriff im Skript:&lt;br /&gt;
&lt;br /&gt;
 if (not Empty(oParams.Values[&#039;kundennr&#039;])) then begin&lt;br /&gt;
     cKundenNr := oParams.Values[&#039;kundennr&#039;];&lt;br /&gt;
 end;&lt;br /&gt;
&lt;br /&gt;
===oBody (TJSONObject)===&lt;br /&gt;
&lt;br /&gt;
Enthält den Request-Body, sofern dieser ein gültiges JSON-Objekt ist. Bei leerem oder nicht-JSON-Body wird &#039;&#039;nil&#039;&#039; übergeben.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Generics funktionieren im Skript nicht.&#039;&#039;&#039; Aus Delphi bekannte&lt;br /&gt;
Schreibweisen wie &#039;&#039;oBody.TryGetValue&amp;amp;lt;string&amp;amp;gt;(&#039;feld&#039;, cVar)&#039;&#039; lassen sich nicht&lt;br /&gt;
übersetzen. Gelesen wird über &#039;&#039;GetValue&#039;&#039;.}}&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;GetValue&#039;&#039; liefert &#039;&#039;nil&#039;&#039;, wenn das Feld im Body &#039;&#039;&#039;nicht enthalten&#039;&#039;&#039; ist -&lt;br /&gt;
daran lässt sich „nicht gesendet&amp;quot; von „leer gesendet&amp;quot; unterscheiden. Der Wert ist&lt;br /&gt;
immer ein &#039;&#039;&#039;String&#039;&#039;&#039; und wird bei Bedarf selbst gewandelt.&lt;br /&gt;
&lt;br /&gt;
 cUser := &#039;&#039;;&lt;br /&gt;
 cPass := &#039;&#039;;&lt;br /&gt;
 if (Assigned(oBody)) then begin&lt;br /&gt;
     oVal := oBody.GetValue(&#039;username&#039;);&lt;br /&gt;
     if (Assigned(oVal)) then begin&lt;br /&gt;
         cUser := oVal.Value;&lt;br /&gt;
     end;&lt;br /&gt;
     oVal := oBody.GetValue(&#039;password&#039;);&lt;br /&gt;
     if (Assigned(oVal)) then begin&lt;br /&gt;
         cPass := oVal.Value;&lt;br /&gt;
     end;&lt;br /&gt;
 end;&lt;br /&gt;
&lt;br /&gt;
Bei mehr als zwei Feldern lohnt eine kleine Hilfsfunktion, die das Muster einmal&lt;br /&gt;
kapselt:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function _BodyStr(oBody: TJSONObject; const cFeld: string): string;&lt;br /&gt;
var oVal: TJSONValue;&lt;br /&gt;
begin&lt;br /&gt;
    result := &#039;&#039;;&lt;br /&gt;
    if (oBody = nil) then begin&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
    oVal := oBody.GetValue(cFeld);&lt;br /&gt;
    if (Assigned(oVal)) then begin&lt;br /&gt;
        result := oVal.Value;&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
// Aufruf:&lt;br /&gt;
cBetreff := _BodyStr(oBody, &#039;betreff&#039;);&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Andere Typen entstehen aus dem String:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Zieltyp !! Umwandlung&lt;br /&gt;
|-&lt;br /&gt;
| Ganzzahl || &amp;lt;code&amp;gt;iVal(_BodyStr(oBody, &#039;menge&#039;))&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Kommazahl || &amp;lt;code&amp;gt;fVal(StrTran(_BodyStr(oBody, &#039;preis&#039;), &#039;.&#039;, &#039;,&#039;))&amp;lt;/code&amp;gt; - &#039;&#039;&#039;JSON liefert den Dezimalpunkt&#039;&#039;&#039;, &#039;&#039;fVal&#039;&#039; erwartet die lokale Notation&lt;br /&gt;
|-&lt;br /&gt;
| Boolean || &amp;lt;code&amp;gt;Lower(_BodyStr(oBody, &#039;aktiv&#039;)) = &#039;true&#039;&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Datum/Zeit || ISO-8601-String selbst zerlegen (&#039;&#039;CToDT&#039;&#039; erwartet das deutsche Format)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Maximale Body-Grösse: 10 MB.&lt;br /&gt;
&lt;br /&gt;
===Reservierte _OBS_-Parameter===&lt;br /&gt;
&lt;br /&gt;
Bei aktiver JWT-Authentifizierung stehen die Token-Claims als Parameter zur Verfügung:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Parameter            !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_ID          || JWT-Id (&#039;&#039;jti&#039;&#039;-Claim) - typisch die User-Id. &#039;&#039;&#039;Muss nicht eindeutig sein&#039;&#039;&#039;: die Sitzung führt der Server über eigene Kennungen (siehe unten)&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_SUBJECT     || Subject (&#039;&#039;sub&#039;&#039;-Claim) - typisch Benutzername&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_AUDIENCE    || Audience (&#039;&#039;aud&#039;&#039;-Claim) - typisch Mandant / Rolle&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_CLAIM_&amp;amp;lt;name&amp;amp;gt; || Beliebiger Custom-Claim des Tokens (z.B. _OBS_JWT_CLAIM_tenant, _OBS_JWT_CLAIM_roles); im Folge-Skript lesbar&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_TRACE_ID        || Korrelations-ID des Requests (auch als Response-Header X-Trace-Id)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Unabhängig von JWT stehen ausserdem immer zur Verfügung:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Parameter            !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_SERVER_TIME     || Aktuelle Serverzeit als ISO-8601 &#039;&#039;&#039;mit UTC-Offset&#039;&#039;&#039; (z.B. &#039;&#039;2026-08-17T09:12:33+02:00&#039;&#039;). Nützlich, um Datumswerte in derselben Zeitzone auszuliefern, in der der Server arbeitet, und damit ein Client seinen Uhren-Versatz bestimmen kann&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_EXP_SEC     || Laufzeit des Access-Tokens in Sekunden (&#039;&#039;JWT-Exp&#039;&#039; × 60). Damit kann ein Konfigurations-Endpunkt denselben Wert ausliefern, den der Token wirklich hat&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Diese Werte werden vom Server gesetzt und können vom Skript für Berechtigungs- und Mandantenprüfungen verwendet werden.&lt;br /&gt;
&lt;br /&gt;
Zusätzlich trägt jedes Token zwei &#039;&#039;&#039;Sitzungskennungen des Servers&#039;&#039;&#039;, lesbar als&lt;br /&gt;
&#039;&#039;_OBS_JWT_CLAIM_sid&#039;&#039; und &#039;&#039;_OBS_JWT_CLAIM_rid&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Claim !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| sid || Sitzung. Bleibt über alle Erneuerungen (Refresh) hinweg gleich und ist der Schlüssel für das Abmelden&lt;br /&gt;
|-&lt;br /&gt;
| rid || Zeilenkennung des einzelnen Token-Paares in &#039;&#039;RESTSRV_TOKEN&#039;&#039;. Bei jeder Erneuerung neu&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Beide sind OBS-UIDs (10 Zeichen) und werden vom Server &#039;&#039;&#039;nach&#039;&#039;&#039; den Claims des&lt;br /&gt;
Skripts gesetzt - ein Skript kann sie also auch mit &#039;&#039;_OBS_JWT_CLAIM_sid&#039;&#039; nicht&lt;br /&gt;
überschreiben. Für Fachlogik sind sie nicht gedacht; sie sind nützlich, um eine&lt;br /&gt;
Sitzung im Protokoll wiederzufinden.&lt;br /&gt;
&lt;br /&gt;
===Pfad-Parameter===&lt;br /&gt;
&lt;br /&gt;
Stammt der Endpunkt aus einem Pfad-Template mit Platzhaltern (siehe [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]), stehen die aus den Platzhaltern erfassten Werte als reservierte Parameter mit Präfix &#039;&#039;&#039;_OBS_PATH_&#039;&#039;&#039; in &#039;&#039;oParams&#039;&#039; bereit:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Template !! Zugriff im Skript&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; || &#039;&#039;oParams.Values[&#039;_OBS_PATH_uid&#039;]&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || &#039;&#039;oParams.Values[&#039;_OBS_PATH_uid&#039;]&#039;&#039;, &#039;&#039;oParams.Values[&#039;_OBS_PATH_code&#039;]&#039;&#039;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Da der Präfix &#039;&#039;_OBS_&#039;&#039; 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 [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4 - Pfad-Parameter]].&lt;br /&gt;
&lt;br /&gt;
===Datei-Uploads===&lt;br /&gt;
&lt;br /&gt;
Ist der Endpunkt für Uploads freigeschaltet (&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;, siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]]), nimmt der Server&lt;br /&gt;
hochgeladene Dateien entgegen, legt sie in einem temporären Verzeichnis ab und&lt;br /&gt;
übergibt dem Skript Pfad, Originalname und Content-Type über reservierte&lt;br /&gt;
Parameter. Das Skript entscheidet selbst über die weitere Verarbeitung (z.B.&lt;br /&gt;
DMS-Ablage). Der Body wird in diesem Fall &#039;&#039;&#039;nicht&#039;&#039;&#039; als JSON geparst - &#039;&#039;oBody&#039;&#039;&lt;br /&gt;
ist &#039;&#039;nil&#039;&#039;. Es gilt nicht das JSON-Body-Limit (10&amp;amp;nbsp;MB), sondern die pro&lt;br /&gt;
Endpunkt konfigurierte Grösse (&#039;&#039;re_upload_size&#039;&#039;, Standard 25&amp;amp;nbsp;MB;&lt;br /&gt;
Überschreitung -&amp;gt; 413).&lt;br /&gt;
&lt;br /&gt;
Beide Übertragungsarten - &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; und der resumable&lt;br /&gt;
&amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;-Upload - liefern dem Skript dieselben Parameter:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Parameter !! Inhalt&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_UPLOAD_PATH || Vollständiger Pfad zur temporären Datei auf dem Server&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_UPLOAD_NAME || Originaldateiname&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_UPLOAD_CONTENTTYPE || Content-Type der Datei (Default &amp;lt;code&amp;gt;application/octet-stream&amp;lt;/code&amp;gt;, wenn der Client keinen angibt)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Der &#039;&#039;&#039;Dateiname ist Pflicht&#039;&#039;&#039;. Fehlt er, lehnt der Server den Upload mit&lt;br /&gt;
&#039;&#039;&#039;400 Bad Request&#039;&#039;&#039; ab und das Skript wird nicht ausgeführt - das Skript kann&lt;br /&gt;
sich also darauf verlassen, dass &#039;&#039;_OBS_UPLOAD_PATH&#039;&#039; und &#039;&#039;_OBS_UPLOAD_NAME&#039;&#039;&lt;br /&gt;
gesetzt sind, sobald es läuft.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Die temporäre Datei wird nicht automatisch verschoben. Das Skript muss sie an ihren Zielort (DMS, Verzeichnis, ...) übernehmen.}}&lt;br /&gt;
&lt;br /&gt;
====Einfacher Upload (multipart/form-data)====&lt;br /&gt;
&lt;br /&gt;
Eine Datei pro Request. Name und Content-Type stammen aus der&lt;br /&gt;
&amp;lt;code&amp;gt;Content-Disposition&amp;lt;/code&amp;gt; der Datei-Partie (&amp;lt;code&amp;gt;filename=&amp;lt;/code&amp;gt;). Der&lt;br /&gt;
Server speichert die erste Datei-Partie und ruft das Skript auf:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes : TJSONObject;&lt;br /&gt;
    cPfad: string;&lt;br /&gt;
    cName: string;&lt;br /&gt;
    cTyp : string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cPfad := oParams.Values[&#039;_OBS_UPLOAD_PATH&#039;];&lt;br /&gt;
        cName := oParams.Values[&#039;_OBS_UPLOAD_NAME&#039;];&lt;br /&gt;
        cTyp  := oParams.Values[&#039;_OBS_UPLOAD_CONTENTTYPE&#039;];&lt;br /&gt;
&lt;br /&gt;
        // ... Datei aus cPfad ins DMS / Zielverzeichnis uebernehmen ...&lt;br /&gt;
&lt;br /&gt;
        oRes.AddPair(&#039;status&#039;   , &#039;ok&#039;);&lt;br /&gt;
        oRes.AddPair(&#039;dateiname&#039;, cName);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
====Resumable/Chunked Upload (Content-Range)====&lt;br /&gt;
&lt;br /&gt;
Für grosse Dateien überträgt der Client die Datei in Teilstücken (Chunks) mit dem&lt;br /&gt;
Header &amp;lt;code&amp;gt;Content-Range: bytes START-END/TOTAL&amp;lt;/code&amp;gt;. Der Server hängt die&lt;br /&gt;
Chunks &#039;&#039;&#039;sequentiell&#039;&#039;&#039; an eine Session-Datei an und behandelt unvollständige&lt;br /&gt;
Uploads selbst - das Endpunkt-Skript läuft &#039;&#039;&#039;erst beim vollständigen Upload&#039;&#039;&#039;&lt;br /&gt;
(dann mit gesetztem &#039;&#039;_OBS_UPLOAD_PATH&#039;&#039;, &#039;&#039;_OBS_UPLOAD_NAME&#039;&#039; und&lt;br /&gt;
&#039;&#039;_OBS_UPLOAD_CONTENTTYPE&#039;&#039;).&lt;br /&gt;
&lt;br /&gt;
Name und Content-Type sind im &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt;-Protokoll nicht enthalten;&lt;br /&gt;
der Client liefert sie deshalb &#039;&#039;&#039;im ersten Chunk&#039;&#039;&#039; über den Header&lt;br /&gt;
&amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt; (Format wie bei tus: kommagetrennte Paare&lt;br /&gt;
&amp;lt;code&amp;gt;schlüssel base64wert&amp;lt;/code&amp;gt;, Werte Base64-kodiert):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
Upload-Metadata: filename ZmlsZS5wZGY=,filetype YXBwbGljYXRpb24vcGRm&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Erkannt werden die Schlüssel &amp;lt;code&amp;gt;filename&amp;lt;/code&amp;gt; (Pflicht) und&lt;br /&gt;
&amp;lt;code&amp;gt;filetype&amp;lt;/code&amp;gt; (optional). Fehlt &amp;lt;code&amp;gt;filename&amp;lt;/code&amp;gt; im ersten Chunk,&lt;br /&gt;
wird der Upload mit &#039;&#039;&#039;400&#039;&#039;&#039; abgelehnt, ohne dass eine Datei angelegt wird.&lt;br /&gt;
Folge-Chunks und Statusabfragen müssen die Metadaten nicht erneut senden.&lt;br /&gt;
&lt;br /&gt;
Ablauf (vom Client gesteuert, der Server antwortet jeweils):&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Request !! Server-Antwort&lt;br /&gt;
|-&lt;br /&gt;
| Erster Chunk &amp;lt;code&amp;gt;Content-Range: bytes 0-1048575/5000000&amp;lt;/code&amp;gt; + &amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt; || &#039;&#039;&#039;308&#039;&#039;&#039; Resume Incomplete + Header &amp;lt;code&amp;gt;Upload-Id&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;Upload-Offset&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;Range&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| weitere Chunks (jeweils &amp;lt;code&amp;gt;Upload-Id&amp;lt;/code&amp;gt; mitsenden) || &#039;&#039;&#039;308&#039;&#039;&#039; mit aktualisiertem &amp;lt;code&amp;gt;Upload-Offset&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| letzter Chunk (Bereich erreicht TOTAL) || normale Skript-Antwort (z.B. &#039;&#039;&#039;200&#039;&#039;&#039;/&#039;&#039;&#039;201&#039;&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| Statusabfrage &amp;lt;code&amp;gt;Content-Range: bytes */5000000&amp;lt;/code&amp;gt; || aktueller &amp;lt;code&amp;gt;Upload-Offset&amp;lt;/code&amp;gt;, ohne anzuhängen (Resume)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Header !! Richtung !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| Content-Range || Request || &amp;lt;code&amp;gt;bytes START-END/TOTAL&amp;lt;/code&amp;gt;; &amp;lt;code&amp;gt;bytes */TOTAL&amp;lt;/code&amp;gt; nur als Statusabfrage&lt;br /&gt;
|-&lt;br /&gt;
| Upload-Metadata || Request || tus-Metadaten im ersten Chunk: &amp;lt;code&amp;gt;filename&amp;lt;/code&amp;gt; (Pflicht), &amp;lt;code&amp;gt;filetype&amp;lt;/code&amp;gt; (optional), Base64-kodiert&lt;br /&gt;
|-&lt;br /&gt;
| 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.&lt;br /&gt;
|-&lt;br /&gt;
| Upload-Offset || Response || Anzahl der bereits gespeicherten Bytes (= Startoffset des nächsten Chunks)&lt;br /&gt;
|-&lt;br /&gt;
| Range || Response || Bereits gespeicherter Bereich (&amp;lt;code&amp;gt;bytes=0-N&amp;lt;/code&amp;gt;)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Der Server hängt einen Chunk &#039;&#039;&#039;nur an, wenn START dem bereits gespeicherten Stand&lt;br /&gt;
entspricht&#039;&#039;&#039;. Bei Abweichung (doppelter Chunk, Lücke) wird nicht angehängt,&lt;br /&gt;
sondern der aktuelle &#039;&#039;Upload-Offset&#039;&#039; zurückgemeldet - der Client setzt ab dort&lt;br /&gt;
neu auf. Eine separate Statusabfrage ist für ein Resume daher nicht zwingend nötig.&lt;br /&gt;
&lt;br /&gt;
Überschreitet ein Chunk bzw. die Gesamtgrösse das Limit (&#039;&#039;re_upload_size&#039;&#039;),&lt;br /&gt;
antwortet der Server mit &#039;&#039;&#039;413 Payload Too Large&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der Upload-Status liegt prozesslokal im Speicher. Nach einem Neustart des Servers muss ein unvollständiger Upload neu begonnen werden.}}&lt;br /&gt;
&lt;br /&gt;
==Rückgabe==&lt;br /&gt;
&lt;br /&gt;
Die Rückgabe ist immer ein &#039;&#039;&#039;JSON-String&#039;&#039;&#039;. Wird ein leerer String zurückgegeben, antwortet der Server automatisch mit &#039;&#039;{}&#039;&#039;. Der Server setzt Content-Type auf &#039;&#039;application/json; charset=utf-8&#039;&#039; und Status auf 200, sofern das Skript nicht selbst einen Fehler signalisiert.&lt;br /&gt;
&lt;br /&gt;
Einfaches Beispiel:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Get(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes: TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        oRes.AddPair(&#039;wert_string&#039;, &#039;123&#039;);&lt;br /&gt;
        oRes.AddPair(&#039;wert_int&#039;   , 456);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Antwort steuern: Statuscode, Header, traceId==&lt;br /&gt;
&lt;br /&gt;
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.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Antwort-Feld !! Wirkung&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_HTTP_STATUS (Zahl) || HTTP-Statuscode (z.B. 201, 204, 400, 409, 422). Ohne Angabe: 200. Bei 204 wird kein Body gesendet.&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_HEADERS (Objekt) || Beliebige Response-Header, z.B. ETag, Location, Retry-After. CR/LF in Namen/Werten werden entfernt (Schutz vor Header-Injection).&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_REVOKE (Text) || Sperrt die Sitzung des vorgelegten Tokens (&#039;&#039;session&#039;&#039;) oder alle Sitzungen des Subjects (&#039;&#039;all&#039;&#039;). Damit wird ein Endpunkt zum Logout, ohne eigene Verwaltung. Scheitert das Sperren, antwortet der Server mit &#039;&#039;&#039;503&#039;&#039;&#039; statt mit der Antwort des Skripts - siehe [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]], Abschnitt Abmelden.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Zusätzlich trägt jede Antwort den Header &#039;&#039;&#039;X-Trace-Id&#039;&#039;&#039; (Korrelations-ID). Dieselbe ID liegt dem Skript als &#039;&#039;oParams.Values[&#039;_OBS_TRACE_ID&#039;]&#039;&#039; vor und erscheint in jeder Server-Fehler-Logzeile in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; - so lässt sich ein Fehler ohne Gerätezugriff im Log wiederfinden.&lt;br /&gt;
&lt;br /&gt;
===Beispiel: Anlegen mit 201, Location und ETag===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oHdr: TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        // ... Datensatz anlegen, neue UID + Version (ETag) ermitteln ...&lt;br /&gt;
        oHdr := TJSONObject.Create();&lt;br /&gt;
        oHdr.AddPair(&#039;Location&#039;, &#039;/v1/orders/&#039; + cNeueUid);&lt;br /&gt;
        oHdr.AddPair(&#039;ETag&#039;, &#039;1&#039;);&lt;br /&gt;
&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 201);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
        oRes.AddPair(&#039;uid&#039;, cNeueUid);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Beispiel: Strukturierter Fehler mit Statuscode und traceId===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oErr: TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        oErr := TJSONObject.Create();&lt;br /&gt;
        oErr.AddPair(&#039;code&#039;, &#039;VALIDATION_FAILED&#039;);&lt;br /&gt;
        oErr.AddPair(&#039;message&#039;, &#039;Feld &amp;quot;menge&amp;quot; fehlt&#039;);&lt;br /&gt;
        oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 422);&lt;br /&gt;
        oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Beispiel: Optimistic Concurrency (ETag / If-Match)===&lt;br /&gt;
&lt;br /&gt;
Mit Statuscode, Headern und dem lesbaren Header &#039;&#039;If-Match&#039;&#039; führt das Skript je Datensatz einen Versionszähler und lehnt veraltete Schreibzugriffe mit 409 ab:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Put(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oHdr: TJSONObject;&lt;br /&gt;
    nAktuell, nIfMatch: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        nAktuell := AuftragVersion(oParams.Values[&#039;_OBS_PATH_uid&#039;]);&lt;br /&gt;
        nIfMatch := iVal(oParams.Values[&#039;if-match&#039;]);&lt;br /&gt;
&lt;br /&gt;
        if (nIfMatch &amp;lt;&amp;gt; nAktuell) then begin&lt;br /&gt;
            oHdr := TJSONObject.Create();&lt;br /&gt;
            oHdr.AddPair(&#039;ETag&#039;, xStr(nAktuell));&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 409);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;VERSION_CONFLICT&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // ... speichern, Version hochzählen ...&lt;br /&gt;
        oHdr := TJSONObject.Create();&lt;br /&gt;
        oHdr.AddPair(&#039;ETag&#039;, xStr(nAktuell + 1));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Idempotenz - was das Skript beachten muss==&lt;br /&gt;
&lt;br /&gt;
Sendet ein Konsument bei &#039;&#039;POST&#039;&#039;/&#039;&#039;PUT&#039;&#039;/&#039;&#039;PATCH&#039;&#039;/&#039;&#039;DELETE&#039;&#039; den Header&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, fängt der &#039;&#039;&#039;Server&#039;&#039;&#039; doppelte Sendungen ab. Das&lt;br /&gt;
Skript braucht dafür &#039;&#039;&#039;keine eigene Logik&#039;&#039;&#039; - keine Schlüssel-Tabelle, keine&lt;br /&gt;
Prüfung am Anfang der Methode.&lt;br /&gt;
&lt;br /&gt;
Was das Skript wissen muss:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Antwort des Skripts !! Wirkung auf den Idempotenz-Speicher&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;2xx&#039;&#039;&#039; || Statuscode, Body und Header werden gespeichert. Eine Wiederholung mit demselben Schlüssel bekommt genau diese Antwort zurück, das Skript läuft nicht erneut&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;4xx&#039;&#039;&#039; || Der Schlüssel wird &#039;&#039;&#039;freigegeben&#039;&#039;&#039;. Der Konsument darf denselben Schlüssel nach Korrektur des Inhalts erneut verwenden&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;5xx&#039;&#039;&#039; oder Exception || Der Schlüssel bleibt belegt, der Fall wird protokolliert. Eine Wiederholung wird abgelehnt, bis der Fall geklärt ist&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Daraus folgen zwei Regeln für Endpunkt-Skripte:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Fachliche Ablehnungen als 4xx melden&#039;&#039;&#039; (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.&lt;br /&gt;
* &#039;&#039;&#039;Die Antwort muss vollständig sein.&#039;&#039;&#039; Was zurückgegeben wird, wird eingefroren. Ein Feld, das erst der zweite Aufruf ergänzen würde, kommt beim Konsumenten nie an.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Vor dieser Server-Funktion mussten Skripte die Idempotenz selbst&lt;br /&gt;
abbilden. Solche Skripte laufen unverändert weiter - die Doppelprüfung schadet&lt;br /&gt;
nicht, ist aber überflüssig und kann beim nächsten Überarbeiten entfallen.}}&lt;br /&gt;
&lt;br /&gt;
Der vollständige Ablauf und die Statuscodes stehen unter&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]].&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierungs-Skript==&lt;br /&gt;
&lt;br /&gt;
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 &#039;&#039;Authenticate&#039;&#039; bereitstellen:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Authenticate(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes  : TJSONObject;&lt;br /&gt;
    oVal  : TJSONValue;&lt;br /&gt;
    cUser : string;&lt;br /&gt;
    cPass : string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        // Eingangsdaten lesen (Body oder Query-Param)&lt;br /&gt;
        cUser := &#039;&#039;;&lt;br /&gt;
        cPass := &#039;&#039;;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;username&#039;);&lt;br /&gt;
            if (Assigned(oVal)) then begin&lt;br /&gt;
                cUser := oVal.Value;&lt;br /&gt;
            end;&lt;br /&gt;
            oVal := oBody.GetValue(&#039;password&#039;);&lt;br /&gt;
            if (Assigned(oVal)) then begin&lt;br /&gt;
                cPass := oVal.Value;&lt;br /&gt;
            end;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // Prüfung gegen eigene Tabelle, Hash-Verfahren, LDAP, ...&lt;br /&gt;
        if (PasswortPasst(cUser, cPass)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;          , 1);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_ID&#039;     , cUser);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_SUBJECT&#039;, cUser);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_AUDIENCE&#039;, &#039;mandant1&#039;);&lt;br /&gt;
        end else begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;, 9);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039; , &#039;Login fehlgeschlagen&#039;);&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Rückgabewerte:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! status !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| 1      || Erfolgreich, der Server erzeugt aus den &#039;&#039;_OBS_JWT_*&#039;&#039;-Werten einen Token (HS256)&lt;br /&gt;
|-&lt;br /&gt;
| 9      || Misserfolg, der Server antwortet mit &#039;&#039;&#039;401&#039;&#039;&#039; und reicht den Wert von &#039;&#039;error&#039;&#039; als &#039;&#039;error.message&#039;&#039; an den Client durch (zusätzlich protokolliert)&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der Text aus &#039;&#039;error&#039;&#039; geht bei &#039;&#039;status&#039;&#039; = 9 &#039;&#039;&#039;an den Konsumenten&#039;&#039;&#039;&lt;br /&gt;
und wird typischerweise direkt unter dem Passwortfeld angezeigt. Er sollte für&lt;br /&gt;
Endanwender verständlich sein und keine internen Details verraten.}}&lt;br /&gt;
&lt;br /&gt;
Liefert das Skript einen anderen Status als 1 oder 9, ist gar nicht lauffähig oder&lt;br /&gt;
gibt kein auswertbares JSON zurück, antwortet der Server mit &#039;&#039;&#039;403&#039;&#039;&#039; und einer&lt;br /&gt;
generischen Meldung - ein defektes Anmeldeskript soll dem Anwender nicht als&lt;br /&gt;
„Passwort falsch&amp;quot; erscheinen.&lt;br /&gt;
&lt;br /&gt;
Kann der Server die &#039;&#039;&#039;Sitzungszeile&#039;&#039;&#039; zum ausgestellten Token nicht schreiben&lt;br /&gt;
(Tabelle fehlt, Datenbankproblem), liefert er &#039;&#039;&#039;kein Token&#039;&#039;&#039; aus und antwortet&lt;br /&gt;
mit &#039;&#039;&#039;503&#039;&#039;&#039; und &#039;&#039;Retry-After&#039;&#039;. Auch das ist bewusst kein 401/403: die&lt;br /&gt;
Zugangsdaten waren richtig, der Versuch darf wiederholt werden.&lt;br /&gt;
&lt;br /&gt;
===Aufbau der Token-Antwort===&lt;br /&gt;
&lt;br /&gt;
Bei Erfolg baut der Server die Antwort aus den Token-Feldern &#039;&#039;&#039;und allen weiteren&lt;br /&gt;
Feldern, die das Skript zurückgibt&#039;&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;benutzerId&amp;quot;: &amp;quot;4711&amp;quot;, &amp;quot;tenant&amp;quot;: &amp;quot;nord&amp;quot;, &amp;quot;roles&amp;quot;: [&amp;quot;TECHNIKER&amp;quot;],&lt;br /&gt;
  &amp;quot;token&amp;quot;:       &amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;accessToken&amp;quot;: &amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;refreshToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:   28800,&lt;br /&gt;
  &amp;quot;serverTime&amp;quot;:  &amp;quot;2026-08-17T09:12:33+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld !! Herkunft&lt;br /&gt;
|-&lt;br /&gt;
| token / accessToken || Derselbe Access-Token unter zwei Namen. &#039;&#039;accessToken&#039;&#039; erwarten die meisten Client-Bibliotheken, &#039;&#039;token&#039;&#039; bleibt für bestehende Konsumenten erhalten&lt;br /&gt;
|-&lt;br /&gt;
| refreshToken || Nur wenn das Skript &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; geliefert hat&lt;br /&gt;
|-&lt;br /&gt;
| expiresIn || Laufzeit des Access-Tokens in &#039;&#039;&#039;Sekunden&#039;&#039;&#039; (&#039;&#039;JWT-Exp&#039;&#039; × 60)&lt;br /&gt;
|-&lt;br /&gt;
| serverTime || Serverzeit ISO-8601 mit Offset&lt;br /&gt;
|-&lt;br /&gt;
| alle übrigen Felder || &#039;&#039;&#039;Frei vom Skript bestimmt.&#039;&#039;&#039; Übernommen wird alles ausser &#039;&#039;status&#039;&#039; und den &#039;&#039;_OBS_&#039;&#039;-Steuerfeldern&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Damit kann das Anmeldeskript alles mitliefern, was der Client direkt nach dem Login&lt;br /&gt;
braucht (Benutzer-Id, Mandant, Rollen, Berechtigungen) - ohne dass dieser dafür&lt;br /&gt;
einen zweiten Aufruf absetzen muss. Ein Feld, das genauso heisst wie eines der&lt;br /&gt;
Token-Felder oben, wird verworfen; die Engine setzt diese selbst.&lt;br /&gt;
&lt;br /&gt;
===Custom-Claims, serverTime und Refresh===&lt;br /&gt;
&lt;br /&gt;
Das Authenticate-Skript kann dem Token zusätzlich &#039;&#039;&#039;beliebige Custom-Claims&#039;&#039;&#039; mitgeben - z.B. Mandant und Rollen - und optional ein &#039;&#039;&#039;Refresh-Token&#039;&#039;&#039; anstoßen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! RÜckgabefeld !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_CLAIM_&amp;amp;lt;name&amp;amp;gt; || Beliebiger Custom-Claim, z.B. _OBS_JWT_CLAIM_tenant, _OBS_JWT_CLAIM_roles. Im Folge-Skript lesbar als oParams.Values[&#039;_OBS_JWT_CLAIM_&amp;amp;lt;name&amp;amp;gt;&#039;].&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_REFRESH_ID || (optional) jti des Refresh-Tokens. Nur wenn gesetzt, stellt der Server ein Refresh-Token aus.&lt;br /&gt;
|-&lt;br /&gt;
| _OBS_JWT_REFRESH_EXP || (optional) Lebensdauer des Refresh-Tokens in Minuten (Default 90 Tage = 129600).&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Jedes Token trägt automatisch den Claim &#039;&#039;token_use&#039;&#039; (&#039;&#039;access&#039;&#039; bzw. &#039;&#039;refresh&#039;&#039;) sowie die Sitzungskennungen &#039;&#039;sid&#039;&#039; und &#039;&#039;rid&#039;&#039; des Servers. Die Antwort enthält bei Erfolg &#039;&#039;serverTime&#039;&#039; (ISO-8601 mit Offset), bei ausgestelltem Refresh zusätzlich &#039;&#039;refreshToken&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;refreshToken&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;serverTime&amp;quot;:&amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Mandant und Rollen immer aus dem Token lesen (&#039;&#039;_OBS_JWT_CLAIM_*&#039;&#039;), nie aus Body oder Query.}}&lt;br /&gt;
&lt;br /&gt;
====Refresh-Methode====&lt;br /&gt;
&lt;br /&gt;
Der Refresh läuft über &#039;&#039;&#039;denselben JWT-Endpunkt&#039;&#039;&#039; und &#039;&#039;&#039;dasselbe Skript&#039;&#039;&#039;. Liegt am JWT-Endpunkt ein &#039;&#039;Authorization: Bearer &amp;lt;token&amp;gt;&#039;&#039; mit einem Refresh-Token vor, ruft der Server statt &#039;&#039;Authenticate&#039;&#039; die Methode &#039;&#039;&#039;Refresh&#039;&#039;&#039; auf (ohne Bearer: Login; &#039;&#039;DELETE&#039;&#039; mit Access-Token: Abmelden, ohne Skript).&lt;br /&gt;
&lt;br /&gt;
Bevor das Skript läuft, hat der Server das vorgelegte Refresh-Token verifiziert&lt;br /&gt;
(Signatur, Ablauf, &#039;&#039;token_use=refresh&#039;&#039;) und in der Sitzung &#039;&#039;&#039;entwertet&#039;&#039;&#039;.&lt;br /&gt;
Einmalgebrauch, Rotation und Sperrliste sind damit Sache des Servers. Das Skript&lt;br /&gt;
liefert nur noch die aktuellen Claims - genau das ist der Sinn des Aufrufs: eine&lt;br /&gt;
zwischenzeitlich geänderte Rolle oder ein gewechselter Mandant wirken spätestens&lt;br /&gt;
mit der nächsten Erneuerung.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Eine &#039;&#039;&#039;eigene Sperrtabelle im Skript ist nicht mehr nötig&#039;&#039;&#039; und sollte&lt;br /&gt;
entfernt werden. Wer sie weiterführt, rotiert zweimal - einmal im Skript, einmal im&lt;br /&gt;
Server - und riskiert, dass beide Seiten unterschiedlicher Meinung sind. Der&lt;br /&gt;
Server sieht die Skript-Tabelle nicht, und das Skript sieht die Sitzung nicht.}}&lt;br /&gt;
&lt;br /&gt;
Ein bereits benutztes Refresh-Token wird mit &#039;&#039;&#039;401&#039;&#039;&#039; abgelehnt. Erfolgt die&lt;br /&gt;
erneute Vorlage innerhalb von 60 Sekunden, bleibt die Sitzung bestehen (paralleler&lt;br /&gt;
Refresh einer App); danach gilt sie als Wiedervorlage und die &#039;&#039;&#039;gesamte Sitzung&lt;br /&gt;
wird gesperrt&#039;&#039;&#039;. Details in [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
&lt;br /&gt;
Das alte Refresh-jti steht weiterhin als &#039;&#039;oParams.Values[&#039;_OBS_JWT_ID&#039;]&#039;&#039; bereit,&lt;br /&gt;
falls das Skript es für eigene Protokollierung braucht.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Refresh(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes : TJSONObject;&lt;br /&gt;
    cUser: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUser := oParams.Values[&#039;_OBS_JWT_SUBJECT&#039;];&lt;br /&gt;
&lt;br /&gt;
        // Der Server hat das vorgelegte Refresh-Token bereits geprueft und&lt;br /&gt;
        // entwertet. Hier wird nur entschieden, ob der Benutzer die Sitzung&lt;br /&gt;
        // fortsetzen darf - und mit welchen Rechten.&lt;br /&gt;
        if (not BenutzerAktiv(cUser)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;, 9);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039; , &#039;Konto ist nicht mehr aktiv, bitte neu anmelden&#039;);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // Rolle und Mandant neu lesen, nicht aus dem alten Token uebernehmen -&lt;br /&gt;
        // sonst wirkt ein Rechteentzug erst beim naechsten Login.&lt;br /&gt;
        oRes.AddPair(&#039;status&#039;               , 1);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());   // loest das neue Refresh-Token aus&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
===Abmelden (Logout)===&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Für das Abmelden braucht das Skript keine Methode.&#039;&#039;&#039; Es läuft über ein&lt;br /&gt;
&#039;&#039;DELETE&#039;&#039; auf denselben JWT-Endpunkt (Access-Token im &#039;&#039;Authorization&#039;&#039;-Header)&lt;br /&gt;
und wird komplett in der Engine erledigt: die Sitzung wird gesperrt, die Antwort&lt;br /&gt;
ist 204. Eine Methode &#039;&#039;Logout&#039;&#039; gibt es nicht und wird nicht aufgerufen.&lt;br /&gt;
Details: [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]], Phase 4.&lt;br /&gt;
&lt;br /&gt;
Wer beim Abmelden zusätzlich fachlich etwas tun will - eine Geräteregistrierung&lt;br /&gt;
löschen, einen Push-Token verwerfen, den Vorgang protokollieren - nimmt einen&lt;br /&gt;
&#039;&#039;&#039;gewöhnlichen Endpunkt&#039;&#039;&#039; und setzt dort &#039;&#039;_OBS_JWT_REVOKE&#039;&#039;. Dasselbe gilt für&lt;br /&gt;
&amp;quot;auf allen Geräten abmelden&amp;quot;, weil das eine Berechtigungsentscheidung braucht:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes: TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        GeraeteRegistrierungLoeschen(oParams.Values[&#039;_OBS_JWT_SUBJECT&#039;]);&lt;br /&gt;
&lt;br /&gt;
        // &#039;session&#039; = diese Sitzung, &#039;all&#039; = alle Sitzungen des Subjects&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_REVOKE&#039; , &#039;session&#039;);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 204);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Gesperrt wird in beiden Wegen die &#039;&#039;&#039;ganze Sitzung&#039;&#039;&#039;, also auch die noch nicht&lt;br /&gt;
abgelaufenen Access-Token vorheriger Erneuerungen. Ein wiederholter Aufruf ist&lt;br /&gt;
unschädlich. Lässt sich die Sitzung nicht sperren, überschreibt der Server die&lt;br /&gt;
Antwort des Skripts mit &#039;&#039;&#039;503&#039;&#039;&#039; - ein Client darf nicht glauben, er sei&lt;br /&gt;
abgemeldet, während sein Token weiterläuft.&lt;br /&gt;
&lt;br /&gt;
==Fehlerbehandlung im Skript==&lt;br /&gt;
&lt;br /&gt;
Tritt im Skript eine Exception auf oder schlägt die Syntax-Prüfung fehl, antwortet der Server mit 500 &#039;&#039;Interner Fehler&#039;&#039; und protokolliert die Detail-Meldung in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; (mit Skript-Fehlertext). Der Konsument sieht keine internen Details.&lt;br /&gt;
&lt;br /&gt;
Server-eigene Fehler (Routing, Rate-Limit, Token, Auffangnetz) haben ein&lt;br /&gt;
einheitliches Format mit einem &#039;&#039;error&#039;&#039;-Objekt - Aufbau siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer|Übersicht]].&lt;br /&gt;
&lt;br /&gt;
Will das Skript einen spezifischen Fehler an den Konsumenten zurückgeben, sollte es&lt;br /&gt;
&#039;&#039;&#039;dasselbe Format und einen passenden Statuscode&#039;&#039;&#039; verwenden:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oErr: TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        if (Empty(oParams.Values[&#039;kundennr&#039;])) then begin&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;VALIDATION_FAILED&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;Parameter &#039;&#039;kundennr&#039;&#039; fehlt&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 422);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
        // ...&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein fachlicher Fehler gehört auf einen &#039;&#039;&#039;4xx&#039;&#039;&#039;-Statuscode, nicht auf&lt;br /&gt;
200 mit Fehlertext im Body. Nur so erkennt der Konsument den Fehlschlag ohne den&lt;br /&gt;
Body auszuwerten - und nur so gibt der Server einen belegten&lt;br /&gt;
&#039;&#039;Idempotency-Key&#039;&#039; wieder frei (siehe Abschnitt Idempotenz).}}&lt;br /&gt;
&lt;br /&gt;
Gängige Codes, die auch der Server selbst verwendet: &#039;&#039;VALIDATION_FAILED&#039;&#039; (422),&lt;br /&gt;
&#039;&#039;NOT_FOUND&#039;&#039; (404), &#039;&#039;FORBIDDEN_ROLE&#039;&#039; (403), &#039;&#039;VERSION_CONFLICT&#039;&#039; (409).&lt;br /&gt;
Eigene, fachlich sprechende Codes sind erlaubt - wichtig ist, dass sie stabil&lt;br /&gt;
bleiben, weil Konsumenten darauf ihre Reaktion aufbauen.&lt;br /&gt;
&lt;br /&gt;
==Gemeinsamen Code auslagern==&lt;br /&gt;
&lt;br /&gt;
Wird Logik in mehreren Endpunkten gebraucht - Berechtigungsprüfungen,&lt;br /&gt;
JSON-Bausteine, Hilfsfunktionen -, muss sie nicht in jedes Skript kopiert werden.&lt;br /&gt;
Die Skript-Engine lädt Textbausteine beim Laden aus einer beliebigen Tabelle nach:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{$L T=&amp;quot;restsrv_endpoints&amp;quot; I=&amp;quot;re_pathtemplate&amp;quot; V=&amp;quot;/meinapp/lib&amp;quot; F=&amp;quot;re_script&amp;quot;}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Attribut !! Bedeutung&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;T&#039;&#039;&#039; || Tabelle, aus der geladen wird&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;I&#039;&#039;&#039; || Spalte, über die gesucht wird&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;V&#039;&#039;&#039; || Wert, der in dieser Spalte stehen muss&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;F&#039;&#039;&#039; || Spalte, deren Inhalt an dieser Stelle eingesetzt wird&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Die Direktive steht üblicherweise direkt unter dem Kopfkommentar des Skripts. Der&lt;br /&gt;
Compiler sieht danach den zusammengesetzten Text - eine Funktion aus der Lib lässt&lt;br /&gt;
sich also aufrufen wie eine im Skript selbst geschriebene.&lt;br /&gt;
&lt;br /&gt;
===Bewährtes Muster: Lib als eigene Endpunkt-Zeile===&lt;br /&gt;
&lt;br /&gt;
Am wenigsten Verwaltung macht es, die Lib &#039;&#039;&#039;als eigene Zeile in&lt;br /&gt;
RESTSRV_ENDPOINTS&#039;&#039;&#039; abzulegen:&lt;br /&gt;
&lt;br /&gt;
# Endpunkt anlegen mit einem eigenen Pfad-Template, z.B. &amp;lt;code&amp;gt;/meinapp/lib&amp;lt;/code&amp;gt;.&lt;br /&gt;
# Den Lib-Quelltext ins Skript-Feld dieser Zeile schreiben (F7).&lt;br /&gt;
# &#039;&#039;&#039;Keine Berechtigung&#039;&#039;&#039; in der Berechtigungs-Liste vergeben - das ist der Schutz: ohne Berechtigung beantwortet der Server einen Aufruf mit 403.&lt;br /&gt;
# In jedem nutzenden Endpunkt die Direktive oben einfügen.&lt;br /&gt;
&lt;br /&gt;
Damit ist die Lib reiner Ablageort und von aussen nicht nutzbar. Eine Änderung&lt;br /&gt;
wirkt auf alle einbindenden Endpunkte, ohne dass ein einziges Endpunkt-Skript&lt;br /&gt;
angefasst werden muss.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;&#039;Lib und Skripte gemeinsam einspielen.&#039;&#039;&#039; Kommt in der Lib eine neue&lt;br /&gt;
Funktion hinzu, genügt es nicht, nur das nutzende Skript zu aktualisieren. Eine&lt;br /&gt;
ältere Lib lädt fehlerfrei - der Compiler meldet dann ausschliesslich die neuen&lt;br /&gt;
Namen als &#039;&#039;Unknown name&#039;&#039;, was leicht wie ein Fehler im Skript aussieht.}}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Auch hier greift der Endpunkt-Cache: eine Änderung an der Lib wird erst&lt;br /&gt;
nach Ablauf des TTL (bis zu 60 Sekunden) wirksam.}}&lt;br /&gt;
&lt;br /&gt;
==Fallstricke im Skript==&lt;br /&gt;
&lt;br /&gt;
Die folgenden Punkte unterscheiden das Skript von normalem Delphi-Code und kosten&lt;br /&gt;
sonst eine Runde Fehlersuche:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Stolperstein !! Richtig&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;DB_SOpen(&#039;Y00…&#039;, oDB, …)&#039;&#039; - der AD-UID-Parameter aus dem Delphi-Code || Im Skript &#039;&#039;&#039;ohne UID&#039;&#039;&#039;: &amp;lt;code&amp;gt;DB_SOpen(oDB, cSql, q)&amp;lt;/code&amp;gt;. Gleiches gilt für &amp;lt;code&amp;gt;qSqlInit(oDB, &#039;tab&#039;)&amp;lt;/code&amp;gt; und &amp;lt;code&amp;gt;qSqlRead(oDB, &#039;tab&#039;, cWhere)&amp;lt;/code&amp;gt; - jede dieser Funktionen beginnt mit &#039;&#039;oDB&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;oBody.TryGetValue&amp;amp;lt;T&amp;amp;gt;(…)&#039;&#039; || Generics gibt es nicht - über &#039;&#039;GetValue&#039;&#039; lesen (siehe oBody)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;EMPTY_DATE&#039;&#039; für ein leeres Datum || &#039;&#039;EMPTY_DATE&#039;&#039; ist im Skript ein &#039;&#039;&#039;String&#039;&#039;&#039;. Für Datumsvariablen und -vergleiche &#039;&#039;&#039;MINDATETIME&#039;&#039;&#039; verwenden&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;fVal&#039;&#039; auf einen JSON-Zahlenwert || JSON liefert den Dezimalpunkt, &#039;&#039;fVal&#039;&#039; erwartet die lokale Notation: vorher &amp;lt;code&amp;gt;StrTran(cVal, &#039;.&#039;, &#039;,&#039;)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;TStringList&#039;&#039; für Zwischenlisten || In Skripten unzuverlässig. Kleine Mengen über einen Delimiter-String führen (&amp;lt;code&amp;gt;&#039;&amp;amp;#124;&#039; + wert + &#039;&amp;amp;#124;&#039;&amp;lt;/code&amp;gt;) und mit &#039;&#039;Pos&#039;&#039; prüfen&lt;br /&gt;
|-&lt;br /&gt;
| Default-Parameter in eigenen Funktionen || Werden nicht unterstützt - alle Parameter ausschreiben&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==Skript-Cache==&lt;br /&gt;
&lt;br /&gt;
Der Server cached das kompilierte Skript pro Endpunkt (Schlüssel: &#039;&#039;sys_date&#039;&#039; 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.&lt;br /&gt;
&lt;br /&gt;
==Verfügbare Bibliotheken==&lt;br /&gt;
&lt;br /&gt;
Im Skript können alle OBS-Standard-Bibliotheken verwendet werden. Typische Einstiegspunkte:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;Base.Tools&#039;&#039; - String-, Datum-, IIF-, Empty-Helper&lt;br /&gt;
* &#039;&#039;Base.DB&#039;&#039; / &#039;&#039;Base.xQuery&#039;&#039; - Datenbank-Operationen&lt;br /&gt;
* &#039;&#039;Base.qSqlReg&#039;&#039; - Schreibzugriffe&lt;br /&gt;
* &#039;&#039;System.JSON&#039;&#039; - JSON-Objekte und -Arrays&lt;br /&gt;
* &#039;&#039;Base.ToolsConst&#039;&#039; - Konstanten wie &#039;&#039;CRLF&#039;&#039;, &#039;&#039;SINGELQUOTE&#039;&#039;, &#039;&#039;MINDATETIME&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
Die wichtigsten Aufrufe mit ihren Skript-Signaturen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Zweck !! Aufruf&lt;br /&gt;
|-&lt;br /&gt;
| Lesen || &amp;lt;code&amp;gt;DB_SOpen(oDB, cSql, q)&amp;lt;/code&amp;gt;, dann &amp;lt;code&amp;gt;q.EoF&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.Next()&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.A2C(&#039;feld&#039;)&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2I&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2F&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2D&amp;lt;/code&amp;gt; / &amp;lt;code&amp;gt;A2UID()&amp;lt;/code&amp;gt;, am Ende &amp;lt;code&amp;gt;DB_Close(q)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Neu anlegen || &amp;lt;code&amp;gt;q := qSqlInit(oDB, &#039;tab&#039;)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.qSet(&#039;feld&#039;, wert)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.SaveData(NEW_RECORD)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;qSqlFree(q)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Ändern || &amp;lt;code&amp;gt;q := qSqlRead(oDB, &#039;tab&#039;, cWhere)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.qSet(…)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;q.SaveData(UPDATE_RECORD)&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;qSqlFree(q)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Direkt ausführen || &amp;lt;code&amp;gt;DB_SqlExec(oDB, cSql)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Existenzprüfung || &amp;lt;code&amp;gt;DB_LSeek(oDB, &#039;tab&#039;, cWhere)&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| Werte quoten (Pflicht) || &amp;lt;code&amp;gt;DB_SQLVal(wert)&amp;lt;/code&amp;gt;&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Alle diese Funktionen beginnen im Skript mit &#039;&#039;oDB&#039;&#039;. Ein zusätzlicher&lt;br /&gt;
AD-UID-Parameter als erstes Argument gehört zur Delphi-Variante und lässt sich im&lt;br /&gt;
Skript nicht übersetzen.}}&lt;br /&gt;
&lt;br /&gt;
Konkrete Beispiele:&lt;br /&gt;
&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel1|Beispiel 1: Daten-Abruf mit JWT]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel2|Beispiel 2: Zeiterfassung als komplette Webanwendung]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel3|Beispiel 3: Datensatz anlegen mit JSON-Body (CRUD)]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel4|Beispiel 4: Pfad-Parameter im Routing]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel5|Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz]]&lt;br /&gt;
* [[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6: Datei-Upload und Ablage im DMS]]&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Zugaenge&amp;diff=64686</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Zugaenge</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Zugaenge&amp;diff=64686"/>
		<updated>2026-08-19T12:00:08Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Zugänge=&lt;br /&gt;
&lt;br /&gt;
Ein &#039;&#039;&#039;Zugang&#039;&#039;&#039; (Account) ist der Kanal, über den ein externer Konsument den REST-Server anspricht. Er bündelt API-Key, Host-Beschränkung, CORS-Konfiguration, optionale JWT-Authentifizierung und optionale mTLS-Subject-Prüfung.&lt;br /&gt;
&lt;br /&gt;
==Aufruf==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Stammdaten -&amp;gt; Z Weitere Stammdaten -&amp;gt; REST-Server -&amp;gt; Zugänge&#039;&#039;&#039;&lt;br /&gt;
&lt;br /&gt;
==Felder==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld          !! Beschreibung&lt;br /&gt;
|-&lt;br /&gt;
| Name          || Sprechender Name, erscheint im Protokoll und der Statistik&lt;br /&gt;
|-&lt;br /&gt;
| Aktiv         || Inaktive Zugänge werden bei der Authentifizierung mit 403 abgewiesen&lt;br /&gt;
|-&lt;br /&gt;
| API-Key       || Eindeutiger Schlüssel, den der Konsument im HTTP-Header &#039;&#039;apikey&#039;&#039; senden muss. Per Schlüsselsymbol generierbar&lt;br /&gt;
|-&lt;br /&gt;
| Host          || Optionaler Hostname oder IP-Adresse des Konsumenten. Stimmt die anfragende IP nicht überein, wird mit 403 abgelehnt&lt;br /&gt;
|-&lt;br /&gt;
| CORS-Origins  || Liste erlaubter Origins für Browser-Aufrufe, mehrere durch &#039;&#039;;&#039;&#039; getrennt&lt;br /&gt;
|-&lt;br /&gt;
| mTLS-Subject  || Erwartete RDN-Komponente(n) im Client-Zertifikat-Subject, Komma-/Semikolon-getrennt (alle müssen vorkommen; nur sinnvoll bei mTLS-Servern)&lt;br /&gt;
|-&lt;br /&gt;
| JWT           || Schalter, ob für diesen Zugang ein JWT-Token nötig ist&lt;br /&gt;
|-&lt;br /&gt;
| JWT-Endpunkt  || Pfad, über den &#039;&#039;&#039;alle Token-Vorgänge&#039;&#039;&#039; laufen: Anmelden, Erneuern und Abmelden (z.B. &#039;&#039;oauth&#039;&#039; oder &#039;&#039;meineapp/dev/auth&#039;&#039;). Verglichen wird der &#039;&#039;&#039;vollständige Pfad&#039;&#039;&#039;, mehrstufige Angaben mit &#039;&#039;/&#039;&#039; sind also erlaubt&lt;br /&gt;
|-&lt;br /&gt;
| JWT-Key       || Geheimer Schlüssel zur Signierung/Verifikation, per Schlüsselsymbol generierbar&lt;br /&gt;
|-&lt;br /&gt;
| JWT-Exp       || Gültigkeitsdauer eines ausgestellten Access-Tokens in &#039;&#039;&#039;Minuten&#039;&#039;&#039;. Derselbe Wert wird dem Konsumenten in der Token-Antwort als &#039;&#039;expiresIn&#039;&#039; in &#039;&#039;&#039;Sekunden&#039;&#039;&#039; mitgeteilt und steht dem Endpunkt-Skript als &#039;&#039;_OBS_JWT_EXP_SEC&#039;&#039; zur Verfügung&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==API-Key==&lt;br /&gt;
&lt;br /&gt;
Der API-Key ist die Basis-Authentifizierung. Der Client übergibt ihn im HTTP-Header:&lt;br /&gt;
&lt;br /&gt;
 apikey: abcd-efgh-ijkl-mnop-qrstuvwxyz12&lt;br /&gt;
&lt;br /&gt;
Wird ein Aufruf ohne &#039;&#039;apikey&#039;&#039;-Header gestellt, sucht der Server intern nach einem Zugang mit API-Key &#039;&#039;&#039;*&#039;&#039;&#039; - damit lassen sich (mit Vorsicht!) auch öffentliche Endpunkte ohne Authentifizierung realisieren.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein Zugang mit API-Key &#039;&#039;*&#039;&#039; sollte nur für bewusst freigegebene Endpunkte verwendet werden und immer in Kombination mit einer Host-Beschränkung oder CORS-Beschränkung betrieben werden.}}&lt;br /&gt;
&lt;br /&gt;
==Host-Beschränkung==&lt;br /&gt;
&lt;br /&gt;
Ist im Feld &#039;&#039;&#039;Host&#039;&#039;&#039; ein Wert hinterlegt, akzeptiert der Server Anfragen unter diesem Zugang nur dann, wenn die Remote-IP exakt diesem Host entspricht oder durch DNS-Auflösung auf diese IP zeigt. DNS-Auflösungen werden 5 Minuten gecached.&lt;br /&gt;
&lt;br /&gt;
==CORS==&lt;br /&gt;
&lt;br /&gt;
Soll ein Endpunkt aus einer Browser-Anwendung (Single-Page-App, JavaScript) heraus aufgerufen werden, muss das Origin der Anwendung in der CORS-Liste des Zugangs eingetragen sein.&lt;br /&gt;
&lt;br /&gt;
Format der Liste:&lt;br /&gt;
&lt;br /&gt;
 https://app.kunde.de;https://test.kunde.de;https://localhost:8080&lt;br /&gt;
&lt;br /&gt;
Ablauf:&lt;br /&gt;
&lt;br /&gt;
# Browser sendet einen Preflight-Request (&#039;&#039;OPTIONS&#039;&#039;). Der REST-Server prüft das Origin gegen die globale Origin-Liste aller Accounts (Cache, TTL 60s) und antwortet mit 204 + CORS-Header, falls erlaubt.&lt;br /&gt;
# Der eigentliche Request wird gegen die Origin-Liste des aktuell verwendeten Zugangs geprüft.&lt;br /&gt;
# Stimmt das Origin nicht, wird mit 403 &#039;&#039;Origin nicht erlaubt für Account ...&#039;&#039; abgelehnt.&lt;br /&gt;
&lt;br /&gt;
Erlaubte Methoden: &#039;&#039;GET, POST, PUT, DELETE, PATCH&#039;&#039;. Erlaubte Header: &#039;&#039;Content-Type, apikey, Authorization, Accept&#039;&#039;. Diese Werte sind systemseitig festgelegt und werden in der Preflight-Antwort zurückgegeben.&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierung==&lt;br /&gt;
&lt;br /&gt;
Wird ein Zugang mit aktiver JWT-Pflicht aufgerufen, sind die folgenden Phasen zu durchlaufen (Phase 3 nur bei genutztem Refresh).&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;&#039;Anmelden, Erneuern und Abmelden laufen über denselben Endpunkt&#039;&#039;&#039; - den im&lt;br /&gt;
Feld JWT-Endpunkt hinterlegten Pfad. Ein eigener Endpunkt fürs Abmelden ist&lt;br /&gt;
nicht nötig und wäre auch umständlicher: der JWT-Endpunkt greift &#039;&#039;&#039;vor&#039;&#039;&#039; der&lt;br /&gt;
Adressauflösung und braucht deshalb weder eine Zeile in &#039;&#039;RESTSRV_ENDPOINTS&#039;&#039;&lt;br /&gt;
noch eine Berechtigung in &#039;&#039;RESTSRV_ACCESS&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Unterschieden wird nach Verb und Token-Art:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Aufruf !! Ablauf&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;POST&#039;&#039; ohne &#039;&#039;Authorization&#039;&#039; || Anmelden (Skript-Methode &#039;&#039;Authenticate&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;POST&#039;&#039; mit Bearer, Refresh-Token || Erneuern (Skript-Methode &#039;&#039;Refresh&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;DELETE&#039;&#039; mit Bearer, Access-Token || Abmelden (kein Skript nötig)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;POST&#039;&#039; mit Bearer, Access-Token || &#039;&#039;&#039;401&#039;&#039;&#039; - unverändert&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Dass das Abmelden am Verb hängt und nicht allein an der Token-Art, ist&lt;br /&gt;
Absicht. Würde ein vorgelegtes Access-Token beim &#039;&#039;POST&#039;&#039; als Abmeldung gedeutet,&lt;br /&gt;
würde ein Client, der seine beiden Token verwechselt, den Anwender still&lt;br /&gt;
abmelden - statt das klare 401 zu bekommen, an dem der Fehler auffällt.}}&lt;br /&gt;
&lt;br /&gt;
===Phase 1: Token-Bezug===&lt;br /&gt;
&lt;br /&gt;
Der Konsument ruft den JWT-Endpunkt des Zugangs auf, z.B.:&lt;br /&gt;
&lt;br /&gt;
 POST https://api.meinserver.de/oauth/&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
 Body:   {&amp;quot;username&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;password&amp;quot;:&amp;quot;...&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Der Server führt das im Zugang hinterlegte &#039;&#039;&#039;Authentifizierungs-Skript&#039;&#039;&#039; aus (F7 in der Zugangs-Liste). Das Skript muss eine Funktion &#039;&#039;Authenticate&#039;&#039; bereitstellen, die ein JSON-Objekt mit:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;status&#039;&#039; = &#039;&#039;1&#039;&#039; bei Erfolg, &#039;&#039;9&#039;&#039; bei Misserfolg&lt;br /&gt;
* &#039;&#039;error&#039;&#039; = Fehlertext (bei status=9)&lt;br /&gt;
* &#039;&#039;_OBS_JWT_ID&#039;&#039;, &#039;&#039;_OBS_JWT_SUBJECT&#039;&#039;, &#039;&#039;_OBS_JWT_AUDIENCE&#039;&#039; für die JWT-Claims (optional)&lt;br /&gt;
* &#039;&#039;_OBS_JWT_CLAIM_&amp;amp;lt;name&amp;amp;gt;&#039;&#039; für beliebige Custom-Claims (z.B. Mandant &#039;&#039;tenant&#039;&#039;, Rollen &#039;&#039;roles&#039;&#039;); im Folge-Skript lesbar&lt;br /&gt;
* &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; (optional) löst die Ausstellung eines Refresh-Tokens aus; &#039;&#039;_OBS_JWT_REFRESH_EXP&#039;&#039; setzt dessen Lebensdauer in Minuten (Default 90 Tage)&lt;br /&gt;
&lt;br /&gt;
zurückliefert. Bei Erfolg signiert der Server einen Token (HS256, Issuer &#039;&#039;OBS REST-Server&#039;&#039;, Lebenszeit gemäß JWT-Exp) und antwortet:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:        &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;accessToken&amp;quot;:  &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:    28800,&lt;br /&gt;
  &amp;quot;serverTime&amp;quot;:   &amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Zum Aufbau der Antwort:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;token&#039;&#039; und &#039;&#039;accessToken&#039;&#039; enthalten &#039;&#039;&#039;denselben&#039;&#039;&#039; Token. &#039;&#039;accessToken&#039;&#039; ist der Name, den die meisten Client-Bibliotheken erwarten; &#039;&#039;token&#039;&#039; bleibt für bestehende Konsumenten erhalten.&lt;br /&gt;
* &#039;&#039;expiresIn&#039;&#039; ist die Gültigkeit in &#039;&#039;&#039;Sekunden&#039;&#039;&#039; (JWT-Exp × 60).&lt;br /&gt;
* &#039;&#039;&#039;Alle weiteren Felder, die das Authenticate-Skript zurückgibt, werden mit ausgeliefert&#039;&#039;&#039; - mit Ausnahme von &#039;&#039;status&#039;&#039; und den &#039;&#039;_OBS_&#039;&#039;-Steuerfeldern. So kann das Skript z.B. eine Benutzer-Id, den Mandanten oder Rollen direkt in die Anmelde-Antwort legen, ohne dass der Client dafür einen zweiten Aufruf braucht.&lt;br /&gt;
&lt;br /&gt;
Beispiel mit Zusatzfeldern aus dem Skript:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;benutzerId&amp;quot;: &amp;quot;4711&amp;quot;, &amp;quot;tenant&amp;quot;: &amp;quot;nord&amp;quot;, &amp;quot;roles&amp;quot;: [&amp;quot;TECHNIKER&amp;quot;],&lt;br /&gt;
  &amp;quot;token&amp;quot;: &amp;quot;eyJ...&amp;quot;, &amp;quot;accessToken&amp;quot;: &amp;quot;eyJ...&amp;quot;, &amp;quot;refreshToken&amp;quot;: &amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;: 28800, &amp;quot;serverTime&amp;quot;: &amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Mit der Ausstellung legt der Server zu dem Token-Paar eine &#039;&#039;&#039;Sitzungszeile&#039;&#039;&#039; in&lt;br /&gt;
&#039;&#039;RESTSRV_TOKEN&#039;&#039; an. Lässt sich diese Zeile nicht schreiben (Tabelle fehlt,&lt;br /&gt;
Datenbankproblem), wird &#039;&#039;&#039;kein Token ausgeliefert&#039;&#039;&#039;: der Server antwortet mit&lt;br /&gt;
&#039;&#039;&#039;503&#039;&#039;&#039; und &#039;&#039;Retry-After&#039;&#039;. Das ist bewusst von der abgelehnten Anmeldung&lt;br /&gt;
unterschieden - die Zugangsdaten waren in Ordnung, der Versuch darf wiederholt&lt;br /&gt;
werden.&lt;br /&gt;
&lt;br /&gt;
Wird die Anmeldung abgelehnt (&#039;&#039;status&#039;&#039; = 9), antwortet der Server mit&lt;br /&gt;
&#039;&#039;&#039;401 Unauthorized&#039;&#039;&#039; und reicht den Text aus dem Feld &#039;&#039;error&#039;&#039; als&lt;br /&gt;
&#039;&#039;error.message&#039;&#039; an den Client durch - der Konsument kann ihn also direkt&lt;br /&gt;
anzeigen. Die Formulierung liegt damit beim Skript.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Der durchgereichte Text ist für den Endanwender gedacht und sollte&lt;br /&gt;
keine internen Details verraten. „Benutzer oder Passwort ist falsch&amp;quot; ist eine gute&lt;br /&gt;
Meldung, „Kein Datensatz in BENUTZ mit b_name=meier&amp;quot; nicht.}}&lt;br /&gt;
&lt;br /&gt;
===Phase 2: Token-Verwendung===&lt;br /&gt;
&lt;br /&gt;
Bei allen folgenden Aufrufen sendet der Client den Token im &#039;&#039;Authorization&#039;&#039;-Header:&lt;br /&gt;
&lt;br /&gt;
 GET https://api.meinserver.de/kalender/v1&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
         Authorization: Bearer eyJhbGciOi...&lt;br /&gt;
&lt;br /&gt;
Der Server verifiziert Signatur, Issuer, Ausstellzeitpunkt und Ablauf (Toleranz: 20 Sekunden Clock-Skew) und prüft anschliessend die &#039;&#039;&#039;Sitzung&#039;&#039;&#039; in &#039;&#039;RESTSRV_TOKEN&#039;&#039;. Im Skript stehen die Claims als Parameter &#039;&#039;_OBS_JWT_ID&#039;&#039;, &#039;&#039;_OBS_JWT_SUBJECT&#039;&#039; und &#039;&#039;_OBS_JWT_AUDIENCE&#039;&#039; zur Verfügung.&lt;br /&gt;
&lt;br /&gt;
Die Sitzungsprüfung weist ein Token ab, das zu keiner Zeile gehört oder dessen&lt;br /&gt;
Sitzung gesperrt wurde - beides mit &#039;&#039;&#039;401&#039;&#039;&#039; und dem Code &#039;&#039;AUTH_EXPIRED&#039;&#039;. Der&lt;br /&gt;
Grund steht jeweils im Protokoll &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; unter &#039;&#039;TokenStore: ...&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein Token, das &#039;&#039;&#039;vor&#039;&#039;&#039; der Einführung dieser Prüfung ausgestellt&lt;br /&gt;
wurde, gehört zu keiner Sitzung und wird abgewiesen. Nach dem Update müssen sich&lt;br /&gt;
angemeldete Konsumenten also einmalig neu anmelden.}}&lt;br /&gt;
&lt;br /&gt;
===Phase 3: Token erneuern (Refresh)===&lt;br /&gt;
&lt;br /&gt;
Soll die App lange ohne Neuanmeldung arbeiten, stellt das Authenticate-Skript zusätzlich ein Refresh-Token aus (Feld &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039;). Zum Erneuern ruft der Client &#039;&#039;&#039;denselben JWT-Endpunkt&#039;&#039;&#039; auf, aber mit dem Refresh-Token im &#039;&#039;Authorization&#039;&#039;-Header:&lt;br /&gt;
&lt;br /&gt;
 POST https://api.meinserver.de/oauth/&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
         Authorization: Bearer eyJhbGciOi... (Refresh-Token)&lt;br /&gt;
&lt;br /&gt;
Der Server erkennt am Bearer-Token den Refresh-Fall, verifiziert das Token (Signatur, Ablauf, &#039;&#039;token_use=refresh&#039;&#039;), &#039;&#039;&#039;entwertet es in der Sitzung&#039;&#039;&#039; und ruft erst dann im selben Skript die Methode &#039;&#039;&#039;Refresh&#039;&#039;&#039; auf. Das Skript liefert nur noch die aktuellen Claims zurück - Rotation, Einmalgebrauch und Sperrliste erledigt der Server. Die Antwort ist ein rotiertes Paar &#039;&#039;{&amp;quot;token&amp;quot;,&amp;quot;accessToken&amp;quot;,&amp;quot;refreshToken&amp;quot;,&amp;quot;expiresIn&amp;quot;,&amp;quot;serverTime&amp;quot;}&#039;&#039; in &#039;&#039;&#039;derselben Sitzung&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
{{Achtung|Eine &#039;&#039;&#039;eigene Sperrtabelle im Skript ist nicht mehr nötig&#039;&#039;&#039; und sollte&lt;br /&gt;
entfernt werden. Wer sie weiterführt, rotiert zweimal: einmal im Skript, einmal im&lt;br /&gt;
Server. Der Server sieht die Skript-Tabelle nicht, und das Skript sieht die&lt;br /&gt;
Sitzung nicht.}}&lt;br /&gt;
&lt;br /&gt;
Verhalten bei einem bereits benutzten Refresh-Token:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Lage !! Antwort !! Wirkung auf die Sitzung&lt;br /&gt;
|-&lt;br /&gt;
| Erneute Vorlage &#039;&#039;&#039;innerhalb von 60 Sekunden&#039;&#039;&#039; || 401 &#039;&#039;AUTH_EXPIRED&#039;&#039; || Keine. Das ist der Normalfall einer App, deren parallele Anfragen gleichzeitig in den Refresh laufen - eine gewinnt, die anderen sollen die vom Gewinner gespeicherten Token benutzen&lt;br /&gt;
|-&lt;br /&gt;
| Erneute Vorlage &#039;&#039;&#039;später&#039;&#039;&#039; || 401 &#039;&#039;AUTH_EXPIRED&#039;&#039; || Die &#039;&#039;&#039;gesamte Sitzung wird gesperrt&#039;&#039;&#039;. Das Token wurde kopiert oder wiederholt, ohne dass ein Gewinner es noch brauchen könnte; der Vorfall wird mit IP protokolliert&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Details und ein vollständiges Beispiel siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
===Phase 4: Abmelden (Logout)===&lt;br /&gt;
&lt;br /&gt;
Abgemeldet wird mit einem &#039;&#039;&#039;DELETE&#039;&#039;&#039; auf denselben JWT-Endpunkt, das&lt;br /&gt;
Access-Token im &#039;&#039;Authorization&#039;&#039;-Header:&lt;br /&gt;
&lt;br /&gt;
 DELETE https://api.meinserver.de/oauth/&lt;br /&gt;
 Header: apikey: [API-KEY]&lt;br /&gt;
         Authorization: Bearer eyJhbGciOi... (Access-Token)&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 204 No Content&lt;br /&gt;
&lt;br /&gt;
Der Server sperrt die &#039;&#039;&#039;ganze Sitzung&#039;&#039;&#039; - also auch die noch nicht&lt;br /&gt;
abgelaufenen Access-Token aller vorherigen Erneuerungen. &#039;&#039;&#039;Ein Skript ist dafür&lt;br /&gt;
nicht nötig&#039;&#039;&#039;: es gibt keine Methode &#039;&#039;Logout&#039;&#039;, der Vorgang läuft komplett in&lt;br /&gt;
der Engine. Damit funktioniert das Abmelden für jeden Zugang mit aktiver&lt;br /&gt;
JWT-Pflicht, ohne dass am Authentifizierungs-Skript etwas geändert wird.&lt;br /&gt;
&lt;br /&gt;
* Ein wiederholter Aufruf ist unschädlich und bleibt &#039;&#039;&#039;204&#039;&#039;&#039; - auch wenn die Sitzung längst gesperrt ist.&lt;br /&gt;
* Ein abgelaufenes oder ungültiges Token ergibt &#039;&#039;&#039;401&#039;&#039;&#039; (es wird schon bei der Prüfung abgewiesen).&lt;br /&gt;
* Ein Refresh-Token statt des Access-Tokens ergibt &#039;&#039;&#039;401&#039;&#039;&#039;.&lt;br /&gt;
* Lässt sich die Sitzung nicht sperren, antwortet der Server &#039;&#039;&#039;503&#039;&#039;&#039; mit &#039;&#039;Retry-After&#039;&#039; - ein Client darf nicht glauben, er sei abgemeldet, während sein Token weiterläuft.&lt;br /&gt;
&lt;br /&gt;
====Alle Geräte abmelden====&lt;br /&gt;
&lt;br /&gt;
Der Fall „Gerät verloren&amp;quot; ist bewusst &#039;&#039;&#039;nicht&#039;&#039;&#039; am JWT-Endpunkt untergebracht:&lt;br /&gt;
er braucht eine fachliche Entscheidung darüber, wer das darf. Dafür gibt es das&lt;br /&gt;
Antwortfeld &#039;&#039;_OBS_JWT_REVOKE&#039;&#039;, das jeder gewöhnliche Endpunkt setzen kann:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes: TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        // &#039;session&#039; = nur diese Sitzung, &#039;all&#039; = alle Sitzungen des Subjects&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_REVOKE&#039; , &#039;all&#039;);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 204);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|&#039;&#039;all&#039;&#039; setzt voraus, dass das Authenticate-Skript &#039;&#039;_OBS_JWT_SUBJECT&#039;&#039;&lt;br /&gt;
gesetzt hat. Ohne Subject lässt sich die Menge der Sitzungen nicht bestimmen, und&lt;br /&gt;
der Aufruf antwortet mit 503.}}&lt;br /&gt;
&lt;br /&gt;
==Sitzungen (RESTSRV_TOKEN)==&lt;br /&gt;
&lt;br /&gt;
Je ausgestelltem Token-Paar entsteht eine Zeile. Sie enthält die Sitzung, die&lt;br /&gt;
Zeilenkennung, das Subject, die beiden Laufzeiten, IP und Trace-ID der Ausstellung&lt;br /&gt;
sowie die Kennzeichen „verbraucht&amp;quot; und „gesperrt&amp;quot;.&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Rotation und Widerruf sind getrennt.&#039;&#039;&#039; Ein Refresh entwertet nur das Refresh-Token; der zugehörige Access-Token bleibt bis zu seinem Ablauf gültig. Sonst bekämen parallel laufende Anfragen ein 401, obwohl niemand abgemeldet ist.&lt;br /&gt;
* &#039;&#039;&#039;Die Sitzung überlebt die Rotation.&#039;&#039;&#039; Alle Paare einer Anmeldung tragen dieselbe Sitzungskennung - deshalb kann ein Logout sie gemeinsam sperren.&lt;br /&gt;
* &#039;&#039;&#039;Aufräumen läuft automatisch&#039;&#039;&#039; beim Serverstart und danach höchstens stündlich. Gelöscht wird nur, was nichts mehr entscheiden kann: Zeilen, deren Access- &#039;&#039;&#039;und&#039;&#039;&#039; Refresh-Laufzeit vorbei sind. Eine verbrauchte Zeile bleibt bis dahin stehen, weil sonst die Erkennung eines später vorgelegten gestohlenen Refresh-Tokens verloren ginge.&lt;br /&gt;
* &#039;&#039;&#039;Kein Eingriff nötig.&#039;&#039;&#039; Die Tabelle wird nicht gepflegt; sie ist Diagnose-Material. Alle Abweisungen stehen als &#039;&#039;TokenStore: ...&#039;&#039; in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==mTLS-Subject-Prüfung==&lt;br /&gt;
&lt;br /&gt;
Wird im Feld &#039;&#039;&#039;mTLS-Subject&#039;&#039;&#039; ein Wert hinterlegt, prüft der Server zusätzlich zur Standard-mTLS-Validierung, ob der Subject-DN des (CA-validierten) Client-Zertifikats die hinterlegten &#039;&#039;&#039;RDN-Komponenten&#039;&#039;&#039; enthält. Verglichen wird jede Komponente &#039;&#039;&#039;vollständig&#039;&#039;&#039; (kein Substring-Match) und case-insensitiv; mehrere Komponenten werden durch Komma oder Semikolon getrennt und müssen &#039;&#039;&#039;alle&#039;&#039;&#039; im Subject vorkommen.&lt;br /&gt;
&lt;br /&gt;
Beispiele für Subject-Strings im Cert (OneLine-DN):&lt;br /&gt;
&lt;br /&gt;
 /C=DE/O=Kunde GmbH/CN=client-prod.kunde.de&lt;br /&gt;
 /C=DE/O=Kunde GmbH/CN=client-test.kunde.de&lt;br /&gt;
&lt;br /&gt;
Mit &#039;&#039;&#039;CN=client-prod.kunde.de&#039;&#039;&#039; im Feld wird genau das prod-Cert akzeptiert, das test-Cert mit 401 abgewiesen. Da jede RDN-Komponente vollständig verglichen wird, matcht z.B. &#039;&#039;&#039;CN=client1&#039;&#039;&#039; nicht fälschlich auch &#039;&#039;&#039;CN=client10&#039;&#039;&#039;. Mehrere Bedingungen lassen sich mit Komma/Semikolon kombinieren, z.B. &#039;&#039;O=Kunde GmbH;CN=client-prod.kunde.de&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
==Listen-Funktionen==&lt;br /&gt;
&lt;br /&gt;
In der Zugänge-Liste (allein):&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Einfg&#039;&#039;&#039; - neuer Zugang&lt;br /&gt;
* &#039;&#039;&#039;Return&#039;&#039;&#039; - Zugang bearbeiten&lt;br /&gt;
* &#039;&#039;&#039;F4&#039;&#039;&#039; - Sortierung&lt;br /&gt;
* &#039;&#039;&#039;F7&#039;&#039;&#039; - JWT-Authentifizierungs-Skript bearbeiten&lt;br /&gt;
* &#039;&#039;&#039;F8&#039;&#039;&#039; - Statistik für den Zugang&lt;br /&gt;
&lt;br /&gt;
Aus der Endpunkt-Liste via F6 geöffnet (Auswahl für Berechtigung):&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;F2&#039;&#039;&#039; - markierte Zugänge dem Endpunkt zuordnen&lt;br /&gt;
* &#039;&#039;&#039;F5&#039;&#039;&#039; - Zugang in der Liste markieren / Markierung aufheben&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel1&amp;diff=64685</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel1</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel1&amp;diff=64685"/>
		<updated>2026-08-19T10:18:04Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: /* JWT-Authentifizierungs-Skript (Zugang) */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 1: Daten-Abruf mit JWT-Authentifizierung=&lt;br /&gt;
&lt;br /&gt;
In diesem Beispiel werden Steuerungs-Variablen aus OBS abgerufen und auf einer Webseite ausgegeben. Der Zugang ist mit JWT-Pflicht eingerichtet, der Konsument muss sich daher zuerst anmelden und einen Token bezogen haben.&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; mit Bindung 0.0.0.0:443&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Web-Dashboard&#039;&#039;&lt;br /&gt;
** API-Key: zufaellig generiert&lt;br /&gt;
** JWT aktiv, JWT-Endpunkt &#039;&#039;oauth&#039;&#039;, JWT-Key zufaellig, JWT-Exp 60 (Minuten)&lt;br /&gt;
** CORS-Origins: &#039;&#039;https://dashboard.kunde.de&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Endpunkt:&#039;&#039;&#039; &#039;&#039;steuerung/v1&#039;&#039;, dem Profil &#039;&#039;Public-API&#039;&#039; zugeordnet&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Web-Dashboard&#039;&#039; fuer Endpunkt &#039;&#039;steuerung/v1&#039;&#039; freigeschaltet&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierungs-Skript (Zugang)==&lt;br /&gt;
&lt;br /&gt;
Wird ueber die Zugaenge-Liste mit &#039;&#039;&#039;F7&#039;&#039;&#039; geoeffnet. Pruefen, ob Benutzername/Passwort gegen die OBS-Benutzerverwaltung passen.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Authenticate(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes  : TJSONObject;&lt;br /&gt;
    oVal  : TJSONValue;&lt;br /&gt;
    cUser : string;&lt;br /&gt;
    cPass : string;&lt;br /&gt;
    cSql  : string;&lt;br /&gt;
    qUser : TxFQuery;&lt;br /&gt;
    lOk   : Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUser := &#039;&#039;;&lt;br /&gt;
        cPass := &#039;&#039;;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;username&#039;);&lt;br /&gt;
            if (Assigned(oVal)) then begin&lt;br /&gt;
                cUser := oVal.Value;&lt;br /&gt;
            end;&lt;br /&gt;
            oVal := oBody.GetValue(&#039;password&#039;);&lt;br /&gt;
            if (Assigned(oVal)) then begin&lt;br /&gt;
                cPass := oVal.Value;&lt;br /&gt;
            end;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        cSql := &#039;SELECT u_nr, u_name FROM benutzer&#039; +&lt;br /&gt;
                &#039; WHERE u_login = &#039; + DB_SQLVal(cUser) +&lt;br /&gt;
                &#039; AND u_passwort_hash = &#039; + DB_SQLVal(HashPasswort(cPass)) +&lt;br /&gt;
                &#039; AND u_aktiv = &#039; + DB_SQLVal(&#039;1&#039;);&lt;br /&gt;
&lt;br /&gt;
        // DB_SOpen liefert true, wenn die ABFRAGE lief - nicht, wenn ein&lt;br /&gt;
        // Datensatz gefunden wurde. Ohne die EoF-Pruefung wuerde jede&lt;br /&gt;
        // Anmeldung gelingen, sobald das SQL fehlerfrei ist.&lt;br /&gt;
        lOk := false;&lt;br /&gt;
        if (DB_SOpen(oDB, cSql, qUser)) then begin&lt;br /&gt;
            if (not qUser.EoF) then begin&lt;br /&gt;
                lOk := true;&lt;br /&gt;
                oRes.AddPair(&#039;status&#039;           , 1);&lt;br /&gt;
                oRes.AddPair(&#039;_OBS_JWT_ID&#039;      , qUser.A2C(&#039;u_nr&#039;));&lt;br /&gt;
                oRes.AddPair(&#039;_OBS_JWT_SUBJECT&#039; , qUser.A2C(&#039;u_name&#039;));&lt;br /&gt;
                oRes.AddPair(&#039;_OBS_JWT_AUDIENCE&#039;, &#039;dashboard&#039;);&lt;br /&gt;
            end;&lt;br /&gt;
        end;&lt;br /&gt;
        DB_Close(qUser);&lt;br /&gt;
&lt;br /&gt;
        if (not lOk) then begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;, 9);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039; , &#039;Benutzer oder Passwort ist falsch&#039;);&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Hier steht in &#039;&#039;_OBS_JWT_ID&#039;&#039; die Benutzernummer - derselbe Wert also bei&lt;br /&gt;
jeder Anmeldung desselben Benutzers. Das ist zulässig: der Server führt seine&lt;br /&gt;
Sitzungen über eigene Kennungen (&#039;&#039;sid&#039;&#039;/&#039;&#039;rid&#039;&#039;), nicht über das &#039;&#039;jti&#039;&#039; des&lt;br /&gt;
Skripts. Mehrfache und parallele Anmeldungen eines Benutzers sind damit&lt;br /&gt;
unproblematisch.}}&lt;br /&gt;
&lt;br /&gt;
==Endpunkt-Skript &#039;&#039;steuerung/v1&#039;&#039;==&lt;br /&gt;
&lt;br /&gt;
Liefert eine Liste von Steuerungs-Werten zurueck. Die Auswertung der JWT-Claims sorgt dafuer, dass nur Audience &#039;&#039;dashboard&#039;&#039; Daten erhaelt.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure _AddVar(oArr: TJSONArray; const cVar: string);&lt;br /&gt;
var oWert  : TJSONObject;&lt;br /&gt;
    cTitel : string;&lt;br /&gt;
    cData  : string;&lt;br /&gt;
    cEinh  : string;&lt;br /&gt;
    lString: Boolean;&lt;br /&gt;
    lAlign : Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    if (ST_Variable(oDB, cVar, cTitel, cData, cEinh, lString, lAlign)) then begin&lt;br /&gt;
        oWert := TJSONObject.Create();&lt;br /&gt;
        oWert.AddPair(&#039;variable&#039;, cVar);&lt;br /&gt;
        oWert.AddPair(&#039;titel&#039;   , cTitel);&lt;br /&gt;
        oWert.AddPair(&#039;wert&#039;    , cData);&lt;br /&gt;
        oWert.AddPair(&#039;einheit&#039; , cEinh);&lt;br /&gt;
        oArr.Add(oWert);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// Hilfsfunktion: Fehlerantwort im einheitlichen Format aufbauen.&lt;br /&gt;
// Fachliche Ablehnungen als 4xx melden, nicht als 200 mit Fehlertext.&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function _Fehler(oParams: TStrings; nStatus: integer; const cCode, cMsg: string): string;&lt;br /&gt;
var oRes: TJSONObject;&lt;br /&gt;
    oErr: TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    oErr := TJSONObject.Create();&lt;br /&gt;
    oErr.AddPair(&#039;code&#039;   , cCode);&lt;br /&gt;
    oErr.AddPair(&#039;message&#039;, cMsg);&lt;br /&gt;
    oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, nStatus);&lt;br /&gt;
        oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
function Get(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oArr: TJSONArray;&lt;br /&gt;
begin&lt;br /&gt;
    // Audience-Pruefung: nur Dashboard-Tokens akzeptieren&lt;br /&gt;
    if (oParams.Values[&#039;_OBS_JWT_AUDIENCE&#039;] &amp;lt;&amp;gt; &#039;dashboard&#039;) then begin&lt;br /&gt;
        result := _Fehler(oParams, 403, &#039;FORBIDDEN_ROLE&#039;, &#039;Token nicht für diese Anwendung ausgestellt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    oArr := TJSONArray.Create();&lt;br /&gt;
    try&lt;br /&gt;
        _AddVar(oArr, &#039;WERT_1&#039;);&lt;br /&gt;
        _AddVar(oArr, &#039;WERT_2&#039;);&lt;br /&gt;
        _AddVar(oArr, &#039;WERT_3&#039;);&lt;br /&gt;
        _AddVar(oArr, &#039;WERT_4&#039;);&lt;br /&gt;
        result := oArr.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oArr);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==JavaScript-Client (Browser)==&lt;br /&gt;
&lt;br /&gt;
Im Browser werden zwei Aufrufe gemacht: erst Token holen, dann Daten lesen.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;javascript&amp;quot; line&amp;gt;&lt;br /&gt;
const API_BASE = &#039;https://api.meinserver.de&#039;;&lt;br /&gt;
const API_KEY  = &#039;[API-KEY]&#039;;&lt;br /&gt;
&lt;br /&gt;
async function login(username, password) {&lt;br /&gt;
    const res = await fetch(`${API_BASE}/oauth/`, {&lt;br /&gt;
        method:  &#039;POST&#039;,&lt;br /&gt;
        headers: { &#039;Content-Type&#039;: &#039;application/json&#039;, &#039;apikey&#039;: API_KEY },&lt;br /&gt;
        body:    JSON.stringify({ username, password })&lt;br /&gt;
    });&lt;br /&gt;
    if (!res.ok) throw new Error(&#039;Login fehlgeschlagen&#039;);&lt;br /&gt;
    const data = await res.json();&lt;br /&gt;
    return data.token;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
async function ladeSteuerung(token) {&lt;br /&gt;
    const res = await fetch(`${API_BASE}/steuerung/v1`, {&lt;br /&gt;
        method:  &#039;GET&#039;,&lt;br /&gt;
        headers: {&lt;br /&gt;
            &#039;apikey&#039;:        API_KEY,&lt;br /&gt;
            &#039;Authorization&#039;: &#039;Bearer &#039; + token&lt;br /&gt;
        }&lt;br /&gt;
    });&lt;br /&gt;
    if (!res.ok) throw new Error(&#039;Abruf fehlgeschlagen: &#039; + res.status);&lt;br /&gt;
    return res.json();&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
async function zeigeTabelle() {&lt;br /&gt;
    const token = await login(&#039;mitarbeiter&#039;, &#039;geheim&#039;);&lt;br /&gt;
    const werte = await ladeSteuerung(token);&lt;br /&gt;
&lt;br /&gt;
    const tbl = document.getElementById(&#039;steuerung&#039;);&lt;br /&gt;
    werte.forEach(w =&amp;gt; {&lt;br /&gt;
        const tr = document.createElement(&#039;tr&#039;);&lt;br /&gt;
        tr.innerHTML = `&amp;lt;td&amp;gt;${w.titel}&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;${w.wert} ${w.einheit}&amp;lt;/td&amp;gt;`;&lt;br /&gt;
        tbl.appendChild(tr);&lt;br /&gt;
    });&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
zeigeTabelle();&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Test mit curl==&lt;br /&gt;
&lt;br /&gt;
Token holen:&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;username\&amp;quot;:\&amp;quot;mitarbeiter\&amp;quot;,\&amp;quot;password\&amp;quot;:\&amp;quot;geheim\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/oauth/&lt;br /&gt;
&lt;br /&gt;
Antwort:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:       &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;accessToken&amp;quot;: &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:   3600,&lt;br /&gt;
  &amp;quot;serverTime&amp;quot;:  &amp;quot;2026-08-19T09:12:33+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Da das Skript kein &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; liefert, enthält die Antwort kein&lt;br /&gt;
&#039;&#039;refreshToken&#039;&#039; - der Client meldet sich nach Ablauf der 60 Minuten neu an.&lt;br /&gt;
&lt;br /&gt;
Daten abrufen:&lt;br /&gt;
&lt;br /&gt;
 curl -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJhbGciOi...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/steuerung/v1&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Zweistufige Anmeldung: API-Key + JWT.&lt;br /&gt;
* Sichere Trennung von Konsumenten-Gruppen ueber die Audience-Claim.&lt;br /&gt;
* Eigenes Authentifizierungs-Skript pro Zugang.&lt;br /&gt;
* Browser-Zugriff ueber CORS-erlaubtes Origin.&lt;br /&gt;
* Anmeldung ohne Refresh-Token: einfachster Fall, der Token laeuft nach der Zeit aus &#039;&#039;JWT-Exp&#039;&#039; ab.&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel5&amp;diff=64684</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel5</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel5&amp;diff=64684"/>
		<updated>2026-08-19T10:16:05Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: /* JWT-Authentifizierungs-Skript (Zugang) */&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz=&lt;br /&gt;
&lt;br /&gt;
Dieses Beispiel zeigt einen schreibenden Endpunkt für eine mobile App: Aufträge werden mit echten HTTP-Statuscodes aktualisiert, konkurrierende Änderungen über ETag/If-Match abgesichert (Optimistic Concurrency) und doppelte Sendungen über einen Idempotency-Key abgefangen. Die Anmeldung nutzt JWT mit Custom-Claims (Mandant, Rollen) und einem Refresh-Token.&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; (Port 443)&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Mobile-App&#039;&#039;&lt;br /&gt;
** API-Key: zufällig generiert&lt;br /&gt;
** JWT aktiv, JWT-Endpunkt &#039;&#039;auth&#039;&#039;, JWT-Key zufällig, JWT-Exp 60 (Minuten)&lt;br /&gt;
* &#039;&#039;&#039;Endpunkte&#039;&#039;&#039; (alle dem Profil &#039;&#039;Public-API&#039;&#039; zugeordnet):&lt;br /&gt;
** &#039;&#039;orders/{uid}&#039;&#039; - Auftrag lesen/Ändern&lt;br /&gt;
** &#039;&#039;orders/{uid}/material&#039;&#039; - Material erfassen&lt;br /&gt;
** &#039;&#039;auth/logout&#039;&#039; - Abmelden&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Mobile-App&#039;&#039; für alle drei Endpunkte freigeschaltet&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierungs-Skript (Zugang)==&lt;br /&gt;
&lt;br /&gt;
Stellt Mandant und Rollen als Custom-Claims aus und löst über &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; ein Refresh-Token aus. Bei einem Refresh ruft der Server im selben Skript die Methode &#039;&#039;Refresh&#039;&#039; auf. Die Hilfsfunktionen (&#039;&#039;PasswortPasst&#039;&#039;, &#039;&#039;TechnikerMandant&#039;&#039;, &#039;&#039;TechnikerRollen&#039;&#039;) sind illustrativ und projektabhängig.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Eine &#039;&#039;&#039;eigene Sperrtabelle für Refresh-jti ist nicht nötig&#039;&#039;&#039;.&lt;br /&gt;
Einmalgebrauch, Rotation und Widerruf führt der Server in &#039;&#039;RESTSRV_TOKEN&#039;&#039; -&lt;br /&gt;
das Skript entscheidet nur, &#039;&#039;&#039;ob&#039;&#039;&#039; und &#039;&#039;&#039;mit welchen Rechten&#039;&#039;&#039; die Sitzung&lt;br /&gt;
fortgesetzt wird. Frühere Fassungen dieses Beispiels zeigten eine Skript-Tabelle;&lt;br /&gt;
wer sie übernommen hat, kann sie entfernen.}}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Authenticate(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes : TJSONObject;&lt;br /&gt;
    oVal : TJSONValue;&lt;br /&gt;
    cUser, cPass: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUser := &#039;&#039;; cPass := &#039;&#039;;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;username&#039;); if (Assigned(oVal)) then cUser := oVal.Value;&lt;br /&gt;
            oVal := oBody.GetValue(&#039;password&#039;); if (Assigned(oVal)) then cPass := oVal.Value;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        if (PasswortPasst(cUser, cPass)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;               , 1);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));   // z.B. &amp;quot;tech,lead&amp;quot;&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());   // loest das Refresh-Token aus&lt;br /&gt;
            // _OBS_JWT_REFRESH_EXP weggelassen -&amp;gt; Default 90 Tage&lt;br /&gt;
        end else begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;, 9);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039; , &#039;Login fehlgeschlagen&#039;);&lt;br /&gt;
        end;&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function Refresh(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes : TJSONObject;&lt;br /&gt;
    cUser: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
&lt;br /&gt;
        cUser := oParams.Values[&#039;_OBS_JWT_SUBJECT&#039;];&lt;br /&gt;
&lt;br /&gt;
        // Das vorgelegte Refresh-Token hat der Server bereits geprueft und&lt;br /&gt;
        // entwertet. Hier wird nur entschieden, ob die Sitzung fortgesetzt&lt;br /&gt;
        // werden darf - und mit welchen Rechten.&lt;br /&gt;
        if (not TechnikerAktiv(cUser)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;, 9);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039; , &#039;Konto ist nicht mehr aktiv, bitte neu anmelden&#039;);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // Mandant und Rollen NEU lesen, nicht aus dem alten Token uebernehmen -&lt;br /&gt;
        // sonst wirkt ein Rechteentzug erst beim naechsten Login.&lt;br /&gt;
&lt;br /&gt;
        oRes.AddPair(&#039;status&#039;               , 1);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Endpunkt &#039;&#039;orders/{uid}&#039;&#039; - ändern mit ETag / If-Match==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;GET&#039;&#039; liefert den Auftrag samt &#039;&#039;ETag&#039;&#039; (Version), &#039;&#039;PUT&#039;&#039; prüft Rolle und &#039;&#039;If-Match&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Get(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oHdr: TJSONObject;&lt;br /&gt;
    cUid: string;&lt;br /&gt;
    nVer: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUid := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
        if (not AuftragLesen(oParams.Values[&#039;_OBS_JWT_CLAIM_tenant&#039;], cUid, oRes, nVer)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 404);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;NOT_FOUND&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
        oHdr := TJSONObject.Create();&lt;br /&gt;
        oHdr.AddPair(&#039;ETag&#039;, xStr(nVer));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function Put(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oHdr: TJSONObject;&lt;br /&gt;
    cUid: string;&lt;br /&gt;
    nAktuell, nIfMatch: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        // nur Rolle &amp;quot;lead&amp;quot; darf ändern - Rolle kommt aus dem Token, nicht aus dem Body.&lt;br /&gt;
        // Mit Trennzeichen suchen: ein blosses Pos(&#039;lead&#039;, ...) wuerde auch in&lt;br /&gt;
        // &amp;quot;leadless&amp;quot; oder &amp;quot;teamlead&amp;quot; treffen.&lt;br /&gt;
        if (Pos(&#039;,lead,&#039;, &#039;,&#039; + oParams.Values[&#039;_OBS_JWT_CLAIM_roles&#039;] + &#039;,&#039;) = 0) then begin&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 403);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;FORBIDDEN_ROLE&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        cUid     := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
        nAktuell := AuftragVersion(cUid);&lt;br /&gt;
        nIfMatch := iVal(oParams.Values[&#039;if-match&#039;]);&lt;br /&gt;
&lt;br /&gt;
        if (nIfMatch &amp;lt;&amp;gt; nAktuell) then begin&lt;br /&gt;
            oHdr := TJSONObject.Create();&lt;br /&gt;
            oHdr.AddPair(&#039;ETag&#039;, xStr(nAktuell));&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 409);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;VERSION_CONFLICT&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        AuftragSpeichern(cUid, oBody);   // setzt Version auf nAktuell + 1&lt;br /&gt;
        oHdr := TJSONObject.Create();&lt;br /&gt;
        oHdr.AddPair(&#039;ETag&#039;, xStr(nAktuell + 1));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Endpunkt &#039;&#039;orders/{uid}/material&#039;&#039; - idempotentes Anlegen==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;POST&#039;&#039; erfasst eine Materialposition: Validierungsfehler -&amp;gt; 422, erfolgreiches Anlegen -&amp;gt; 201.&lt;br /&gt;
&lt;br /&gt;
Gegen doppelte Sendungen schickt der Client den Header &#039;&#039;Idempotency-Key&#039;&#039;. &#039;&#039;&#039;Das&lt;br /&gt;
Skript muss dafür nichts tun&#039;&#039;&#039; - der Server erkennt den Header, merkt sich das&lt;br /&gt;
Ergebnis und liefert bei einer Wiederholung mit demselben Schlüssel die&lt;br /&gt;
gespeicherte Antwort zurück, ohne das Skript erneut zu starten (siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]], Abschnitt&lt;br /&gt;
Idempotenz). Das Skript kümmert sich nur um seine Fachlogik:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oErr: TJSONObject;&lt;br /&gt;
    oVal: TJSONValue;&lt;br /&gt;
    cUid, cArtikel, cNeueUid: string;&lt;br /&gt;
    nMenge: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUid := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
&lt;br /&gt;
        // 1) Validierung -&amp;gt; 422 mit traceId.&lt;br /&gt;
        //    Wichtig: fachliche Ablehnung als 4xx melden, nicht als 200 mit&lt;br /&gt;
        //    Fehlertext - nur dann gibt der Server den Idempotency-Key wieder&lt;br /&gt;
        //    frei und der Client darf ihn nach Korrektur erneut verwenden.&lt;br /&gt;
        cArtikel := &#039;&#039;;&lt;br /&gt;
        nMenge   := 0;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;artikel&#039;); if (Assigned(oVal)) then cArtikel := oVal.Value;&lt;br /&gt;
            oVal := oBody.GetValue(&#039;menge&#039;);   if (Assigned(oVal)) then nMenge   := iVal(oVal.Value);&lt;br /&gt;
        end;&lt;br /&gt;
        if ((Empty(cArtikel)) or (nMenge &amp;lt;= 0)) then begin&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;VALIDATION_FAILED&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;artikel und menge sind Pflicht&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 422);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // 2) Buchen. Die Transaktionssteuerung bleibt beim Skript; der&lt;br /&gt;
        //    Idempotenz-Speicher des Servers läuft getrennt davon und&lt;br /&gt;
        //    umschliesst diesen Block nicht.&lt;br /&gt;
        cNeueUid := MaterialAnlegen(cUid, cArtikel, nMenge);&lt;br /&gt;
&lt;br /&gt;
        // 3) Antwort vollständig aufbauen - genau sie wird eingefroren und bei&lt;br /&gt;
        //    einer Wiederholung erneut ausgeliefert.&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 201);&lt;br /&gt;
        oRes.AddPair(&#039;uid&#039;, cNeueUid);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Endpunkt &#039;&#039;auth/logout&#039;&#039; - Abmelden==&lt;br /&gt;
&lt;br /&gt;
Beim Abmelden soll das Gerät seine Sitzung wirklich verlieren - nicht erst mit dem&lt;br /&gt;
Ablauf des Tokens. Dafür genügt ein Antwortfeld; die Sperre führt der Server in&lt;br /&gt;
&#039;&#039;RESTSRV_TOKEN&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes: TJSONObject;&lt;br /&gt;
    oVal: TJSONValue;&lt;br /&gt;
    cAlle: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        // Optional: {&amp;quot;alleGeraete&amp;quot;: true} meldet den Techniker ueberall ab&lt;br /&gt;
        cAlle := &#039;session&#039;;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;alleGeraete&#039;);&lt;br /&gt;
            if (Assigned(oVal)) and (Lower(oVal.Value) = &#039;true&#039;) then begin&lt;br /&gt;
                cAlle := &#039;all&#039;;&lt;br /&gt;
            end;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_REVOKE&#039; , cAlle);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 204);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Gesperrt wird die ganze Sitzung, also auch die Access-Token vorheriger&lt;br /&gt;
Erneuerungen. Ein zweiter Aufruf ist unschädlich. Kann der Server die Sitzung&lt;br /&gt;
nicht sperren, antwortet er mit &#039;&#039;&#039;503&#039;&#039;&#039; statt mit dem 204 des Skripts - die App&lt;br /&gt;
darf den Abmeldevorgang dann wiederholen.&lt;br /&gt;
&lt;br /&gt;
==Test mit curl==&lt;br /&gt;
&lt;br /&gt;
Token holen:&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;username\&amp;quot;:\&amp;quot;tech1\&amp;quot;,\&amp;quot;password\&amp;quot;:\&amp;quot;geheim\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:&amp;quot;eyJ...&amp;quot;,&amp;quot;accessToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&amp;quot;refreshToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:3600,&amp;quot;serverTime&amp;quot;:&amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Bei falschen Zugangsdaten antwortet der Server mit &#039;&#039;&#039;401&#039;&#039;&#039; und dem Text aus dem&lt;br /&gt;
Authenticate-Skript als &#039;&#039;error.message&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Auftrag lesen (liefert den ETag-Header):&lt;br /&gt;
&lt;br /&gt;
 curl -i -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
 ... ETag: 7&lt;br /&gt;
&lt;br /&gt;
ändern mit korrektem If-Match -&amp;gt; 200, mit veraltetem If-Match -&amp;gt; 409:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X PUT -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;If-Match: 7&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;status\&amp;quot;:\&amp;quot;erledigt\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
&lt;br /&gt;
Material idempotent erfassen (zweiter Aufruf mit gleichem Key -&amp;gt; gleiche Antwort, keine Doppelbuchung):&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Idempotency-Key: 9c84-7f2a-...&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;artikel\&amp;quot;:\&amp;quot;A100\&amp;quot;,\&amp;quot;menge\&amp;quot;:3}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711/material&lt;br /&gt;
&lt;br /&gt;
Die Antwort des zweiten Aufrufs trägt zusätzlich den Header&lt;br /&gt;
&#039;&#039;Idempotent-Replay: true&#039;&#039; - daran ist erkennbar, dass sie aus dem Speicher kam&lt;br /&gt;
und nichts erneut gebucht wurde.&lt;br /&gt;
&lt;br /&gt;
Token erneuern (Refresh-Token im Authorization-Header an denselben JWT-Endpunkt):&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer &amp;lt;refreshToken&amp;gt;&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
Die Antwort enthält ein neues Paar. Das alte Refresh-Token ist damit verbraucht:&lt;br /&gt;
ein zweiter Aufruf mit demselben Token antwortet &#039;&#039;&#039;401&#039;&#039;&#039;. Erfolgt er innerhalb&lt;br /&gt;
von 60 Sekunden, bleibt die Sitzung bestehen (paralleler Refresh der App); später&lt;br /&gt;
gilt er als Wiedervorlage und die &#039;&#039;&#039;ganze Sitzung wird gesperrt&#039;&#039;&#039; - der&lt;br /&gt;
Techniker muss sich neu anmelden, und der Vorfall steht mit IP im Protokoll.&lt;br /&gt;
&lt;br /&gt;
Abmelden (Access-Token im Authorization-Header):&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/logout&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 204 No Content&lt;br /&gt;
&lt;br /&gt;
Derselbe Token danach noch einmal verwendet:&lt;br /&gt;
&lt;br /&gt;
 curl -i -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 401 Unauthorized&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;error&amp;quot;: {&lt;br /&gt;
    &amp;quot;code&amp;quot;:    &amp;quot;AUTH_EXPIRED&amp;quot;,&lt;br /&gt;
    &amp;quot;uid&amp;quot;:     &amp;quot;Y00G9TOK09&amp;quot;,&lt;br /&gt;
    &amp;quot;message&amp;quot;: &amp;quot;Die Sitzung ist nicht mehr gültig, bitte neu anmelden&amp;quot;,&lt;br /&gt;
    &amp;quot;traceId&amp;quot;: &amp;quot;20260819T091233123-00001A&amp;quot;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Echte HTTP-Statuscodes aus dem Skript: 201, 403, 404, 409, 422.&lt;br /&gt;
* Optimistic Concurrency über &#039;&#039;ETag&#039;&#039; (GET) und &#039;&#039;If-Match&#039;&#039; (PUT) -&amp;gt; 409 VERSION_CONFLICT.&lt;br /&gt;
* Idempotente Schreibzugriffe über den &#039;&#039;Idempotency-Key&#039;&#039; - &#039;&#039;&#039;vom Server erledigt&#039;&#039;&#039;, das Skript enthält dafür keine Zeile Code.&lt;br /&gt;
* Rollenprüfung aus dem Token-Claim &#039;&#039;_OBS_JWT_CLAIM_roles&#039;&#039;, nicht aus dem Body.&lt;br /&gt;
* JWT mit Custom-Claims (&#039;&#039;tenant&#039;&#039;/&#039;&#039;roles&#039;&#039;) und Refresh-Token mit Rotation - &#039;&#039;&#039;vom Server erledigt&#039;&#039;&#039;, das Skript führt keine Sperrtabelle.&lt;br /&gt;
* Abmelden über &#039;&#039;_OBS_JWT_REVOKE&#039;&#039;, wahlweise für diese Sitzung oder alle Geräte des Technikers.&lt;br /&gt;
* &#039;&#039;traceId&#039;&#039; im Fehler-Body für die Support-Nachverfolgung (auch als Header &#039;&#039;X-Trace-Id&#039;&#039;).&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel5&amp;diff=64683</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel5</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel5&amp;diff=64683"/>
		<updated>2026-08-19T10:15:50Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 5: Schreibzugriff mit Statuscodes, ETag und Idempotenz=&lt;br /&gt;
&lt;br /&gt;
Dieses Beispiel zeigt einen schreibenden Endpunkt für eine mobile App: Aufträge werden mit echten HTTP-Statuscodes aktualisiert, konkurrierende Änderungen über ETag/If-Match abgesichert (Optimistic Concurrency) und doppelte Sendungen über einen Idempotency-Key abgefangen. Die Anmeldung nutzt JWT mit Custom-Claims (Mandant, Rollen) und einem Refresh-Token.&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; (Port 443)&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Mobile-App&#039;&#039;&lt;br /&gt;
** API-Key: zufällig generiert&lt;br /&gt;
** JWT aktiv, JWT-Endpunkt &#039;&#039;auth&#039;&#039;, JWT-Key zufällig, JWT-Exp 60 (Minuten)&lt;br /&gt;
* &#039;&#039;&#039;Endpunkte&#039;&#039;&#039; (alle dem Profil &#039;&#039;Public-API&#039;&#039; zugeordnet):&lt;br /&gt;
** &#039;&#039;orders/{uid}&#039;&#039; - Auftrag lesen/Ändern&lt;br /&gt;
** &#039;&#039;orders/{uid}/material&#039;&#039; - Material erfassen&lt;br /&gt;
** &#039;&#039;auth/logout&#039;&#039; - Abmelden&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Mobile-App&#039;&#039; für alle drei Endpunkte freigeschaltet&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierungs-Skript (Zugang)==&lt;br /&gt;
&lt;br /&gt;
Stellt Mandant und Rollen als Custom-Claims aus und löst über &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; ein Refresh-Token aus. Bei einem Refresh ruft der Server im selben Skript die Methode &#039;&#039;Refresh&#039;&#039; auf. Die Hilfsfunktionen (&#039;&#039;PasswortPasst&#039;&#039;, &#039;&#039;TechnikerMandant&#039;&#039;, &#039;&#039;TechnikerRollen&#039;&#039;) sind illustrativ und projektabhängig.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Eine &#039;&#039;&#039;eigene Sperrtabelle für Refresh-jti ist nicht nötig&#039;&#039;&#039;.&lt;br /&gt;
Einmalgebrauch, Rotation und Widerruf führt der Server in &#039;&#039;RESTSRV_TOKEN&#039;&#039; -&lt;br /&gt;
das Skript entscheidet nur, &#039;&#039;&#039;ob&#039;&#039;&#039; und &#039;&#039;&#039;mit welchen Rechten&#039;&#039;&#039; die Sitzung&lt;br /&gt;
fortgesetzt wird. Frühere Fassungen dieses Beispiels zeigten eine Skript-Tabelle;&lt;br /&gt;
wer sie übernommen hat, kann sie entfernen.}}&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Authenticate(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes : TJSONObject;&lt;br /&gt;
    oVal : TJSONValue;&lt;br /&gt;
    cUser, cPass: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUser := &#039;&#039;; cPass := &#039;&#039;;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;username&#039;); if (Assigned(oVal)) then cUser := oVal.Value;&lt;br /&gt;
            oVal := oBody.GetValue(&#039;password&#039;); if (Assigned(oVal)) then cPass := oVal.Value;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        if (PasswortPasst(cUser, cPass)) then begin&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;               , 1);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));   // z.B. &amp;quot;tech,lead&amp;quot;&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());   // loest das Refresh-Token aus&lt;br /&gt;
            // _OBS_JWT_REFRESH_EXP weggelassen -&amp;gt; Default 90 Tage&lt;br /&gt;
        end else begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;, 9);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039; , &#039;Login fehlgeschlagen&#039;);&lt;br /&gt;
        end;&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function Refresh(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes : TJSONObject;&lt;br /&gt;
    cUser: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
&lt;br /&gt;
        cUser := oParams.Values[&#039;_OBS_JWT_SUBJECT&#039;];&lt;br /&gt;
&lt;br /&gt;
        // Das vorgelegte Refresh-Token hat der Server bereits geprueft und&lt;br /&gt;
        // entwertet. Hier wird nur entschieden, ob die Sitzung fortgesetzt&lt;br /&gt;
        // werden darf - und mit welchen Rechten.&lt;br /&gt;
        if (not TechnikerAktiv(cUser)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;, 9);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039; , &#039;Konto ist nicht mehr aktiv, bitte neu anmelden&#039;);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // Mandant und Rollen NEU lesen, nicht aus dem alten Token uebernehmen -&lt;br /&gt;
        // sonst wirkt ein Rechteentzug erst beim naechsten Login.&lt;br /&gt;
&lt;br /&gt;
        oRes.AddPair(&#039;status&#039;               , 1);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_ID&#039;          , cUser);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_SUBJECT&#039;     , cUser);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_CLAIM_tenant&#039;, TechnikerMandant(cUser));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_CLAIM_roles&#039; , TechnikerRollen(cUser));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_REFRESH_ID&#039;  , GlobalUID());&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Endpunkt &#039;&#039;orders/{uid}&#039;&#039; - ändern mit ETag / If-Match==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;GET&#039;&#039; liefert den Auftrag samt &#039;&#039;ETag&#039;&#039; (Version), &#039;&#039;PUT&#039;&#039; prüft Rolle und &#039;&#039;If-Match&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Get(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oHdr: TJSONObject;&lt;br /&gt;
    cUid: string;&lt;br /&gt;
    nVer: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUid := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
        if (not AuftragLesen(oParams.Values[&#039;_OBS_JWT_CLAIM_tenant&#039;], cUid, oRes, nVer)) then begin&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 404);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;NOT_FOUND&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
        oHdr := TJSONObject.Create();&lt;br /&gt;
        oHdr.AddPair(&#039;ETag&#039;, xStr(nVer));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function Put(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oHdr: TJSONObject;&lt;br /&gt;
    cUid: string;&lt;br /&gt;
    nAktuell, nIfMatch: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        // nur Rolle &amp;quot;lead&amp;quot; darf ändern - Rolle kommt aus dem Token, nicht aus dem Body.&lt;br /&gt;
        // Mit Trennzeichen suchen: ein blosses Pos(&#039;lead&#039;, ...) wuerde auch in&lt;br /&gt;
        // &amp;quot;leadless&amp;quot; oder &amp;quot;teamlead&amp;quot; treffen.&lt;br /&gt;
        if (Pos(&#039;,lead,&#039;, &#039;,&#039; + oParams.Values[&#039;_OBS_JWT_CLAIM_roles&#039;] + &#039;,&#039;) = 0) then begin&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 403);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;FORBIDDEN_ROLE&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        cUid     := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
        nAktuell := AuftragVersion(cUid);&lt;br /&gt;
        nIfMatch := iVal(oParams.Values[&#039;if-match&#039;]);&lt;br /&gt;
&lt;br /&gt;
        if (nIfMatch &amp;lt;&amp;gt; nAktuell) then begin&lt;br /&gt;
            oHdr := TJSONObject.Create();&lt;br /&gt;
            oHdr.AddPair(&#039;ETag&#039;, xStr(nAktuell));&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 409);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, TJSONObject.Create.AddPair(&#039;code&#039;, &#039;VERSION_CONFLICT&#039;));&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        AuftragSpeichern(cUid, oBody);   // setzt Version auf nAktuell + 1&lt;br /&gt;
        oHdr := TJSONObject.Create();&lt;br /&gt;
        oHdr.AddPair(&#039;ETag&#039;, xStr(nAktuell + 1));&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HEADERS&#039;, oHdr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Endpunkt &#039;&#039;orders/{uid}/material&#039;&#039; - idempotentes Anlegen==&lt;br /&gt;
&lt;br /&gt;
&#039;&#039;POST&#039;&#039; erfasst eine Materialposition: Validierungsfehler -&amp;gt; 422, erfolgreiches Anlegen -&amp;gt; 201.&lt;br /&gt;
&lt;br /&gt;
Gegen doppelte Sendungen schickt der Client den Header &#039;&#039;Idempotency-Key&#039;&#039;. &#039;&#039;&#039;Das&lt;br /&gt;
Skript muss dafür nichts tun&#039;&#039;&#039; - der Server erkennt den Header, merkt sich das&lt;br /&gt;
Ergebnis und liefert bei einer Wiederholung mit demselben Schlüssel die&lt;br /&gt;
gespeicherte Antwort zurück, ohne das Skript erneut zu starten (siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]], Abschnitt&lt;br /&gt;
Idempotenz). Das Skript kümmert sich nur um seine Fachlogik:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes, oErr: TJSONObject;&lt;br /&gt;
    oVal: TJSONValue;&lt;br /&gt;
    cUid, cArtikel, cNeueUid: string;&lt;br /&gt;
    nMenge: integer;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUid := oParams.Values[&#039;_OBS_PATH_uid&#039;];&lt;br /&gt;
&lt;br /&gt;
        // 1) Validierung -&amp;gt; 422 mit traceId.&lt;br /&gt;
        //    Wichtig: fachliche Ablehnung als 4xx melden, nicht als 200 mit&lt;br /&gt;
        //    Fehlertext - nur dann gibt der Server den Idempotency-Key wieder&lt;br /&gt;
        //    frei und der Client darf ihn nach Korrektur erneut verwenden.&lt;br /&gt;
        cArtikel := &#039;&#039;;&lt;br /&gt;
        nMenge   := 0;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;artikel&#039;); if (Assigned(oVal)) then cArtikel := oVal.Value;&lt;br /&gt;
            oVal := oBody.GetValue(&#039;menge&#039;);   if (Assigned(oVal)) then nMenge   := iVal(oVal.Value);&lt;br /&gt;
        end;&lt;br /&gt;
        if ((Empty(cArtikel)) or (nMenge &amp;lt;= 0)) then begin&lt;br /&gt;
            oErr := TJSONObject.Create();&lt;br /&gt;
            oErr.AddPair(&#039;code&#039;   , &#039;VALIDATION_FAILED&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;message&#039;, &#039;artikel und menge sind Pflicht&#039;);&lt;br /&gt;
            oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
            oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 422);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
            result := oRes.ToJSON();&lt;br /&gt;
            exit;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        // 2) Buchen. Die Transaktionssteuerung bleibt beim Skript; der&lt;br /&gt;
        //    Idempotenz-Speicher des Servers läuft getrennt davon und&lt;br /&gt;
        //    umschliesst diesen Block nicht.&lt;br /&gt;
        cNeueUid := MaterialAnlegen(cUid, cArtikel, nMenge);&lt;br /&gt;
&lt;br /&gt;
        // 3) Antwort vollständig aufbauen - genau sie wird eingefroren und bei&lt;br /&gt;
        //    einer Wiederholung erneut ausgeliefert.&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 201);&lt;br /&gt;
        oRes.AddPair(&#039;uid&#039;, cNeueUid);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Endpunkt &#039;&#039;auth/logout&#039;&#039; - Abmelden==&lt;br /&gt;
&lt;br /&gt;
Beim Abmelden soll das Gerät seine Sitzung wirklich verlieren - nicht erst mit dem&lt;br /&gt;
Ablauf des Tokens. Dafür genügt ein Antwortfeld; die Sperre führt der Server in&lt;br /&gt;
&#039;&#039;RESTSRV_TOKEN&#039;&#039;:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Post(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes: TJSONObject;&lt;br /&gt;
    oVal: TJSONValue;&lt;br /&gt;
    cAlle: string;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        // Optional: {&amp;quot;alleGeraete&amp;quot;: true} meldet den Techniker ueberall ab&lt;br /&gt;
        cAlle := &#039;session&#039;;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;alleGeraete&#039;);&lt;br /&gt;
            if (Assigned(oVal)) and (Lower(oVal.Value) = &#039;true&#039;) then begin&lt;br /&gt;
                cAlle := &#039;all&#039;;&lt;br /&gt;
            end;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_JWT_REVOKE&#039; , cAlle);&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, 204);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Gesperrt wird die ganze Sitzung, also auch die Access-Token vorheriger&lt;br /&gt;
Erneuerungen. Ein zweiter Aufruf ist unschädlich. Kann der Server die Sitzung&lt;br /&gt;
nicht sperren, antwortet er mit &#039;&#039;&#039;503&#039;&#039;&#039; statt mit dem 204 des Skripts - die App&lt;br /&gt;
darf den Abmeldevorgang dann wiederholen.&lt;br /&gt;
&lt;br /&gt;
==Test mit curl==&lt;br /&gt;
&lt;br /&gt;
Token holen:&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;username\&amp;quot;:\&amp;quot;tech1\&amp;quot;,\&amp;quot;password\&amp;quot;:\&amp;quot;geheim\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:&amp;quot;eyJ...&amp;quot;,&amp;quot;accessToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&amp;quot;refreshToken&amp;quot;:&amp;quot;eyJ...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:3600,&amp;quot;serverTime&amp;quot;:&amp;quot;2026-06-29T15:30:12+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Bei falschen Zugangsdaten antwortet der Server mit &#039;&#039;&#039;401&#039;&#039;&#039; und dem Text aus dem&lt;br /&gt;
Authenticate-Skript als &#039;&#039;error.message&#039;&#039;.&lt;br /&gt;
&lt;br /&gt;
Auftrag lesen (liefert den ETag-Header):&lt;br /&gt;
&lt;br /&gt;
 curl -i -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
 ... ETag: 7&lt;br /&gt;
&lt;br /&gt;
ändern mit korrektem If-Match -&amp;gt; 200, mit veraltetem If-Match -&amp;gt; 409:&lt;br /&gt;
&lt;br /&gt;
 curl -i -X PUT -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;If-Match: 7&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;status\&amp;quot;:\&amp;quot;erledigt\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
&lt;br /&gt;
Material idempotent erfassen (zweiter Aufruf mit gleichem Key -&amp;gt; gleiche Antwort, keine Doppelbuchung):&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      -H &amp;quot;Idempotency-Key: 9c84-7f2a-...&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;artikel\&amp;quot;:\&amp;quot;A100\&amp;quot;,\&amp;quot;menge\&amp;quot;:3}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711/material&lt;br /&gt;
&lt;br /&gt;
Die Antwort des zweiten Aufrufs trägt zusätzlich den Header&lt;br /&gt;
&#039;&#039;Idempotent-Replay: true&#039;&#039; - daran ist erkennbar, dass sie aus dem Speicher kam&lt;br /&gt;
und nichts erneut gebucht wurde.&lt;br /&gt;
&lt;br /&gt;
Token erneuern (Refresh-Token im Authorization-Header an denselben JWT-Endpunkt):&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer &amp;lt;refreshToken&amp;gt;&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/&lt;br /&gt;
&lt;br /&gt;
Die Antwort enthält ein neues Paar. Das alte Refresh-Token ist damit verbraucht:&lt;br /&gt;
ein zweiter Aufruf mit demselben Token antwortet &#039;&#039;&#039;401&#039;&#039;&#039;. Erfolgt er innerhalb&lt;br /&gt;
von 60 Sekunden, bleibt die Sitzung bestehen (paralleler Refresh der App); später&lt;br /&gt;
gilt er als Wiedervorlage und die &#039;&#039;&#039;ganze Sitzung wird gesperrt&#039;&#039;&#039; - der&lt;br /&gt;
Techniker muss sich neu anmelden, und der Vorfall steht mit IP im Protokoll.&lt;br /&gt;
&lt;br /&gt;
Abmelden (Access-Token im Authorization-Header):&lt;br /&gt;
&lt;br /&gt;
 curl -i -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/auth/logout&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 204 No Content&lt;br /&gt;
&lt;br /&gt;
Derselbe Token danach noch einmal verwendet:&lt;br /&gt;
&lt;br /&gt;
 curl -i -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJ...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/orders/4711&lt;br /&gt;
&lt;br /&gt;
 HTTP/1.1 401 Unauthorized&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
{&lt;br /&gt;
  &amp;quot;error&amp;quot;: {&lt;br /&gt;
    &amp;quot;code&amp;quot;:    &amp;quot;AUTH_EXPIRED&amp;quot;,&lt;br /&gt;
    &amp;quot;uid&amp;quot;:     &amp;quot;Y00G9TOK09&amp;quot;,&lt;br /&gt;
    &amp;quot;message&amp;quot;: &amp;quot;Die Sitzung ist nicht mehr gültig, bitte neu anmelden&amp;quot;,&lt;br /&gt;
    &amp;quot;traceId&amp;quot;: &amp;quot;20260819T091233123-00001A&amp;quot;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Echte HTTP-Statuscodes aus dem Skript: 201, 403, 404, 409, 422.&lt;br /&gt;
* Optimistic Concurrency über &#039;&#039;ETag&#039;&#039; (GET) und &#039;&#039;If-Match&#039;&#039; (PUT) -&amp;gt; 409 VERSION_CONFLICT.&lt;br /&gt;
* Idempotente Schreibzugriffe über den &#039;&#039;Idempotency-Key&#039;&#039; - &#039;&#039;&#039;vom Server erledigt&#039;&#039;&#039;, das Skript enthält dafür keine Zeile Code.&lt;br /&gt;
* Rollenprüfung aus dem Token-Claim &#039;&#039;_OBS_JWT_CLAIM_roles&#039;&#039;, nicht aus dem Body.&lt;br /&gt;
* JWT mit Custom-Claims (&#039;&#039;tenant&#039;&#039;/&#039;&#039;roles&#039;&#039;) und Refresh-Token mit Rotation - &#039;&#039;&#039;vom Server erledigt&#039;&#039;&#039;, das Skript führt keine Sperrtabelle.&lt;br /&gt;
* Abmelden über &#039;&#039;_OBS_JWT_REVOKE&#039;&#039;, wahlweise für diese Sitzung oder alle Geräte des Technikers.&lt;br /&gt;
* &#039;&#039;traceId&#039;&#039; im Fehler-Body für die Support-Nachverfolgung (auch als Header &#039;&#039;X-Trace-Id&#039;&#039;).&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Einrichtung&amp;diff=64682</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Einrichtung</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Einrichtung&amp;diff=64682"/>
		<updated>2026-08-19T10:15:30Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Einrichtung eines REST-Endpunktes=&lt;br /&gt;
&lt;br /&gt;
Die folgende Reihenfolge stellt sicher, dass ein lauffähiger Endpunkt mit möglichst wenigen Korrektur-Schleifen entsteht.&lt;br /&gt;
&lt;br /&gt;
==Vorbereitung==&lt;br /&gt;
&lt;br /&gt;
# OBS-Support kontaktieren und das REST-Server-Modul aktivieren lassen.&lt;br /&gt;
# Prüfen, ob auf dem Zielrechner (OBS-Server oder dedizierter REST-Server) Port und IP-Adresse frei sind.&lt;br /&gt;
# Bei TLS-Profilen: TLS-Zertifikat (PEM, ggf. mit Chain), privaten Schlüssel (PEM) und - falls mTLS - das CA-Root-Zertifikat bereithalten.&lt;br /&gt;
&lt;br /&gt;
==Schritt 1: Server-Profil anlegen==&lt;br /&gt;
&lt;br /&gt;
In &#039;&#039;&#039;Stammdaten -&amp;gt; Z Weitere Stammdaten -&amp;gt; REST-Server&#039;&#039;&#039; den Punkt &#039;&#039;&#039;Server&#039;&#039;&#039; öffnen und mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; ein neues Profil anlegen. Pflichtfelder:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Nr&#039;&#039;&#039; - eindeutige Nummer (1-99), wird beim Anlegen automatisch vorgeschlagen.&lt;br /&gt;
* &#039;&#039;&#039;Name&#039;&#039;&#039; - sprechender Name (z.B. &#039;&#039;Public-API&#039;&#039;, &#039;&#039;Internal-mTLS&#039;&#039;, &#039;&#039;Debug-Local&#039;&#039;).&lt;br /&gt;
* Auswahl des Modus:&lt;br /&gt;
** &#039;&#039;&#039;Standard-TLS&#039;&#039;&#039; (Default): Zertifikat + Key + Root-Zertifikat hinterlegen.&lt;br /&gt;
** &#039;&#039;&#039;mTLS&#039;&#039;&#039; aktivieren: zusätzlich CA-Root-Zertifikat hinterlegen, jeder Client muss ein gültiges Cert vorlegen.&lt;br /&gt;
** &#039;&#039;&#039;Kein SSL&#039;&#039;&#039; aktivieren: ausschliesslich für lokale Tests, der Server akzeptiert dann nur Plain-HTTP.&lt;br /&gt;
&lt;br /&gt;
Optional lassen sich am Profil eigene &#039;&#039;&#039;Rate-Limit-Schwellen&#039;&#039;&#039; hinterlegen. Ohne&lt;br /&gt;
Angabe gelten die Standardwerte (60-Sekunden-Fenster, 120 erfolgreiche und 10&lt;br /&gt;
fehlgeschlagene Anfragen je Schlüssel, 600 Anfragen je Profil). Für Anwendungen&lt;br /&gt;
ohne eigenen API-Key - typisch mobile Apps - sollten die Werte bewusst gesetzt&lt;br /&gt;
werden, weil dann alle Benutzer hinter einer IP auf denselben Zähler laufen.&lt;br /&gt;
&lt;br /&gt;
Details siehe [[OBS/Kostenpflichtige Module/RESTServer/Server-Profile|Server-Profile]].&lt;br /&gt;
&lt;br /&gt;
==Schritt 2: Bindung anlegen==&lt;br /&gt;
&lt;br /&gt;
Im Server-Profil mit &#039;&#039;&#039;F6&#039;&#039;&#039; die Bindungs-Liste öffnen und mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; eine neue Bindung anlegen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Host&#039;&#039;&#039; - IP-Adresse, auf der gelauscht werden soll (z.B. &#039;&#039;0.0.0.0&#039;&#039; für alle Adressen, &#039;&#039;127.0.0.1&#039;&#039; für lokal).&lt;br /&gt;
* &#039;&#039;&#039;Port&#039;&#039;&#039; - Port-Nummer (typisch 443 für HTTPS, 8099 für Debug, ggf. abweichend).&lt;br /&gt;
* &#039;&#039;&#039;Standard&#039;&#039;&#039; setzen, wenn diese Bindung die Standard-Bindung des Servers ist (pro Server-Profil nur eine).&lt;br /&gt;
&lt;br /&gt;
==Schritt 3: REST-Dienst starten / neustarten==&lt;br /&gt;
&lt;br /&gt;
Damit Änderungen an Server-Profilen oder Bindungen wirksam werden, muss der OBS REST-Server-Dienst (oder die Konsole) gestartet bzw. neugestartet werden. Das Profil wird beim Start aus der Datenbank gelesen.&lt;br /&gt;
&lt;br /&gt;
==Schritt 4: Zugang anlegen==&lt;br /&gt;
&lt;br /&gt;
Unter &#039;&#039;&#039;Zugänge&#039;&#039;&#039; mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; einen neuen Zugang anlegen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Name&#039;&#039;&#039; - erscheint im Protokoll und in der Statistik.&lt;br /&gt;
* &#039;&#039;&#039;API-Key&#039;&#039;&#039; - eindeutig, mit dem Schlüsselsymbol kann ein zufälliger Key generiert werden.&lt;br /&gt;
* &#039;&#039;&#039;Host&#039;&#039;&#039; - optional die IP oder der Hostname des Konsumenten (sollte, wenn möglich, immer gesetzt werden).&lt;br /&gt;
* Optional: &#039;&#039;&#039;CORS-Origins&#039;&#039;&#039; und &#039;&#039;&#039;JWT-Konfiguration&#039;&#039;&#039;, siehe [[OBS/Kostenpflichtige Module/RESTServer/Zugaenge|Zugänge]].&lt;br /&gt;
&lt;br /&gt;
==Schritt 5: Endpunkt anlegen==&lt;br /&gt;
&lt;br /&gt;
Unter &#039;&#039;&#039;Endpunkte&#039;&#039;&#039; mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; einen neuen Endpunkt anlegen:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Endpunkt&#039;&#039;&#039; - Ressourcen-/Anzeigename (z.B. &#039;&#039;orders&#039;&#039;, &#039;&#039;kalender&#039;&#039;); dient nur der Übersicht, nicht dem Routing.&lt;br /&gt;
* &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; - der maßgebliche Routing-Pfad, ggf. mit Platzhaltern (z.B. &#039;&#039;/orders&#039;&#039;, &#039;&#039;/orders/{uid}&#039;&#039;, &#039;&#039;/orders/{uid}/modules/{code}&#039;&#039;).&lt;br /&gt;
* &#039;&#039;&#039;Server&#039;&#039;&#039; - die Server-Auswahl bestimmt, über welches Server-Profil der Endpunkt erreichbar ist.&lt;br /&gt;
* &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; - nur setzen, wenn der Endpunkt Dateien entgegennehmen soll; dann auch die &#039;&#039;&#039;Max. Upload-Grösse&#039;&#039;&#039; in MB festlegen (siehe [[OBS/Kostenpflichtige Module/RESTServer/Beispiel6|Beispiel 6]]).&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Die früheren Felder &#039;&#039;&#039;Sub-URL&#039;&#039;&#039; und &#039;&#039;&#039;Version&#039;&#039;&#039; werden nicht mehr ausgewertet. Eine Versionskennung wird ggf. als statisches Segment ins Pfad-Template aufgenommen (z.B. &#039;&#039;/orders/v1&#039;&#039;). Details: [[OBS/Kostenpflichtige Module/RESTServer/Endpunkte|Endpunkte]].}}&lt;br /&gt;
&lt;br /&gt;
Anschliessend mit &#039;&#039;&#039;F7&#039;&#039;&#039; das Endpunkt-Skript pflegen (siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]]).&lt;br /&gt;
&lt;br /&gt;
==Schritt 6: Zugang für den Endpunkt freischalten==&lt;br /&gt;
&lt;br /&gt;
Aus der Endpunkt-Liste den neuen Endpunkt markieren und &#039;&#039;&#039;F6&#039;&#039;&#039; drücken. Es öffnet sich die Berechtigungs-Liste. Mit &#039;&#039;&#039;Einfg&#039;&#039;&#039; kann ein oder mehrere Zugänge ausgewählt und mit &#039;&#039;&#039;F2&#039;&#039;&#039; übernommen werden. Erst nach dieser Freischaltung darf ein Zugang den Endpunkt aufrufen.&lt;br /&gt;
&lt;br /&gt;
==Schritt 7: Funktionsprüfung==&lt;br /&gt;
&lt;br /&gt;
Aufruf mit einem HTTP-Client (curl, Postman, Browser-Plug-in):&lt;br /&gt;
&lt;br /&gt;
 curl -H &amp;quot;apikey: [API-KEY]&amp;quot; https://api.meinserver.de/[Pfad-Template]&lt;br /&gt;
&lt;br /&gt;
Bei korrektem Setup erscheint die JSON-Antwort des Skripts. Fehlerfälle werden in &#039;&#039;&#039;RESTSRV_PROTO&#039;&#039;&#039; protokolliert.&lt;br /&gt;
&lt;br /&gt;
==Schritt 8: Statistik und Protokoll prüfen==&lt;br /&gt;
&lt;br /&gt;
* In der Endpunkt-Liste &#039;&#039;&#039;F8&#039;&#039;&#039; für die Statistik des Endpunkts.&lt;br /&gt;
* In der Zugänge-Liste &#039;&#039;&#039;F8&#039;&#039;&#039; für die Statistik des Zugangs.&lt;br /&gt;
* In &#039;&#039;&#039;Stammdaten -&amp;gt; Z Weitere Stammdaten -&amp;gt; REST-Server -&amp;gt; Protokoll&#039;&#039;&#039; die Ereignisse prüfen.&lt;br /&gt;
&lt;br /&gt;
==Wartung der Server-Tabellen==&lt;br /&gt;
&lt;br /&gt;
Die Tabellen des REST-Servers pflegen sich selbst; ein regelmässiger Eingriff ist&lt;br /&gt;
nicht vorgesehen:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Tabelle !! Aufräumen&lt;br /&gt;
|-&lt;br /&gt;
| RESTSRV_IDEMPOTENCY || Abgeschlossene Einträge werden nach 30 Tagen gelöscht. Offene Reservierungen bleiben absichtlich stehen und werden ab 24 Stunden im Protokoll gemeldet - sie sind ein Support-Fall, siehe Stolpersteine&lt;br /&gt;
|-&lt;br /&gt;
| RESTSRV_TOKEN || Zeilen werden gelöscht, sobald Access- &#039;&#039;&#039;und&#039;&#039;&#039; Refresh-Laufzeit vorbei sind. Läuft beim Serverstart und danach höchstens stündlich&lt;br /&gt;
|-&lt;br /&gt;
| RESTSRV_PROTO / RESTSRV_STATS || Wachsen mit dem Betrieb. Bei hohem Aufkommen empfiehlt sich ein turnusmässiges Archivieren&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
==Häufige Stolpersteine==&lt;br /&gt;
&lt;br /&gt;
* Bindung angelegt, aber der Server wurde nicht neugestartet -&amp;gt; die Bindung ist noch nicht aktiv.&lt;br /&gt;
* Endpunkt einem falschen Server zugeordnet -&amp;gt; Aufruf liefert 404 &#039;&#039;Endpunkt nicht vorhanden oder inaktiv&#039;&#039;.&lt;br /&gt;
* Pfad-Template falsch geschrieben oder Segmentzahl passt nicht -&amp;gt; kein Template matcht, Aufruf liefert 404.&lt;br /&gt;
* Zugang für den Endpunkt nicht freigeschaltet -&amp;gt; Aufruf liefert 403 &#039;&#039;Keine Berechtigung für die Endpunkt-Nutzung&#039;&#039;.&lt;br /&gt;
* Falscher API-Key oder falscher Host beim Zugang -&amp;gt; Aufruf liefert 401 bzw. 403.&lt;br /&gt;
* mTLS aktiv, aber kein Client-Zertifikat installiert -&amp;gt; Verbindung wird während des TLS-Handshakes abgebrochen.&lt;br /&gt;
* Datei-Upload ohne gesetzten Haken &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; am Endpunkt -&amp;gt; Aufruf liefert 415, das Skript läuft nicht an.&lt;br /&gt;
* Skript geändert, aber der Aufruf verhält sich noch wie vorher -&amp;gt; Endpunkt-Cache abwarten (bis zu 60 Sekunden).&lt;br /&gt;
* Wiederholter Schreibaufruf liefert 422 &#039;&#039;IDEMPOTENCY_KEY_REUSED&#039;&#039; -&amp;gt; der Client sendet denselben &#039;&#039;Idempotency-Key&#039;&#039; mit geändertem Inhalt. Pro Vorgang genau ein Schlüssel, über alle Wiederholversuche hinweg unverändert.&lt;br /&gt;
* Wiederholter Schreibaufruf liefert 409 &#039;&#039;IDEMPOTENCY_UNRESOLVED&#039;&#039; -&amp;gt; ein früherer Versuch wurde unterbrochen. Ob gebucht wurde, ist offen: im Protokoll und in den Fachdaten prüfen, dann den Eintrag in &#039;&#039;&#039;RESTSRV_IDEMPOTENCY&#039;&#039;&#039; entfernen.&lt;br /&gt;
* Nach einem Update antworten &#039;&#039;&#039;alle&#039;&#039;&#039; bestehenden Token mit 401 &#039;&#039;AUTH_EXPIRED&#039;&#039; -&amp;gt; erwartetes Verhalten. Token, die vor der Einführung der Sitzungsprüfung ausgestellt wurden, gehören zu keiner Sitzung; die Konsumenten müssen sich einmalig neu anmelden.&lt;br /&gt;
* Anmeldung liefert 503 statt eines Tokens -&amp;gt; die Sitzungszeile konnte nicht geschrieben werden. Ursache im Protokoll unter &#039;&#039;TokenStore: ...&#039;&#039; nachsehen; typisch ist eine fehlende Tabelle &#039;&#039;&#039;RESTSRV_TOKEN&#039;&#039;&#039; nach einem Update ohne Datenbank-Abgleich.&lt;br /&gt;
* Abmelden liefert 503 -&amp;gt; die Sitzung liess sich nicht sperren. Der Client ist &#039;&#039;&#039;nicht&#039;&#039;&#039; abgemeldet und muss den Aufruf wiederholen; Details im Protokoll.&lt;br /&gt;
* Techniker wird beim Arbeiten unerwartet abgemeldet -&amp;gt; im Protokoll nach &#039;&#039;WIEDERVORLAGE&#039;&#039; suchen. Ein doppelt verwendetes Refresh-Token sperrt die Sitzung. Legt die App Token mehrfach parallel ab, gehört das clientseitig behoben.&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Endpunkte&amp;diff=64681</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Endpunkte</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Endpunkte&amp;diff=64681"/>
		<updated>2026-08-19T10:15:05Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
= Endpunkte =&lt;br /&gt;
&lt;br /&gt;
Ein &#039;&#039;&#039;Endpunkt&#039;&#039;&#039; stellt eine Adresse bereit, über die der REST-Server konkrete&lt;br /&gt;
Funktionen anbietet. Jeder Endpunkt wird durch ein OBS-Skript oder einen WebHook&lt;br /&gt;
realisiert und ist genau einem Server-Profil zugeordnet.&lt;br /&gt;
&lt;br /&gt;
== Routing über Pfad-Templates ==&lt;br /&gt;
&lt;br /&gt;
Das Routing erfolgt über die Spalte &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_pathtemplate&amp;lt;/code&amp;gt;).&lt;br /&gt;
Ein Template beschreibt den vollständigen Pfad nach dem Host und kann&lt;br /&gt;
&#039;&#039;&#039;Platzhalter&#039;&#039;&#039; enthalten:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Statische Segmente&#039;&#039;&#039; müssen exakt übereinstimmen (Groß-/Kleinschreibung wird ignoriert).&lt;br /&gt;
* &#039;&#039;&#039;Platzhalter&#039;&#039;&#039; in geschweiften Klammern – z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;{uid}&amp;lt;/code&amp;gt; – passen auf einen beliebigen Wert und werden als [[#Pfad-Parameter im Skript|Pfad-Parameter]] erfasst.&lt;br /&gt;
&lt;br /&gt;
Beispiele für gültige Templates:&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Template !! Passt auf !! Pfad-Parameter&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; || –&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/4711&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;uid = 4711&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/orders/4711/modules/A1&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;uid = 4711&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;code = A1&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &amp;lt;code&amp;gt;/kalender/v1&amp;lt;/code&amp;gt; || &amp;lt;code&amp;gt;/kalender/v1&amp;lt;/code&amp;gt; || –&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
=== Präzedenz bei mehreren Treffern ===&lt;br /&gt;
&lt;br /&gt;
Passen mehrere Templates auf denselben Pfad, &#039;&#039;&#039;gewinnt das spezifischste&#039;&#039;&#039; –&lt;br /&gt;
also das mit den meisten statischen Segmenten. So schlägt &amp;lt;code&amp;gt;/orders/summary&amp;lt;/code&amp;gt;&lt;br /&gt;
das Template &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt;, während &amp;lt;code&amp;gt;/orders/4711&amp;lt;/code&amp;gt; auf&lt;br /&gt;
&amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; matcht.&lt;br /&gt;
&lt;br /&gt;
== Hauptfelder eines Endpunkts ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Feld !! Spalte !! Zweck&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Pfad-Template&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_pathtemplate&amp;lt;/code&amp;gt; || &#039;&#039;&#039;Maßgeblich fürs Routing.&#039;&#039;&#039; Vollständiger Pfad mit Platzhaltern, z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders/{uid}/modules/{code}&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Server&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_server&amp;lt;/code&amp;gt; || Zuordnung zum Server-Profil (erforderlich)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Aktiv&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_aktiv&amp;lt;/code&amp;gt; || Statusflag; inaktive Endpunkte liefern 404&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Skript&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_script&amp;lt;/code&amp;gt; || DwScript-Quelltext des Handlers (siehe [[/OBS/Kostenpflichtige_Module/RESTServer/Scripting|Scripting]])&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;WebHook&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_webhook&amp;lt;/code&amp;gt; || optional: Verweis auf einen Einmal-Endpunkt statt Skript&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Info&#039;&#039;&#039; || – || Dokumentations-Freitext&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Datei-Upload&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_upload&amp;lt;/code&amp;gt; || Erlaubt Datei-Uploads an diesem Endpunkt (&amp;lt;code&amp;gt;0&amp;lt;/code&amp;gt; = aus, &amp;lt;code&amp;gt;1&amp;lt;/code&amp;gt; = an). Ohne Freigabe werden Upload-Anfragen mit 415 abgelehnt.&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;Max. Upload-Grösse&#039;&#039;&#039; || &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt; || Maximale Dateigrösse in MB. Leer/0 = Standard 25&amp;amp;nbsp;MB. Überschreitung wird mit 413 abgelehnt.&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
== Pfad-Parameter im Skript ==&lt;br /&gt;
&lt;br /&gt;
Die aus den Platzhaltern erfassten Werte stehen im Endpunkt-Skript als&lt;br /&gt;
reservierte Parameter mit Präfix &amp;lt;code&amp;gt;_OBS_PATH_&amp;lt;/code&amp;gt; zur Verfügung:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot;&amp;gt;&lt;br /&gt;
function Get(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var cUid: string;&lt;br /&gt;
begin&lt;br /&gt;
    cUid := oParams.Values[&#039;_OBS_PATH_uid&#039;];   // aus /orders/{uid}&lt;br /&gt;
    // ...&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Da der Präfix &amp;lt;code&amp;gt;_OBS_&amp;lt;/code&amp;gt; für von außen gelieferte Header/Query-Parameter&lt;br /&gt;
gesperrt ist, können diese Werte nicht durch den Client überschrieben werden.&lt;br /&gt;
&lt;br /&gt;
== Datei-Uploads ==&lt;br /&gt;
&lt;br /&gt;
Ein Endpunkt nimmt Datei-Uploads nur entgegen, wenn er dafür freigeschaltet ist&lt;br /&gt;
(&amp;lt;code&amp;gt;re_upload = 1&amp;lt;/code&amp;gt;). Andernfalls werden Upload-Anfragen mit&lt;br /&gt;
&#039;&#039;&#039;415 Unsupported Media Type&#039;&#039;&#039; abgelehnt und das Skript wird nicht ausgeführt.&lt;br /&gt;
&lt;br /&gt;
Die maximal zulässige Dateigrösse wird pro Endpunkt über &amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;&lt;br /&gt;
(in MB) festgelegt; ohne Angabe gilt der Standard von &#039;&#039;&#039;25&amp;amp;nbsp;MB&#039;&#039;&#039;. Wird das&lt;br /&gt;
Limit überschritten, antwortet der Server mit &#039;&#039;&#039;413 Payload Too Large&#039;&#039;&#039;. Für&lt;br /&gt;
Uploads gilt nicht das JSON-Body-Limit (10&amp;amp;nbsp;MB), sondern dieses Endpunkt-Limit.&lt;br /&gt;
Der Originalname (beim resumable Upload über den Header &amp;lt;code&amp;gt;Upload-Metadata&amp;lt;/code&amp;gt;) ist &#039;&#039;&#039;Pflicht&#039;&#039;&#039;; fehlt er, wird der Upload mit 413/400 abgelehnt. Dem Skript stehen Pfad, Name und Content-Type als &amp;lt;code&amp;gt;_OBS_UPLOAD_*&amp;lt;/code&amp;gt;-Parameter zur Verfügung (Details: [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]]).&lt;br /&gt;
&lt;br /&gt;
Unterstützt werden zwei Übertragungsarten:&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Einfacher Upload&#039;&#039;&#039; per &amp;lt;code&amp;gt;multipart/form-data&amp;lt;/code&amp;gt; (eine Datei pro Request).&lt;br /&gt;
* &#039;&#039;&#039;Resumable/Chunked Upload&#039;&#039;&#039; per &amp;lt;code&amp;gt;Content-Range&amp;lt;/code&amp;gt; (grosse Dateien, fortsetzbar nach Abbruch).&lt;br /&gt;
&lt;br /&gt;
Der Server legt die hochgeladene Datei in einem temporären Verzeichnis ab und&lt;br /&gt;
übergibt dem Endpunkt-Skript den Pfad. Was mit der Datei geschieht (Ablage,&lt;br /&gt;
DMS-Verknüpfung, Weiterverarbeitung), entscheidet allein das Skript. Details und&lt;br /&gt;
Beispiele: [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
== HTTP-Methode ==&lt;br /&gt;
&lt;br /&gt;
Welche Funktion aufgerufen wird, ergibt sich aus dem HTTP-Verb: Der Server ruft&lt;br /&gt;
die gleichnamige Skript-Methode auf (&amp;lt;code&amp;gt;Get&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Post&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Put&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Delete&amp;lt;/code&amp;gt;/&amp;lt;code&amp;gt;Patch&amp;lt;/code&amp;gt;).&lt;br /&gt;
Ein Template entspricht damit &#039;&#039;&#039;einem&#039;&#039;&#039; Endpunkt-Skript; unterschiedliche&lt;br /&gt;
Pfad-Formen (z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders&amp;lt;/code&amp;gt; vs. &amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt;) sind&lt;br /&gt;
&#039;&#039;&#039;eigene Endpunkt-Einträge&#039;&#039;&#039; mit eigenem Skript und eigener Berechtigung.&lt;br /&gt;
&lt;br /&gt;
== URL-Struktur ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
http://[Host][:Port][Pfad-Template]&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Beispiele:&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders/4711&amp;lt;/code&amp;gt;&lt;br /&gt;
* &amp;lt;code&amp;gt;https://api.meinserver.de/orders/4711/modules/A1&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
== Versionierung ==&lt;br /&gt;
&lt;br /&gt;
Da es kein eigenes Versions-Feld mehr gibt, wird die Version als statisches&lt;br /&gt;
Segment ins Template aufgenommen, z.&amp;amp;nbsp;B. &amp;lt;code&amp;gt;/orders/v1&amp;lt;/code&amp;gt; oder&lt;br /&gt;
&amp;lt;code&amp;gt;/v1/orders/{uid}&amp;lt;/code&amp;gt;. Änderung ohne Breaking Change:&lt;br /&gt;
&lt;br /&gt;
# Bestehenden Endpunkt unverändert lassen.&lt;br /&gt;
# Neuen Endpunkt mit gleichem Ressourcennamen, aber neuem Versions-Segment im Template anlegen.&lt;br /&gt;
# Berechtigungen für neue Konsumenten setzen.&lt;br /&gt;
# Alten Endpunkt deaktivieren, wenn die Migration abgeschlossen ist.&lt;br /&gt;
&lt;br /&gt;
== Zugriffskontrolle ==&lt;br /&gt;
&lt;br /&gt;
Endpunkte sind Server-Profilen zugeordnet; ein Zugang benötigt eine explizite&lt;br /&gt;
Berechtigung (Tabelle &amp;lt;code&amp;gt;RESTSRV_ACCESS&amp;lt;/code&amp;gt;), um einen Endpunkt nutzen zu&lt;br /&gt;
dürfen. Weil jede Pfad-Form ein eigener Endpunkt-Eintrag ist, lässt sich der&lt;br /&gt;
Zugriff &#039;&#039;&#039;pro Pfad-Form&#039;&#039;&#039; granular vergeben (z.&amp;amp;nbsp;B. Lesen von&lt;br /&gt;
&amp;lt;code&amp;gt;/orders/{uid}&amp;lt;/code&amp;gt; erlauben, aber das ändernde&lt;br /&gt;
&amp;lt;code&amp;gt;PUT /orders/{uid}/modules/{code}&amp;lt;/code&amp;gt; nicht).&lt;br /&gt;
&lt;br /&gt;
== Caching ==&lt;br /&gt;
&lt;br /&gt;
Die Endpunkt-Definitionen werden pro Server-Profil zwischengespeichert&lt;br /&gt;
(TTL, Standard 60&amp;amp;nbsp;s). Neue oder geänderte Endpunkte/Templates werden daher&lt;br /&gt;
erst nach Ablauf des Caches (bzw. nach einer Cache-Invalidierung) wirksam – nicht&lt;br /&gt;
zwingend sofort.&lt;br /&gt;
&lt;br /&gt;
== HTTP-Fehlercodes ==&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Code !! Ursache&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;404&#039;&#039;&#039; || Kein Template passt zum Pfad, Endpunkt inaktiv oder falsches Server-Profil&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;403&#039;&#039;&#039; || Zugang fehlt in der Berechtigungsliste&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;401&#039;&#039;&#039; || API-Key ungültig/fehlend, JWT abgelaufen oder Sitzung gesperrt (&#039;&#039;AUTH_EXPIRED&#039;&#039;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;500&#039;&#039;&#039; || Skript- oder Syntax-Fehler (Details in &amp;lt;code&amp;gt;RESTSRV_PROTO&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;415&#039;&#039;&#039; || Datei-Upload an einen Endpunkt, der dafür nicht freigeschaltet ist (&amp;lt;code&amp;gt;re_upload = 0&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;413&#039;&#039;&#039; || Hochgeladene Datei überschreitet die zulässige Maximalgrösse (&amp;lt;code&amp;gt;re_upload_size&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;429&#039;&#039;&#039; || Rate-Limit überschritten; die Antwort enthält den Header &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;503&#039;&#039;&#039; || Eine Anfrage mit demselben &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; wird gerade verarbeitet; die Antwort enthält &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;409&#039;&#039;&#039; || Ein früherer Aufruf mit demselben &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; ist ohne Ergebnis geblieben (&amp;lt;code&amp;gt;IDEMPOTENCY_UNRESOLVED&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;422&#039;&#039;&#039; || Derselbe &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; wurde mit &#039;&#039;&#039;anderem&#039;&#039;&#039; Inhalt gesendet (&amp;lt;code&amp;gt;IDEMPOTENCY_KEY_REUSED&amp;lt;/code&amp;gt;)&lt;br /&gt;
|-&lt;br /&gt;
| &#039;&#039;&#039;400&#039;&#039;&#039; || Die Anfrage wurde unverschlüsselt an einen TLS-Port geschickt (&amp;lt;code&amp;gt;http&amp;lt;/code&amp;gt; statt &amp;lt;code&amp;gt;https&amp;lt;/code&amp;gt;). Die Abweisung erfolgt vor Authentifizierung und Endpunkt-Skript&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Jede Fehlerantwort besteht aus einem &amp;lt;code&amp;gt;error&amp;lt;/code&amp;gt;-Objekt mit&lt;br /&gt;
maschinenlesbarem Code - Aufbau siehe&lt;br /&gt;
[[OBS/Kostenpflichtige Module/RESTServer|Übersicht]].&lt;br /&gt;
&lt;br /&gt;
Daneben kann ein Endpunkt-Skript den Statuscode selbst setzen (z.B. &amp;lt;code&amp;gt;201&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;204&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;409&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;422&amp;lt;/code&amp;gt;) sowie Response-Header wie &amp;lt;code&amp;gt;ETag&amp;lt;/code&amp;gt; oder &amp;lt;code&amp;gt;Location&amp;lt;/code&amp;gt; - siehe [[OBS/Kostenpflichtige Module/RESTServer/Scripting|Scripting]].&lt;br /&gt;
&lt;br /&gt;
== Idempotenz ==&lt;br /&gt;
&lt;br /&gt;
Schickt ein Konsument bei &amp;lt;code&amp;gt;POST&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;PUT&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;PATCH&amp;lt;/code&amp;gt;&lt;br /&gt;
oder &amp;lt;code&amp;gt;DELETE&amp;lt;/code&amp;gt; den Header &amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt;, sorgt der Server&lt;br /&gt;
selbst dafür, dass eine wiederholte Sendung &#039;&#039;&#039;keine Zweitwirkung&#039;&#039;&#039; hat. Das gilt&lt;br /&gt;
für &#039;&#039;&#039;jeden&#039;&#039;&#039; Endpunkt - es muss weder am Endpunkt etwas eingestellt noch im&lt;br /&gt;
Skript etwas programmiert werden.&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
! Situation !! Antwort des Servers&lt;br /&gt;
|-&lt;br /&gt;
| Erster Aufruf || Endpunkt läuft normal, das Ergebnis wird zum Schlüssel gespeichert&lt;br /&gt;
|-&lt;br /&gt;
| Wiederholung, gleicher Inhalt || &#039;&#039;&#039;Gespeicherte Antwort&#039;&#039;&#039; (gleicher Statuscode, gleicher Body, gleiche Header) plus Header &amp;lt;code&amp;gt;Idempotent-Replay: true&amp;lt;/code&amp;gt;. Das Skript läuft &#039;&#039;&#039;nicht&#039;&#039;&#039;&lt;br /&gt;
|-&lt;br /&gt;
| Wiederholung, anderer Inhalt || &#039;&#039;&#039;422&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_KEY_REUSED&amp;lt;/code&amp;gt; - derselbe Schlüssel für einen anderen Inhalt ist ein Client-Fehler&lt;br /&gt;
|-&lt;br /&gt;
| Erster Aufruf läuft noch || &#039;&#039;&#039;503&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_IN_PROGRESS&amp;lt;/code&amp;gt; mit &amp;lt;code&amp;gt;Retry-After&amp;lt;/code&amp;gt;. Beim nächsten Versuch liegt die gespeicherte Antwort vor&lt;br /&gt;
|-&lt;br /&gt;
| Früherer Aufruf ohne Ergebnis || &#039;&#039;&#039;409&#039;&#039;&#039; &amp;lt;code&amp;gt;IDEMPOTENCY_UNRESOLVED&amp;lt;/code&amp;gt;. Der Dienst wurde zwischen Verarbeitung und Festschreiben unterbrochen; ob die Buchung stattgefunden hat, ist unbekannt und von Hand zu klären&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
Wichtig für die Auslegung eines Endpunkts:&lt;br /&gt;
&lt;br /&gt;
* Antwortet das Skript mit &#039;&#039;&#039;2xx&#039;&#039;&#039;, wird die Antwort gespeichert und bei einer Wiederholung erneut ausgeliefert.&lt;br /&gt;
* Antwortet das Skript mit &#039;&#039;&#039;4xx&#039;&#039;&#039; (fachliche Ablehnung), wird der Schlüssel wieder &#039;&#039;&#039;freigegeben&#039;&#039;&#039; - der Konsument darf ihn nach Korrektur erneut verwenden. Sonst würde ein einziger Validierungsfehler den Schlüssel dauerhaft blockieren.&lt;br /&gt;
* Endet die Verarbeitung mit &#039;&#039;&#039;5xx&#039;&#039;&#039; oder einem Skript-Fehler, bleibt der Schlüssel belegt und der Fall wird protokolliert. Das ist Absicht: lieber ein Fall für den Support als eine mögliche Doppelbuchung.&lt;br /&gt;
&lt;br /&gt;
Der Schlüssel gilt je &#039;&#039;&#039;Zugang, Methode und Pfad&#039;&#039;&#039;. Derselbe&lt;br /&gt;
&amp;lt;code&amp;gt;Idempotency-Key&amp;lt;/code&amp;gt; an einem anderen Endpunkt ist damit ein eigener&lt;br /&gt;
Vorgang - der Konsument muss ihn nicht global eindeutig vergeben, aber pro Vorgang&lt;br /&gt;
&#039;&#039;&#039;stabil wiederverwenden&#039;&#039;&#039; (also nicht bei jedem Wiederholversuch neu erzeugen).&lt;br /&gt;
&lt;br /&gt;
Die Einträge stehen in &amp;lt;code&amp;gt;RESTSRV_IDEMPOTENCY&amp;lt;/code&amp;gt; und werden nach 30 Tagen&lt;br /&gt;
automatisch aufgeräumt. Einträge ohne Ergebnis werden &#039;&#039;&#039;nicht&#039;&#039;&#039; automatisch&lt;br /&gt;
gelöscht, sondern im Protokoll gemeldet.&lt;br /&gt;
&lt;br /&gt;
== WebHooks ==&lt;br /&gt;
&lt;br /&gt;
WebHooks sind &#039;&#039;&#039;Einmal-Endpunkte&#039;&#039;&#039; für asynchrone Callbacks. Beim ersten&lt;br /&gt;
erfolgreichen Aufruf wird die Antwort in der Tabelle &amp;lt;code&amp;gt;REMOTE_HOOK_URL&amp;lt;/code&amp;gt;&lt;br /&gt;
gespeichert und der Endpunkt anschließend automatisch gelöscht.&lt;br /&gt;
&lt;br /&gt;
Typischer Einsatz: OBS stösst einen Vorgang bei einem Fremdsystem an (Bezahldienst,&lt;br /&gt;
Versanddienstleister) und gibt diesem eine Rückruf-Adresse mit, die nur &#039;&#039;&#039;ein&lt;br /&gt;
einziges Mal&#039;&#039;&#039; gültig ist. Damit kann die Adresse nicht später erneut - oder von&lt;br /&gt;
jemand anderem - benutzt werden.&lt;br /&gt;
&lt;br /&gt;
Ablauf:&lt;br /&gt;
&lt;br /&gt;
# OBS legt einen Eintrag in &amp;lt;code&amp;gt;REMOTE_HOOK_URL&amp;lt;/code&amp;gt; an und dazu einen Endpunkt, dessen Feld &#039;&#039;&#039;WebHook&#039;&#039;&#039; (&amp;lt;code&amp;gt;re_webhook&amp;lt;/code&amp;gt;) auf diesen Eintrag zeigt. Ein Skript wird für diesen Endpunkt nicht gepflegt.&lt;br /&gt;
# Die Adresse dieses Endpunkts wird dem Fremdsystem als Callback-URL übergeben.&lt;br /&gt;
# Das Fremdsystem ruft die Adresse auf. Der Server legt den übergebenen Inhalt in &amp;lt;code&amp;gt;REMOTE_HOOK_URL.hu_response&amp;lt;/code&amp;gt; ab, antwortet mit &amp;lt;code&amp;gt;{&amp;quot;status&amp;quot;: &amp;quot;ok&amp;quot;}&amp;lt;/code&amp;gt; und &#039;&#039;&#039;löscht den Endpunkt&#039;&#039;&#039;.&lt;br /&gt;
# Der weiterverarbeitende OBS-Prozess findet die Rückmeldung in &amp;lt;code&amp;gt;hu_response&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Ein zweiter Aufruf derselben Adresse läuft ins Leere: Der Endpunkt&lt;br /&gt;
existiert nicht mehr, die Antwort ist 404. Das ist gewollt - ein WebHook ist keine&lt;br /&gt;
dauerhafte Schnittstelle. Für eine dauerhaft erreichbare Rückmelde-Adresse einen&lt;br /&gt;
normalen Endpunkt mit Skript anlegen.}}&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
	<entry>
		<id>https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel1&amp;diff=64680</id>
		<title>OBS/Kostenpflichtige Module/RESTServer/Beispiel1</title>
		<link rel="alternate" type="text/html" href="https://wiki.bergau.de/index.php?title=OBS/Kostenpflichtige_Module/RESTServer/Beispiel1&amp;diff=64680"/>
		<updated>2026-08-19T10:14:47Z</updated>

		<summary type="html">&lt;p&gt;Rademacker: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Kostenpflichtige Module}}&lt;br /&gt;
&lt;br /&gt;
=Beispiel 1: Daten-Abruf mit JWT-Authentifizierung=&lt;br /&gt;
&lt;br /&gt;
In diesem Beispiel werden Steuerungs-Variablen aus OBS abgerufen und auf einer Webseite ausgegeben. Der Zugang ist mit JWT-Pflicht eingerichtet, der Konsument muss sich daher zuerst anmelden und einen Token bezogen haben.&lt;br /&gt;
&lt;br /&gt;
==Einrichtung in OBS==&lt;br /&gt;
&lt;br /&gt;
* &#039;&#039;&#039;Server-Profil:&#039;&#039;&#039; Standard-TLS-Profil &#039;&#039;Public-API&#039;&#039; mit Bindung 0.0.0.0:443&lt;br /&gt;
* &#039;&#039;&#039;Zugang:&#039;&#039;&#039; &#039;&#039;Web-Dashboard&#039;&#039;&lt;br /&gt;
** API-Key: zufaellig generiert&lt;br /&gt;
** JWT aktiv, JWT-Endpunkt &#039;&#039;oauth&#039;&#039;, JWT-Key zufaellig, JWT-Exp 60 (Minuten)&lt;br /&gt;
** CORS-Origins: &#039;&#039;https://dashboard.kunde.de&#039;&#039;&lt;br /&gt;
* &#039;&#039;&#039;Endpunkt:&#039;&#039;&#039; &#039;&#039;steuerung/v1&#039;&#039;, dem Profil &#039;&#039;Public-API&#039;&#039; zugeordnet&lt;br /&gt;
* &#039;&#039;&#039;Berechtigung:&#039;&#039;&#039; Zugang &#039;&#039;Web-Dashboard&#039;&#039; fuer Endpunkt &#039;&#039;steuerung/v1&#039;&#039; freigeschaltet&lt;br /&gt;
&lt;br /&gt;
==JWT-Authentifizierungs-Skript (Zugang)==&lt;br /&gt;
&lt;br /&gt;
Wird ueber die Zugaenge-Liste mit &#039;&#039;&#039;F7&#039;&#039;&#039; geoeffnet. Pruefen, ob Benutzername/Passwort gegen die OBS-Benutzerverwaltung passen.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
function Authenticate(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oRes  : TJSONObject;&lt;br /&gt;
    oVal  : TJSONValue;&lt;br /&gt;
    cUser : string;&lt;br /&gt;
    cPass : string;&lt;br /&gt;
    cSql  : string;&lt;br /&gt;
    qUser : TxFQuery;&lt;br /&gt;
    lOk   : Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        cUser := &#039;&#039;;&lt;br /&gt;
        cPass := &#039;&#039;;&lt;br /&gt;
        if (Assigned(oBody)) then begin&lt;br /&gt;
            oVal := oBody.GetValue(&#039;username&#039;);&lt;br /&gt;
            if (Assigned(oVal)) then begin&lt;br /&gt;
                cUser := oVal.Value;&lt;br /&gt;
            end;&lt;br /&gt;
            oVal := oBody.GetValue(&#039;password&#039;);&lt;br /&gt;
            if (Assigned(oVal)) then begin&lt;br /&gt;
                cPass := oVal.Value;&lt;br /&gt;
            end;&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        cSql := &#039;SELECT u_nr, u_name FROM benutzer&#039; +&lt;br /&gt;
                &#039; WHERE u_login = &#039; + DB_SQLVal(cUser) +&lt;br /&gt;
                &#039; AND u_passwort_hash = &#039; + DB_SQLVal(HashPasswort(cPass)) +&lt;br /&gt;
                &#039; AND u_aktiv = &#039; + DB_SQLVal(&#039;1&#039;);&lt;br /&gt;
&lt;br /&gt;
        // DB_SOpen liefert true, wenn die ABFRAGE lief - nicht, wenn ein&lt;br /&gt;
        // Datensatz gefunden wurde. Ohne die EoF-Pruefung wuerde jede&lt;br /&gt;
        // Anmeldung gelingen, sobald das SQL fehlerfrei ist.&lt;br /&gt;
        lOk := false;&lt;br /&gt;
        if (DB_SOpen(oDB, cSql, qUser)) then begin&lt;br /&gt;
            if (not qUser.EoF) then begin&lt;br /&gt;
                lOk := true;&lt;br /&gt;
                oRes.AddPair(&#039;status&#039;           , 1);&lt;br /&gt;
                oRes.AddPair(&#039;_OBS_JWT_ID&#039;      , qUser.A2C(&#039;u_nr&#039;));&lt;br /&gt;
                oRes.AddPair(&#039;_OBS_JWT_SUBJECT&#039; , qUser.A2C(&#039;u_name&#039;));&lt;br /&gt;
                oRes.AddPair(&#039;_OBS_JWT_AUDIENCE&#039;, &#039;dashboard&#039;);&lt;br /&gt;
            end;&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
        end;&lt;br /&gt;
        DB_Close(qUser);&lt;br /&gt;
&lt;br /&gt;
        if (not lOk) then begin&lt;br /&gt;
            oRes.AddPair(&#039;status&#039;, 9);&lt;br /&gt;
            oRes.AddPair(&#039;error&#039; , &#039;Benutzer oder Passwort ist falsch&#039;);&lt;br /&gt;
        end;&lt;br /&gt;
&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{{Hinweis|Hier steht in &#039;&#039;_OBS_JWT_ID&#039;&#039; die Benutzernummer - derselbe Wert also bei&lt;br /&gt;
jeder Anmeldung desselben Benutzers. Das ist zulässig: der Server führt seine&lt;br /&gt;
Sitzungen über eigene Kennungen (&#039;&#039;sid&#039;&#039;/&#039;&#039;rid&#039;&#039;), nicht über das &#039;&#039;jti&#039;&#039; des&lt;br /&gt;
Skripts. Mehrfache und parallele Anmeldungen eines Benutzers sind damit&lt;br /&gt;
unproblematisch.}}&lt;br /&gt;
&lt;br /&gt;
==Endpunkt-Skript &#039;&#039;steuerung/v1&#039;&#039;==&lt;br /&gt;
&lt;br /&gt;
Liefert eine Liste von Steuerungs-Werten zurueck. Die Auswertung der JWT-Claims sorgt dafuer, dass nur Audience &#039;&#039;dashboard&#039;&#039; Daten erhaelt.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;pascal&amp;quot; line&amp;gt;&lt;br /&gt;
procedure _AddVar(oArr: TJSONArray; const cVar: string);&lt;br /&gt;
var oWert  : TJSONObject;&lt;br /&gt;
    cTitel : string;&lt;br /&gt;
    cData  : string;&lt;br /&gt;
    cEinh  : string;&lt;br /&gt;
    lString: Boolean;&lt;br /&gt;
    lAlign : Boolean;&lt;br /&gt;
begin&lt;br /&gt;
    if (ST_Variable(oDB, cVar, cTitel, cData, cEinh, lString, lAlign)) then begin&lt;br /&gt;
        oWert := TJSONObject.Create();&lt;br /&gt;
        oWert.AddPair(&#039;variable&#039;, cVar);&lt;br /&gt;
        oWert.AddPair(&#039;titel&#039;   , cTitel);&lt;br /&gt;
        oWert.AddPair(&#039;wert&#039;    , cData);&lt;br /&gt;
        oWert.AddPair(&#039;einheit&#039; , cEinh);&lt;br /&gt;
        oArr.Add(oWert);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
// Hilfsfunktion: Fehlerantwort im einheitlichen Format aufbauen.&lt;br /&gt;
// Fachliche Ablehnungen als 4xx melden, nicht als 200 mit Fehlertext.&lt;br /&gt;
//------------------------------------------------------------------------------&lt;br /&gt;
&lt;br /&gt;
function _Fehler(oParams: TStrings; nStatus: integer; const cCode, cMsg: string): string;&lt;br /&gt;
var oRes: TJSONObject;&lt;br /&gt;
    oErr: TJSONObject;&lt;br /&gt;
begin&lt;br /&gt;
    oErr := TJSONObject.Create();&lt;br /&gt;
    oErr.AddPair(&#039;code&#039;   , cCode);&lt;br /&gt;
    oErr.AddPair(&#039;message&#039;, cMsg);&lt;br /&gt;
    oErr.AddPair(&#039;traceId&#039;, oParams.Values[&#039;_OBS_TRACE_ID&#039;]);&lt;br /&gt;
&lt;br /&gt;
    oRes := TJSONObject.Create();&lt;br /&gt;
    try&lt;br /&gt;
        oRes.AddPair(&#039;_OBS_HTTP_STATUS&#039;, nStatus);&lt;br /&gt;
        oRes.AddPair(&#039;error&#039;, oErr);&lt;br /&gt;
        result := oRes.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oRes);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&lt;br /&gt;
function Get(oParams: TStrings; oBody: TJSONObject): string;&lt;br /&gt;
var oArr: TJSONArray;&lt;br /&gt;
begin&lt;br /&gt;
    // Audience-Pruefung: nur Dashboard-Tokens akzeptieren&lt;br /&gt;
    if (oParams.Values[&#039;_OBS_JWT_AUDIENCE&#039;] &amp;lt;&amp;gt; &#039;dashboard&#039;) then begin&lt;br /&gt;
        result := _Fehler(oParams, 403, &#039;FORBIDDEN_ROLE&#039;, &#039;Token nicht für diese Anwendung ausgestellt&#039;);&lt;br /&gt;
        exit;&lt;br /&gt;
    end;&lt;br /&gt;
&lt;br /&gt;
    oArr := TJSONArray.Create();&lt;br /&gt;
    try&lt;br /&gt;
        _AddVar(oArr, &#039;WERT_1&#039;);&lt;br /&gt;
        _AddVar(oArr, &#039;WERT_2&#039;);&lt;br /&gt;
        _AddVar(oArr, &#039;WERT_3&#039;);&lt;br /&gt;
        _AddVar(oArr, &#039;WERT_4&#039;);&lt;br /&gt;
        result := oArr.ToJSON();&lt;br /&gt;
    finally&lt;br /&gt;
        MyFreeAndNil(oArr);&lt;br /&gt;
    end;&lt;br /&gt;
end;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==JavaScript-Client (Browser)==&lt;br /&gt;
&lt;br /&gt;
Im Browser werden zwei Aufrufe gemacht: erst Token holen, dann Daten lesen.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;javascript&amp;quot; line&amp;gt;&lt;br /&gt;
const API_BASE = &#039;https://api.meinserver.de&#039;;&lt;br /&gt;
const API_KEY  = &#039;[API-KEY]&#039;;&lt;br /&gt;
&lt;br /&gt;
async function login(username, password) {&lt;br /&gt;
    const res = await fetch(`${API_BASE}/oauth/`, {&lt;br /&gt;
        method:  &#039;POST&#039;,&lt;br /&gt;
        headers: { &#039;Content-Type&#039;: &#039;application/json&#039;, &#039;apikey&#039;: API_KEY },&lt;br /&gt;
        body:    JSON.stringify({ username, password })&lt;br /&gt;
    });&lt;br /&gt;
    if (!res.ok) throw new Error(&#039;Login fehlgeschlagen&#039;);&lt;br /&gt;
    const data = await res.json();&lt;br /&gt;
    return data.token;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
async function ladeSteuerung(token) {&lt;br /&gt;
    const res = await fetch(`${API_BASE}/steuerung/v1`, {&lt;br /&gt;
        method:  &#039;GET&#039;,&lt;br /&gt;
        headers: {&lt;br /&gt;
            &#039;apikey&#039;:        API_KEY,&lt;br /&gt;
            &#039;Authorization&#039;: &#039;Bearer &#039; + token&lt;br /&gt;
        }&lt;br /&gt;
    });&lt;br /&gt;
    if (!res.ok) throw new Error(&#039;Abruf fehlgeschlagen: &#039; + res.status);&lt;br /&gt;
    return res.json();&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
async function zeigeTabelle() {&lt;br /&gt;
    const token = await login(&#039;mitarbeiter&#039;, &#039;geheim&#039;);&lt;br /&gt;
    const werte = await ladeSteuerung(token);&lt;br /&gt;
&lt;br /&gt;
    const tbl = document.getElementById(&#039;steuerung&#039;);&lt;br /&gt;
    werte.forEach(w =&amp;gt; {&lt;br /&gt;
        const tr = document.createElement(&#039;tr&#039;);&lt;br /&gt;
        tr.innerHTML = `&amp;lt;td&amp;gt;${w.titel}&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;${w.wert} ${w.einheit}&amp;lt;/td&amp;gt;`;&lt;br /&gt;
        tbl.appendChild(tr);&lt;br /&gt;
    });&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
zeigeTabelle();&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
==Test mit curl==&lt;br /&gt;
&lt;br /&gt;
Token holen:&lt;br /&gt;
&lt;br /&gt;
 curl -X POST -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Content-Type: application/json&amp;quot; ^&lt;br /&gt;
      -d &amp;quot;{\&amp;quot;username\&amp;quot;:\&amp;quot;mitarbeiter\&amp;quot;,\&amp;quot;password\&amp;quot;:\&amp;quot;geheim\&amp;quot;}&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/oauth/&lt;br /&gt;
&lt;br /&gt;
Antwort:&lt;br /&gt;
&lt;br /&gt;
 {&amp;quot;token&amp;quot;:       &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;accessToken&amp;quot;: &amp;quot;eyJhbGciOi...&amp;quot;,&lt;br /&gt;
  &amp;quot;expiresIn&amp;quot;:   3600,&lt;br /&gt;
  &amp;quot;serverTime&amp;quot;:  &amp;quot;2026-08-19T09:12:33+02:00&amp;quot;}&lt;br /&gt;
&lt;br /&gt;
Da das Skript kein &#039;&#039;_OBS_JWT_REFRESH_ID&#039;&#039; liefert, enthält die Antwort kein&lt;br /&gt;
&#039;&#039;refreshToken&#039;&#039; - der Client meldet sich nach Ablauf der 60 Minuten neu an.&lt;br /&gt;
&lt;br /&gt;
Daten abrufen:&lt;br /&gt;
&lt;br /&gt;
 curl -H &amp;quot;apikey: [API-KEY]&amp;quot; -H &amp;quot;Authorization: Bearer eyJhbGciOi...&amp;quot; ^&lt;br /&gt;
      https://api.meinserver.de/steuerung/v1&lt;br /&gt;
&lt;br /&gt;
==Was zeigt das Beispiel?==&lt;br /&gt;
&lt;br /&gt;
* Zweistufige Anmeldung: API-Key + JWT.&lt;br /&gt;
* Sichere Trennung von Konsumenten-Gruppen ueber die Audience-Claim.&lt;br /&gt;
* Eigenes Authentifizierungs-Skript pro Zugang.&lt;br /&gt;
* Browser-Zugriff ueber CORS-erlaubtes Origin.&lt;br /&gt;
* Anmeldung ohne Refresh-Token: einfachster Fall, der Token laeuft nach der Zeit aus &#039;&#039;JWT-Exp&#039;&#039; ab.&lt;/div&gt;</summary>
		<author><name>Rademacker</name></author>
	</entry>
</feed>