Kostenlos testen
Handbuch

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 Kundennummer
  • searchKunden — Kunden eines Orts, mit Limit
  • createAnfrage — 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: false reicht für getKunde und searchKunden. Nur wenn createAnfrage gebraucht 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 executePreparedQuery nicht und antworten mit „Unknown action". ensureGateway erkennt 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 immer executePreparedQuery.
  • mode: 'write' geht nur, wenn das aufgerufene Tool external_api_write trägt und das Gateway mit allow_write: true deployt ist.
  • Werte nie per String-Verkettung ins SQL bauen, auch keine Spaltennamen oder Sortierrichtungen aus Args. Wenn der Aufrufer sortieren können soll: enum im 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 LIMIT und ggf. Offset-Parameter arbeiten.
  • Das Gateway unterstützt MySQL/MariaDB.

Verwandt

Zuletzt aktualisiert: 2026-09-25