Rezept: API für eine Bestandsdatenbank
Eine bestehende MySQL-Datenbank (Shop, CMS, Eigenbau) per API-Key als REST-API nutzbar machen — lesend mit parametrisierten Queries, schreibend nur wo ausdrücklich freigegeben.
Die Kundendaten liegen in der Datenbank eines Altsystems, und ein neues System soll sie abfragen: Shop, Außendienst-App, Partnerportal. Statt eine eigene API zu programmieren, legst du in TaskMonkey ein paar Tools an. Das Database Gateway stellt die Verbindung zur Datenbank her, die API-Keys regeln den Zugriff.
Ziel
getKunde— ein Kunde per KundennummersearchKunden— Kunden eines Orts, mit LimitcreateAnfrage— eine Anfrage in die Datenbank schreiben (bewusst freigegeben)- Kein freies SQL von außen: SQL steht fest im Tool, der Aufrufer liefert nur Werte
Architektur
Partner-System ──Bearer tm_live_…──▶ /api/v1/tools/getKunde
│ Key prüfen, Gate, Rate-Limit,
│ Parameter gegen Schema prüfen
▼
Tool getKunde (festes SQL)
│ executePreparedQuery(sql, params)
▼
DB-Gateway auf dem Kunden-Server
│ Prepared Statement, READ ONLY
▼
MySQL des Altsystems
Voraussetzungen
- SSH-Zugang zum Webserver, auf dem die Datenbank erreichbar ist
- Connection-Config im Workspace, siehe Database Gateway:
<?php
// config/tenants/<workspace>/database_connections.php
// SSH-Key als Inline-PEM aus den Workspace-Secrets (Backend → Secrets)
$sshKey = secret('SSH_KUNDE_KEY');
return [
'remote_databases' => [
'bestand' => [
'hostname' => 'crm.kunde.de',
'remote_user' => 'deploy',
'web_root' => '/var/www/crm/public',
'ssh_key' => $sshKey,
'base_url' => 'https://crm.kunde.de',
// Eigenbau-CRM = kein bekanntes System: das Gateway laesst nur
// die hier genannten Tabellen zu (Default-Deny).
'tables' => [
'read' => ['kunden', 'anfragen'],
'write' => ['anfragen'],
],
],
],
];
Für bekannte Systeme (Contao, WordPress, Shopware, …) bringt das Gateway eigene Tabellen-Profile mit, siehe Database Gateway → System-Profile.
Beispielschema der Datenbank:
CREATE TABLE kunden (
id INT AUTO_INCREMENT PRIMARY KEY,
kundennummer VARCHAR(10) UNIQUE,
firma VARCHAR(100),
ort VARCHAR(80),
umsatz_vorjahr DECIMAL(12,2)
);
CREATE TABLE anfragen (
id INT AUTO_INCREMENT PRIMARY KEY,
kundennummer VARCHAR(10),
nachricht TEXT,
quelle VARCHAR(100),
created DATETIME DEFAULT CURRENT_TIMESTAMP
);
1. Gateway deployen
bin/cake test_tool -t <workspace> --tool deployGateway \
--args='{"project":"bestand","allow_write":false,"expiry_hours":48}'
-
allow_write: falsereicht fürgetKundeundsearchKunden. Nur wenncreateAnfragegebraucht wird:true. -
Das Gateway läuft ab (
expiry_hours, Default 24). Für eine dauerhafte API den täglichen Redeploy per Scheduled Task einrichten (refreshDatabaseGateways, siehe Database Gateway → Dauerbetrieb). Das rotiert den internen Token, räumt das alte Script ab und meldet Fehler per Mail:// config/tenants/<workspace>/scheduled.php 'refresh_db_gateways' => [ 'enabled' => true, 'tool' => 'refreshDatabaseGateways', 'interval' => '03:30', 'args' => ['projects' => ['bestand'], 'expiry_hours' => 48], ], -
Nach Änderungen am Gateway (z. B. einem TaskMonkey-Update) neu deployen. Ältere Gateways kennen
executePreparedQuerynicht und antworten mit „Unknown action".ensureGatewayerkennt veraltete Gateways und ersetzt sie automatisch.
2. Tools anlegen
config/tenants/<workspace>/tools/api/bestand_api.php — auch unter docs/examples/custom-api/bestand_api.php.
<?php
return [
'tools' => [
'getKunde' => [
'description' => 'Liefert Stammdaten eines Kunden per Kundennummer.',
'external_api_description' => 'Kunden per Kundennummer abrufen (Firma, Ort, Umsatz Vorjahr).',
'internal' => true,
'external_api' => true,
'parameters' => [
'type' => 'object',
'properties' => [
'kundennummer' => [
'type' => 'string',
'pattern' => '^K[0-9]{5}$',
'description' => 'Kundennummer, z.B. K10001',
],
],
'required' => ['kundennummer'],
],
'external_api_response' => [
'type' => 'object',
'properties' => [
'success' => ['type' => 'boolean'],
'found' => ['type' => 'boolean'],
'kunde' => [
'type' => ['object', 'null'],
'properties' => [
'kundennummer' => ['type' => 'string'],
'firma' => ['type' => 'string'],
'ort' => ['type' => 'string'],
'umsatz_vorjahr' => ['type' => 'number'],
],
],
],
],
'handler' => function (array $results, array $args, array $ctx): array {
$res = $ctx['tool']('executePreparedQuery', [
'project' => 'bestand',
'sql' => 'SELECT kundennummer, firma, ort, umsatz_vorjahr FROM kunden WHERE kundennummer = ? LIMIT 1',
'params' => [$args['kundennummer']],
]);
if (empty($res['success'])) {
$ctx['logger']->error('getKunde: ' . ($res['error'] ?? 'unbekannt'));
return ['success' => false, 'error' => 'Datenbank nicht erreichbar'];
}
$row = $res['rows'][0] ?? null;
if ($row !== null) {
$row['umsatz_vorjahr'] = (float)$row['umsatz_vorjahr'];
}
return ['success' => true, 'found' => $row !== null, 'kunde' => $row];
},
],
'searchKunden' => [
'description' => 'Sucht Kunden nach Ort.',
'external_api_description' => 'Kunden eines Orts auflisten (alphabetisch, max. 100).',
'internal' => true,
'external_api' => true,
'parameters' => [
'type' => 'object',
'properties' => [
'ort' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 80],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
],
'required' => ['ort'],
],
'handler' => function (array $results, array $args, array $ctx): array {
$res = $ctx['tool']('executePreparedQuery', [
'project' => 'bestand',
'sql' => 'SELECT kundennummer, firma, ort FROM kunden WHERE ort = ? ORDER BY firma LIMIT ?',
'params' => [$args['ort'], (int)($args['limit'] ?? 20)],
]);
if (empty($res['success'])) {
$ctx['logger']->error('searchKunden: ' . ($res['error'] ?? 'unbekannt'));
return ['success' => false, 'error' => 'Datenbank nicht erreichbar'];
}
return ['success' => true, 'count' => $res['row_count'], 'kunden' => $res['rows']];
},
],
// Schreibend: explizit freigegeben. Ohne external_api_write liefe das
// Tool ueber die API read-only und executePreparedQuery(mode=write)
// wuerde verweigern.
'createAnfrage' => [
'description' => 'Legt eine Anfrage zu einem Kunden an.',
'external_api_description' => 'Anfrage zu einem Kunden anlegen. Liefert die neue Anfrage-ID.',
'internal' => true,
'external_api' => true,
'external_api_write' => true,
'parameters' => [
'type' => 'object',
'properties' => [
'kundennummer' => ['type' => 'string', 'pattern' => '^K[0-9]{5}$'],
'nachricht' => ['type' => 'string', 'minLength' => 3, 'maxLength' => 2000],
],
'required' => ['kundennummer', 'nachricht'],
],
'handler' => function (array $results, array $args, array $ctx): array {
$res = $ctx['tool']('executePreparedQuery', [
'project' => 'bestand',
'mode' => 'write',
'sql' => 'INSERT INTO anfragen (kundennummer, nachricht, quelle) VALUES (?, ?, ?)',
'params' => [
$args['kundennummer'],
$args['nachricht'],
'api:' . ($args['_api_key']['name'] ?? 'unbekannt'),
],
]);
if (empty($res['success'])) {
$ctx['logger']->error('createAnfrage: ' . ($res['error'] ?? 'unbekannt'));
return ['success' => false, 'error' => 'Anfrage konnte nicht gespeichert werden'];
}
return ['success' => true, 'id' => (int)$res['last_insert_id']];
},
],
],
];
executePreparedQuery
Der Baustein für alle Datenbank-Tools, die über die API erreichbar sind:
| Arg | Bedeutung |
|---|---|
project |
Projekt-Key aus remote_databases |
sql |
genau ein Statement mit positionalen ?-Platzhaltern |
params |
Werte für die Platzhalter, in Reihenfolge — nur Skalare oder null |
mode |
read (Default): nur SELECT, läuft in einer READ ONLY-Transaktion. write: nur INSERT/UPDATE/DELETE/REPLACE |
Das Gateway führt echte Prepared Statements aus (keine Emulation, keine Multi-Statements). Ein Wert wie K1' OR 1=1 -- bleibt ein Wert — er findet schlicht keinen Kunden. Rückgabe im Lesemodus: rows, columns, row_count, truncated (max. 500 Zeilen). Im Schreibmodus: affected_rows, last_insert_id.
Drei Regeln, die über die API zusätzlich gelten:
executeQuery(freies SQL) ist im API-Kontext gesperrt, auch mit festem SQL-String. Nimm immerexecutePreparedQuery.mode: 'write'geht nur, wenn das aufgerufene Toolexternal_api_writeträgt und das Gateway mitallow_write: truedeployt ist.- Werte nie per String-Verkettung ins SQL bauen, auch keine Spaltennamen oder Sortierrichtungen aus Args. Wenn der Aufrufer sortieren können soll:
enumim Schema und eine feste Zuordnung im Handler (['name' => 'firma ASC', 'umsatz' => 'umsatz_vorjahr DESC'][$args['sort']]).
3. Lokal testen
bin/cake test_tool -t <workspace> --tool getKunde --args='{"kundennummer":"K10001"}'
bin/cake test_tool -t <workspace> --tool searchKunden --args='{"ort":"Bremen","limit":5}'
4. API-Key anlegen
Backend → Verwaltung → API-Keys → „Neuer API-Key". Für eine reine Lese-Anbindung nur getKunde und searchKunden anhaken. Ein Partner, der Anfragen einstellen darf, bekommt einen eigenen Key mit createAnfrage.
5. Aufrufen
KEY=tm_live_…
API=https://app.taskmonkey.de/api/v1
curl -s -X POST $API/tools/getKunde \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"args":{"kundennummer":"K10003"}}'
# 200 {"success":true,"found":true,"kunde":{"kundennummer":"K10003","firma":"Baumschule Nord","ort":"Bremen","umsatz_vorjahr":99100}}
curl -s -X POST $API/tools/searchKunden \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"args":{"ort":"Bremen","limit":10}}'
curl -s -X POST $API/tools/createAnfrage \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"args":{"kundennummer":"K10001","nachricht":"Bitte Angebot für Frühjahrsware"}}'
# 200 {"success":true,"id":3}
Was abgelehnt wird:
# Format passt nicht zum pattern → 400, die Datenbank wird nicht angefragt
-d '{"args":{"kundennummer":"K1'"'"' OR 1=1 --"}}'
# {"success":false,"error":"Ungueltige Parameter","details":["Parameter 'kundennummer' hat ein ungueltiges Format."]}
# Tool nicht im Key oder nicht freigegeben (z.B. executeQuery) → 403
# {"success":false,"error":"Tool nicht verfuegbar"}
# Limit überschritten → 429 mit Retry-After
Grenzen
- Das Gateway ist ein PHP-Script auf dem Kunden-Server; jeder API-Aufruf ist ein HTTP-Roundtrip dorthin. Für hohe Last (dutzende Aufrufe pro Sekunde) ist eine direkte Integration oder ein Daten-Spiegel in Supabase besser.
- Max. 500 Zeilen pro Abfrage — für Listen immer mit
LIMITund ggf. Offset-Parameter arbeiten. - Das Gateway unterstützt MySQL/MariaDB.
Verwandt
- Eigene API bauen — Referenz zu Keys, Freigabe, Statuscodes
- Database Gateway
- Rezept: API für ein Google Sheet