Rezept: API für ein Google Sheet
Ein Google Sheet als kleine Datenbank mit authentifizierter REST-API: Produkte lesen, Anfragen anhängen — mit API-Key, Rate-Limit und OpenAPI.
Viele Teams pflegen Stammdaten längst in einem Google Sheet: Produktliste, Preise, Termine, Lagerbestand. Mit einer API darauf können Shop, Website-Backend oder ein Partner die Daten direkt abfragen — ohne CSV-Export, ohne eigenen Server.
Ziel
POST /api/v1/tools/getProdukteliefert die Produkte aus dem Sheet als JSON, optional gefiltertPOST /api/v1/tools/createSheetAnfragehängt eine Anfrage als neue Zeile an- Zugriff nur mit API-Key, Rate-Limit pro Key, OpenAPI-Spec für den Aufrufer
Voraussetzungen
- Sheet anlegen mit zwei Tabs:
Produkte— Kopfzeilesku | name | kategorie | preis | bestandAnfragen— Kopfzeilezeitpunkt | sku | email | nachricht | quelle
- Mit dem Plattform-Service-Account teilen (E-Mail-Adresse steht unter Backend → Verwaltung → Google Dokumente). Nur lesen: Betrachter. Mit
createSheetAnfrage: Bearbeiter. - Sheet registrieren unter Backend → Verwaltung → Google Dokumente, Slug
produkte. Der Slug verbindet das Tool mit genau diesem Sheet; ein fremdes Sheet-ID-Argument akzeptiert die API nicht.
1. Tools anlegen
config/tenants/<workspace>/tools/api/sheet_api.php — vollständiges Beispiel auch unter docs/examples/custom-api/sheet_api.php.
Der Aufbau folgt „ein Tool = ein API-Call": zwei interne Tools greifen fest verdrahtet auf je einen Tab zu, zwei freigegebene Tools enthalten die Fachlogik.
<?php
return [
'tools' => [
// --- Interne Bausteine: ein Sheet-Zugriff, fest verdrahtet ---------
'readProdukteSheet' => [
'description' => 'Liest den Tab Produkte aus dem Produkte-Sheet (roh).',
'internal' => true,
'handler' => [\App\Tooling\GoogleSheetsTools::class, 'readFromGoogleSheet'],
'slug' => 'produkte',
'defaultSheetName' => 'Produkte',
'parameters' => [
'type' => 'object',
'properties' => [
'range' => ['type' => 'string', 'description' => 'A1-Bereich, z.B. A1:E'],
],
],
],
'appendAnfrageRow' => [
'description' => 'Haengt eine Zeile an den Tab Anfragen an.',
'internal' => true,
'handler' => [\App\Tooling\GoogleSheetsTools::class, 'writeToGoogleSheet'],
'slug' => 'produkte',
'defaultSheetName' => 'Anfragen',
'parameters' => [
'type' => 'object',
'properties' => [
'data' => ['type' => 'array', 'items' => ['type' => 'array']],
'mode' => ['type' => 'string', 'enum' => ['append']],
],
'required' => ['data'],
],
],
// --- Extern freigegeben ------------------------------------------
'getProdukte' => [
'description' => 'Liefert Produkte aus dem Produkte-Sheet, optional gefiltert nach Kategorie.',
'external_api_description' => 'Produkte abrufen (sku, name, kategorie, preis, bestand). Optional nach Kategorie filtern.',
'internal' => true,
'external_api' => true,
'parameters' => [
'type' => 'object',
'properties' => [
'kategorie' => ['type' => 'string', 'maxLength' => 60],
'nur_verfuegbar' => ['type' => 'boolean', 'description' => 'Nur Produkte mit Bestand > 0'],
],
],
'handler' => function (array $results, array $args, array $ctx): array {
$raw = $ctx['tool']('readProdukteSheet', ['range' => 'A1:E']);
if (empty($raw['success'])) {
$ctx['logger']->error('getProdukte: ' . ($raw['error'] ?? 'unbekannt'));
return ['success' => false, 'error' => 'Sheet nicht lesbar'];
}
$rows = $raw['data'] ?? [];
$header = array_map(fn($h) => strtolower(trim((string)$h)), array_shift($rows) ?? []);
$kategorie = isset($args['kategorie']) ? mb_strtolower(trim($args['kategorie'])) : null;
$nurVerfuegbar = !empty($args['nur_verfuegbar']);
$produkte = [];
foreach ($rows as $row) {
$row = array_pad($row, count($header), '');
$p = array_combine($header, array_slice($row, 0, count($header)));
if (($p['sku'] ?? '') === '') {
continue;
}
$p['preis'] = (float)str_replace(',', '.', (string)($p['preis'] ?? '0'));
$p['bestand'] = (int)($p['bestand'] ?? 0);
if ($kategorie !== null && mb_strtolower((string)($p['kategorie'] ?? '')) !== $kategorie) {
continue;
}
if ($nurVerfuegbar && $p['bestand'] <= 0) {
continue;
}
$produkte[] = $p;
}
return ['success' => true, 'count' => count($produkte), 'produkte' => $produkte];
},
],
'createSheetAnfrage' => [
'description' => 'Schreibt eine Produktanfrage in den Tab Anfragen.',
'external_api_description' => 'Produktanfrage anlegen (landet als neue Zeile im Tab Anfragen).',
'internal' => true,
'external_api' => true,
// Bewusst schreibend: haengt nur eine Zeile an, ueberschreibt nichts.
'external_api_write' => true,
'parameters' => [
'type' => 'object',
'properties' => [
'sku' => ['type' => 'string', 'pattern' => '^[A-Z0-9-]{2,20}$'],
'email' => ['type' => 'string', 'maxLength' => 200, 'pattern' => '^[^@\s]+@[^@\s]+\.[^@\s]+$'],
'nachricht' => ['type' => 'string', 'minLength' => 3, 'maxLength' => 1000],
],
'required' => ['sku', 'email', 'nachricht'],
],
'handler' => function (array $results, array $args, array $ctx): array {
// Formel-Injection verhindern: Sheets wertet =, +, -, @ am
// Zellanfang als Formel aus.
$cell = fn(string $v): string => preg_match('/^[=+\-@]/', $v) ? "'" . $v : $v;
$res = $ctx['tool']('appendAnfrageRow', [
'mode' => 'append',
'data' => [[
date('Y-m-d H:i:s'),
$cell($args['sku']),
$cell($args['email']),
$cell($args['nachricht']),
'api:' . ($args['_api_key']['name'] ?? 'unbekannt'),
]],
]);
if (!empty($res['error']) || (isset($res['success']) && !$res['success'])) {
$ctx['logger']->error('createSheetAnfrage: ' . ($res['error'] ?? 'unbekannt'));
return ['success' => false, 'error' => 'Anfrage konnte nicht gespeichert werden'];
}
return ['success' => true];
},
],
],
];
Worauf es ankommt:
- Kein
slug-Parameter in den freigegebenen Tools. Die API verwirft undeklarierte Args; so kann niemand das Tool auf ein anderes Sheet umbiegen. getProdukteist lesend und läuft im Read-only-Kontext. Würde es versehentlichappendAnfrageRowrufen, verweigert der Sheets-Handler.createSheetAnfrageschreibt und trägt deshalbexternal_api_write. MitUSER_ENTEREDwürde Sheets Eingaben wie=HYPERLINK(…)als Formel ausführen — der Handler setzt davor ein Hochkomma.
2. Lokal testen
bin/cake test_tool -t <workspace> --tool getProdukte --args='{"kategorie":"Zimmerpflanzen","nur_verfuegbar":true}'
{
"success": true,
"count": 1,
"produkte": [
{"sku": "MON-01", "name": "Monstera", "kategorie": "Zimmerpflanzen", "preis": 24.9, "bestand": 12}
]
}
3. API-Key anlegen
Backend → Verwaltung → API-Keys → „Neuer API-Key": Name z. B. Shop-Backend, Tools getProdukte (und bei Bedarf createSheetAnfrage) anhaken, Limit 60/Minute. Key kopieren — er wird nur einmal angezeigt.
4. Aufrufen
KEY=tm_live_…
# Produkte einer Kategorie
curl -s -X POST https://app.taskmonkey.de/api/v1/tools/getProdukte \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"args":{"kategorie":"Zimmerpflanzen"}}'
# Anfrage anlegen
curl -s -X POST https://app.taskmonkey.de/api/v1/tools/createSheetAnfrage \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"args":{"sku":"MON-01","email":"kunde@example.com","nachricht":"Ist die Monstera auch größer lieferbar?"}}'
# OpenAPI-Spec für diesen Key
curl -s https://app.taskmonkey.de/api/v1/openapi.json -H "Authorization: Bearer $KEY"
Ungültige Eingaben werden abgelehnt, bevor das Sheet angefasst wird:
curl -s -X POST https://app.taskmonkey.de/api/v1/tools/createSheetAnfrage \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"args":{"sku":"x","email":"keine-mail"}}'
# 400 {"success":false,"error":"Ungueltige Parameter","details":["Parameter 'nachricht' fehlt.", …]}
Grenzen
- Die Sheets-API hat eigene Quoten (Richtwert: 60 Lesezugriffe pro Minute und Nutzer). Für viel Traffic das Key-Limit entsprechend niedrig halten oder die Daten in Supabase spiegeln.
- Jeder Aufruf liest das Sheet neu — keine Zwischenspeicherung. Für große Sheets den
rangeeingrenzen. - Ein Sheet ist keine Transaktions-Datenbank: parallele Anfragen landen in beliebiger Reihenfolge.
Verwandt
- Eigene API bauen — Referenz zu Keys, Freigabe, Statuscodes
- Rezept: API für eine Bestandsdatenbank
- Google Workspace