History

Derive Google custom service identity

Rhys Sullivan committed 4 months ago

Commit b14985d1 parent 609d07e7 files · +851 −446Browse files

Unified · Split

7 files
  1. e2e/scenarios/google-per-service-add-ui.test.ts +4 −2
  2. packages/plugins/google/src/api/group.ts +1 −1
  3. packages/plugins/google/src/react/AddGoogleSource.test.ts +33 −7
  4. packages/plugins/google/src/react/AddGoogleSource.tsx +69 −60
  5. packages/plugins/google/src/sdk/discovery.ts +47 −31
  6. packages/plugins/google/src/sdk/plugin.test.ts +319 −267
  7. packages/plugins/google/src/sdk/plugin.ts +378 −78
e2e/scenarios/google-per-service-add-ui.test.ts+4−2
@@ -99,12 +99,13 @@ scenario( · 98 unmodified lines
9999 await row.getByRole("link", { name: "Open" }).waitFor();
100100 }
101101
102− const customRow = page.getByTestId("add-result-row-custom");
102+ const customRow = page.getByTestId("add-result-row-google_tasks");
103103 await customRow.waitFor({ timeout: 120_000 });
104104 expect(
105105 await customRow.getAttribute("data-state"),
106106 "the custom Discovery URL is added through the services endpoint",
107107 ).toBe("added");
108+ await customRow.getByText("Google Tasks").waitFor();
108109 await customRow.getByRole("link", { name: "Open" }).waitFor();
109110 });
110111
@@ -128,13 +129,14 @@ scenario( · 17 unmodified lines
128129 await page.goto("/integrations", { waitUntil: "networkidle" });
129130 // Each fanned-out integration is its own list entry, keyed by its slug
130131 // (the list renders the slug as each entry's description).
131− for (const slug of ["google_calendar", "google_gmail", "google_drive", "google_custom"]) {
132+ for (const slug of ["google_calendar", "google_gmail", "google_drive", "google_tasks"]) {
132133 await page
133134 .getByRole("main")
134135 .getByText(slug, { exact: true })
135136 .first()
136137 .waitFor({ timeout: 20_000 });
137138 }
139+ await page.getByRole("main").getByText("Google Tasks").first().waitFor({ timeout: 20_000 });
138140 });
139141
140142 await step("Re-adding an existing product reports it as skipped", async () => {
packages/plugins/google/src/api/group.ts+1−1
@@ -56,7 +56,7 @@ const AddCustomServicePayload = Schema.Struct({ · 55 unmodified lines
5656 custom: Schema.Struct({
5757 urls: Schema.Array(Schema.String),
5858 slug: Schema.optional(Schema.String),
59− name: Schema.String,
59+ name: Schema.optional(Schema.String),
6060 description: Schema.optional(Schema.String),
6161 }),
6262 });
packages/plugins/google/src/react/AddGoogleSource.test.ts+33−7
@@ -98,6 +98,11 @@ describe("AddGoogleSource per-service submit", () => { · 97 unmodified lines
9898 presetId: "custom",
9999 error: "Custom Discovery fetch failed",
100100 },
101+ {
102+ slug: IntegrationSlug.make("google_tasks"),
103+ presetId: "google_tasks",
104+ error: "Tasks Discovery fetch failed",
105+ },
101106 ],
102107 };
103108
@@ -117,6 +122,8 @@ describe("AddGoogleSource per-service submit", () => { · 13 unmodified lines
117122 expect(text).toContain("Discovery fetch failed");
118123 expect(text).toContain("Custom Discovery URLs");
119124 expect(text).toContain("Custom Discovery fetch failed");
125+ expect(text).toContain("Google Tasks");
126+ expect(text).toContain("Tasks Discovery fetch failed");
120127 expect(text).toContain("Retry");
121128 });
122129
@@ -181,15 +188,12 @@ describe("AddGoogleSource per-service submit", () => { · 58 unmodified lines
181188 });
182189 });
183190
184− it("submits custom Discovery URLs as one service entry", () => {
191+ it("submits custom Discovery URLs without generic identity overrides", () => {
185192 expect(
186193 googleAddServicesPayload({
187194 presetIds: ["google-calendar"],
188195 custom: {
189196 urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"],
190− slug: "google_custom",
191− name: "Custom Google APIs",
192− description: "Custom Google APIs.",
193197 },
194198 baseUrl: " https://proxy.example ",
195199 }),
@@ -199,13 +203,35 @@ describe("AddGoogleSource per-service submit", () => { · 3 unmodified lines
199203 {
200204 custom: {
201205 urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"],
202− slug: "google_custom",
203− name: "Custom Google APIs",
204− description: "Custom Google APIs.",
205206 },
206207 },
207208 ],
208209 baseUrl: "https://proxy.example",
209210 });
210211 });
212+
213+ it("passes explicit custom Discovery URL identity overrides when supplied", () => {
214+ expect(
215+ googleAddServicesPayload({
216+ presetIds: [],
217+ custom: {
218+ urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"],
219+ slug: "team_tasks",
220+ name: "Team Tasks",
221+ description: "Internal task workflow.",
222+ },
223+ }),
224+ ).toEqual({
225+ services: [
226+ {
227+ custom: {
228+ urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"],
229+ slug: "team_tasks",
230+ name: "Team Tasks",
231+ description: "Internal task workflow.",
232+ },
233+ },
234+ ],
235+ });
236+ });
211237 });
packages/plugins/google/src/react/AddGoogleSource.tsx+69−60
@@ -13,12 +13,7 @@ import { Button } from "@executor-js/react/components/button"; · 12 unmodified lines
1313 import { FieldLabel } from "@executor-js/react/components/field";
1414 import { FloatActions } from "@executor-js/react/components/float-actions";
1515 import { Input } from "@executor-js/react/components/input";
16−import {
17− errorMessageFromExit,
18− FormErrorAlert,
19− SlugCollisionAlert,
20− useSlugAlreadyExists,
21−} from "@executor-js/react/lib/integration-add";
16+import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add";
2217 import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";
2318
2419 import { addGoogleServices } from "./atoms";
@@ -52,8 +47,8 @@ export type GoogleServiceIdentityOverride = { · 27 unmodified lines
5247
5348 export type GoogleCustomServiceInput = {
5449 readonly urls: readonly string[];
55− readonly slug: string;
56− readonly name: string;
50+ readonly slug?: string;
51+ readonly name?: string;
5752 readonly description?: string;
5853 };
5954
@@ -81,8 +76,8 @@ export const googleAddServicesPayload = (input: { · 21 unmodified lines
8176 {
8277 custom: {
8378 urls: [...input.custom.urls],
84− slug: input.custom.slug,
85− name: input.custom.name,
79+ ...(input.custom.slug?.trim() ? { slug: input.custom.slug.trim() } : {}),
80+ ...(input.custom.name?.trim() ? { name: input.custom.name.trim() } : {}),
8681 ...(input.custom.description?.trim()
8782 ? { description: input.custom.description.trim() }
8883 : {}),
@@ -152,28 +147,73 @@ export const googleAddServicesResultRows = ( · 63 unmodified lines
152147 })),
153148 ];
154149
150+const googleAddServicesResultRowId = (row: GoogleServiceResultRow): string =>
151+ row.presetId === GOOGLE_CUSTOM_SERVICE_ID ? row.slug : row.presetId;
152+
155153 export const mergeGoogleAddServicesResult = (
156154 previous: GoogleAddServicesResult,
157155 next: GoogleAddServicesResult,
158156 ): GoogleAddServicesResult => {
159− const nextPresetIds = new Set(googleAddServicesResultRows(next).map((row) => row.presetId));
157+ const nextRowIds = new Set(googleAddServicesResultRows(next).map(googleAddServicesResultRowId));
160158 return {
161− added: [...previous.added.filter((entry) => !nextPresetIds.has(entry.presetId)), ...next.added],
159+ added: [
160+ ...previous.added.filter(
161+ (entry) =>
162+ !nextRowIds.has(
163+ googleAddServicesResultRowId({
164+ status: "added",
165+ presetId: entry.presetId,
166+ slug: String(entry.slug),
167+ toolCount: entry.toolCount,
168+ }),
169+ ),
170+ ),
171+ ...next.added,
172+ ],
162173 skipped: [
163− ...previous.skipped.filter((entry) => !nextPresetIds.has(entry.presetId)),
174+ ...previous.skipped.filter(
175+ (entry) =>
176+ !nextRowIds.has(
177+ googleAddServicesResultRowId({
178+ status: "skipped",
179+ presetId: entry.presetId,
180+ slug: String(entry.slug),
181+ reason: entry.reason,
182+ }),
183+ ),
184+ ),
164185 ...next.skipped,
165186 ],
166187 failed: [
167− ...previous.failed.filter((entry) => !nextPresetIds.has(entry.presetId)),
188+ ...previous.failed.filter(
189+ (entry) =>
190+ !nextRowIds.has(
191+ googleAddServicesResultRowId({
192+ status: "failed",
193+ presetId: entry.presetId,
194+ slug: String(entry.slug),
195+ error: entry.error,
196+ }),
197+ ),
198+ ),
168199 ...next.failed,
169200 ],
170201 };
171202 };
172203
173−const googlePresetName = (presetId: string): string =>
174− presetId === GOOGLE_CUSTOM_SERVICE_ID
204+const humanizeGoogleSlug = (slug: string): string =>
205+ slug
206+ .replace(/^google_/, "")
207+ .split("_")
208+ .filter((part) => part.length > 0)
209+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
210+ .join(" ");
211+
212+const googlePresetName = (row: GoogleServiceResultRow): string =>
213+ row.presetId === GOOGLE_CUSTOM_SERVICE_ID || row.slug === "google_custom"
175214 ? "Custom Discovery URLs"
176− : (googleOpenApiPresetById.get(presetId)?.name ?? presetId);
215+ : (googleOpenApiPresetById.get(row.presetId)?.name ??
216+ (row.slug.startsWith("google_") ? `Google ${humanizeGoogleSlug(row.slug)}` : row.slug));
177217
178218 export function GoogleServiceResultPanel(props: {
179219 readonly result: GoogleAddServicesResult;
@@ -196,11 +236,12 @@ export function GoogleServiceResultPanel(props: { · 16 unmodified lines
196236 </div>
197237 <ul className="space-y-2">
198238 {rows.map((row: GoogleServiceResultRow) => {
199− const presetName = googlePresetName(row.presetId);
239+ const rowId = googleAddServicesResultRowId(row);
240+ const presetName = googlePresetName(row);
200241 return (
201242 <li
202− key={`${row.status}:${row.presetId}`}
203− data-testid={`add-result-row-${row.presetId}`}
243+ key={`${row.status}:${rowId}`}
244+ data-testid={`add-result-row-${rowId}`}
204245 data-state={row.status}
205246 className="flex items-start gap-2 rounded-md border border-border bg-background px-2.5 py-2"
206247 >
@@ -249,9 +290,9 @@ export function GoogleServiceResultPanel(props: { · 42 unmodified lines
249290 type="button"
250291 variant="outline"
251292 size="xs"
252− data-testid={`add-result-retry-${row.presetId}`}
253− loading={props.retryingPresetId === row.presetId}
254− onClick={() => void props.onRetry(row.presetId)}
293+ data-testid={`add-result-retry-${rowId}`}
294+ loading={props.retryingPresetId === rowId}
295+ onClick={() => void props.onRetry(rowId)}
255296 >
256297 Retry
257298 </Button>
@@ -302,7 +343,6 @@ export default function AddGoogleSource(props: { · 44 unmodified lines
302343 );
303344 const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]);
304345 const [baseUrl, setBaseUrl] = useState("");
305− const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null);
306346 const [adding, setAdding] = useState(false);
307347 const [retryingPresetId, setRetryingPresetId] = useState<string | null>(null);
308348 const [addError, setAddError] = useState<string | null>(null);
@@ -357,16 +397,6 @@ export default function AddGoogleSource(props: { · 48 unmodified lines
357397 (hasCustomDiscoveryUrls
358398 ? "Custom Google APIs"
359399 : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google")));
360− const resolvedDescription =
361− descriptionDraft ??
362− (hasCustomDiscoveryUrls
363− ? "Custom Google APIs."
364− : isGooglePhotosPreset
365− ? "Google Photos albums, uploads, app-created media, and selected picker media."
366− : "Google APIs");
367− const customSlugAlreadyExists = useSlugAlreadyExists(
368− hasCustomDiscoveryUrls ? resolvedSourceId : "",
369− );
370400 const identityOverride =
371401 selectedIds.length === 1 && !hasCustomDiscoveryUrls
372402 ? { slug: resolvedSourceId, name: resolvedDisplayName }
@@ -375,15 +405,9 @@ export default function AddGoogleSource(props: { · 2 unmodified lines
375405 customDiscoveryUrls.length > 0
376406 ? {
377407 urls: [...customDiscoveryUrls],
378− slug: resolvedSourceId,
379− name: resolvedDisplayName,
380− description: resolvedDescription,
381408 }
382409 : undefined;
383− const canAdd =
384− (selectedIds.length > 0 || customDiscoveryUrls.length > 0) &&
385− !customSlugAlreadyExists &&
386− !adding;
410+ const canAdd = (selectedIds.length > 0 || customDiscoveryUrls.length > 0) && !adding;
387411
388412 const handleAdd = async () => {
389413 setAdding(true);
@@ -407,7 +431,7 @@ export default function AddGoogleSource(props: { · 17 unmodified lines
407431 const handleRetry = async (presetId: string) => {
408432 setRetryingPresetId(presetId);
409433 setAddError(null);
410− const retryingCustom = presetId === GOOGLE_CUSTOM_SERVICE_ID;
434+ const retryingCustom = !googleOpenApiPresetById.has(presetId);
411435 const retryIdentityOverride =
412436 !retryingCustom && identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId
413437 ? identityOverride
@@ -429,19 +453,9 @@ export default function AddGoogleSource(props: { · 15 unmodified lines
429453 setRetryingPresetId(null);
430454 };
431455
432− const showIdentityDetails = selectedIds.length === 1 || hasCustomDiscoveryUrls;
433− const detailTitle = hasCustomDiscoveryUrls
434− ? "Custom Google Discovery URLs"
435− : (singleSelectedPreset?.name ?? "Google");
436− const detailSubtitle = hasCustomDiscoveryUrls
437− ? selectedIds.length > 0
438− ? `${customDiscoveryUrls.length} custom URL${
439− customDiscoveryUrls.length === 1 ? "" : "s"
440− } added as its own integration. Selected products keep preset names.`
441− : `${customDiscoveryUrls.length} custom URL${
442− customDiscoveryUrls.length === 1 ? "" : "s"
443− } added as its own integration.`
444− : "This product is added as its own integration.";
456+ const showIdentityDetails = selectedIds.length === 1 && !hasCustomDiscoveryUrls;
457+ const detailTitle = singleSelectedPreset?.name ?? "Google";
458+ const detailSubtitle = "This product is added as its own integration.";
445459
446460 const dismiss = () => {
447461 if (servicesResult) {
@@ -473,9 +487,6 @@ export default function AddGoogleSource(props: { · 25 unmodified lines
473487 title={detailTitle}
474488 subtitle={detailSubtitle}
475489 identity={identity}
476− {...(hasCustomDiscoveryUrls
477− ? { description: resolvedDescription, onDescriptionChange: setDescriptionDraft }
478− : {})}
479490 baseUrl={baseUrl}
480491 onBaseUrlChange={setBaseUrl}
481492 baseUrlLabel="Base URL override (optional)"
@@ -486,8 +497,6 @@ export default function AddGoogleSource(props: { · 4 unmodified lines
486497 <BaseUrlSettings baseUrl={baseUrl} onBaseUrlChange={setBaseUrl} />
487498 )}
488499
489− {customSlugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />}
490−
491500 {addError && <FormErrorAlert message={addError} />}
492501
493502 {servicesResult && (
packages/plugins/google/src/sdk/discovery.ts+47−31
@@ -241,6 +241,7 @@ const DiscoveryDocument = Schema.Struct({ · 240 unmodified lines
241241 name: TextOption,
242242 version: TextOption,
243243 title: TextOption,
244+ description: TextOption,
244245 rootUrl: TextOption,
245246 servicePath: Schema.optional(Schema.Trim).pipe(
246247 Schema.withDecodingDefaultType(Effect.succeed("")),
@@ -268,6 +269,12 @@ const DiscoveryDocument = Schema.Struct({ · 21 unmodified lines
268269 });
269270 type DiscoveryDocument = typeof DiscoveryDocument.Type;
270271
272+export interface GoogleDiscoveryDocumentIdentity {
273+ readonly name: string;
274+ readonly title?: string;
275+ readonly description?: string;
276+}
277+
271278 export interface GoogleDiscoveryOpenApiConversion {
272279 readonly specText: string;
273280 readonly baseUrl: string;
@@ -286,6 +293,25 @@ const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod); · 12 unmodified lines
286293 const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource);
287294 const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
288295
296+const parseDiscoveryDocument = (documentText: string) =>
297+ parseJson(documentText).pipe(
298+ Effect.mapError(
299+ () =>
300+ new OpenApiParseError({
301+ message: "Failed to parse Google Discovery document",
302+ }),
303+ ),
304+ Effect.flatMap((parsed) =>
305+ Effect.try({
306+ try: () => decodeDiscoveryDocument(parsed),
307+ catch: () =>
308+ new OpenApiParseError({
309+ message: "Failed to decode Google Discovery document",
310+ }),
311+ }),
312+ ),
313+ );
314+
289315 const DISCOVERY_SERVICE_PATH_RE =
290316 /^\/discovery\/v1\/apis\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/rest\/?$/;
291317 const DISCOVERY_VERSION_RE = /^[A-Za-z0-9._-]+$/;
@@ -389,6 +415,25 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov · 97 unmodified lines
389415 },
390416 );
391417
418+export const googleDiscoveryDocumentIdentity = Effect.fn("OpenApi.googleDiscoveryDocumentIdentity")(
419+ function* (input: { readonly documentText: string }) {
420+ const document = yield* parseDiscoveryDocument(input.documentText);
421+ const name = Option.getOrUndefined(document.name);
422+ if (!name) {
423+ return yield* new OpenApiParseError({
424+ message: "Google Discovery document is missing name",
425+ });
426+ }
427+ const title = Option.getOrUndefined(document.title);
428+ const description = Option.getOrUndefined(document.description);
429+ return {
430+ name,
431+ ...(title !== undefined ? { title } : {}),
432+ ...(description !== undefined ? { description } : {}),
433+ };
434+ },
435+);
436+
392437 const schemaRef = (name: string) => `#/components/schemas/${name}`;
393438
394439 const identitySchemaName = (name: string): string => name;
@@ -946,22 +991,7 @@ const hasOperation = ( · 551 unmodified lines
946991
947992 export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleDiscovery")(
948993 function* (input: { readonly discoveryUrl: string; readonly documentText: string }) {
949− const parsed = yield* parseJson(input.documentText).pipe(
950− Effect.mapError(
951− () =>
952− new OpenApiParseError({
953− message: "Failed to parse Google Discovery document",
954− }),
955− ),
956− );
957− const document = yield* Effect.try({
958− try: () => decodeDiscoveryDocument(parsed),
959− catch: () =>
960− new OpenApiParseError({
961− message: "Failed to decode Google Discovery document",
962− }),
963− });
964−
994+ const document = yield* parseDiscoveryDocument(input.documentText);
965995 const info = yield* discoveryDocumentInfo(document, input.discoveryUrl);
966996 const { service, version, rootUrl, baseUrl, title } = info;
967997 const paths: Record<string, Record<string, OpenApiOperationObject>> = {};
@@ -1095,21 +1125,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 127 unmodified lines
10951125
10961126 const infos = yield* Effect.forEach(input.documents, ({ discoveryUrl, documentText }) =>
10971127 Effect.gen(function* () {
1098− const parsed = yield* parseJson(documentText).pipe(
1099− Effect.mapError(
1100− () =>
1101− new OpenApiParseError({
1102− message: "Failed to parse Google Discovery document",
1103− }),
1104− ),
1105− );
1106− const document = yield* Effect.try({
1107− try: () => decodeDiscoveryDocument(parsed),
1108− catch: () =>
1109− new OpenApiParseError({
1110− message: "Failed to decode Google Discovery document",
1111− }),
1112− });
1128+ const document = yield* parseDiscoveryDocument(documentText);
11131129 return yield* discoveryDocumentInfo(document, discoveryUrl);
11141130 }),
11151131 );
packages/plugins/google/src/sdk/plugin.test.ts+319−267
@@ -1,14 +1,9 @@
11 // ---------------------------------------------------------------------------
2−// Google bundle add flow, "customize your Google connection".
2+// Google per-service add flow, "customize your Google connection".
33 //
4−// The product picker emits a URL list. The server fetches each Discovery
5−// document, merges them into ONE `google`
6−// integration spec, and stores the unioned `googleOAuth2` auth template. These
7−// tests exercise that path end-to-end against a stubbed Discovery host:
8−// - a 3-API bundle (calendar + gmail + drive) produces a single `google`
9−// integration whose merged tools carry NO name collisions (each method id
10−// is service-prefixed) even when two APIs share a generic method name;
11−// - the stored oauth template carries the UNION of every API's scopes.
4+// The product picker emits preset ids and custom Discovery URLs. The server
5+// fans them out into separate integrations, deriving custom identity from the
6+// fetched Discovery document before it compiles the OpenAPI surface.
127 // ---------------------------------------------------------------------------
138
149 import { describe, expect, it } from "@effect/vitest";
@@ -24,7 +19,6 @@ import { · 9 unmodified lines
2419 import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
2520
2621 import { defaultGoogleHealthCheck, googlePlugin } from "./plugin";
27−import { googleOpenApiPresets, type GoogleOpenApiPreset } from "./presets";
2822
2923 // --- Canned Discovery documents -------------------------------------------
3024 // Each carries one method. Calendar and Gmail BOTH expose a generic `list`
@@ -37,6 +31,7 @@ const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; · 6 unmodified lines
3731 const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest";
3832 const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest";
3933 const DOCS_URL = "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest";
34+const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest";
4035 const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest";
4136 const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1";
4237 const PEOPLE_URL = "https://www.googleapis.com/discovery/v1/apis/people/v1/rest";
@@ -219,6 +214,40 @@ const docsDoc = { · 176 unmodified lines
219214 },
220215 };
221216
217+const tasksDoc = {
218+ name: "tasks",
219+ version: "v1",
220+ title: "Tasks API",
221+ description: "Manage your tasks and task lists.",
222+ rootUrl: "https://tasks.googleapis.com/",
223+ servicePath: "tasks/v1/",
224+ auth: {
225+ oauth2: {
226+ scopes: {
227+ "https://www.googleapis.com/auth/tasks": { description: "Manage tasks" },
228+ },
229+ },
230+ },
231+ resources: {
232+ tasks: {
233+ methods: {
234+ list: {
235+ id: "tasks.tasks.list",
236+ httpMethod: "GET",
237+ path: "lists/{tasklist}/tasks",
238+ scopes: ["https://www.googleapis.com/auth/tasks"],
239+ parameters: {
240+ tasklist: { location: "path", required: true, type: "string" },
241+ },
242+ },
243+ },
244+ },
245+ },
246+ schemas: {
247+ Task: { id: "Task", type: "object", properties: { id: { type: "string" } } },
248+ },
249+};
250+
222251 const photosLibraryDoc = {
223252 name: "photoslibrary",
224253 version: "v1",
@@ -425,6 +454,7 @@ const DISCOVERY_BODIES: Readonly<Record<string, string>> = { · 200 unmodified lines
425454 [SHEETS_URL]: toJson(sheetsDoc),
426455 [DRIVE_URL]: toJson(driveDoc),
427456 [DOCS_URL]: toJson(docsDoc),
457+ [TASKS_URL]: toJson(tasksDoc),
428458 [PHOTOS_LIBRARY_URL]: toJson(photosLibraryDoc),
429459 [PHOTOS_PICKER_URL]: toJson(photosPickerDoc),
430460 [PEOPLE_URL]: toJson(peopleDoc),
@@ -499,6 +529,240 @@ describe("Google custom service add flow", () => { · 68 unmodified lines
499529 expect(presetIds).toContain("google-photos");
500530 });
501531
532+ it.effect("derives custom integration identity from the Discovery document", () =>
533+ Effect.scoped(
534+ Effect.gen(function* () {
535+ const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
536+
537+ const result = yield* executor.google.addServices({
538+ services: [
539+ {
540+ custom: {
541+ urls: [TASKS_URL],
542+ },
543+ },
544+ ],
545+ });
546+
547+ expect(result).toEqual({
548+ added: [
549+ {
550+ slug: IntegrationSlug.make("google_tasks"),
551+ presetId: "google_tasks",
552+ toolCount: 4,
553+ },
554+ ],
555+ skipped: [],
556+ failed: [],
557+ });
558+
559+ const integration = (yield* executor.integrations.list()).find(
560+ (item) => String(item.slug) === "google_tasks",
561+ );
562+ expect(integration?.name).toBe("Google Tasks");
563+ expect(integration?.description).toBe("Manage your tasks and task lists.");
564+
565+ const config = yield* executor.google.getConfig("google_tasks");
566+ expect(config?.googleDiscoveryUrls).toEqual([TASKS_URL, OAUTH2_URL]);
567+ expect(oauthScopesFromConfig(config)).toEqual(
568+ ["email", "https://www.googleapis.com/auth/tasks", "openid", "profile"].sort(),
569+ );
570+ }),
571+ ),
572+ );
573+
574+ it.effect("uses explicit custom identity overrides for a single Discovery URL", () =>
575+ Effect.scoped(
576+ Effect.gen(function* () {
577+ const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
578+
579+ const result = yield* executor.google.addServices({
580+ services: [
581+ {
582+ custom: {
583+ urls: [TASKS_URL],
584+ slug: "team_tasks",
585+ name: "Team Tasks",
586+ description: "Internal task workflow.",
587+ },
588+ },
589+ ],
590+ });
591+
592+ expect(result).toEqual({
593+ added: [
594+ {
595+ slug: IntegrationSlug.make("team_tasks"),
596+ presetId: "team_tasks",
597+ toolCount: 4,
598+ },
599+ ],
600+ skipped: [],
601+ failed: [],
602+ });
603+
604+ const integration = (yield* executor.integrations.list()).find(
605+ (item) => String(item.slug) === "team_tasks",
606+ );
607+ expect(integration?.name).toBe("Team Tasks");
608+ expect(integration?.description).toBe("Internal task workflow.");
609+ }),
610+ ),
611+ );
612+
613+ it.effect("rejects explicit custom identity overrides for multiple Discovery URLs", () =>
614+ Effect.scoped(
615+ Effect.gen(function* () {
616+ const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
617+
618+ const result = yield* executor.google.addServices({
619+ services: [
620+ {
621+ custom: {
622+ urls: [TASKS_URL, CALENDAR_URL],
623+ slug: "team_google",
624+ name: "Team Google",
625+ },
626+ },
627+ ],
628+ });
629+
630+ expect(result).toEqual({
631+ added: [],
632+ skipped: [],
633+ failed: [
634+ {
635+ slug: IntegrationSlug.make("team_google"),
636+ presetId: "team_google",
637+ error: "Custom Google service identity overrides require exactly one Discovery URL",
638+ },
639+ ],
640+ });
641+ }),
642+ ),
643+ );
644+
645+ it.effect("reports fetch failures with the fallback custom slug", () =>
646+ Effect.scoped(
647+ Effect.gen(function* () {
648+ const failingLayer = makeDiscoveryHttpClientLayer({
649+ failedUrls: new Set([TASKS_URL]),
650+ });
651+ const executor = yield* createExecutor(
652+ makeTestConfig({
653+ plugins: [googlePlugin({ httpClientLayer: failingLayer }), memoryCredentialsPlugin()],
654+ }),
655+ );
656+
657+ const result = yield* executor.google.addServices({
658+ services: [
659+ {
660+ custom: {
661+ urls: [TASKS_URL],
662+ },
663+ },
664+ ],
665+ });
666+
667+ expect(result).toEqual({
668+ added: [],
669+ skipped: [],
670+ failed: [
671+ {
672+ slug: IntegrationSlug.make("google_custom"),
673+ presetId: "google_custom",
674+ error: "Failed to fetch Google Discovery document: HTTP 503",
675+ },
676+ ],
677+ });
678+ expect(yield* executor.google.getIntegration("google_custom")).toBeNull();
679+ }),
680+ ),
681+ );
682+
683+ it.effect("adds one integration per custom Discovery URL", () =>
684+ Effect.scoped(
685+ Effect.gen(function* () {
686+ const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
687+
688+ const result = yield* executor.google.addServices({
689+ services: [
690+ {
691+ custom: {
692+ urls: [TASKS_URL, CALENDAR_URL],
693+ },
694+ },
695+ ],
696+ });
697+
698+ expect(result).toEqual({
699+ added: [
700+ {
701+ slug: IntegrationSlug.make("google_tasks"),
702+ presetId: "google_tasks",
703+ toolCount: 4,
704+ },
705+ {
706+ slug: IntegrationSlug.make("google_calendar"),
707+ presetId: "google_calendar",
708+ toolCount: 4,
709+ },
710+ ],
711+ skipped: [],
712+ failed: [],
713+ });
714+ const integrations = yield* executor.integrations.list();
715+ expect(integrations.find((item) => String(item.slug) === "google_tasks")?.name).toBe(
716+ "Google Tasks",
717+ );
718+ expect(integrations.find((item) => String(item.slug) === "google_calendar")?.name).toBe(
719+ "Google Calendar",
720+ );
721+ }),
722+ ),
723+ );
724+
725+ it.effect("skips a duplicate custom Discovery URL in the same request", () =>
726+ Effect.scoped(
727+ Effect.gen(function* () {
728+ const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
729+
730+ const result = yield* executor.google.addServices({
731+ services: [
732+ {
733+ custom: {
734+ urls: [TASKS_URL, TASKS_URL],
735+ },
736+ },
737+ ],
738+ });
739+
740+ expect(result).toEqual({
741+ added: [
742+ {
743+ slug: IntegrationSlug.make("google_tasks"),
744+ presetId: "google_tasks",
745+ toolCount: 4,
746+ },
747+ ],
748+ skipped: [
749+ {
750+ slug: IntegrationSlug.make("google_tasks"),
751+ presetId: "google_tasks",
752+ reason: "already_exists",
753+ },
754+ ],
755+ failed: [],
756+ });
757+
758+ const integrations = yield* executor.integrations.list();
759+ expect(
760+ integrations.filter((integration) => String(integration.slug) === "google_tasks"),
761+ ).toHaveLength(1);
762+ }),
763+ ),
764+ );
765+
502766 it.effect("rejects lookalike Discovery hosts before fetching custom service documents", () =>
503767 Effect.scoped(
504768 Effect.gen(function* () {
@@ -541,7 +805,7 @@ describe("Google custom service add flow", () => { · 36 unmodified lines
541805 failed: [
542806 {
543807 slug: IntegrationSlug.make("bad_google"),
544− presetId: "custom",
808+ presetId: "bad_google",
545809 error:
546810 "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint",
547811 },
@@ -552,239 +816,47 @@ describe("Google custom service add flow", () => { · 4 unmodified lines
552816 ),
553817 );
554818
555− it.effect(
556− "addServices custom entry merges calendar+gmail+drive into one requested integration with no tool-name collisions",
557− () =>
558− Effect.scoped(
559− Effect.gen(function* () {
560− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
561−
562− const result = yield* executor.google.addServices({
563− services: [
564− {
565− custom: {
566− urls: [CALENDAR_URL, GMAIL_URL, DRIVE_URL],
567− slug: "google_custom",
568− name: "Custom Google APIs",
569− description: "Google",
570− },
571− },
572− ],
573− });
574− expect(result).toEqual({
575− added: [
576− {
577− slug: IntegrationSlug.make("google_custom"),
578− presetId: "custom",
579− toolCount: 6,
580− },
581− ],
582− skipped: [],
583− failed: [],
584− });
585−
586− const integration = yield* executor.google.getIntegration("google_custom");
587− expect(integration?.slug).toBe(IntegrationSlug.make("google_custom"));
588−
589− // The stored oauth template carries the COMPACTED union of every API's
590− // scopes plus the hidden OAuth2 identity scopes that `oauth.start`
591− // requests.
592− // `calendar.readonly` collapses under `calendar`, and `gmail.readonly`
593− // collapses under `https://mail.google.com/`, so the requested consent
594− // is clean rather than the raw per-method union.
595− const config = yield* executor.google.getConfig("google_custom");
596− expect(config?.googleDiscoveryUrls).toEqual([
597− CALENDAR_URL,
598− GMAIL_URL,
599− DRIVE_URL,
600− OAUTH2_URL,
601− ]);
602− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
603− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
604− [
605− "email",
606− "https://mail.google.com/",
607− "https://www.googleapis.com/auth/calendar",
608− "https://www.googleapis.com/auth/drive",
609− "openid",
610− "profile",
611− ].sort(),
612− );
613−
614− // A connection stamps the merged tools; assert all three `list`s are
615− // present under distinct service-prefixed names (no collision).
616− yield* executor.connections.create({
617− owner: "org",
618− name: ConnectionName.make("main"),
619− integration: IntegrationSlug.make("google_custom"),
620− template: AuthTemplateSlug.make("googleOAuth2"),
621− value: "token-xyz",
622− });
623−
624− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
625− expect(toolNames).toContain("calendar.events.list");
626− expect(toolNames).toContain("gmail.users.messages.list");
627− expect(toolNames).toContain("drive.files.list");
628−
629− // No duplicate tool names across the merged surface.
630− const googleTools = toolNames.filter((name) => name.endsWith(".list"));
631− expect(new Set(googleTools).size).toBe(googleTools.length);
632− expect(googleTools.length).toBe(3);
633− }),
634− ),
635− );
636−
637− it.effect(
638− "addServices custom entry constrains Google Photos to the preset scopes and upload tool",
639− () =>
640− Effect.scoped(
641− Effect.gen(function* () {
642− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
643−
644− yield* executor.google.addServices({
645− services: [
646− {
647− custom: {
648− urls: [
649− PHOTOS_LIBRARY_URL,
650− "https://photospicker.googleapis.com/$discovery/rest?version=v1",
651− ],
652− slug: "google_photos",
653− name: "Google Photos",
654− },
655− },
656− ],
657− });
658−
659− const config = yield* executor.google.getConfig("google_photos");
660− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
661− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
662− [
663− "email",
664− "https://www.googleapis.com/auth/photoslibrary.appendonly",
665− "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
666− "https://www.googleapis.com/auth/photospicker.mediaitems.readonly",
667− "openid",
668− "profile",
669− ].sort(),
670− );
671−
672− yield* executor.connections.create({
673− owner: "org",
674− name: ConnectionName.make("main"),
675− integration: IntegrationSlug.make("google_photos"),
676− template: AuthTemplateSlug.make("googleOAuth2"),
677− value: "token-xyz",
678− });
679−
680− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
681− expect(toolNames).toContain("photoslibrary.mediaItems.upload");
682− expect(toolNames).toContain("photoslibrary.mediaItems.search");
683− expect(toolNames).toContain("photospicker.mediaItems.list");
684− expect(toolNames).not.toContain("photoslibrary.albums.list");
685− }),
686− ),
687− );
688−
689− it.effect(
690− "addServices custom entry keeps Google Photos scoped when combined with another API",
691− () =>
692− Effect.scoped(
693− Effect.gen(function* () {
694− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
695−
696− yield* executor.google.addServices({
697− services: [
698− {
699− custom: {
700− urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL, PHOTOS_PICKER_URL],
701− slug: "google_photos_calendar",
702− name: "Google Photos and Calendar",
703− },
704− },
705− ],
706− });
707−
708− const config = yield* executor.google.getConfig("google_photos_calendar");
709− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
710− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
711− [
712− "email",
713− "https://www.googleapis.com/auth/calendar",
714− "https://www.googleapis.com/auth/photoslibrary.appendonly",
715− "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
716− "https://www.googleapis.com/auth/photospicker.mediaitems.readonly",
717− "openid",
718− "profile",
719− ].sort(),
720− );
721−
722− yield* executor.connections.create({
723− owner: "org",
724− name: ConnectionName.make("main"),
725− integration: IntegrationSlug.make("google_photos_calendar"),
726− template: AuthTemplateSlug.make("googleOAuth2"),
727− value: "token-xyz",
728− });
729−
730− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
731− expect(toolNames).toContain("calendar.events.list");
732− expect(toolNames).toContain("photoslibrary.mediaItems.upload");
733− expect(toolNames).toContain("photoslibrary.mediaItems.search");
734− expect(toolNames).toContain("photospicker.mediaItems.list");
735− expect(toolNames).not.toContain("photoslibrary.albums.list");
736− }),
737− ),
738− );
739−
740− it.effect(
741− "addServices custom entry scopes partial Google Photos URLs when mixed with another API",
742− () =>
743− Effect.scoped(
744− Effect.gen(function* () {
745− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
819+ it.effect("custom Google Photos Library uses the focused Photos scopes and upload tool", () =>
820+ Effect.scoped(
821+ Effect.gen(function* () {
822+ const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
746823
747− yield* executor.google.addServices({
748− services: [
749− {
750− custom: {
751− urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL],
752− slug: "google_photos_library_calendar",
753− name: "Google Photos Library and Calendar",
754− },
824+ yield* executor.google.addServices({
825+ services: [
826+ {
827+ custom: {
828+ urls: [PHOTOS_LIBRARY_URL],
755829 },
756− ],
757− });
830+ },
831+ ],
832+ });
758833
759− const config = yield* executor.google.getConfig("google_photos_library_calendar");
760− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
761− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
762− [
763− "email",
764− "https://www.googleapis.com/auth/calendar",
765− "https://www.googleapis.com/auth/photoslibrary.appendonly",
766− "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
767− "openid",
768− "profile",
769− ].sort(),
770− );
834+ const config = yield* executor.google.getConfig("google_photoslibrary");
835+ const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
836+ expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
837+ [
838+ "email",
839+ "https://www.googleapis.com/auth/photoslibrary.appendonly",
840+ "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
841+ "openid",
842+ "profile",
843+ ].sort(),
844+ );
771845
772− yield* executor.connections.create({
773− owner: "org",
774− name: ConnectionName.make("main"),
775− integration: IntegrationSlug.make("google_photos_library_calendar"),
776− template: AuthTemplateSlug.make("googleOAuth2"),
777− value: "token-xyz",
778− });
846+ yield* executor.connections.create({
847+ owner: "org",
848+ name: ConnectionName.make("main"),
849+ integration: IntegrationSlug.make("google_photoslibrary"),
850+ template: AuthTemplateSlug.make("googleOAuth2"),
851+ value: "token-xyz",
852+ });
779853
780− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
781− expect(toolNames).toContain("calendar.events.list");
782− expect(toolNames).toContain("photoslibrary.mediaItems.upload");
783− expect(toolNames).toContain("photoslibrary.mediaItems.search");
784− expect(toolNames).not.toContain("photospicker.mediaItems.list");
785− expect(toolNames).not.toContain("photoslibrary.albums.list");
786− }),
787− ),
854+ const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
855+ expect(toolNames).toContain("photoslibrary.mediaItems.upload");
856+ expect(toolNames).toContain("photoslibrary.mediaItems.search");
857+ expect(toolNames).not.toContain("photoslibrary.albums.list");
858+ }),
859+ ),
788860 );
789861 });
790862
@@ -1021,31 +1093,23 @@ describe("Google per-service add flow", () => { · 230 unmodified lines
10211093 });
10221094
10231095 describe("Google health-check default", () => {
1024− it.effect("custom service with featured URLs auto-configures OAuth2 userinfo identity", () =>
1096+ it.effect("custom service auto-configures OAuth2 userinfo identity", () =>
10251097 Effect.scoped(
10261098 Effect.gen(function* () {
10271099 const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
1028− const defaultUrls = googleOpenApiPresets
1029− .filter((preset: GoogleOpenApiPreset) => preset.featured)
1030− .flatMap((preset: GoogleOpenApiPreset) => (preset.url ? [preset.url] : []));
1031−
1032− expect(defaultUrls).toEqual([CALENDAR_URL, GMAIL_URL, SHEETS_URL, DRIVE_URL, DOCS_URL]);
10331100
10341101 yield* executor.google.addServices({
10351102 services: [
10361103 {
10371104 custom: {
1038− urls: defaultUrls,
1039− slug: "google_default",
1040− name: "Google",
1041− description: "Google",
1105+ urls: [TASKS_URL],
10421106 },
10431107 },
10441108 ],
10451109 });
10461110
10471111 const stored = yield* executor.integrations.healthCheck.get(
1048− IntegrationSlug.make("google_default"),
1112+ IntegrationSlug.make("google_tasks"),
10491113 );
10501114 expect(stored?.operation, "the default check targets OAuth2 userinfo").toBe(
10511115 "oauth2.userinfo.get",
@@ -1053,24 +1117,15 @@ describe("Google health-check default", () => { · 1 unmodified line
10531117 expect(stored?.args, "userinfo needs no args").toBeUndefined();
10541118 expect(stored?.identityField, "the default reads the account email").toBe("email");
10551119
1056− const config = yield* executor.google.getConfig("google_default");
1057− expect(config?.googleDiscoveryUrls).toEqual([...defaultUrls, OAUTH2_URL]);
1120+ const config = yield* executor.google.getConfig("google_tasks");
1121+ expect(config?.googleDiscoveryUrls).toEqual([TASKS_URL, OAUTH2_URL]);
10581122 const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
10591123 expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
1060− [
1061− "email",
1062− "https://mail.google.com/",
1063− "https://www.googleapis.com/auth/calendar",
1064− "https://www.googleapis.com/auth/documents",
1065− "https://www.googleapis.com/auth/drive",
1066− "https://www.googleapis.com/auth/spreadsheets",
1067− "openid",
1068− "profile",
1069− ].sort(),
1124+ ["email", "https://www.googleapis.com/auth/tasks", "openid", "profile"].sort(),
10701125 );
10711126
10721127 const candidates = yield* executor.integrations.healthCheck.candidates(
1073− IntegrationSlug.make("google_default"),
1128+ IntegrationSlug.make("google_tasks"),
10741129 );
10751130 const userinfo = candidates.find((c) => c.operation === "oauth2.userinfo.get");
10761131 expect(userinfo, "OAuth2 userinfo is a ranked candidate").toBeDefined();
@@ -1091,10 +1146,7 @@ describe("Google health-check default", () => { · 14 unmodified lines
10911146 services: [
10921147 {
10931148 custom: {
1094− urls: [PEOPLE_URL, CALENDAR_URL],
1095− slug: "google_people",
1096− name: "Google",
1097− description: "Google",
1149+ urls: [PEOPLE_URL],
10981150 },
10991151 },
11001152 ],
packages/plugins/google/src/sdk/plugin.ts+378−78
@@ -38,7 +38,9 @@ import { · 37 unmodified lines
3838 import {
3939 convertGoogleDiscoveryBundleToOpenApi,
4040 fetchGoogleDiscoveryDocument,
41+ googleDiscoveryDocumentIdentity,
4142 normalizeGoogleDiscoveryUrl,
43+ type GoogleDiscoveryDocumentIdentity,
4244 } from "./discovery";
4345 import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";
4446 import {
@@ -103,9 +105,15 @@ export interface GooglePresetServiceConfig { · 58 unmodified lines
103105
104106 export interface GoogleCustomServiceConfig {
105107 readonly custom: {
108+ /**
109+ * Each Discovery URL is added as its own integration. `slug`, `name`, and
110+ * `description` are explicit overrides for a single URL; supplying them
111+ * with multiple URLs returns a failed row instead of guessing which service
112+ * the override belongs to.
113+ */
106114 readonly urls: readonly string[];
107115 readonly slug?: string;
108− readonly name: string;
116+ readonly name?: string;
109117 readonly description?: string;
110118 };
111119 }
@@ -162,6 +170,8 @@ export interface GooglePluginOptions { · 50 unmodified lines
162170 }
163171
164172 const DEFAULT_GOOGLE_SLUG = "google";
173+const DEFAULT_GOOGLE_CUSTOM_SLUG = "google_custom";
174+const MAX_GOOGLE_CUSTOM_DESCRIPTION_LENGTH = 500;
165175
166176 const googleOpenApiPresetById: ReadonlyMap<string, GoogleOpenApiPreset> = new Map(
167177 googleOpenApiPresets.map((preset) => [preset.id, preset]),
@@ -177,7 +187,7 @@ const googleServiceEntryId = (service: GoogleServiceConfig): string => · 9 unmodified lines
177187 const googleServiceEntrySlug = (service: GoogleServiceConfig): IntegrationSlug =>
178188 isGooglePresetServiceConfig(service)
179189 ? IntegrationSlug.make(service.slug?.trim() || googleServiceSlug(service.presetId))
180− : IntegrationSlug.make(service.custom.slug?.trim() || "google_custom");
190+ : IntegrationSlug.make(service.custom.slug?.trim() || DEFAULT_GOOGLE_CUSTOM_SLUG);
181191
182192 type GoogleAddServiceOutcome = {
183193 readonly added: readonly GoogleAddServicesAdded[];
@@ -185,16 +195,21 @@ type GoogleAddServiceOutcome = { · 1 unmodified line
185195 readonly failed: readonly GoogleAddServicesFailed[];
186196 };
187197
188−const googleAddServiceFailure = (
189− service: GoogleServiceConfig,
190− slug: IntegrationSlug,
191− error: string,
192−): GoogleAddServiceOutcome => ({
198+const googleAddServiceFailure = (input: {
199+ readonly slug: IntegrationSlug;
200+ readonly presetId: string;
201+ readonly error: string;
202+}): GoogleAddServiceOutcome => ({
193203 added: [],
194204 skipped: [],
195− failed: [{ slug, presetId: googleServiceEntryId(service), error }],
205+ failed: [{ slug: input.slug, presetId: input.presetId, error: input.error }],
196206 });
197207
208+type GoogleFetchedDiscoveryDocument = {
209+ readonly discoveryUrl: string;
210+ readonly documentText: string;
211+};
212+
198213 const googlePhotosBundlePresetIdByUrl = new Map(
199214 googlePhotosOpenApiPresets.flatMap((preset) =>
200215 preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [],
@@ -213,20 +228,28 @@ const googlePhotosBundleConsentScopes = ( · 12 unmodified lines
213228 : undefined;
214229 };
215230
216−const fetchGoogleBundleConversion = (
231+const fetchGoogleDiscoveryDocuments = (
217232 urls: readonly string[],
218233 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
219− consentScopesOverride?: readonly string[],
220234 ) =>
221235 Effect.forEach(
222236 urls,
223237 (url) =>
224238 fetchGoogleDiscoveryDocument(url).pipe(
225239 Effect.provide(httpClientLayer),
226− Effect.map((documentText) => ({ discoveryUrl: url, documentText })),
240+ Effect.map(
241+ (documentText): GoogleFetchedDiscoveryDocument => ({ discoveryUrl: url, documentText }),
242+ ),
227243 ),
228244 { concurrency: 4 },
229− ).pipe(
245+ );
246+
247+const fetchGoogleBundleConversion = (
248+ urls: readonly string[],
249+ httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
250+ consentScopesOverride?: readonly string[],
251+) =>
252+ fetchGoogleDiscoveryDocuments(urls, httpClientLayer).pipe(
230253 Effect.flatMap((documents) => {
231254 const consentScopes = consentScopesOverride ?? googlePhotosBundleConsentScopes(urls);
232255 return convertGoogleDiscoveryBundleToOpenApi({
@@ -258,6 +281,132 @@ const googleBundleUrlsWithIdentity = ( · 25 unmodified lines
258281 return uniqueUrls([...normalized, GOOGLE_OAUTH2_DISCOVERY_URL]);
259282 });
260283
284+type GoogleServiceAddPlan =
285+ | {
286+ readonly kind: "preset";
287+ readonly service: GooglePresetServiceConfig;
288+ }
289+ | {
290+ readonly kind: "custom";
291+ readonly service: GoogleCustomServiceConfig;
292+ readonly url: string;
293+ readonly fallbackSlug: IntegrationSlug;
294+ }
295+ | {
296+ readonly kind: "invalid-custom";
297+ readonly service: GoogleCustomServiceConfig;
298+ readonly slug: IntegrationSlug;
299+ readonly presetId: string;
300+ readonly error: string;
301+ };
302+
303+const hasCustomGoogleServiceOverride = (service: GoogleCustomServiceConfig): boolean =>
304+ Boolean(
305+ service.custom.slug?.trim() ||
306+ service.custom.name?.trim() ||
307+ service.custom.description?.trim(),
308+ );
309+
310+const googleCustomFallbackSlug = (index: number): IntegrationSlug =>
311+ IntegrationSlug.make(
312+ index === 0 ? DEFAULT_GOOGLE_CUSTOM_SLUG : `${DEFAULT_GOOGLE_CUSTOM_SLUG}_${index + 1}`,
313+ );
314+
315+const googleCustomResultId = (slug: IntegrationSlug): string => String(slug);
316+
317+const googleDiscoveryNameSlugPart = (name: string): string =>
318+ name
319+ .trim()
320+ .toLowerCase()
321+ .replace(/[^a-z0-9_-]+/g, "_")
322+ .replace(/^_+|_+$/g, "");
323+
324+const googleDiscoverySlug = (name: string): IntegrationSlug =>
325+ IntegrationSlug.make(
326+ googleServiceSlug(`google-${googleDiscoveryNameSlugPart(name) || "custom"}`),
327+ );
328+
329+const googleDiscoveryDisplayName = (identity: GoogleDiscoveryDocumentIdentity): string => {
330+ const title = identity.title
331+ ?.trim()
332+ .replace(/\s+API(?:\s+v[0-9][A-Za-z0-9._-]*)?$/i, "")
333+ .trim();
334+ if (title && title.length > 0) {
335+ return /^google\b/i.test(title) ? title : `Google ${title}`;
336+ }
337+ return identity.name
338+ .trim()
339+ .replace(/[-_.]+/g, " ")
340+ .replace(/\b\w/g, (char) => char.toUpperCase());
341+};
342+
343+const googleDiscoveryDescription = (
344+ identity: GoogleDiscoveryDocumentIdentity,
345+ displayName: string,
346+): string => {
347+ const description = identity.description?.trim();
348+ if (description && description.length > 0) {
349+ return description.length > MAX_GOOGLE_CUSTOM_DESCRIPTION_LENGTH
350+ ? `${description.slice(0, MAX_GOOGLE_CUSTOM_DESCRIPTION_LENGTH - 3).trimEnd()}...`
351+ : description;
352+ }
353+ return `${displayName}.`;
354+};
355+
356+const googleServiceAddPlans = (
357+ services: readonly GoogleServiceConfig[],
358+): readonly GoogleServiceAddPlan[] => {
359+ const plans: GoogleServiceAddPlan[] = [];
360+ let customIndex = 0;
361+ for (const service of services) {
362+ if (isGooglePresetServiceConfig(service)) {
363+ plans.push({ kind: "preset", service });
364+ continue;
365+ }
366+
367+ if (service.custom.urls.length === 0) {
368+ const slug = googleCustomFallbackSlug(customIndex);
369+ customIndex += 1;
370+ plans.push({
371+ kind: "invalid-custom",
372+ service,
373+ slug,
374+ presetId: googleCustomResultId(slug),
375+ error: "Custom Google service requires at least one Discovery URL",
376+ });
377+ continue;
378+ }
379+
380+ if (service.custom.urls.length > 1 && hasCustomGoogleServiceOverride(service)) {
381+ const slug = googleServiceEntrySlug(service);
382+ plans.push({
383+ kind: "invalid-custom",
384+ service,
385+ slug,
386+ presetId: googleCustomResultId(slug),
387+ error: "Custom Google service identity overrides require exactly one Discovery URL",
388+ });
389+ continue;
390+ }
391+
392+ for (const url of service.custom.urls) {
393+ const overrideSlug = service.custom.slug?.trim();
394+ const fallbackSlug =
395+ overrideSlug && overrideSlug.length > 0
396+ ? IntegrationSlug.make(overrideSlug)
397+ : googleCustomFallbackSlug(customIndex);
398+ plans.push({
399+ kind: "custom",
400+ service,
401+ url,
402+ fallbackSlug,
403+ });
404+ customIndex += 1;
405+ }
406+ }
407+ return plans;
408+};
409+
261410 const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {
262411 const config = decodeGoogleIntegrationConfig(record.config);
263412 if (!config) return [];
@@ -292,8 +441,9 @@ const makeGooglePluginExtension = ( · 28 unmodified lines
292441 ) => {
293442 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
294443
295− const addGoogleOpenApiIntegration = (input: {
444+ const addGoogleOpenApiIntegrationFromDocuments = (input: {
296445 readonly urls: readonly string[];
446+ readonly documents: readonly GoogleFetchedDiscoveryDocument[];
297447 readonly slug: IntegrationSlug;
298448 readonly name: string;
299449 readonly description: string;
@@ -301,12 +451,11 @@ const makeGooglePluginExtension = ( · 1 unmodified line
301451 readonly consentScopes?: readonly string[];
302452 }) =>
303453 Effect.gen(function* () {
304− const urls = yield* googleBundleUrlsWithIdentity(input.urls);
305− const conversion = yield* fetchGoogleBundleConversion(
306− urls,
307− httpClientLayer,
308− input.consentScopes,
309− );
454+ const consentScopes = input.consentScopes ?? googlePhotosBundleConsentScopes(input.urls);
455+ const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({
456+ documents: input.documents,
457+ ...(consentScopes !== undefined ? { consentScopes } : {}),
458+ });
310459 const compiled = yield* compileOpenApiSpec(conversion.specText);
311460 const slug = input.slug;
312461
@@ -318,7 +467,7 @@ const makeGooglePluginExtension = ( · 5 unmodified lines
318467 const specHash = yield* sha256Hex(conversion.specText);
319468 const integrationConfig: GoogleIntegrationConfig = {
320469 specHash,
321− googleDiscoveryUrls: urls,
470+ googleDiscoveryUrls: input.urls,
322471 ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
323472 ...(conversion.authenticationTemplate
324473 ? { authenticationTemplate: conversion.authenticationTemplate }
@@ -349,7 +498,7 @@ const makeGooglePluginExtension = ( · 24 unmodified lines
349498 // added to every new Google OpenAPI integration. Older integrations
350499 // without oauth2/v2 can still fall back to the People API identity
351500 // operation.
352− const defaultHealthCheck = defaultGoogleHealthCheck(urls, compiled.definitions);
501+ const defaultHealthCheck = defaultGoogleHealthCheck(input.urls, compiled.definitions);
353502 if (defaultHealthCheck) {
354503 yield* ctx.core.integrations.setHealthCheck(slug, defaultHealthCheck);
355504 }
@@ -357,23 +506,26 @@ const makeGooglePluginExtension = ( · 1 unmodified line
357506 return { slug, toolCount: compiled.definitions.length };
358507 });
359508
360− const addOneService = (service: GoogleServiceConfig, baseUrl?: string) =>
509+ const addGoogleOpenApiIntegration = (input: {
510+ readonly urls: readonly string[];
511+ readonly slug: IntegrationSlug;
512+ readonly name: string;
513+ readonly description: string;
514+ readonly baseUrl?: string;
515+ readonly consentScopes?: readonly string[];
516+ }) =>
361517 Effect.gen(function* () {
362− if (!isGooglePresetServiceConfig(service)) {
363− if (service.custom.urls.length === 0) {
364− return yield* new OpenApiParseError({
365− message: "Custom Google service requires at least one Discovery URL",
366− });
367− }
368− return yield* addGoogleOpenApiIntegration({
369− urls: service.custom.urls,
370− slug: googleServiceEntrySlug(service),
371− name: service.custom.name.trim() || "Custom Google APIs",
372− description: service.custom.description ?? "Custom Google APIs.",
373− baseUrl,
374− });
375− }
518+ const urls = yield* googleBundleUrlsWithIdentity(input.urls);
519+ const documents = yield* fetchGoogleDiscoveryDocuments(urls, httpClientLayer);
520+ return yield* addGoogleOpenApiIntegrationFromDocuments({
521+ ...input,
522+ urls,
523+ documents,
524+ });
525+ });
376526
527+ const addOnePresetService = (service: GooglePresetServiceConfig, baseUrl?: string) =>
528+ Effect.gen(function* () {
377529 const preset = googleOpenApiPresetById.get(service.presetId);
378530 if (!preset?.url) {
379531 return yield* new OpenApiParseError({
@@ -391,53 +543,201 @@ const makeGooglePluginExtension = ( · 11 unmodified lines
391543 });
392544 });
393545
394− const addServices = (input: GoogleAddServicesInput) =>
395− Effect.gen(function* () {
396− const outcomes = yield* Effect.forEach(
397− input.services,
398− (service): Effect.Effect<GoogleAddServiceOutcome, never> => {
399− const slug = googleServiceEntrySlug(service);
400− const presetId = googleServiceEntryId(service);
401− return addOneService(service, input.baseUrl).pipe(
402− Effect.map(
403− (result): GoogleAddServiceOutcome => ({
404− added: [
405− {
406− slug: result.slug,
407− presetId,
408− toolCount: result.toolCount,
409− },
410− ],
411− skipped: [],
412− failed: [],
546+ const addCustomServicePlanOutcome = (
547+ plan: Extract<GoogleServiceAddPlan, { kind: "custom" }>,
548+ baseUrl?: string,
549+ ): Effect.Effect<GoogleAddServiceOutcome, never> => {
550+ const fallbackSlug = plan.fallbackSlug;
551+ const fallbackPresetId = googleCustomResultId(fallbackSlug);
552+ const fallbackFailure = (error: string): GoogleAddServiceOutcome =>
553+ googleAddServiceFailure({
554+ slug: fallbackSlug,
555+ presetId: fallbackPresetId,
556+ error,
557+ });
558+
559+ return Effect.gen(function* () {
560+ const urls = yield* googleBundleUrlsWithIdentity([plan.url]);
561+ const documents = yield* fetchGoogleDiscoveryDocuments(urls, httpClientLayer);
562+ const serviceDocument = documents[0];
563+ if (!serviceDocument) {
564+ return yield* new OpenApiParseError({
565+ message: "Custom Google service requires a Discovery document",
566+ });
567+ }
568+ const identity = yield* googleDiscoveryDocumentIdentity({
569+ documentText: serviceDocument.documentText,
570+ });
571+ const overrideSlug = plan.service.custom.slug?.trim();
572+ const overrideName = plan.service.custom.name?.trim();
573+ const displayName =
574+ overrideName && overrideName.length > 0
575+ ? overrideName
576+ : googleDiscoveryDisplayName(identity);
577+ const slug =
578+ overrideSlug && overrideSlug.length > 0
579+ ? IntegrationSlug.make(overrideSlug)
580+ : googleDiscoverySlug(identity.name);
581+ const presetId = googleCustomResultId(slug);
582+ const description =
583+ plan.service.custom.description?.trim() ||
584+ googleDiscoveryDescription(identity, displayName);
585+
586+ return yield* addGoogleOpenApiIntegrationFromDocuments({
587+ urls,
588+ documents,
589+ slug,
590+ name: displayName,
591+ description,
592+ baseUrl,
593+ }).pipe(
594+ Effect.map(
595+ (result): GoogleAddServiceOutcome => ({
596+ added: [
597+ {
598+ slug: result.slug,
599+ presetId,
600+ toolCount: result.toolCount,
601+ },
602+ ],
603+ skipped: [],
604+ failed: [],
605+ }),
606+ ),
607+ Effect.catchTag("IntegrationAlreadyExistsError", (error) => {
608+ const skippedSlug = error.slug;
609+ return Effect.succeed({
610+ added: [],
611+ skipped: [
612+ {
613+ slug: skippedSlug,
614+ presetId: googleCustomResultId(skippedSlug),
615+ reason: "already_exists" as const,
616+ },
617+ ],
618+ failed: [],
619+ });
620+ }),
621+ Effect.catchTags({
622+ OpenApiParseError: (error: OpenApiParseError) =>
623+ Effect.succeed(
624+ googleAddServiceFailure({
625+ slug,
626+ presetId,
627+ error: error.message,
413628 }),
414629 ),
415− Effect.catchTag("IntegrationAlreadyExistsError", () =>
416− Effect.succeed({
417− added: [],
418− skipped: [
419− {
420− slug,
421− presetId,
422− reason: "already_exists" as const,
423− },
424− ],
425− failed: [],
630+ OpenApiExtractionError: (error: OpenApiExtractionError) =>
631+ Effect.succeed(
632+ googleAddServiceFailure({
633+ slug,
634+ presetId,
635+ error: error.message,
426636 }),
427637 ),
428− Effect.catchTags({
429− OpenApiParseError: (error: OpenApiParseError) =>
430− Effect.succeed(googleAddServiceFailure(service, slug, error.message)),
431− OpenApiExtractionError: (error: OpenApiExtractionError) =>
432− Effect.succeed(googleAddServiceFailure(service, slug, error.message)),
638+ }),
639+ );
640+ }).pipe(
641+ Effect.catchTags({
642+ OpenApiParseError: (error: OpenApiParseError) =>
643+ Effect.succeed(fallbackFailure(error.message)),
644+ }),
645+ );
646+ };
647+
648+ const addServicePlanOutcome = (
649+ plan: GoogleServiceAddPlan,
650+ baseUrl?: string,
651+ ): Effect.Effect<GoogleAddServiceOutcome, never> => {
652+ if (plan.kind === "invalid-custom") {
653+ return Effect.succeed(
654+ googleAddServiceFailure({
655+ slug: plan.slug,
656+ presetId: plan.presetId,
657+ error: plan.error,
658+ }),
659+ );
660+ }
661+
662+ if (plan.kind === "custom") {
663+ return addCustomServicePlanOutcome(plan, baseUrl);
664+ }
665+
666+ const fallbackSlug = googleServiceEntrySlug(plan.service);
667+ const fallbackPresetId = googleServiceEntryId(plan.service);
668+ const add = addOnePresetService(plan.service, baseUrl).pipe(
669+ Effect.map((result) => ({
670+ slug: result.slug,
671+ presetId: googleServiceEntryId(plan.service),
672+ toolCount: result.toolCount,
673+ })),
674+ );
675+
676+ return add.pipe(
677+ Effect.map(
678+ (result): GoogleAddServiceOutcome => ({
679+ added: [
680+ {
681+ slug: result.slug,
682+ presetId: result.presetId,
683+ toolCount: result.toolCount,
684+ },
685+ ],
686+ skipped: [],
687+ failed: [],
688+ }),
689+ ),
690+ Effect.catchTag("IntegrationAlreadyExistsError", (error) => {
691+ const skippedSlug = error.slug;
692+ return Effect.succeed({
693+ added: [],
694+ skipped: [
695+ {
696+ slug: skippedSlug,
697+ presetId: fallbackPresetId,
698+ reason: "already_exists" as const,
699+ },
700+ ],
701+ failed: [],
702+ });
703+ }),
704+ Effect.catchTags({
705+ OpenApiParseError: (error: OpenApiParseError) =>
706+ Effect.succeed(
707+ googleAddServiceFailure({
708+ slug: fallbackSlug,
709+ presetId: fallbackPresetId,
710+ error: error.message,
433711 }),
434− Effect.catch(() =>
435− Effect.succeed(
436− googleAddServiceFailure(service, slug, "Failed to add Google service"),
437− ),
438− ),
439− );
440− },
712+ ),
713+ OpenApiExtractionError: (error: OpenApiExtractionError) =>
714+ Effect.succeed(
715+ googleAddServiceFailure({
716+ slug: fallbackSlug,
717+ presetId: fallbackPresetId,
718+ error: error.message,
719+ }),
720+ ),
721+ }),
722+ Effect.catch(() =>
723+ Effect.succeed(
724+ googleAddServiceFailure({
725+ slug: fallbackSlug,
726+ presetId: fallbackPresetId,
727+ error: "Failed to add Google service",
728+ }),
729+ ),
730+ ),
731+ );
732+ };
733+
734+ const addServices = (input: GoogleAddServicesInput) =>
735+ Effect.gen(function* () {
736+ const plans = googleServiceAddPlans(input.services);
737+ const outcomes = yield* Effect.forEach(
738+ plans,
739+ (plan): Effect.Effect<GoogleAddServiceOutcome, never> =>
740+ addServicePlanOutcome(plan, input.baseUrl),
441741 { concurrency: 1 },
442742 );
443743