import crypto from "node:crypto"; import express, { Request, Response } from "express"; type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; type GoogleRequestBody = { method?: string; url?: string; baseUrl?: string; path?: string; query?: Record; headers?: Record; body?: JsonValue; accessToken?: string; accessTokenEnv?: string; apiKey?: string; apiKeyEnv?: string; serviceAccountJson?: GoogleServiceAccountJson | string; serviceAccountJsonEnv?: string; serviceAccountScopes?: string[] | string; serviceAccountSubject?: string; }; type TokenExchangeBody = { grantType?: string; code?: string; refreshToken?: string; redirectUri?: string; scope?: string; clientId?: string; clientSecret?: string; }; type ServiceAccountTokenBody = { scopes?: string[]; scope?: string; subject?: string; serviceAccountEmail?: string; privateKey?: string; serviceAccountJson?: GoogleServiceAccountJson | string; serviceAccountJsonEnv?: string; }; type GoogleServiceAccountJson = { type?: string; project_id?: string; private_key_id?: string; private_key?: string; client_email?: string; client_id?: string; token_uri?: string; }; type SheetWriteByUrlBody = { spreadsheetUrl?: string; sheetName?: string; values?: JsonValue[][]; startCell?: string; mode?: "append" | "update"; majorDimension?: "ROWS" | "COLUMNS"; valueInputOption?: "RAW" | "USER_ENTERED"; insertDataOption?: "OVERWRITE" | "INSERT_ROWS"; }; type AuthorizationUrlQuery = { redirectUri?: string; scope?: string; scopes?: string; state?: string; accessType?: string; prompt?: string; loginHint?: string; includeGrantedScopes?: string; clientId?: string; }; type GoogleRouteOptions = { baseUrl: string; path: string; method?: string; query?: Record; body?: JsonValue; }; const app = express(); const port = Number(process.env.PORT || 3000); const rootPath = normalizeRootPath(process.env.ROOT_PATH || ""); const recommendedScopes = { calendar: ["https://www.googleapis.com/auth/calendar"], sheets: ["https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive.metadata.readonly"], drive: ["https://www.googleapis.com/auth/drive"], gmail: ["https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.send"], docs: ["https://www.googleapis.com/auth/documents"], slides: ["https://www.googleapis.com/auth/presentations"], forms: ["https://www.googleapis.com/auth/forms.body", "https://www.googleapis.com/auth/forms.responses.readonly"], people: ["https://www.googleapis.com/auth/contacts"], tasks: ["https://www.googleapis.com/auth/tasks"] }; const googleHosts = new Set([ "accounts.google.com", "androidpublisher.googleapis.com", "analyticsadmin.googleapis.com", "analyticsdata.googleapis.com", "bigquery.googleapis.com", "blogger.googleapis.com", "books.googleapis.com", "calendar-json.googleapis.com", "calendar.googleapis.com", "chat.googleapis.com", "classroom.googleapis.com", "cloudbilling.googleapis.com", "cloudidentity.googleapis.com", "cloudresourcemanager.googleapis.com", "compute.googleapis.com", "contacts.googleapis.com", "content.googleapis.com", "customsearch.googleapis.com", "datastore.googleapis.com", "dialogflow.googleapis.com", "discovery.googleapis.com", "displayvideo.googleapis.com", "dns.googleapis.com", "docs.googleapis.com", "drive.googleapis.com", "firebase.googleapis.com", "firebaseappdistribution.googleapis.com", "firebasehosting.googleapis.com", "firestore.googleapis.com", "forms.googleapis.com", "gmail.googleapis.com", "googleads.googleapis.com", "groupssettings.googleapis.com", "iam.googleapis.com", "iamcredentials.googleapis.com", "indexing.googleapis.com", "kgsearch.googleapis.com", "language.googleapis.com", "licensing.googleapis.com", "logging.googleapis.com", "monitoring.googleapis.com", "mybusinessaccountmanagement.googleapis.com", "mybusinessbusinessinformation.googleapis.com", "mybusinessnotifications.googleapis.com", "oauth2.googleapis.com", "people.googleapis.com", "photoslibrary.googleapis.com", "playdeveloperreporting.googleapis.com", "pubsub.googleapis.com", "run.googleapis.com", "sheets.googleapis.com", "slides.googleapis.com", "sqladmin.googleapis.com", "storage.googleapis.com", "sts.googleapis.com", "tagmanager.googleapis.com", "tasks.googleapis.com", "translate.googleapis.com", "vision.googleapis.com", "walletobjects.googleapis.com", "www.googleapis.com", "youtube.googleapis.com", "youtubeanalytics.googleapis.com", "youtubereporting.googleapis.com" ]); app.disable("x-powered-by"); app.set("trust proxy", true); app.use(express.json({ limit: "20mb" })); app.get("/", (_req, res) => { res.json({ name: "google-service", service: "google-service", status: "ok", docs: withRootPath("/docs"), openapi: withRootPath("/openapi.json") }); }); app.get("/health", (_req, res) => { res.json({ status: "ok" }); }); app.get("/docs", (_req, res) => { res.type("html").send(renderDocsHtml()); }); app.get("/openapi.json", (_req, res) => { res.json(buildOpenApiDocument()); }); app.get("/google/discovery/apis", async (_req, res) => { try { await pipeGoogleJson(res, "https://www.googleapis.com/discovery/v1/apis"); } catch (error) { sendError(res, error); } }); app.get("/google/discovery/apis/:api/:version/rest", async (req, res) => { try { const api = encodeURIComponent(req.params.api); const version = encodeURIComponent(req.params.version); await pipeGoogleJson(res, `https://www.googleapis.com/discovery/v1/apis/${api}/${version}/rest`); } catch (error) { sendError(res, error); } }); app.post("/google/oauth/token", async (req, res) => { try { const body = req.body as TokenExchangeBody; const clientId = body.clientId || req.header("x-google-client-id") || process.env.GOOGLE_CLIENT_ID; const clientSecret = body.clientSecret || req.header("x-google-client-secret") || process.env.GOOGLE_CLIENT_SECRET; const grantType = body.grantType || (body.refreshToken ? "refresh_token" : "authorization_code"); if (!clientId || !clientSecret) { return badRequest(res, "Missing GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET or clientId/clientSecret."); } const params = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: grantType }); if (grantType === "authorization_code") { if (!body.code || !body.redirectUri) { return badRequest(res, "Authorization code exchange requires code and redirectUri."); } params.set("code", body.code); params.set("redirect_uri", body.redirectUri); } else if (grantType === "refresh_token") { if (!body.refreshToken) { return badRequest(res, "Refresh token exchange requires refreshToken."); } params.set("refresh_token", body.refreshToken); } else { return badRequest(res, "Supported grantType values are authorization_code and refresh_token."); } if (body.scope) { params.set("scope", body.scope); } await pipeGoogleForm(res, "https://oauth2.googleapis.com/token", params); } catch (error) { sendError(res, error); } }); app.post("/google/oauth/service-account-token", async (req, res) => { try { const body = req.body as ServiceAccountTokenBody; const serviceAccountJson = readServiceAccountJson(req, body); const email = body.serviceAccountEmail || serviceAccountJson?.client_email || req.header("x-google-service-account-email") || process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL; const rawPrivateKey = body.privateKey || serviceAccountJson?.private_key || req.header("x-google-private-key") || process.env.GOOGLE_PRIVATE_KEY; const privateKey = rawPrivateKey?.replace(/\\n/g, "\n"); const scope = body.scope || body.scopes?.join(" ") || req.header("x-google-scopes") || process.env.GOOGLE_SCOPES; if (!email || !privateKey || !scope) { return badRequest( res, "Service account flow requires GOOGLE_SERVICE_ACCOUNT_EMAIL, GOOGLE_PRIVATE_KEY and GOOGLE_SCOPES or matching request fields." ); } const now = Math.floor(Date.now() / 1000); const claimSet: Record = { iss: email, scope, aud: serviceAccountJson?.token_uri || "https://oauth2.googleapis.com/token", iat: now, exp: now + 3600 }; if (body.subject) { claimSet.sub = body.subject; } const assertion = signJwt({ alg: "RS256", typ: "JWT" }, claimSet, privateKey); const params = new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }); await pipeGoogleForm(res, serviceAccountJson?.token_uri || "https://oauth2.googleapis.com/token", params); } catch (error) { sendError(res, error); } }); app.post("/google/oauth/revoke", async (req, res) => { try { const token = readRequiredString(req.body, "token"); await pipeGoogleForm(res, "https://oauth2.googleapis.com/revoke", new URLSearchParams({ token })); } catch (error) { sendError(res, error); } }); app.get("/google/oauth/tokeninfo", async (req, res) => { try { const token = String(req.query.access_token || req.query.id_token || ""); if (!token) { return badRequest(res, "Query must include access_token or id_token."); } const url = new URL("https://oauth2.googleapis.com/tokeninfo"); if (req.query.id_token) { url.searchParams.set("id_token", token); } else { url.searchParams.set("access_token", token); } await pipeGoogleJson(res, url.toString()); } catch (error) { sendError(res, error); } }); app.get("/google/oauth/scopes", (_req, res) => { res.json({ recommendedScopes, all: Array.from(new Set(Object.values(recommendedScopes).flat())) }); }); app.get("/google/configuration", (_req, res) => { res.json({ environmentVariables: [ { name: "GOOGLE_CLIENT_ID", usedBy: ["/google/oauth/authorize-url", "/google/oauth/token"], alternatives: ["clientId", "X-Google-Client-Id"] }, { name: "GOOGLE_CLIENT_SECRET", usedBy: ["/google/oauth/token"], alternatives: ["clientSecret", "X-Google-Client-Secret"] }, { name: "GOOGLE_REDIRECT_URI", usedBy: ["/google/oauth/authorize-url"], alternatives: ["redirectUri", "X-Google-Redirect-Uri"] }, { name: "GOOGLE_SERVICE_ACCOUNT_EMAIL", usedBy: ["/google/oauth/service-account-token"], alternatives: ["serviceAccountEmail", "X-Google-Service-Account-Email"] }, { name: "GOOGLE_PRIVATE_KEY", usedBy: ["/google/oauth/service-account-token"], alternatives: ["privateKey", "X-Google-Private-Key"] }, { name: "GOOGLE_SCOPES", usedBy: ["/google/oauth/service-account-token"], alternatives: ["scope", "scopes", "X-Google-Scopes"] }, { name: "GOOGLE_SERVICE_ACCOUNT_JSON", usedBy: ["/google/oauth/service-account-token", "Google product endpoints", "/google/request"], alternatives: ["serviceAccountJson", "serviceAccountJsonEnv", "X-Google-Service-Account-Json", "X-Google-Service-Account-Json-Env"] }, { name: "GOOGLE_ACCESS_TOKEN", usedBy: ["Google product endpoints", "/google/request"], alternatives: ["Authorization: Bearer", "accessToken", "accessTokenEnv", "X-Google-Access-Token", "X-Google-Access-Token-Env"] }, { name: "GOOGLE_API_KEY", usedBy: ["Google product endpoints", "/google/request"], alternatives: ["apiKey", "apiKeyEnv", "X-Google-Api-Key", "X-Google-Api-Key-Env"] } ], note: "Endpoint intentionally returns only variable names and usage metadata, never secret values." }); }); app.get("/google/oauth/authorize-url", (req, res) => { const query = req.query as AuthorizationUrlQuery; const clientId = query.clientId || req.header("x-google-client-id") || process.env.GOOGLE_CLIENT_ID; const redirectUri = query.redirectUri || req.header("x-google-redirect-uri") || process.env.GOOGLE_REDIRECT_URI; if (!clientId || !redirectUri) { return badRequest(res, "Authorize URL requires GOOGLE_CLIENT_ID and GOOGLE_REDIRECT_URI or clientId and redirectUri query parameters."); } const scopes = parseScopes(query.scope || query.scopes) || Object.values(recommendedScopes).flat(); const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); url.searchParams.set("client_id", clientId); url.searchParams.set("redirect_uri", redirectUri); url.searchParams.set("response_type", "code"); url.searchParams.set("scope", scopes.join(" ")); url.searchParams.set("access_type", query.accessType || "offline"); url.searchParams.set("prompt", query.prompt || "consent"); url.searchParams.set("include_granted_scopes", query.includeGrantedScopes || "true"); if (query.state) { url.searchParams.set("state", query.state); } if (query.loginHint) { url.searchParams.set("login_hint", query.loginHint); } res.json({ authorizationUrl: url.toString(), clientId, redirectUri, scopes }); }); app.get("/google/calendar/calendars", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: "/calendar/v3/users/me/calendarList" }); }); app.post("/google/calendar/calendars", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: "/calendar/v3/calendars", method: "POST", body: req.body as JsonValue }); }); app.get("/google/calendar/calendars/:calendarId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/calendar/v3/calendars/${encodePathParam(req.params.calendarId)}` }); }); app.patch("/google/calendar/calendars/:calendarId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/calendar/v3/calendars/${encodePathParam(req.params.calendarId)}`, method: "PATCH", body: req.body as JsonValue }); }); app.delete("/google/calendar/calendars/:calendarId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/calendar/v3/calendars/${encodePathParam(req.params.calendarId)}`, method: "DELETE" }); }); app.get("/google/calendar/calendars/:calendarId/events", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/calendar/v3/calendars/${encodePathParam(req.params.calendarId)}/events` }); }); app.post("/google/calendar/calendars/:calendarId/events", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/calendar/v3/calendars/${encodePathParam(req.params.calendarId)}/events`, method: "POST", body: req.body as JsonValue }); }); app.get("/google/calendar/calendars/:calendarId/events/:eventId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/calendar/v3/calendars/${encodePathParam(req.params.calendarId)}/events/${encodePathParam(req.params.eventId)}` }); }); app.patch("/google/calendar/calendars/:calendarId/events/:eventId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/calendar/v3/calendars/${encodePathParam(req.params.calendarId)}/events/${encodePathParam(req.params.eventId)}`, method: "PATCH", body: req.body as JsonValue }); }); app.delete("/google/calendar/calendars/:calendarId/events/:eventId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/calendar/v3/calendars/${encodePathParam(req.params.calendarId)}/events/${encodePathParam(req.params.eventId)}`, method: "DELETE" }); }); app.post("/google/calendar/calendars/:calendarId/events/:eventId/move", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/calendar/v3/calendars/${encodePathParam(req.params.calendarId)}/events/${encodePathParam(req.params.eventId)}/move`, method: "POST" }); }); app.post("/google/sheets/spreadsheets", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", path: "/v4/spreadsheets", method: "POST", body: req.body as JsonValue }); }); app.get("/google/sheets/spreadsheets", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: "/drive/v3/files", query: { q: "mimeType='application/vnd.google-apps.spreadsheet' and trashed=false", fields: "files(id,name,mimeType,webViewLink,createdTime,modifiedTime,owners(displayName,emailAddress)),nextPageToken" } }); }); app.get("/google/sheets/spreadsheets/:spreadsheetId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", path: `/v4/spreadsheets/${encodePathParam(req.params.spreadsheetId)}` }); }); app.get("/google/sheets/spreadsheets/:spreadsheetId/sheets", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", path: `/v4/spreadsheets/${encodePathParam(req.params.spreadsheetId)}`, query: { fields: "spreadsheetId,properties.title,sheets.properties" } }); }); app.post("/google/sheets/write-by-url", async (req, res) => { const body = req.body as SheetWriteByUrlBody; const spreadsheetUrl = body.spreadsheetUrl; const sheetName = body.sheetName; const values = body.values; if (!spreadsheetUrl || !sheetName || !Array.isArray(values)) { return badRequest(res, "Body requires spreadsheetUrl, sheetName and values."); } const spreadsheetId = extractSpreadsheetId(spreadsheetUrl); if (!spreadsheetId) { return badRequest(res, "spreadsheetUrl must contain /spreadsheets/d/{spreadsheetId} or id={spreadsheetId}."); } const startCell = body.startCell || "A1"; const range = `${quoteSheetName(sheetName)}!${startCell}`; const mode = body.mode || "append"; const valueInputOption = body.valueInputOption || "USER_ENTERED"; const requestBody = { majorDimension: body.majorDimension || "ROWS", values }; if (mode === "update") { await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", path: `/v4/spreadsheets/${encodePathParam(spreadsheetId)}/values/${encodePathParam(range)}`, method: "PUT", query: { valueInputOption }, body: requestBody }); return; } await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", path: `/v4/spreadsheets/${encodePathParam(spreadsheetId)}/values/${encodePathParam(range)}:append`, method: "POST", query: { valueInputOption, insertDataOption: body.insertDataOption || "INSERT_ROWS" }, body: requestBody }); }); app.post("/google/sheets/spreadsheets/:spreadsheetId/batch-update", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", path: `/v4/spreadsheets/${encodePathParam(req.params.spreadsheetId)}:batchUpdate`, method: "POST", body: req.body as JsonValue }); }); app.get("/google/sheets/spreadsheets/:spreadsheetId/values", async (req, res) => { const range = requireQueryString(req, res, "range"); if (!range) return; await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", path: `/v4/spreadsheets/${encodePathParam(req.params.spreadsheetId)}/values/${encodePathParam(range)}` }); }); app.put("/google/sheets/spreadsheets/:spreadsheetId/values", async (req, res) => { const range = requireQueryString(req, res, "range"); if (!range) return; await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", path: `/v4/spreadsheets/${encodePathParam(req.params.spreadsheetId)}/values/${encodePathParam(range)}`, method: "PUT", body: req.body as JsonValue }); }); app.post("/google/sheets/spreadsheets/:spreadsheetId/values/append", async (req, res) => { const range = requireQueryString(req, res, "range"); if (!range) return; await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", path: `/v4/spreadsheets/${encodePathParam(req.params.spreadsheetId)}/values/${encodePathParam(range)}:append`, method: "POST", body: req.body as JsonValue }); }); app.post("/google/sheets/spreadsheets/:spreadsheetId/values/batch-update", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", path: `/v4/spreadsheets/${encodePathParam(req.params.spreadsheetId)}/values:batchUpdate`, method: "POST", body: req.body as JsonValue }); }); app.post("/google/sheets/spreadsheets/:spreadsheetId/values/batch-clear", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", path: `/v4/spreadsheets/${encodePathParam(req.params.spreadsheetId)}/values:batchClear`, method: "POST", body: req.body as JsonValue }); }); app.get("/google/drive/files", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: "/drive/v3/files" }); }); app.post("/google/drive/files", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: "/drive/v3/files", method: "POST", body: req.body as JsonValue }); }); app.get("/google/drive/files/:fileId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/drive/v3/files/${encodePathParam(req.params.fileId)}` }); }); app.patch("/google/drive/files/:fileId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/drive/v3/files/${encodePathParam(req.params.fileId)}`, method: "PATCH", body: req.body as JsonValue }); }); app.delete("/google/drive/files/:fileId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/drive/v3/files/${encodePathParam(req.params.fileId)}`, method: "DELETE" }); }); app.get("/google/drive/files/:fileId/export", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/drive/v3/files/${encodePathParam(req.params.fileId)}/export` }); }); app.get("/google/drive/files/:fileId/permissions", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/drive/v3/files/${encodePathParam(req.params.fileId)}/permissions` }); }); app.post("/google/drive/files/:fileId/permissions", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/drive/v3/files/${encodePathParam(req.params.fileId)}/permissions`, method: "POST", body: req.body as JsonValue }); }); app.delete("/google/drive/files/:fileId/permissions/:permissionId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: `/drive/v3/files/${encodePathParam(req.params.fileId)}/permissions/${encodePathParam(req.params.permissionId)}`, method: "DELETE" }); }); app.get("/google/gmail/profile", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://gmail.googleapis.com", path: "/gmail/v1/users/me/profile" }); }); app.get("/google/gmail/messages", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://gmail.googleapis.com", path: "/gmail/v1/users/me/messages" }); }); app.get("/google/gmail/messages/:messageId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://gmail.googleapis.com", path: `/gmail/v1/users/me/messages/${encodePathParam(req.params.messageId)}` }); }); app.post("/google/gmail/messages/send", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://gmail.googleapis.com", path: "/gmail/v1/users/me/messages/send", method: "POST", body: req.body as JsonValue }); }); app.get("/google/gmail/labels", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://gmail.googleapis.com", path: "/gmail/v1/users/me/labels" }); }); app.post("/google/gmail/labels", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://gmail.googleapis.com", path: "/gmail/v1/users/me/labels", method: "POST", body: req.body as JsonValue }); }); app.post("/google/docs/documents", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://docs.googleapis.com", path: "/v1/documents", method: "POST", body: req.body as JsonValue }); }); app.get("/google/docs/documents/:documentId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://docs.googleapis.com", path: `/v1/documents/${encodePathParam(req.params.documentId)}` }); }); app.post("/google/docs/documents/:documentId/batch-update", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://docs.googleapis.com", path: `/v1/documents/${encodePathParam(req.params.documentId)}:batchUpdate`, method: "POST", body: req.body as JsonValue }); }); app.post("/google/slides/presentations", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://slides.googleapis.com", path: "/v1/presentations", method: "POST", body: req.body as JsonValue }); }); app.get("/google/slides/presentations/:presentationId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://slides.googleapis.com", path: `/v1/presentations/${encodePathParam(req.params.presentationId)}` }); }); app.post("/google/slides/presentations/:presentationId/batch-update", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://slides.googleapis.com", path: `/v1/presentations/${encodePathParam(req.params.presentationId)}:batchUpdate`, method: "POST", body: req.body as JsonValue }); }); app.get("/google/forms/forms/:formId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://forms.googleapis.com", path: `/v1/forms/${encodePathParam(req.params.formId)}` }); }); app.post("/google/forms/forms", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://forms.googleapis.com", path: "/v1/forms", method: "POST", body: req.body as JsonValue }); }); app.post("/google/forms/forms/:formId/batch-update", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://forms.googleapis.com", path: `/v1/forms/${encodePathParam(req.params.formId)}:batchUpdate`, method: "POST", body: req.body as JsonValue }); }); app.get("/google/forms/forms/:formId/responses", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://forms.googleapis.com", path: `/v1/forms/${encodePathParam(req.params.formId)}/responses` }); }); app.get("/google/people/connections", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://people.googleapis.com", path: "/v1/people/me/connections" }); }); app.get("/google/people/:resourceName", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://people.googleapis.com", path: `/v1/people/${encodePathParam(req.params.resourceName)}` }); }); app.post("/google/people/contacts", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://people.googleapis.com", path: "/v1/people:createContact", method: "POST", body: req.body as JsonValue }); }); app.patch("/google/people/:resourceName", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://people.googleapis.com", path: `/v1/people/${encodePathParam(req.params.resourceName)}:updateContact`, method: "PATCH", body: req.body as JsonValue }); }); app.delete("/google/people/:resourceName", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://people.googleapis.com", path: `/v1/people/${encodePathParam(req.params.resourceName)}:deleteContact`, method: "DELETE" }); }); app.get("/google/tasks/lists", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://tasks.googleapis.com", path: "/tasks/v1/users/@me/lists" }); }); app.post("/google/tasks/lists", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://tasks.googleapis.com", path: "/tasks/v1/users/@me/lists", method: "POST", body: req.body as JsonValue }); }); app.get("/google/tasks/lists/:tasklistId/tasks", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://tasks.googleapis.com", path: `/tasks/v1/lists/${encodePathParam(req.params.tasklistId)}/tasks` }); }); app.post("/google/tasks/lists/:tasklistId/tasks", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://tasks.googleapis.com", path: `/tasks/v1/lists/${encodePathParam(req.params.tasklistId)}/tasks`, method: "POST", body: req.body as JsonValue }); }); app.patch("/google/tasks/lists/:tasklistId/tasks/:taskId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://tasks.googleapis.com", path: `/tasks/v1/lists/${encodePathParam(req.params.tasklistId)}/tasks/${encodePathParam(req.params.taskId)}`, method: "PATCH", body: req.body as JsonValue }); }); app.delete("/google/tasks/lists/:tasklistId/tasks/:taskId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://tasks.googleapis.com", path: `/tasks/v1/lists/${encodePathParam(req.params.tasklistId)}/tasks/${encodePathParam(req.params.taskId)}`, method: "DELETE" }); }); app.post("/google/request", async (req, res) => { try { const requestBody = req.body as GoogleRequestBody; applyCredentialHeaders(req, requestBody); await applyServiceAccountAuthorization(req, requestBody); const targetUrl = buildGoogleUrl(requestBody); const headers = buildGoogleHeaders(req, requestBody); const method = (requestBody.method || "GET").toUpperCase(); const init: RequestInit = { method, headers }; if (!["GET", "HEAD"].includes(method) && requestBody.body !== undefined) { init.body = JSON.stringify(requestBody.body); } const response = await fetch(targetUrl, init); await relayResponse(res, response); } catch (error) { sendError(res, error); } }); app.use((_req, res) => { res.status(404).json({ error: "Not found" }); }); app.use((error: unknown, _req: Request, res: Response, _next: unknown) => { sendError(res, error); }); app.listen(port, "0.0.0.0", () => { console.log("google-service listening on port " + port); }); function normalizeRootPath(value: string): string { if (!value) { return ""; } const withLeadingSlash = value.startsWith("/") ? value : "/" + value; return withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash; } function withRootPath(path: string): string { return rootPath + path; } async function callGoogle(req: Request, res: Response, options: GoogleRouteOptions): Promise { try { const requestBody: GoogleRequestBody = { baseUrl: options.baseUrl, path: options.path, method: options.method || req.method, query: { ...queryToRecord(req.query), ...(options.query || {}) }, body: options.body, accessToken: readBodyString(req.body, "accessToken"), accessTokenEnv: readBodyString(req.body, "accessTokenEnv"), apiKey: readBodyString(req.body, "apiKey"), apiKeyEnv: readBodyString(req.body, "apiKeyEnv") }; applyCredentialHeaders(req, requestBody); await applyServiceAccountAuthorization(req, requestBody); const targetUrl = buildGoogleUrl(requestBody); const headers = buildGoogleHeaders(req, requestBody); const method = (requestBody.method || "GET").toUpperCase(); const init: RequestInit = { method, headers }; if (!["GET", "HEAD"].includes(method) && options.body !== undefined) { init.body = JSON.stringify(stripCredentialFields(options.body)); } const response = await fetch(targetUrl, init); await relayResponse(res, response); } catch (error) { sendError(res, error); } } function queryToRecord(query: Request["query"]): Record { const result: Record = {}; for (const [key, value] of Object.entries(query)) { if (typeof value === "string") { result[key] = value; } else if (Array.isArray(value)) { result[key] = value.map((item) => String(item)).join(","); } } return result; } function readBodyString(body: unknown, key: string): string | undefined { if (!body || typeof body !== "object") { return undefined; } const value = (body as Record)[key]; return typeof value === "string" ? value : undefined; } function stripCredentialFields(body: JsonValue): JsonValue { if (!body || typeof body !== "object" || Array.isArray(body)) { return body; } const credentialFields = new Set([ "accessToken", "accessTokenEnv", "apiKey", "apiKeyEnv", "serviceAccountJson", "serviceAccountJsonEnv", "serviceAccountScopes", "serviceAccountSubject" ]); const result: Record = {}; for (const [key, value] of Object.entries(body)) { if (!credentialFields.has(key)) { result[key] = value; } } return result; } function applyCredentialHeaders(req: Request, body: GoogleRequestBody): void { body.accessToken ||= req.header("x-google-access-token"); body.accessTokenEnv ||= req.header("x-google-access-token-env"); body.apiKey ||= req.header("x-google-api-key"); body.apiKeyEnv ||= req.header("x-google-api-key-env"); body.serviceAccountJson ||= req.header("x-google-service-account-json"); body.serviceAccountJsonEnv ||= req.header("x-google-service-account-json-env"); body.serviceAccountScopes ||= req.header("x-google-service-account-scopes"); body.serviceAccountSubject ||= req.header("x-google-service-account-subject"); } async function applyServiceAccountAuthorization(req: Request, body: GoogleRequestBody): Promise { if (body.accessToken || body.accessTokenEnv || readBearerToken(req)) { return; } const serviceAccountJson = readServiceAccountJson(req, body); if (!serviceAccountJson) { return; } const scopes = readServiceAccountScopes(req, body); if (scopes.length === 0) { throw new Error("Service account authentication requires serviceAccountScopes, X-Google-Service-Account-Scopes, or GOOGLE_SCOPES."); } body.accessToken = await createServiceAccountAccessToken( serviceAccountJson, scopes.join(" "), body.serviceAccountSubject || req.header("x-google-service-account-subject") || undefined ); } function readServiceAccountJson(req: Request, body?: GoogleRequestBody | ServiceAccountTokenBody): GoogleServiceAccountJson | undefined { const bodyValue = body && "serviceAccountJson" in body ? body.serviceAccountJson : undefined; const envName = body && "serviceAccountJsonEnv" in body ? body.serviceAccountJsonEnv : undefined; const raw = bodyValue || req.header("x-google-service-account-json") || readEnvValue(envName) || readEnvValue(req.header("x-google-service-account-json-env")) || process.env.GOOGLE_SERVICE_ACCOUNT_JSON; if (!raw) { return undefined; } if (typeof raw === "object") { return raw; } try { return JSON.parse(raw) as GoogleServiceAccountJson; } catch { throw new Error("serviceAccountJson must be a valid Google service account JSON object or JSON string."); } } function readServiceAccountScopes(req: Request, body: GoogleRequestBody): string[] { const value = body.serviceAccountScopes || readBodyString(req.body, "serviceAccountScopes") || req.header("x-google-service-account-scopes") || process.env.GOOGLE_SCOPES; if (Array.isArray(value)) { return value.map((item) => String(item)).filter(Boolean); } return parseScopes(typeof value === "string" ? value : undefined) || []; } function requireQueryString(req: Request, res: Response, key: string): string | undefined { const value = req.query[key]; if (typeof value === "string" && value) { return value; } badRequest(res, `Missing required query parameter: ${key}`); return undefined; } function encodePathParam(value: string): string { return encodeURIComponent(value); } function extractSpreadsheetId(spreadsheetUrl: string): string | undefined { const fromPath = spreadsheetUrl.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/); if (fromPath?.[1]) { return fromPath[1]; } try { const url = new URL(spreadsheetUrl); const id = url.searchParams.get("id"); return id || undefined; } catch { return undefined; } } function quoteSheetName(sheetName: string): string { return `'${sheetName.replace(/'/g, "''")}'`; } function parseScopes(value: string | undefined): string[] | undefined { if (!value) { return undefined; } return value .split(/[,\s]+/) .map((scope) => scope.trim()) .filter(Boolean); } function buildGoogleUrl(body: GoogleRequestBody): string { const rawUrl = body.url || joinBaseAndPath(body.baseUrl || "https://www.googleapis.com", body.path || ""); const url = new URL(rawUrl); if (url.protocol !== "https:") { throw new Error("Only https Google API URLs are allowed."); } if (!isAllowedGoogleHost(url.hostname)) { throw new Error("Only Google API hosts are allowed."); } for (const [key, value] of Object.entries(body.query || {})) { if (value !== undefined && value !== null) { url.searchParams.set(key, String(value)); } } const apiKey = body.apiKey || readEnvValue(body.apiKeyEnv) || process.env.GOOGLE_API_KEY; if (apiKey && !url.searchParams.has("key")) { url.searchParams.set("key", apiKey); } return url.toString(); } function joinBaseAndPath(baseUrl: string, path: string): string { const normalizedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; const normalizedPath = path.startsWith("/") ? path : "/" + path; return normalizedBase + normalizedPath; } function isAllowedGoogleHost(hostname: string): boolean { return googleHosts.has(hostname) || hostname.endsWith(".googleapis.com") || hostname.endsWith(".google.com"); } function buildGoogleHeaders(req: Request, body: GoogleRequestBody): Headers { const headers = new Headers(); headers.set("accept", "application/json"); for (const [key, value] of Object.entries(body.headers || {})) { if (!["host", "connection", "content-length"].includes(key.toLowerCase())) { headers.set(key, value); } } const bearer = body.accessToken || readEnvValue(body.accessTokenEnv) || readBearerToken(req); if (bearer) { headers.set("authorization", bearer.toLowerCase().startsWith("bearer ") ? bearer : `Bearer ${bearer}`); } if (body.body !== undefined && !headers.has("content-type")) { headers.set("content-type", "application/json"); } return headers; } function readBearerToken(req: Request): string | undefined { const authorization = req.header("authorization"); if (!authorization?.toLowerCase().startsWith("bearer ")) { return undefined; } return authorization.slice("bearer ".length); } function readEnvValue(name: string | undefined): string | undefined { if (!name) { return undefined; } return process.env[name]; } async function pipeGoogleJson(res: Response, url: string): Promise { const response = await fetch(url, { headers: { accept: "application/json" } }); await relayResponse(res, response); } async function pipeGoogleForm(res: Response, url: string, params: URLSearchParams): Promise { const response = await fetch(url, { method: "POST", headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, body: params }); await relayResponse(res, response); } async function createServiceAccountAccessToken( serviceAccountJson: GoogleServiceAccountJson, scope: string, subject?: string ): Promise { const email = serviceAccountJson.client_email; const rawPrivateKey = serviceAccountJson.private_key; const privateKey = rawPrivateKey?.replace(/\\n/g, "\n"); const tokenUri = serviceAccountJson.token_uri || "https://oauth2.googleapis.com/token"; if (!email || !privateKey) { throw new Error("Google service account JSON must contain client_email and private_key."); } const now = Math.floor(Date.now() / 1000); const claimSet: Record = { iss: email, scope, aud: tokenUri, iat: now, exp: now + 3600 }; if (subject) { claimSet.sub = subject; } const assertion = signJwt({ alg: "RS256", typ: "JWT" }, claimSet, privateKey); const response = await fetch(tokenUri, { method: "POST", headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }) }); const text = await response.text(); let payload: unknown; try { payload = text ? JSON.parse(text) : {}; } catch { payload = {}; } if (!response.ok) { throw new Error(`Service account token request failed with HTTP ${response.status}: ${text}`); } const accessToken = typeof payload === "object" && payload && "access_token" in payload ? (payload as Record).access_token : undefined; if (typeof accessToken !== "string" || !accessToken) { throw new Error("Service account token response did not contain access_token."); } return accessToken; } async function relayResponse(res: Response, response: globalThis.Response): Promise { const contentType = response.headers.get("content-type") || "application/json"; const text = await response.text(); res.status(response.status).type(contentType); if (!text) { res.end(); return; } res.send(text); } function signJwt(header: Record, payload: Record, privateKey: string): string { const encodedHeader = base64Url(JSON.stringify(header)); const encodedPayload = base64Url(JSON.stringify(payload)); const input = `${encodedHeader}.${encodedPayload}`; const signature = crypto.createSign("RSA-SHA256").update(input).sign(privateKey); return `${input}.${base64Url(signature)}`; } function base64Url(input: string | Buffer): string { return Buffer.from(input) .toString("base64") .replace(/=/g, "") .replace(/\+/g, "-") .replace(/\//g, "_"); } function readRequiredString(value: unknown, key: string): string { if (!value || typeof value !== "object" || typeof (value as Record)[key] !== "string") { throw new Error(`Missing required string field: ${key}`); } return (value as Record)[key]; } function badRequest(res: Response, message: string): Response { return res.status(400).json({ error: message }); } function sendError(res: Response, error: unknown): void { const message = error instanceof Error ? error.message : "Unexpected error"; res.status(400).json({ error: message }); } function renderDocsHtml(): string { const specUrl = withRootPath("/openapi.json"); return ` google-service API
`; } function buildOpenApiDocument(): Record { return { openapi: "3.0.3", info: { title: "google-service API", version: "1.0.0", description: "Kompletni REST wrapper pro Google API. OAuth postup: 1) GET /google/oauth/scopes pro vyber scope, 2) GET /google/oauth/authorize-url pro URL souhlasu, 3) po redirectu vymen code pres POST /google/oauth/token, 4) access_token posilej jako Authorization: Bearer . ID objektu ziskas list endpointy: spreadsheetId pres GET /google/sheets/spreadsheets, calendarId pres GET /google/calendar/calendars, fileId pres GET /google/drive/files." }, servers: [{ url: rootPath || "/" }], tags: [ { name: "System" }, { name: "Google Discovery" }, { name: "Google OAuth" }, { name: "Google Calendar" }, { name: "Google Sheets" }, { name: "Google Drive" }, { name: "Gmail" }, { name: "Google Docs" }, { name: "Google Slides" }, { name: "Google Forms" }, { name: "Google People" }, { name: "Google Tasks" }, { name: "Google API" } ], paths: { "/health": { get: { tags: ["System"], summary: "Health check", responses: { "200": { description: "Service is ready" } } } }, "/google/discovery/apis": { get: { tags: ["Google Discovery"], summary: "Seznam verejnych Google API z Discovery service", responses: { "200": { description: "Google Discovery API list" } } } }, "/google/discovery/apis/{api}/{version}/rest": { get: { tags: ["Google Discovery"], summary: "Discovery dokument konkretniho Google API", parameters: [ { name: "api", in: "path", required: true, schema: { type: "string" }, example: "drive" }, { name: "version", in: "path", required: true, schema: { type: "string" }, example: "v3" } ], responses: { "200": { description: "Google Discovery REST document" } } } }, "/google/oauth/token": { post: { tags: ["Google OAuth"], summary: "Vymena authorization code nebo refresh tokenu za access token", description: "Pro authorization_code nejdriv otevri authorizationUrl z /google/oauth/authorize-url. Google presmeruje na redirectUri s query parametrem code. Ten posli sem. Pro dalsi obnoveni tokenu pouzij refreshToken.", parameters: oauthClientHeaderParameters(), requestBody: jsonRequestBody({ grantType: "authorization_code", code: "authorization-code", redirectUri: "https://example.test/oauth/callback", clientId: "google-oauth-client-id.apps.googleusercontent.com", clientSecret: "google-oauth-client-secret" }, "OAuthTokenRequest"), responses: { "200": jsonResponse("OAuth token response", "OAuthTokenResponse"), "400": { description: "Invalid request" } } } }, "/google/oauth/scopes": { get: { tags: ["Google OAuth"], summary: "Doporucene OAuth scopes pro podporovane Google sluzby", description: "Vraci scopes rozdelene podle Calendar, Sheets, Drive, Gmail, Docs, Slides, Forms, People a Tasks.", responses: { "200": jsonResponse("Recommended scopes", "ScopesResponse") } } }, "/google/configuration": { get: { tags: ["Google OAuth"], summary: "Prehled podporovanych env promennych a request alternativ", description: "Vraci jen nazvy env promennych, kde se pouzivaji a jak je predat pres request body/header. Nikdy nevraci hodnoty secrets.", responses: { "200": jsonResponse("Google service configuration metadata", "ConfigurationResponse") } } }, "/google/oauth/authorize-url": { get: { tags: ["Google OAuth"], summary: "Vygeneruje Google OAuth consent URL", description: "Otevri authorizationUrl v prohlizeci. Po souhlasu Google presmeruje na redirectUri s query parametrem code. Tento code vymen pres POST /google/oauth/token.", parameters: [ { name: "clientId", in: "query", required: false, schema: { type: "string" }, description: "Fallback je X-Google-Client-Id nebo GOOGLE_CLIENT_ID." }, { name: "redirectUri", in: "query", required: false, schema: { type: "string" }, description: "Fallback je GOOGLE_REDIRECT_URI." }, { name: "scopes", in: "query", required: false, schema: { type: "string" }, description: "Scopes oddelene mezerou nebo carkou. Pokud chybi, pouziji se vsechny doporucene scopes." }, { name: "state", in: "query", required: false, schema: { type: "string" } }, { name: "loginHint", in: "query", required: false, schema: { type: "string" } }, ...authorizeHeaderParameters() ], responses: { "200": jsonResponse("Authorization URL", "AuthorizationUrlResponse"), "400": { description: "Invalid request" } } } }, "/google/oauth/service-account-token": { post: { tags: ["Google OAuth"], summary: "Vystaveni access tokenu pro service account JWT flow", description: "Pouzij pro server-to-server komunikaci. Pro Workspace data uzivatelu je potreba domain-wide delegation a subject.", parameters: serviceAccountHeaderParameters(), requestBody: jsonRequestBody({ serviceAccountEmail: "service-account@project.iam.gserviceaccount.com", privateKey: "-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n", scopes: ["https://www.googleapis.com/auth/cloud-platform"] }, "ServiceAccountTokenRequest"), responses: { "200": jsonResponse("OAuth token response", "OAuthTokenResponse"), "400": { description: "Invalid request" } } } }, "/google/oauth/revoke": { post: { tags: ["Google OAuth"], summary: "Revokace Google OAuth tokenu", requestBody: jsonRequestBody({ token: "token-to-revoke" }), responses: { "200": { description: "Token revoked" }, "400": { description: "Invalid request" } } } }, "/google/oauth/tokeninfo": { get: { tags: ["Google OAuth"], summary: "Informace o access tokenu nebo ID tokenu", parameters: [ { name: "access_token", in: "query", required: false, schema: { type: "string" } }, { name: "id_token", in: "query", required: false, schema: { type: "string" } } ], responses: { "200": { description: "Token info" }, "400": { description: "Invalid request" } } } }, ...buildGoogleServiceOpenApiPaths(), "/google/request": { post: { tags: ["Google API"], summary: "Obecne volani Google REST API", description: "Credentials lze predat v body, pres X-Google-* headery, nebo jako env promennou uvedenou v *Env poli. Dostupne env fallbacky jsou GOOGLE_ACCESS_TOKEN a GOOGLE_API_KEY.", security: [{ bearerAuth: [] }], parameters: googleCredentialHeaderParameters(), requestBody: jsonRequestBody({ method: "GET", baseUrl: "https://www.googleapis.com", path: "/drive/v3/files", query: { pageSize: 10 }, accessTokenEnv: "GOOGLE_ACCESS_TOKEN" }, "GoogleRequest"), responses: { "200": { description: "Google API response" }, "400": { description: "Invalid request" } } } } }, components: { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } }, schemas: buildOpenApiSchemas() } }; } function jsonRequestBody(example: Record, schemaName?: string): Record { return { required: true, content: { "application/json": { schema: schemaName ? { $ref: `#/components/schemas/${schemaName}` } : { type: "object", additionalProperties: true }, example } } }; } function jsonResponse(description: string, schemaName: string): Record { return { description, content: { "application/json": { schema: { $ref: `#/components/schemas/${schemaName}` } } } }; } function buildOpenApiSchemas(): Record { return { OAuthTokenRequest: { type: "object", properties: { grantType: { type: "string", enum: ["authorization_code", "refresh_token"], default: "authorization_code" }, code: { type: "string", description: "Code z Google redirectu pro authorization_code flow." }, refreshToken: { type: "string", description: "Refresh token pro refresh_token flow." }, redirectUri: { type: "string", description: "Fallback/alternativa: GOOGLE_REDIRECT_URI se pouziva jen pro authorize-url, token endpoint potrebuje redirectUri v body pro authorization_code." }, scope: { type: "string" }, clientId: { type: "string", description: "Alternativa k X-Google-Client-Id nebo env GOOGLE_CLIENT_ID." }, clientSecret: { type: "string", description: "Alternativa k X-Google-Client-Secret nebo env GOOGLE_CLIENT_SECRET." } } }, OAuthTokenResponse: { type: "object", properties: { access_token: { type: "string", description: "Bearer token pro volani Google API." }, expires_in: { type: "integer", example: 3599 }, refresh_token: { type: "string", description: "Vraci se typicky pri prvnim consentu s access_type=offline." }, scope: { type: "string" }, token_type: { type: "string", example: "Bearer" }, id_token: { type: "string" } } }, AuthorizationUrlResponse: { type: "object", properties: { authorizationUrl: { type: "string", description: "URL otevri v prohlizeci pro Google consent." }, clientId: { type: "string" }, redirectUri: { type: "string" }, scopes: { type: "array", items: { type: "string" } } } }, ScopesResponse: { type: "object", properties: { recommendedScopes: { type: "object", additionalProperties: { type: "array", items: { type: "string" } } }, all: { type: "array", items: { type: "string" } } } }, ConfigurationResponse: { type: "object", properties: { environmentVariables: { type: "array", items: { type: "object", properties: { name: { type: "string", example: "GOOGLE_CLIENT_ID" }, usedBy: { type: "array", items: { type: "string" } }, alternatives: { type: "array", items: { type: "string" }, description: "Request body fields, query fields, or X-Google-* headers usable instead of env." } } } }, note: { type: "string" } } }, ServiceAccountTokenRequest: { type: "object", properties: { serviceAccountEmail: { type: "string", description: "Alternativa k X-Google-Service-Account-Email nebo env GOOGLE_SERVICE_ACCOUNT_EMAIL." }, privateKey: { type: "string", description: "Alternativa k X-Google-Private-Key nebo env GOOGLE_PRIVATE_KEY. Podporuje \\n escapovani." }, serviceAccountJson: { $ref: "#/components/schemas/GoogleServiceAccountJson" }, serviceAccountJsonEnv: { type: "string", example: "GOOGLE_SERVICE_ACCOUNT_JSON" }, scopes: { type: "array", items: { type: "string" }, description: "Alternativa k X-Google-Scopes nebo env GOOGLE_SCOPES." }, scope: { type: "string" }, subject: { type: "string", description: "Workspace user pro domain-wide delegation." } } }, GoogleRequest: { type: "object", properties: { method: { type: "string", example: "GET" }, url: { type: "string", description: "Plna Google API URL. Alternativa k baseUrl+path." }, baseUrl: { type: "string", example: "https://www.googleapis.com" }, path: { type: "string", example: "/drive/v3/files" }, query: { type: "object", additionalProperties: true }, headers: { type: "object", additionalProperties: { type: "string" } }, body: { type: "object", additionalProperties: true }, accessToken: { type: "string", description: "Alternativa k Authorization Bearer nebo X-Google-Access-Token." }, accessTokenEnv: { type: "string", example: "GOOGLE_ACCESS_TOKEN", description: "Jmeno env promenne s access tokenem." }, apiKey: { type: "string", description: "Alternativa k X-Google-Api-Key nebo GOOGLE_API_KEY." }, apiKeyEnv: { type: "string", example: "GOOGLE_API_KEY", description: "Jmeno env promenne s API key." }, serviceAccountJson: { $ref: "#/components/schemas/GoogleServiceAccountJson" }, serviceAccountJsonEnv: { type: "string", example: "GOOGLE_SERVICE_ACCOUNT_JSON" }, serviceAccountScopes: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], description: "Scopes pro automaticke vystaveni tokenu ze service account JSON." }, serviceAccountSubject: { type: "string", description: "Volitelny Workspace uzivatel pro domain-wide delegation." } } }, GoogleServiceAccountJson: { type: "object", required: ["client_email", "private_key"], properties: { type: { type: "string", example: "service_account" }, project_id: { type: "string" }, private_key_id: { type: "string" }, private_key: { type: "string", description: "Private key ze service account JSON. Secret se nevraci ve vystupu." }, client_email: { type: "string", description: "Service account email pouzity jako JWT iss." }, client_id: { type: "string" }, auth_uri: { type: "string" }, token_uri: { type: "string", example: "https://oauth2.googleapis.com/token" }, auth_provider_x509_cert_url: { type: "string" }, client_x509_cert_url: { type: "string" }, universe_domain: { type: "string", example: "googleapis.com" } } }, Calendar: { type: "object", properties: { id: { type: "string", description: "calendarId pouzitelne v /google/calendar/calendars/{calendarId}/events." }, summary: { type: "string" }, description: { type: "string" }, timeZone: { type: "string", example: "Europe/Prague" } } }, CalendarEvent: { type: "object", properties: { id: { type: "string", description: "eventId." }, summary: { type: "string" }, description: { type: "string" }, location: { type: "string" }, start: { $ref: "#/components/schemas/EventDateTime" }, end: { $ref: "#/components/schemas/EventDateTime" }, attendees: { type: "array", items: { type: "object", properties: { email: { type: "string" } } } } } }, EventDateTime: { type: "object", properties: { dateTime: { type: "string", example: "2026-06-15T10:00:00+02:00" }, date: { type: "string", example: "2026-06-15" }, timeZone: { type: "string", example: "Europe/Prague" } } }, DriveFile: { type: "object", properties: { id: { type: "string", description: "fileId. U Google Sheets souboru je to zaroven spreadsheetId." }, name: { type: "string" }, mimeType: { type: "string" }, webViewLink: { type: "string" }, createdTime: { type: "string" }, modifiedTime: { type: "string" } } }, DriveFileList: { type: "object", properties: { files: { type: "array", items: { $ref: "#/components/schemas/DriveFile" } }, nextPageToken: { type: "string" } } }, Spreadsheet: { type: "object", properties: { spreadsheetId: { type: "string", description: "ID spreadsheetu pro vsechny /google/sheets/spreadsheets/{spreadsheetId} endpointy." }, spreadsheetUrl: { type: "string" }, properties: { type: "object", properties: { title: { type: "string" } } }, sheets: { type: "array", items: { $ref: "#/components/schemas/Sheet" } } } }, Sheet: { type: "object", properties: { properties: { type: "object", properties: { sheetId: { type: "integer", description: "Numericke ID tabu/listu pro batchUpdate requesty." }, title: { type: "string", description: "Nazev listu pouzitelny v A1 range, napr. Sheet1!A1:B10." }, index: { type: "integer" } } } } }, ValueRange: { type: "object", properties: { range: { type: "string", example: "Sheet1!A1:C3" }, majorDimension: { type: "string", example: "ROWS" }, values: { type: "array", items: { type: "array", items: { type: "string" } } } } }, BatchUpdateValuesRequest: { type: "object", properties: { valueInputOption: { type: "string", example: "USER_ENTERED" }, data: { type: "array", items: { $ref: "#/components/schemas/ValueRange" } } } }, SheetWriteByUrlRequest: { type: "object", required: ["spreadsheetUrl", "sheetName", "values"], properties: { spreadsheetUrl: { type: "string", description: "Plna URL Google Sheets, napr. https://docs.google.com/spreadsheets/d/{spreadsheetId}/edit#gid=0." }, sheetName: { type: "string", description: "Nazev listu/tabu ve spreadsheetu. Sluzba sama slozi A1 range a spravne escapuje mezery i apostrofy." }, startCell: { type: "string", default: "A1", example: "A1" }, mode: { type: "string", enum: ["append", "update"], default: "append" }, majorDimension: { type: "string", enum: ["ROWS", "COLUMNS"], default: "ROWS" }, valueInputOption: { type: "string", enum: ["RAW", "USER_ENTERED"], default: "USER_ENTERED" }, insertDataOption: { type: "string", enum: ["OVERWRITE", "INSERT_ROWS"], default: "INSERT_ROWS" }, values: { type: "array", items: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }] } }, example: [["Datum", "Castka"], ["2026-06-15", 1234]] }, accessToken: { type: "string", description: "Volitelne. Lepsi je poslat Authorization: Bearer ." }, accessTokenEnv: { type: "string", description: "Volitelne jmeno env var s access tokenem." }, serviceAccountJson: { $ref: "#/components/schemas/GoogleServiceAccountJson" }, serviceAccountJsonEnv: { type: "string", example: "GOOGLE_SERVICE_ACCOUNT_JSON" }, serviceAccountScopes: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], example: ["https://www.googleapis.com/auth/spreadsheets"], description: "Scopes pro automaticke vystaveni tokenu ze service account JSON." } } }, SheetWriteResponse: { type: "object", properties: { spreadsheetId: { type: "string" }, tableRange: { type: "string" }, updatedRange: { type: "string" }, updatedRows: { type: "integer" }, updatedColumns: { type: "integer" }, updatedCells: { type: "integer" }, updates: { type: "object" } } }, GmailMessage: { type: "object", properties: { id: { type: "string", description: "messageId." }, threadId: { type: "string" }, labelIds: { type: "array", items: { type: "string" } }, snippet: { type: "string" }, raw: { type: "string", description: "Base64url encoded RFC 2822 message, pouziva se pro send." } } }, GoogleDocument: { type: "object", properties: { documentId: { type: "string" }, title: { type: "string" }, body: { type: "object" } } }, Presentation: { type: "object", properties: { presentationId: { type: "string" }, title: { type: "string" }, slides: { type: "array", items: { type: "object" } } } }, GoogleForm: { type: "object", properties: { formId: { type: "string" }, info: { type: "object", properties: { title: { type: "string" } } } } }, Person: { type: "object", properties: { resourceName: { type: "string", description: "Identifikator kontaktu pro People API." }, names: { type: "array", items: { type: "object" } }, emailAddresses: { type: "array", items: { type: "object" } } } }, Task: { type: "object", properties: { id: { type: "string", description: "taskId." }, title: { type: "string" }, notes: { type: "string" }, status: { type: "string" }, due: { type: "string" } } } }; } function buildGoogleServiceOpenApiPaths(): Record { return { "/google/calendar/calendars": { get: googleOperation("Google Calendar", "List calendars"), post: googleOperation("Google Calendar", "Create calendar", { summary: "Team calendar" }) }, "/google/calendar/calendars/{calendarId}": { get: googleOperation("Google Calendar", "Get calendar", undefined, ["calendarId"]), patch: googleOperation("Google Calendar", "Update calendar", { summary: "Updated calendar" }, ["calendarId"]), delete: googleOperation("Google Calendar", "Delete calendar", undefined, ["calendarId"]) }, "/google/calendar/calendars/{calendarId}/events": { get: googleOperation("Google Calendar", "List events", undefined, ["calendarId"]), post: googleOperation("Google Calendar", "Create event", { summary: "Meeting", start: {}, end: {} }, ["calendarId"]) }, "/google/calendar/calendars/{calendarId}/events/{eventId}": { get: googleOperation("Google Calendar", "Get event", undefined, ["calendarId", "eventId"]), patch: googleOperation("Google Calendar", "Update event", { summary: "Updated meeting" }, ["calendarId", "eventId"]), delete: googleOperation("Google Calendar", "Delete event", undefined, ["calendarId", "eventId"]) }, "/google/calendar/calendars/{calendarId}/events/{eventId}/move": { post: googleOperation("Google Calendar", "Move event to another calendar", undefined, ["calendarId", "eventId"]) }, "/google/sheets/spreadsheets": { get: googleOperation("Google Sheets", "List spreadsheet files. spreadsheetId is files[].id in the response"), post: googleOperation("Google Sheets", "Create spreadsheet", { properties: { title: "New spreadsheet" } }) }, "/google/sheets/spreadsheets/{spreadsheetId}": { get: googleOperation("Google Sheets", "Get spreadsheet", undefined, ["spreadsheetId"]) }, "/google/sheets/spreadsheets/{spreadsheetId}/sheets": { get: googleOperation("Google Sheets", "List sheets/tabs. sheetId is sheets[].properties.sheetId and A1 ranges use sheets[].properties.title", undefined, ["spreadsheetId"]) }, "/google/sheets/write-by-url": { post: { tags: ["Google Sheets"], summary: "Write values by spreadsheet URL and sheet name", description: "Nejjednodussi zapis do Google Sheets. Posli plnou spreadsheetUrl, sheetName a values. Sluzba sama vytahne spreadsheetId z URL a slozi A1 range. mode=append prida radky pod existujici tabulku, mode=update prepise hodnoty od startCell.", security: [{ bearerAuth: [] }], parameters: googleCredentialHeaderParameters(), requestBody: jsonRequestBody( { spreadsheetUrl: "https://docs.google.com/spreadsheets/d/1abcDEFghiJKLmnopQRstuVWXyz/edit#gid=0", sheetName: "Objednavky", startCell: "A1", mode: "append", valueInputOption: "USER_ENTERED", values: [ ["Datum", "Zakaznik", "Castka"], ["2026-06-15", "ACME", 1234] ] }, "SheetWriteByUrlRequest" ), responses: { "200": jsonResponse("Google Sheets write response", "SheetWriteResponse"), "400": { description: "Invalid request or Google API error" } } } }, "/google/sheets/spreadsheets/{spreadsheetId}/batch-update": { post: googleOperation("Google Sheets", "Batch update spreadsheet", { requests: [] }, ["spreadsheetId"]) }, "/google/sheets/spreadsheets/{spreadsheetId}/values": { get: googleOperation("Google Sheets", "Get values by range query parameter", undefined, ["spreadsheetId"], ["range"]), put: googleOperation("Google Sheets", "Update values by range query parameter", { values: [["value"]] }, ["spreadsheetId"], ["range"]) }, "/google/sheets/spreadsheets/{spreadsheetId}/values/append": { post: googleOperation("Google Sheets", "Append values by range query parameter", { values: [["value"]] }, ["spreadsheetId"], ["range"]) }, "/google/sheets/spreadsheets/{spreadsheetId}/values/batch-update": { post: googleOperation("Google Sheets", "Batch update values", { data: [], valueInputOption: "USER_ENTERED" }, ["spreadsheetId"]) }, "/google/sheets/spreadsheets/{spreadsheetId}/values/batch-clear": { post: googleOperation("Google Sheets", "Batch clear values", { ranges: [] }, ["spreadsheetId"]) }, "/google/drive/files": { get: googleOperation("Google Drive", "List files"), post: googleOperation("Google Drive", "Create file metadata", { name: "New file" }) }, "/google/drive/files/{fileId}": { get: googleOperation("Google Drive", "Get file metadata", undefined, ["fileId"]), patch: googleOperation("Google Drive", "Update file metadata", { name: "Renamed file" }, ["fileId"]), delete: googleOperation("Google Drive", "Delete file", undefined, ["fileId"]) }, "/google/drive/files/{fileId}/export": { get: googleOperation("Google Drive", "Export Google Workspace file", undefined, ["fileId"], ["mimeType"]) }, "/google/drive/files/{fileId}/permissions": { get: googleOperation("Google Drive", "List file permissions", undefined, ["fileId"]), post: googleOperation("Google Drive", "Create file permission", { type: "user", role: "reader", emailAddress: "user@example.com" }, ["fileId"]) }, "/google/drive/files/{fileId}/permissions/{permissionId}": { delete: googleOperation("Google Drive", "Delete file permission", undefined, ["fileId", "permissionId"]) }, "/google/gmail/profile": { get: googleOperation("Gmail", "Get Gmail profile") }, "/google/gmail/messages": { get: googleOperation("Gmail", "List messages") }, "/google/gmail/messages/{messageId}": { get: googleOperation("Gmail", "Get message", undefined, ["messageId"]) }, "/google/gmail/messages/send": { post: googleOperation("Gmail", "Send message", { raw: "base64url-rfc2822-message" }) }, "/google/gmail/labels": { get: googleOperation("Gmail", "List labels"), post: googleOperation("Gmail", "Create label", { name: "Label name" }) }, "/google/docs/documents": { post: googleOperation("Google Docs", "Create document", { title: "New document" }) }, "/google/docs/documents/{documentId}": { get: googleOperation("Google Docs", "Get document", undefined, ["documentId"]) }, "/google/docs/documents/{documentId}/batch-update": { post: googleOperation("Google Docs", "Batch update document", { requests: [] }, ["documentId"]) }, "/google/slides/presentations": { post: googleOperation("Google Slides", "Create presentation", { title: "New presentation" }) }, "/google/slides/presentations/{presentationId}": { get: googleOperation("Google Slides", "Get presentation", undefined, ["presentationId"]) }, "/google/slides/presentations/{presentationId}/batch-update": { post: googleOperation("Google Slides", "Batch update presentation", { requests: [] }, ["presentationId"]) }, "/google/forms/forms": { post: googleOperation("Google Forms", "Create form", { info: { title: "New form" } }) }, "/google/forms/forms/{formId}": { get: googleOperation("Google Forms", "Get form", undefined, ["formId"]) }, "/google/forms/forms/{formId}/batch-update": { post: googleOperation("Google Forms", "Batch update form", { requests: [] }, ["formId"]) }, "/google/forms/forms/{formId}/responses": { get: googleOperation("Google Forms", "List form responses", undefined, ["formId"]) }, "/google/people/connections": { get: googleOperation("Google People", "List connections") }, "/google/people/{resourceName}": { get: googleOperation("Google People", "Get person/contact", undefined, ["resourceName"]), patch: googleOperation("Google People", "Update contact", {}, ["resourceName"]), delete: googleOperation("Google People", "Delete contact", undefined, ["resourceName"]) }, "/google/people/contacts": { post: googleOperation("Google People", "Create contact", { names: [{ givenName: "Ada", familyName: "Lovelace" }] }) }, "/google/tasks/lists": { get: googleOperation("Google Tasks", "List task lists"), post: googleOperation("Google Tasks", "Create task list", { title: "Task list" }) }, "/google/tasks/lists/{tasklistId}/tasks": { get: googleOperation("Google Tasks", "List tasks", undefined, ["tasklistId"]), post: googleOperation("Google Tasks", "Create task", { title: "Task" }, ["tasklistId"]) }, "/google/tasks/lists/{tasklistId}/tasks/{taskId}": { patch: googleOperation("Google Tasks", "Update task", { title: "Updated task" }, ["tasklistId", "taskId"]), delete: googleOperation("Google Tasks", "Delete task", undefined, ["tasklistId", "taskId"]) } }; } function googleCredentialHeaderParameters(): Record[] { return [ { name: "X-Google-Access-Token", in: "header", required: false, schema: { type: "string" }, description: "Volitelny Google OAuth access token. Alternativy: Authorization: Bearer , body.accessToken, nebo env pres X-Google-Access-Token-Env/body.accessTokenEnv." }, { name: "X-Google-Access-Token-Env", in: "header", required: false, schema: { type: "string", example: "GOOGLE_ACCESS_TOKEN" }, description: "Jmeno env promenne obsahujici access token. Hodnota env se nevraci ve vystupu." }, { name: "X-Google-Api-Key", in: "header", required: false, schema: { type: "string" }, description: "Volitelny Google API key pro endpointy, ktere API key podporuji. Alternativy: body.apiKey nebo env GOOGLE_API_KEY / X-Google-Api-Key-Env." }, { name: "X-Google-Api-Key-Env", in: "header", required: false, schema: { type: "string", example: "GOOGLE_API_KEY" }, description: "Jmeno env promenne obsahujici Google API key. Hodnota env se nevraci ve vystupu." }, { name: "X-Google-Service-Account-Json", in: "header", required: false, schema: { type: "string" }, description: "Cely Google service account JSON jako string. Sluzba z nej vystavi access token automaticky. Secret se nevraci ve vystupu." }, { name: "X-Google-Service-Account-Json-Env", in: "header", required: false, schema: { type: "string", example: "GOOGLE_SERVICE_ACCOUNT_JSON" }, description: "Jmeno env promenne obsahujici cely service account JSON." }, { name: "X-Google-Service-Account-Scopes", in: "header", required: false, schema: { type: "string", example: "https://www.googleapis.com/auth/spreadsheets" }, description: "Scopes pro automaticke vystaveni tokenu ze service account JSON." }, { name: "X-Google-Service-Account-Subject", in: "header", required: false, schema: { type: "string" }, description: "Volitelny Workspace uzivatel pro domain-wide delegation." } ]; } function oauthClientHeaderParameters(): Record[] { return [ { name: "X-Google-Client-Id", in: "header", required: false, schema: { type: "string" }, description: "Alternativa k body.clientId nebo env GOOGLE_CLIENT_ID." }, { name: "X-Google-Client-Secret", in: "header", required: false, schema: { type: "string" }, description: "Alternativa k body.clientSecret nebo env GOOGLE_CLIENT_SECRET. Secret se nevraci ve vystupu." } ]; } function authorizeHeaderParameters(): Record[] { return [ { name: "X-Google-Client-Id", in: "header", required: false, schema: { type: "string" }, description: "Alternativa k query clientId nebo env GOOGLE_CLIENT_ID." }, { name: "X-Google-Redirect-Uri", in: "header", required: false, schema: { type: "string" }, description: "Alternativa k query redirectUri nebo env GOOGLE_REDIRECT_URI." } ]; } function serviceAccountHeaderParameters(): Record[] { return [ { name: "X-Google-Service-Account-Email", in: "header", required: false, schema: { type: "string" }, description: "Alternativa k body.serviceAccountEmail nebo env GOOGLE_SERVICE_ACCOUNT_EMAIL." }, { name: "X-Google-Private-Key", in: "header", required: false, schema: { type: "string" }, description: "Alternativa k body.privateKey nebo env GOOGLE_PRIVATE_KEY. Secret se nevraci ve vystupu." }, { name: "X-Google-Scopes", in: "header", required: false, schema: { type: "string" }, description: "Alternativa k body.scope/body.scopes nebo env GOOGLE_SCOPES." }, { name: "X-Google-Service-Account-Json", in: "header", required: false, schema: { type: "string" }, description: "Cely Google service account JSON jako string. Alternativa k body.serviceAccountJson nebo env GOOGLE_SERVICE_ACCOUNT_JSON." } ]; } function googleOperation( tag: string, summary: string, example?: Record, pathParams: string[] = [], queryParams: string[] = [] ): Record { const parameters = [ ...pathParams.map((name) => ({ name, in: "path", required: true, schema: { type: "string" } })), ...queryParams.map((name) => ({ name, in: "query", required: true, schema: { type: "string" } })), ...googleCredentialHeaderParameters() ]; const operation: Record = { tags: [tag], summary, description: descriptionForOperation(tag, summary), security: [{ bearerAuth: [] }], responses: { "200": jsonResponse("Google API response", responseSchemaForOperation(tag, summary)), "400": { description: "Invalid request or Google API error" } } }; if (parameters.length > 0) { operation.parameters = parameters; } if (example) { operation.requestBody = jsonRequestBody(example, requestSchemaForOperation(tag, summary)); } return operation; } function responseSchemaForOperation(tag: string, summary: string): string { if (tag === "Google Sheets" && summary.includes("spreadsheetId is files")) return "DriveFileList"; if (tag === "Google Calendar" && summary.includes("event")) return "CalendarEvent"; if (tag === "Google Calendar") return "Calendar"; if (tag === "Google Sheets" && summary.includes("values")) return "ValueRange"; if (tag === "Google Sheets") return "Spreadsheet"; if (tag === "Google Drive" || tag === "Google Sheets" && summary.includes("List")) return "DriveFileList"; if (tag === "Gmail") return "GmailMessage"; if (tag === "Google Docs") return "GoogleDocument"; if (tag === "Google Slides") return "Presentation"; if (tag === "Google Forms") return "GoogleForm"; if (tag === "Google People") return "Person"; if (tag === "Google Tasks") return "Task"; return "DriveFileList"; } function requestSchemaForOperation(tag: string, summary: string): string | undefined { if (tag === "Google Calendar" && summary.includes("event")) return "CalendarEvent"; if (tag === "Google Calendar") return "Calendar"; if (tag === "Google Sheets" && summary.includes("values")) return summary.includes("Batch") ? "BatchUpdateValuesRequest" : "ValueRange"; if (tag === "Google Sheets") return "Spreadsheet"; if (tag === "Google Drive") return "DriveFile"; if (tag === "Gmail") return "GmailMessage"; if (tag === "Google Docs") return "GoogleDocument"; if (tag === "Google Slides") return "Presentation"; if (tag === "Google Forms") return "GoogleForm"; if (tag === "Google People") return "Person"; if (tag === "Google Tasks") return "Task"; return undefined; } function descriptionForOperation(tag: string, summary: string): string { if (summary.includes("spreadsheetId is files")) { return "Pouzij tento endpoint pro ziskani spreadsheetId. V odpovedi je spreadsheetId v poli files[].id. Stejna hodnota je ID Google Drive souboru."; } if (summary.includes("sheetId is sheets")) { return "Pouzij tento endpoint pro ziskani tabu/listu ve spreadsheetu. Numericky sheetId je sheets[].properties.sheetId. Pro values endpointy pouzij A1 range z title, napr. Sheet1!A1:B10."; } if (tag === "Google Calendar" && summary.includes("List calendars")) { return "Pouzij pro ziskani calendarId. Hlavni kalendar lze typicky volat jako calendarId=primary, ostatni ID jsou v id."; } if (tag === "Google Drive" && summary.includes("List files")) { return "Pouzij pro ziskani fileId pro Drive/Docs/Sheets/Slides soubory. Google Sheets spreadsheetId odpovida Drive file id."; } return "Volej s Authorization: Bearer . Token ziskas pres /google/oauth/authorize-url a /google/oauth/token, nebo service account flow."; }