2238 lines
81 KiB
TypeScript
2238 lines
81 KiB
TypeScript
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<string, string | number | boolean | null | undefined>;
|
|
headers?: Record<string, string>;
|
|
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<string, string | number | boolean | null | undefined>;
|
|
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<string, string | number> = {
|
|
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<void> {
|
|
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<string, string | number | boolean | null | undefined> {
|
|
const result: Record<string, string | number | boolean | null | undefined> = {};
|
|
|
|
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<string, unknown>)[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<string, JsonValue> = {};
|
|
|
|
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<void> {
|
|
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<void> {
|
|
const response = await fetch(url, { headers: { accept: "application/json" } });
|
|
await relayResponse(res, response);
|
|
}
|
|
|
|
async function pipeGoogleForm(res: Response, url: string, params: URLSearchParams): Promise<void> {
|
|
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<string> {
|
|
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<string, string | number> = {
|
|
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<string, unknown>).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<void> {
|
|
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<string, string>, payload: Record<string, string | number>, 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<string, unknown>)[key] !== "string") {
|
|
throw new Error(`Missing required string field: ${key}`);
|
|
}
|
|
|
|
return (value as Record<string, string>)[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 `<!doctype html>
|
|
<html lang="cs">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>google-service API</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
|
<style>
|
|
body { margin: 0; background: #f7f7f7; }
|
|
.topbar { display: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="swagger-ui"></div>
|
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
<script>
|
|
window.ui = SwaggerUIBundle({
|
|
url: ${JSON.stringify(specUrl)},
|
|
dom_id: "#swagger-ui",
|
|
deepLinking: true,
|
|
persistAuthorization: true
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function buildOpenApiDocument(): Record<string, unknown> {
|
|
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 <token>. 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<string, unknown>, schemaName?: string): Record<string, unknown> {
|
|
return {
|
|
required: true,
|
|
content: {
|
|
"application/json": {
|
|
schema: schemaName ? { $ref: `#/components/schemas/${schemaName}` } : { type: "object", additionalProperties: true },
|
|
example
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function jsonResponse(description: string, schemaName: string): Record<string, unknown> {
|
|
return {
|
|
description,
|
|
content: {
|
|
"application/json": {
|
|
schema: { $ref: `#/components/schemas/${schemaName}` }
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function buildOpenApiSchemas(): Record<string, unknown> {
|
|
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 <token>." },
|
|
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<string, unknown> {
|
|
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<string, unknown>[] {
|
|
return [
|
|
{
|
|
name: "X-Google-Access-Token",
|
|
in: "header",
|
|
required: false,
|
|
schema: { type: "string" },
|
|
description: "Volitelny Google OAuth access token. Alternativy: Authorization: Bearer <token>, 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<string, unknown>[] {
|
|
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<string, unknown>[] {
|
|
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<string, unknown>[] {
|
|
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<string, unknown>,
|
|
pathParams: string[] = [],
|
|
queryParams: string[] = []
|
|
): Record<string, unknown> {
|
|
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<string, unknown> = {
|
|
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 <access_token>. Token ziskas pres /google/oauth/authorize-url a /google/oauth/token, nebo service account flow.";
|
|
}
|