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
99 await row.getByRole("link", { name: "Open" }).waitFor();99 await row.getByRole("link", { name: "Open" }).waitFor();
100 }100 }
101 101
102 const customRow = page.getByTestId("add-result-row-custom");102 const customRow = page.getByTestId("add-result-row-google_tasks");
103 await customRow.waitFor({ timeout: 120_000 });103 await customRow.waitFor({ timeout: 120_000 });
104 expect(104 expect(
105 await customRow.getAttribute("data-state"),105 await customRow.getAttribute("data-state"),
106 "the custom Discovery URL is added through the services endpoint",106 "the custom Discovery URL is added through the services endpoint",
107 ).toBe("added");107 ).toBe("added");
108 await customRow.getByText("Google Tasks").waitFor();
108 await customRow.getByRole("link", { name: "Open" }).waitFor();109 await customRow.getByRole("link", { name: "Open" }).waitFor();
109 });110 });
110 111
@@ -128,13 +129,14 @@ scenario( · 17 unmodified lines
128 await page.goto("/integrations", { waitUntil: "networkidle" });129 await page.goto("/integrations", { waitUntil: "networkidle" });
129 // Each fanned-out integration is its own list entry, keyed by its slug130 // Each fanned-out integration is its own list entry, keyed by its slug
130 // (the list renders the slug as each entry's description).131 // (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"]) {
132 await page133 await page
133 .getByRole("main")134 .getByRole("main")
134 .getByText(slug, { exact: true })135 .getByText(slug, { exact: true })
135 .first()136 .first()
136 .waitFor({ timeout: 20_000 });137 .waitFor({ timeout: 20_000 });
137 }138 }
139 await page.getByRole("main").getByText("Google Tasks").first().waitFor({ timeout: 20_000 });
138 });140 });
139 141
140 await step("Re-adding an existing product reports it as skipped", async () => {142 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
56 custom: Schema.Struct({56 custom: Schema.Struct({
57 urls: Schema.Array(Schema.String),57 urls: Schema.Array(Schema.String),
58 slug: Schema.optional(Schema.String),58 slug: Schema.optional(Schema.String),
59 name: Schema.String,59 name: Schema.optional(Schema.String),
60 description: Schema.optional(Schema.String),60 description: Schema.optional(Schema.String),
61 }),61 }),
62});62});
packages/plugins/google/src/react/AddGoogleSource.test.ts+33−7
@@ -98,6 +98,11 @@ describe("AddGoogleSource per-service submit", () => { · 97 unmodified lines
98 presetId: "custom",98 presetId: "custom",
99 error: "Custom Discovery fetch failed",99 error: "Custom Discovery fetch failed",
100 },100 },
101 {
102 slug: IntegrationSlug.make("google_tasks"),
103 presetId: "google_tasks",
104 error: "Tasks Discovery fetch failed",
105 },
101 ],106 ],
102 };107 };
103 108
@@ -117,6 +122,8 @@ describe("AddGoogleSource per-service submit", () => { · 13 unmodified lines
117 expect(text).toContain("Discovery fetch failed");122 expect(text).toContain("Discovery fetch failed");
118 expect(text).toContain("Custom Discovery URLs");123 expect(text).toContain("Custom Discovery URLs");
119 expect(text).toContain("Custom Discovery fetch failed");124 expect(text).toContain("Custom Discovery fetch failed");
125 expect(text).toContain("Google Tasks");
126 expect(text).toContain("Tasks Discovery fetch failed");
120 expect(text).toContain("Retry");127 expect(text).toContain("Retry");
121 });128 });
122 129
@@ -181,15 +188,12 @@ describe("AddGoogleSource per-service submit", () => { · 58 unmodified lines
181 });188 });
182 });189 });
183 190
184 it("submits custom Discovery URLs as one service entry", () => {191 it("submits custom Discovery URLs without generic identity overrides", () => {
185 expect(192 expect(
186 googleAddServicesPayload({193 googleAddServicesPayload({
187 presetIds: ["google-calendar"],194 presetIds: ["google-calendar"],
188 custom: {195 custom: {
189 urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"],196 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.",
193 },197 },
194 baseUrl: " https://proxy.example ",198 baseUrl: " https://proxy.example ",
195 }),199 }),
@@ -199,13 +203,35 @@ describe("AddGoogleSource per-service submit", () => { · 3 unmodified lines
199 {203 {
200 custom: {204 custom: {
201 urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"],205 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.",
205 },206 },
206 },207 },
207 ],208 ],
208 baseUrl: "https://proxy.example",209 baseUrl: "https://proxy.example",
209 });210 });
210 });211 });
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 });
211});237});
packages/plugins/google/src/react/AddGoogleSource.tsx+69−60
@@ -13,12 +13,7 @@ import { Button } from "@executor-js/react/components/button"; · 12 unmodified lines
13import { FieldLabel } from "@executor-js/react/components/field";13import { FieldLabel } from "@executor-js/react/components/field";
14import { FloatActions } from "@executor-js/react/components/float-actions";14import { FloatActions } from "@executor-js/react/components/float-actions";
15import { Input } from "@executor-js/react/components/input";15import { Input } from "@executor-js/react/components/input";
16import {16import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add";
17 errorMessageFromExit,
18 FormErrorAlert,
19 SlugCollisionAlert,
20 useSlugAlreadyExists,
21} from "@executor-js/react/lib/integration-add";
22import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";17import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";
23 18
24import { addGoogleServices } from "./atoms";19import { addGoogleServices } from "./atoms";
@@ -52,8 +47,8 @@ export type GoogleServiceIdentityOverride = { · 27 unmodified lines
52 47
53export type GoogleCustomServiceInput = {48export type GoogleCustomServiceInput = {
54 readonly urls: readonly string[];49 readonly urls: readonly string[];
55 readonly slug: string;50 readonly slug?: string;
56 readonly name: string;51 readonly name?: string;
57 readonly description?: string;52 readonly description?: string;
58};53};
59 54
@@ -81,8 +76,8 @@ export const googleAddServicesPayload = (input: { · 21 unmodified lines
81 {76 {
82 custom: {77 custom: {
83 urls: [...input.custom.urls],78 urls: [...input.custom.urls],
84 slug: input.custom.slug,79 ...(input.custom.slug?.trim() ? { slug: input.custom.slug.trim() } : {}),
85 name: input.custom.name,80 ...(input.custom.name?.trim() ? { name: input.custom.name.trim() } : {}),
86 ...(input.custom.description?.trim()81 ...(input.custom.description?.trim()
87 ? { description: input.custom.description.trim() }82 ? { description: input.custom.description.trim() }
88 : {}),83 : {}),
@@ -152,28 +147,73 @@ export const googleAddServicesResultRows = ( · 63 unmodified lines
152 })),147 })),
153];148];
154 149
150const googleAddServicesResultRowId = (row: GoogleServiceResultRow): string =>
151 row.presetId === GOOGLE_CUSTOM_SERVICE_ID ? row.slug : row.presetId;
152
155export const mergeGoogleAddServicesResult = (153export const mergeGoogleAddServicesResult = (
156 previous: GoogleAddServicesResult,154 previous: GoogleAddServicesResult,
157 next: GoogleAddServicesResult,155 next: GoogleAddServicesResult,
158): GoogleAddServicesResult => {156): GoogleAddServicesResult => {
159 const nextPresetIds = new Set(googleAddServicesResultRows(next).map((row) => row.presetId));157 const nextRowIds = new Set(googleAddServicesResultRows(next).map(googleAddServicesResultRowId));
160 return {158 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 ],
162 skipped: [173 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 ),
164 ...next.skipped,185 ...next.skipped,
165 ],186 ],
166 failed: [187 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 ),
168 ...next.failed,199 ...next.failed,
169 ],200 ],
170 };201 };
171};202};
172 203
173const googlePresetName = (presetId: string): string =>204const humanizeGoogleSlug = (slug: string): string =>
174 presetId === GOOGLE_CUSTOM_SERVICE_ID205 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
212const googlePresetName = (row: GoogleServiceResultRow): string =>
213 row.presetId === GOOGLE_CUSTOM_SERVICE_ID || row.slug === "google_custom"
175 ? "Custom Discovery URLs"214 ? "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));
177 217
178export function GoogleServiceResultPanel(props: {218export function GoogleServiceResultPanel(props: {
179 readonly result: GoogleAddServicesResult;219 readonly result: GoogleAddServicesResult;
@@ -196,11 +236,12 @@ export function GoogleServiceResultPanel(props: { · 16 unmodified lines
196 </div>236 </div>
197 <ul className="space-y-2">237 <ul className="space-y-2">
198 {rows.map((row: GoogleServiceResultRow) => {238 {rows.map((row: GoogleServiceResultRow) => {
199 const presetName = googlePresetName(row.presetId);239 const rowId = googleAddServicesResultRowId(row);
240 const presetName = googlePresetName(row);
200 return (241 return (
201 <li242 <li
202 key={`${row.status}:${row.presetId}`}243 key={`${row.status}:${rowId}`}
203 data-testid={`add-result-row-${row.presetId}`}244 data-testid={`add-result-row-${rowId}`}
204 data-state={row.status}245 data-state={row.status}
205 className="flex items-start gap-2 rounded-md border border-border bg-background px-2.5 py-2"246 className="flex items-start gap-2 rounded-md border border-border bg-background px-2.5 py-2"
206 >247 >
@@ -249,9 +290,9 @@ export function GoogleServiceResultPanel(props: { · 42 unmodified lines
249 type="button"290 type="button"
250 variant="outline"291 variant="outline"
251 size="xs"292 size="xs"
252 data-testid={`add-result-retry-${row.presetId}`}293 data-testid={`add-result-retry-${rowId}`}
253 loading={props.retryingPresetId === row.presetId}294 loading={props.retryingPresetId === rowId}
254 onClick={() => void props.onRetry(row.presetId)}295 onClick={() => void props.onRetry(rowId)}
255 >296 >
256 Retry297 Retry
257 </Button>298 </Button>
@@ -302,7 +343,6 @@ export default function AddGoogleSource(props: { · 44 unmodified lines
302 );343 );
303 const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]);344 const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]);
304 const [baseUrl, setBaseUrl] = useState("");345 const [baseUrl, setBaseUrl] = useState("");
305 const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null);
306 const [adding, setAdding] = useState(false);346 const [adding, setAdding] = useState(false);
307 const [retryingPresetId, setRetryingPresetId] = useState<string | null>(null);347 const [retryingPresetId, setRetryingPresetId] = useState<string | null>(null);
308 const [addError, setAddError] = useState<string | null>(null);348 const [addError, setAddError] = useState<string | null>(null);
@@ -357,16 +397,6 @@ export default function AddGoogleSource(props: { · 48 unmodified lines
357 (hasCustomDiscoveryUrls397 (hasCustomDiscoveryUrls
358 ? "Custom Google APIs"398 ? "Custom Google APIs"
359 : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google")));399 : (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 );
370 const identityOverride =400 const identityOverride =
371 selectedIds.length === 1 && !hasCustomDiscoveryUrls401 selectedIds.length === 1 && !hasCustomDiscoveryUrls
372 ? { slug: resolvedSourceId, name: resolvedDisplayName }402 ? { slug: resolvedSourceId, name: resolvedDisplayName }
@@ -375,15 +405,9 @@ export default function AddGoogleSource(props: { · 2 unmodified lines
375 customDiscoveryUrls.length > 0405 customDiscoveryUrls.length > 0
376 ? {406 ? {
377 urls: [...customDiscoveryUrls],407 urls: [...customDiscoveryUrls],
378 slug: resolvedSourceId,
379 name: resolvedDisplayName,
380 description: resolvedDescription,
381 }408 }
382 : undefined;409 : undefined;
383 const canAdd =410 const canAdd = (selectedIds.length > 0 || customDiscoveryUrls.length > 0) && !adding;
384 (selectedIds.length > 0 || customDiscoveryUrls.length > 0) &&
385 !customSlugAlreadyExists &&
386 !adding;
387 411
388 const handleAdd = async () => {412 const handleAdd = async () => {
389 setAdding(true);413 setAdding(true);
@@ -407,7 +431,7 @@ export default function AddGoogleSource(props: { · 17 unmodified lines
407 const handleRetry = async (presetId: string) => {431 const handleRetry = async (presetId: string) => {
408 setRetryingPresetId(presetId);432 setRetryingPresetId(presetId);
409 setAddError(null);433 setAddError(null);
410 const retryingCustom = presetId === GOOGLE_CUSTOM_SERVICE_ID;434 const retryingCustom = !googleOpenApiPresetById.has(presetId);
411 const retryIdentityOverride =435 const retryIdentityOverride =
412 !retryingCustom && identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId436 !retryingCustom && identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId
413 ? identityOverride437 ? identityOverride
@@ -429,19 +453,9 @@ export default function AddGoogleSource(props: { · 15 unmodified lines
429 setRetryingPresetId(null);453 setRetryingPresetId(null);
430 };454 };
431 455
432 const showIdentityDetails = selectedIds.length === 1 || hasCustomDiscoveryUrls;456 const showIdentityDetails = selectedIds.length === 1 && !hasCustomDiscoveryUrls;
433 const detailTitle = hasCustomDiscoveryUrls457 const detailTitle = singleSelectedPreset?.name ?? "Google";
434 ? "Custom Google Discovery URLs"458 const detailSubtitle = "This product is added as its own integration.";
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.";
445 459
446 const dismiss = () => {460 const dismiss = () => {
447 if (servicesResult) {461 if (servicesResult) {
@@ -473,9 +487,6 @@ export default function AddGoogleSource(props: { · 25 unmodified lines
473 title={detailTitle}487 title={detailTitle}
474 subtitle={detailSubtitle}488 subtitle={detailSubtitle}
475 identity={identity}489 identity={identity}
476 {...(hasCustomDiscoveryUrls
477 ? { description: resolvedDescription, onDescriptionChange: setDescriptionDraft }
478 : {})}
479 baseUrl={baseUrl}490 baseUrl={baseUrl}
480 onBaseUrlChange={setBaseUrl}491 onBaseUrlChange={setBaseUrl}
481 baseUrlLabel="Base URL override (optional)"492 baseUrlLabel="Base URL override (optional)"
@@ -486,8 +497,6 @@ export default function AddGoogleSource(props: { · 4 unmodified lines
486 <BaseUrlSettings baseUrl={baseUrl} onBaseUrlChange={setBaseUrl} />497 <BaseUrlSettings baseUrl={baseUrl} onBaseUrlChange={setBaseUrl} />
487 )}498 )}
488 499
489 {customSlugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />}
490
491 {addError && <FormErrorAlert message={addError} />}500 {addError && <FormErrorAlert message={addError} />}
492 501
493 {servicesResult && (502 {servicesResult && (
packages/plugins/google/src/sdk/discovery.ts+47−31
@@ -241,6 +241,7 @@ const DiscoveryDocument = Schema.Struct({ · 240 unmodified lines
241 name: TextOption,241 name: TextOption,
242 version: TextOption,242 version: TextOption,
243 title: TextOption,243 title: TextOption,
244 description: TextOption,
244 rootUrl: TextOption,245 rootUrl: TextOption,
245 servicePath: Schema.optional(Schema.Trim).pipe(246 servicePath: Schema.optional(Schema.Trim).pipe(
246 Schema.withDecodingDefaultType(Effect.succeed("")),247 Schema.withDecodingDefaultType(Effect.succeed("")),
@@ -268,6 +269,12 @@ const DiscoveryDocument = Schema.Struct({ · 21 unmodified lines
268});269});
269type DiscoveryDocument = typeof DiscoveryDocument.Type;270type DiscoveryDocument = typeof DiscoveryDocument.Type;
270 271
272export interface GoogleDiscoveryDocumentIdentity {
273 readonly name: string;
274 readonly title?: string;
275 readonly description?: string;
276}
277
271export interface GoogleDiscoveryOpenApiConversion {278export interface GoogleDiscoveryOpenApiConversion {
272 readonly specText: string;279 readonly specText: string;
273 readonly baseUrl: string;280 readonly baseUrl: string;
@@ -286,6 +293,25 @@ const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod); · 12 unmodified lines
286const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource);293const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource);
287const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));294const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
288 295
296const 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
289const DISCOVERY_SERVICE_PATH_RE =315const DISCOVERY_SERVICE_PATH_RE =
290 /^\/discovery\/v1\/apis\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/rest\/?$/;316 /^\/discovery\/v1\/apis\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/rest\/?$/;
291const DISCOVERY_VERSION_RE = /^[A-Za-z0-9._-]+$/;317const DISCOVERY_VERSION_RE = /^[A-Za-z0-9._-]+$/;
@@ -389,6 +415,25 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov · 97 unmodified lines
389 },415 },
390);416);
391 417
418export 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
392const schemaRef = (name: string) => `#/components/schemas/${name}`;437const schemaRef = (name: string) => `#/components/schemas/${name}`;
393 438
394const identitySchemaName = (name: string): string => name;439const identitySchemaName = (name: string): string => name;
@@ -946,22 +991,7 @@ const hasOperation = ( · 551 unmodified lines
946 991
947export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleDiscovery")(992export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleDiscovery")(
948 function* (input: { readonly discoveryUrl: string; readonly documentText: string }) {993 function* (input: { readonly discoveryUrl: string; readonly documentText: string }) {
949 const parsed = yield* parseJson(input.documentText).pipe(994 const document = yield* parseDiscoveryDocument(input.documentText);
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
965 const info = yield* discoveryDocumentInfo(document, input.discoveryUrl);995 const info = yield* discoveryDocumentInfo(document, input.discoveryUrl);
966 const { service, version, rootUrl, baseUrl, title } = info;996 const { service, version, rootUrl, baseUrl, title } = info;
967 const paths: Record<string, Record<string, OpenApiOperationObject>> = {};997 const paths: Record<string, Record<string, OpenApiOperationObject>> = {};
@@ -1095,21 +1125,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 127 unmodified lines
1095 1125
1096 const infos = yield* Effect.forEach(input.documents, ({ discoveryUrl, documentText }) =>1126 const infos = yield* Effect.forEach(input.documents, ({ discoveryUrl, documentText }) =>
1097 Effect.gen(function* () {1127 Effect.gen(function* () {
1098 const parsed = yield* parseJson(documentText).pipe(1128 const document = yield* parseDiscoveryDocument(documentText);
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 });
1113 return yield* discoveryDocumentInfo(document, discoveryUrl);1129 return yield* discoveryDocumentInfo(document, discoveryUrl);
1114 }),1130 }),
1115 );1131 );
packages/plugins/google/src/sdk/plugin.test.ts+319−267
@@ -1,14 +1,9 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Google bundle add flow, "customize your Google connection".2// Google per-service add flow, "customize your Google connection".
3//3//
4// The product picker emits a URL list. The server fetches each Discovery4// The product picker emits preset ids and custom Discovery URLs. The server
5// document, merges them into ONE `google`5// fans them out into separate integrations, deriving custom identity from the
6// integration spec, and stores the unioned `googleOAuth2` auth template. These6// fetched Discovery document before it compiles the OpenAPI surface.
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.
12// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
13 8
14import { describe, expect, it } from "@effect/vitest";9import { describe, expect, it } from "@effect/vitest";
@@ -24,7 +19,6 @@ import { · 9 unmodified lines
24import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";19import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
25 20
26import { defaultGoogleHealthCheck, googlePlugin } from "./plugin";21import { defaultGoogleHealthCheck, googlePlugin } from "./plugin";
27import { googleOpenApiPresets, type GoogleOpenApiPreset } from "./presets";
28 22
29// --- Canned Discovery documents -------------------------------------------23// --- Canned Discovery documents -------------------------------------------
30// Each carries one method. Calendar and Gmail BOTH expose a generic `list`24// 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
37const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest";31const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest";
38const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest";32const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest";
39const DOCS_URL = "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest";33const DOCS_URL = "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest";
34const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest";
40const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest";35const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest";
41const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1";36const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1";
42const PEOPLE_URL = "https://www.googleapis.com/discovery/v1/apis/people/v1/rest";37const PEOPLE_URL = "https://www.googleapis.com/discovery/v1/apis/people/v1/rest";
@@ -219,6 +214,40 @@ const docsDoc = { · 176 unmodified lines
219 },214 },
220};215};
221 216
217const 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
222const photosLibraryDoc = {251const photosLibraryDoc = {
223 name: "photoslibrary",252 name: "photoslibrary",
224 version: "v1",253 version: "v1",
@@ -425,6 +454,7 @@ const DISCOVERY_BODIES: Readonly<Record<string, string>> = { · 200 unmodified lines
425 [SHEETS_URL]: toJson(sheetsDoc),454 [SHEETS_URL]: toJson(sheetsDoc),
426 [DRIVE_URL]: toJson(driveDoc),455 [DRIVE_URL]: toJson(driveDoc),
427 [DOCS_URL]: toJson(docsDoc),456 [DOCS_URL]: toJson(docsDoc),
457 [TASKS_URL]: toJson(tasksDoc),
428 [PHOTOS_LIBRARY_URL]: toJson(photosLibraryDoc),458 [PHOTOS_LIBRARY_URL]: toJson(photosLibraryDoc),
429 [PHOTOS_PICKER_URL]: toJson(photosPickerDoc),459 [PHOTOS_PICKER_URL]: toJson(photosPickerDoc),
430 [PEOPLE_URL]: toJson(peopleDoc),460 [PEOPLE_URL]: toJson(peopleDoc),
@@ -499,6 +529,240 @@ describe("Google custom service add flow", () => { · 68 unmodified lines
499 expect(presetIds).toContain("google-photos");529 expect(presetIds).toContain("google-photos");
500 });530 });
501 531
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
502 it.effect("rejects lookalike Discovery hosts before fetching custom service documents", () =>766 it.effect("rejects lookalike Discovery hosts before fetching custom service documents", () =>
503 Effect.scoped(767 Effect.scoped(
504 Effect.gen(function* () {768 Effect.gen(function* () {
@@ -541,7 +805,7 @@ describe("Google custom service add flow", () => { · 36 unmodified lines
541 failed: [805 failed: [
542 {806 {
543 slug: IntegrationSlug.make("bad_google"),807 slug: IntegrationSlug.make("bad_google"),
544 presetId: "custom",808 presetId: "bad_google",
545 error:809 error:
546 "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint",810 "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint",
547 },811 },
@@ -552,239 +816,47 @@ describe("Google custom service add flow", () => { · 4 unmodified lines
552 ),816 ),
553 );817 );
554 818
555 it.effect(819 it.effect("custom Google Photos Library uses the focused Photos scopes and upload tool", () =>
556 "addServices custom entry merges calendar+gmail+drive into one requested integration with no tool-name collisions",820 Effect.scoped(
557 () =>821 Effect.gen(function* () {
558 Effect.scoped(822 const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
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() }));
746 823
747 yield* executor.google.addServices({824 yield* executor.google.addServices({
748 services: [825 services: [
749 {826 {
750 custom: {827 custom: {
751 urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL],828 urls: [PHOTOS_LIBRARY_URL],
752 slug: "google_photos_library_calendar",
753 name: "Google Photos Library and Calendar",
754 },
755 },829 },
756 ],830 },
757 });831 ],
832 });
758 833
759 const config = yield* executor.google.getConfig("google_photos_library_calendar");834 const config = yield* executor.google.getConfig("google_photoslibrary");
760 const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");835 const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
761 expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(836 expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
762 [837 [
763 "email",838 "email",
764 "https://www.googleapis.com/auth/calendar",839 "https://www.googleapis.com/auth/photoslibrary.appendonly",
765 "https://www.googleapis.com/auth/photoslibrary.appendonly",840 "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
766 "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",841 "openid",
767 "openid",842 "profile",
768 "profile",843 ].sort(),
769 ].sort(),844 );
770 );
771 845
772 yield* executor.connections.create({846 yield* executor.connections.create({
773 owner: "org",847 owner: "org",
774 name: ConnectionName.make("main"),848 name: ConnectionName.make("main"),
775 integration: IntegrationSlug.make("google_photos_library_calendar"),849 integration: IntegrationSlug.make("google_photoslibrary"),
776 template: AuthTemplateSlug.make("googleOAuth2"),850 template: AuthTemplateSlug.make("googleOAuth2"),
777 value: "token-xyz",851 value: "token-xyz",
778 });852 });
779 853
780 const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));854 const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
781 expect(toolNames).toContain("calendar.events.list");855 expect(toolNames).toContain("photoslibrary.mediaItems.upload");
782 expect(toolNames).toContain("photoslibrary.mediaItems.upload");856 expect(toolNames).toContain("photoslibrary.mediaItems.search");
783 expect(toolNames).toContain("photoslibrary.mediaItems.search");857 expect(toolNames).not.toContain("photoslibrary.albums.list");
784 expect(toolNames).not.toContain("photospicker.mediaItems.list");858 }),
785 expect(toolNames).not.toContain("photoslibrary.albums.list");859 ),
786 }),
787 ),
788 );860 );
789});861});
790 862
@@ -1021,31 +1093,23 @@ describe("Google per-service add flow", () => { · 230 unmodified lines
1021});1093});
1022 1094
1023describe("Google health-check default", () => {1095describe("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", () =>
1025 Effect.scoped(1097 Effect.scoped(
1026 Effect.gen(function* () {1098 Effect.gen(function* () {
1027 const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));1099 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]);
1033 1100
1034 yield* executor.google.addServices({1101 yield* executor.google.addServices({
1035 services: [1102 services: [
1036 {1103 {
1037 custom: {1104 custom: {
1038 urls: defaultUrls,1105 urls: [TASKS_URL],
1039 slug: "google_default",
1040 name: "Google",
1041 description: "Google",
1042 },1106 },
1043 },1107 },
1044 ],1108 ],
1045 });1109 });
1046 1110
1047 const stored = yield* executor.integrations.healthCheck.get(1111 const stored = yield* executor.integrations.healthCheck.get(
1048 IntegrationSlug.make("google_default"),1112 IntegrationSlug.make("google_tasks"),
1049 );1113 );
1050 expect(stored?.operation, "the default check targets OAuth2 userinfo").toBe(1114 expect(stored?.operation, "the default check targets OAuth2 userinfo").toBe(
1051 "oauth2.userinfo.get",1115 "oauth2.userinfo.get",
@@ -1053,24 +1117,15 @@ describe("Google health-check default", () => { · 1 unmodified line
1053 expect(stored?.args, "userinfo needs no args").toBeUndefined();1117 expect(stored?.args, "userinfo needs no args").toBeUndefined();
1054 expect(stored?.identityField, "the default reads the account email").toBe("email");1118 expect(stored?.identityField, "the default reads the account email").toBe("email");
1055 1119
1056 const config = yield* executor.google.getConfig("google_default");1120 const config = yield* executor.google.getConfig("google_tasks");
1057 expect(config?.googleDiscoveryUrls).toEqual([...defaultUrls, OAUTH2_URL]);1121 expect(config?.googleDiscoveryUrls).toEqual([TASKS_URL, OAUTH2_URL]);
1058 const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");1122 const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
1059 expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(1123 expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
1060 [1124 ["email", "https://www.googleapis.com/auth/tasks", "openid", "profile"].sort(),
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(),
1070 );1125 );
1071 1126
1072 const candidates = yield* executor.integrations.healthCheck.candidates(1127 const candidates = yield* executor.integrations.healthCheck.candidates(
1073 IntegrationSlug.make("google_default"),1128 IntegrationSlug.make("google_tasks"),
1074 );1129 );
1075 const userinfo = candidates.find((c) => c.operation === "oauth2.userinfo.get");1130 const userinfo = candidates.find((c) => c.operation === "oauth2.userinfo.get");
1076 expect(userinfo, "OAuth2 userinfo is a ranked candidate").toBeDefined();1131 expect(userinfo, "OAuth2 userinfo is a ranked candidate").toBeDefined();
@@ -1091,10 +1146,7 @@ describe("Google health-check default", () => { · 14 unmodified lines
1091 services: [1146 services: [
1092 {1147 {
1093 custom: {1148 custom: {
1094 urls: [PEOPLE_URL, CALENDAR_URL],1149 urls: [PEOPLE_URL],
1095 slug: "google_people",
1096 name: "Google",
1097 description: "Google",
1098 },1150 },
1099 },1151 },
1100 ],1152 ],
packages/plugins/google/src/sdk/plugin.ts+378−78
@@ -38,7 +38,9 @@ import { · 37 unmodified lines
38import {38import {
39 convertGoogleDiscoveryBundleToOpenApi,39 convertGoogleDiscoveryBundleToOpenApi,
40 fetchGoogleDiscoveryDocument,40 fetchGoogleDiscoveryDocument,
41 googleDiscoveryDocumentIdentity,
41 normalizeGoogleDiscoveryUrl,42 normalizeGoogleDiscoveryUrl,
43 type GoogleDiscoveryDocumentIdentity,
42} from "./discovery";44} from "./discovery";
43import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";45import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";
44import {46import {
@@ -103,9 +105,15 @@ export interface GooglePresetServiceConfig { · 58 unmodified lines
103 105
104export interface GoogleCustomServiceConfig {106export interface GoogleCustomServiceConfig {
105 readonly custom: {107 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 */
106 readonly urls: readonly string[];114 readonly urls: readonly string[];
107 readonly slug?: string;115 readonly slug?: string;
108 readonly name: string;116 readonly name?: string;
109 readonly description?: string;117 readonly description?: string;
110 };118 };
111}119}
@@ -162,6 +170,8 @@ export interface GooglePluginOptions { · 50 unmodified lines
162}170}
163 171
164const DEFAULT_GOOGLE_SLUG = "google";172const DEFAULT_GOOGLE_SLUG = "google";
173const DEFAULT_GOOGLE_CUSTOM_SLUG = "google_custom";
174const MAX_GOOGLE_CUSTOM_DESCRIPTION_LENGTH = 500;
165 175
166const googleOpenApiPresetById: ReadonlyMap<string, GoogleOpenApiPreset> = new Map(176const googleOpenApiPresetById: ReadonlyMap<string, GoogleOpenApiPreset> = new Map(
167 googleOpenApiPresets.map((preset) => [preset.id, preset]),177 googleOpenApiPresets.map((preset) => [preset.id, preset]),
@@ -177,7 +187,7 @@ const googleServiceEntryId = (service: GoogleServiceConfig): string => · 9 unmodified lines
177const googleServiceEntrySlug = (service: GoogleServiceConfig): IntegrationSlug =>187const googleServiceEntrySlug = (service: GoogleServiceConfig): IntegrationSlug =>
178 isGooglePresetServiceConfig(service)188 isGooglePresetServiceConfig(service)
179 ? IntegrationSlug.make(service.slug?.trim() || googleServiceSlug(service.presetId))189 ? 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);
181 191
182type GoogleAddServiceOutcome = {192type GoogleAddServiceOutcome = {
183 readonly added: readonly GoogleAddServicesAdded[];193 readonly added: readonly GoogleAddServicesAdded[];
@@ -185,16 +195,21 @@ type GoogleAddServiceOutcome = { · 1 unmodified line
185 readonly failed: readonly GoogleAddServicesFailed[];195 readonly failed: readonly GoogleAddServicesFailed[];
186};196};
187 197
188const googleAddServiceFailure = (198const googleAddServiceFailure = (input: {
189 service: GoogleServiceConfig,199 readonly slug: IntegrationSlug;
190 slug: IntegrationSlug,200 readonly presetId: string;
191 error: string,201 readonly error: string;
192): GoogleAddServiceOutcome => ({202}): GoogleAddServiceOutcome => ({
193 added: [],203 added: [],
194 skipped: [],204 skipped: [],
195 failed: [{ slug, presetId: googleServiceEntryId(service), error }],205 failed: [{ slug: input.slug, presetId: input.presetId, error: input.error }],
196});206});
197 207
208type GoogleFetchedDiscoveryDocument = {
209 readonly discoveryUrl: string;
210 readonly documentText: string;
211};
212
198const googlePhotosBundlePresetIdByUrl = new Map(213const googlePhotosBundlePresetIdByUrl = new Map(
199 googlePhotosOpenApiPresets.flatMap((preset) =>214 googlePhotosOpenApiPresets.flatMap((preset) =>
200 preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [],215 preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [],
@@ -213,20 +228,28 @@ const googlePhotosBundleConsentScopes = ( · 12 unmodified lines
213 : undefined;228 : undefined;
214};229};
215 230
216const fetchGoogleBundleConversion = (231const fetchGoogleDiscoveryDocuments = (
217 urls: readonly string[],232 urls: readonly string[],
218 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,233 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
219 consentScopesOverride?: readonly string[],
220) =>234) =>
221 Effect.forEach(235 Effect.forEach(
222 urls,236 urls,
223 (url) =>237 (url) =>
224 fetchGoogleDiscoveryDocument(url).pipe(238 fetchGoogleDiscoveryDocument(url).pipe(
225 Effect.provide(httpClientLayer),239 Effect.provide(httpClientLayer),
226 Effect.map((documentText) => ({ discoveryUrl: url, documentText })),240 Effect.map(
241 (documentText): GoogleFetchedDiscoveryDocument => ({ discoveryUrl: url, documentText }),
242 ),
227 ),243 ),
228 { concurrency: 4 },244 { concurrency: 4 },
229 ).pipe(245 );
246
247const fetchGoogleBundleConversion = (
248 urls: readonly string[],
249 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
250 consentScopesOverride?: readonly string[],
251) =>
252 fetchGoogleDiscoveryDocuments(urls, httpClientLayer).pipe(
230 Effect.flatMap((documents) => {253 Effect.flatMap((documents) => {
231 const consentScopes = consentScopesOverride ?? googlePhotosBundleConsentScopes(urls);254 const consentScopes = consentScopesOverride ?? googlePhotosBundleConsentScopes(urls);
232 return convertGoogleDiscoveryBundleToOpenApi({255 return convertGoogleDiscoveryBundleToOpenApi({
@@ -258,6 +281,132 @@ const googleBundleUrlsWithIdentity = ( · 25 unmodified lines
258 return uniqueUrls([...normalized, GOOGLE_OAUTH2_DISCOVERY_URL]);281 return uniqueUrls([...normalized, GOOGLE_OAUTH2_DISCOVERY_URL]);
259 });282 });
260 283
284type 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
303const hasCustomGoogleServiceOverride = (service: GoogleCustomServiceConfig): boolean =>
304 Boolean(
305 service.custom.slug?.trim() ||
306 service.custom.name?.trim() ||
307 service.custom.description?.trim(),
308 );
309
310const googleCustomFallbackSlug = (index: number): IntegrationSlug =>
311 IntegrationSlug.make(
312 index === 0 ? DEFAULT_GOOGLE_CUSTOM_SLUG : `${DEFAULT_GOOGLE_CUSTOM_SLUG}_${index + 1}`,
313 );
314
315const googleCustomResultId = (slug: IntegrationSlug): string => String(slug);
316
317const googleDiscoveryNameSlugPart = (name: string): string =>
318 name
319 .trim()
320 .toLowerCase()
321 .replace(/[^a-z0-9_-]+/g, "_")
322 .replace(/^_+|_+$/g, "");
323
324const googleDiscoverySlug = (name: string): IntegrationSlug =>
325 IntegrationSlug.make(
326 googleServiceSlug(`google-${googleDiscoveryNameSlugPart(name) || "custom"}`),
327 );
328
329const 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
343const 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
356const 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
261const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {410const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {
262 const config = decodeGoogleIntegrationConfig(record.config);411 const config = decodeGoogleIntegrationConfig(record.config);
263 if (!config) return [];412 if (!config) return [];
@@ -292,8 +441,9 @@ const makeGooglePluginExtension = ( · 28 unmodified lines
292) => {441) => {
293 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;442 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
294 443
295 const addGoogleOpenApiIntegration = (input: {444 const addGoogleOpenApiIntegrationFromDocuments = (input: {
296 readonly urls: readonly string[];445 readonly urls: readonly string[];
446 readonly documents: readonly GoogleFetchedDiscoveryDocument[];
297 readonly slug: IntegrationSlug;447 readonly slug: IntegrationSlug;
298 readonly name: string;448 readonly name: string;
299 readonly description: string;449 readonly description: string;
@@ -301,12 +451,11 @@ const makeGooglePluginExtension = ( · 1 unmodified line
301 readonly consentScopes?: readonly string[];451 readonly consentScopes?: readonly string[];
302 }) =>452 }) =>
303 Effect.gen(function* () {453 Effect.gen(function* () {
304 const urls = yield* googleBundleUrlsWithIdentity(input.urls);454 const consentScopes = input.consentScopes ?? googlePhotosBundleConsentScopes(input.urls);
305 const conversion = yield* fetchGoogleBundleConversion(455 const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({
306 urls,456 documents: input.documents,
307 httpClientLayer,457 ...(consentScopes !== undefined ? { consentScopes } : {}),
308 input.consentScopes,458 });
309 );
310 const compiled = yield* compileOpenApiSpec(conversion.specText);459 const compiled = yield* compileOpenApiSpec(conversion.specText);
311 const slug = input.slug;460 const slug = input.slug;
312 461
@@ -318,7 +467,7 @@ const makeGooglePluginExtension = ( · 5 unmodified lines
318 const specHash = yield* sha256Hex(conversion.specText);467 const specHash = yield* sha256Hex(conversion.specText);
319 const integrationConfig: GoogleIntegrationConfig = {468 const integrationConfig: GoogleIntegrationConfig = {
320 specHash,469 specHash,
321 googleDiscoveryUrls: urls,470 googleDiscoveryUrls: input.urls,
322 ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),471 ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
323 ...(conversion.authenticationTemplate472 ...(conversion.authenticationTemplate
324 ? { authenticationTemplate: conversion.authenticationTemplate }473 ? { authenticationTemplate: conversion.authenticationTemplate }
@@ -349,7 +498,7 @@ const makeGooglePluginExtension = ( · 24 unmodified lines
349 // added to every new Google OpenAPI integration. Older integrations498 // added to every new Google OpenAPI integration. Older integrations
350 // without oauth2/v2 can still fall back to the People API identity499 // without oauth2/v2 can still fall back to the People API identity
351 // operation.500 // operation.
352 const defaultHealthCheck = defaultGoogleHealthCheck(urls, compiled.definitions);501 const defaultHealthCheck = defaultGoogleHealthCheck(input.urls, compiled.definitions);
353 if (defaultHealthCheck) {502 if (defaultHealthCheck) {
354 yield* ctx.core.integrations.setHealthCheck(slug, defaultHealthCheck);503 yield* ctx.core.integrations.setHealthCheck(slug, defaultHealthCheck);
355 }504 }
@@ -357,23 +506,26 @@ const makeGooglePluginExtension = ( · 1 unmodified line
357 return { slug, toolCount: compiled.definitions.length };506 return { slug, toolCount: compiled.definitions.length };
358 });507 });
359 508
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 }) =>
361 Effect.gen(function* () {517 Effect.gen(function* () {
362 if (!isGooglePresetServiceConfig(service)) {518 const urls = yield* googleBundleUrlsWithIdentity(input.urls);
363 if (service.custom.urls.length === 0) {519 const documents = yield* fetchGoogleDiscoveryDocuments(urls, httpClientLayer);
364 return yield* new OpenApiParseError({520 return yield* addGoogleOpenApiIntegrationFromDocuments({
365 message: "Custom Google service requires at least one Discovery URL",521 ...input,
366 });522 urls,
367 }523 documents,
368 return yield* addGoogleOpenApiIntegration({524 });
369 urls: service.custom.urls,525 });
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 }
376 526
527 const addOnePresetService = (service: GooglePresetServiceConfig, baseUrl?: string) =>
528 Effect.gen(function* () {
377 const preset = googleOpenApiPresetById.get(service.presetId);529 const preset = googleOpenApiPresetById.get(service.presetId);
378 if (!preset?.url) {530 if (!preset?.url) {
379 return yield* new OpenApiParseError({531 return yield* new OpenApiParseError({
@@ -391,53 +543,201 @@ const makeGooglePluginExtension = ( · 11 unmodified lines
391 });543 });
392 });544 });
393 545
394 const addServices = (input: GoogleAddServicesInput) =>546 const addCustomServicePlanOutcome = (
395 Effect.gen(function* () {547 plan: Extract<GoogleServiceAddPlan, { kind: "custom" }>,
396 const outcomes = yield* Effect.forEach(548 baseUrl?: string,
397 input.services,549 ): Effect.Effect<GoogleAddServiceOutcome, never> => {
398 (service): Effect.Effect<GoogleAddServiceOutcome, never> => {550 const fallbackSlug = plan.fallbackSlug;
399 const slug = googleServiceEntrySlug(service);551 const fallbackPresetId = googleCustomResultId(fallbackSlug);
400 const presetId = googleServiceEntryId(service);552 const fallbackFailure = (error: string): GoogleAddServiceOutcome =>
401 return addOneService(service, input.baseUrl).pipe(553 googleAddServiceFailure({
402 Effect.map(554 slug: fallbackSlug,
403 (result): GoogleAddServiceOutcome => ({555 presetId: fallbackPresetId,
404 added: [556 error,
405 {557 });
406 slug: result.slug,558
407 presetId,559 return Effect.gen(function* () {
408 toolCount: result.toolCount,560 const urls = yield* googleBundleUrlsWithIdentity([plan.url]);
409 },561 const documents = yield* fetchGoogleDiscoveryDocuments(urls, httpClientLayer);
410 ],562 const serviceDocument = documents[0];
411 skipped: [],563 if (!serviceDocument) {
412 failed: [],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,
413 }),628 }),
414 ),629 ),
415 Effect.catchTag("IntegrationAlreadyExistsError", () =>630 OpenApiExtractionError: (error: OpenApiExtractionError) =>
416 Effect.succeed({631 Effect.succeed(
417 added: [],632 googleAddServiceFailure({
418 skipped: [633 slug,
419 {634 presetId,
420 slug,635 error: error.message,
421 presetId,
422 reason: "already_exists" as const,
423 },
424 ],
425 failed: [],
426 }),636 }),
427 ),637 ),
428 Effect.catchTags({638 }),
429 OpenApiParseError: (error: OpenApiParseError) =>639 );
430 Effect.succeed(googleAddServiceFailure(service, slug, error.message)),640 }).pipe(
431 OpenApiExtractionError: (error: OpenApiExtractionError) =>641 Effect.catchTags({
432 Effect.succeed(googleAddServiceFailure(service, slug, error.message)),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,
433 }),711 }),
434 Effect.catch(() =>712 ),
435 Effect.succeed(713 OpenApiExtractionError: (error: OpenApiExtractionError) =>
436 googleAddServiceFailure(service, slug, "Failed to add Google service"),714 Effect.succeed(
437 ),715 googleAddServiceFailure({
438 ),716 slug: fallbackSlug,
439 );717 presetId: fallbackPresetId,
440 },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),
441 { concurrency: 1 },741 { concurrency: 1 },
442 );742 );
443 743