From 1d3e6c05b635831bbe97b1348a812b939c5a2872 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:37:54 -0700 Subject: [PATCH 1/5] Unify Google adds on services --- packages/plugins/google/src/api/group.ts | 29 +- packages/plugins/google/src/api/handlers.ts | 14 - .../google/src/react/AddGoogleSource.test.ts | 35 ++ .../google/src/react/AddGoogleSource.tsx | 168 ++++----- packages/plugins/google/src/react/atoms.ts | 2 - packages/plugins/google/src/react/index.ts | 1 - packages/plugins/google/src/sdk/index.ts | 4 +- .../plugins/google/src/sdk/plugin.test.ts | 354 +++++++++++------- packages/plugins/google/src/sdk/plugin.ts | 78 ++-- packages/plugins/google/src/sdk/presets.ts | 4 +- 10 files changed, 385 insertions(+), 304 deletions(-) diff --git a/packages/plugins/google/src/api/group.ts b/packages/plugins/google/src/api/group.ts index e01f98ace..ea0a69aa8 100644 --- a/packages/plugins/google/src/api/group.ts +++ b/packages/plugins/google/src/api/group.ts @@ -46,24 +46,22 @@ const OAuthTemplatePayload = Schema.Struct({ const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]); const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]); -const AddBundlePayload = Schema.Struct({ - urls: Schema.Array(Schema.String), +const AddPresetServicePayload = Schema.Struct({ + presetId: Schema.String, slug: Schema.optional(Schema.String), name: Schema.optional(Schema.String), - description: Schema.optional(Schema.String), - baseUrl: Schema.optional(Schema.String), }); -const AddBundleResponse = Schema.Struct({ - slug: IntegrationSlug, - toolCount: Schema.Number, +const AddCustomServicePayload = Schema.Struct({ + custom: Schema.Struct({ + urls: Schema.Array(Schema.String), + slug: Schema.optional(Schema.String), + name: Schema.String, + description: Schema.optional(Schema.String), + }), }); -const AddServicePayload = Schema.Struct({ - presetId: Schema.String, - slug: Schema.optional(Schema.String), - name: Schema.optional(Schema.String), -}); +const AddServicePayload = Schema.Union([AddPresetServicePayload, AddCustomServicePayload]); const AddServicesPayload = Schema.Struct({ services: Schema.Array(AddServicePayload), @@ -131,13 +129,6 @@ const ConfigureResponse = Schema.Struct({ }); export const GoogleGroup = HttpApiGroup.make("google") - .add( - HttpApiEndpoint.post("addBundle", "/google/bundles", { - payload: AddBundlePayload, - success: AddBundleResponse, - error: DomainErrors, - }), - ) .add( HttpApiEndpoint.post("addServices", "/google/services", { payload: AddServicesPayload, diff --git a/packages/plugins/google/src/api/handlers.ts b/packages/plugins/google/src/api/handlers.ts index 8dc071f8b..f352e14dd 100644 --- a/packages/plugins/google/src/api/handlers.ts +++ b/packages/plugins/google/src/api/handlers.ts @@ -14,20 +14,6 @@ const ExecutorApiWithGoogle = addGroup(GoogleGroup); export const GoogleHandlers = HttpApiBuilder.group(ExecutorApiWithGoogle, "google", (handlers) => handlers - .handle("addBundle", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* GoogleExtensionService; - return yield* ext.addBundle({ - urls: payload.urls, - slug: payload.slug, - name: payload.name, - description: payload.description, - baseUrl: payload.baseUrl, - }); - }), - ), - ) .handle("addServices", ({ payload }) => capture( Effect.gen(function* () { diff --git a/packages/plugins/google/src/react/AddGoogleSource.test.ts b/packages/plugins/google/src/react/AddGoogleSource.test.ts index cb022190d..ce59e3bae 100644 --- a/packages/plugins/google/src/react/AddGoogleSource.test.ts +++ b/packages/plugins/google/src/react/AddGoogleSource.test.ts @@ -93,6 +93,11 @@ describe("AddGoogleSource per-service submit", () => { presetId: "google-drive", error: "Discovery fetch failed", }, + { + slug: IntegrationSlug.make("google_custom"), + presetId: "custom", + error: "Custom Discovery fetch failed", + }, ], }; @@ -110,6 +115,8 @@ describe("AddGoogleSource per-service submit", () => { expect(text).toContain("Already exists"); expect(text).toContain("Google Drive"); expect(text).toContain("Discovery fetch failed"); + expect(text).toContain("Custom Discovery URLs"); + expect(text).toContain("Custom Discovery fetch failed"); expect(text).toContain("Retry"); }); @@ -173,4 +180,32 @@ describe("AddGoogleSource per-service submit", () => { ], }); }); + + it("submits custom Discovery URLs as one service entry", () => { + expect( + googleAddServicesPayload({ + presetIds: ["google-calendar"], + custom: { + urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"], + slug: "google_custom", + name: "Custom Google APIs", + description: "Custom Google APIs.", + }, + baseUrl: " https://proxy.example ", + }), + ).toEqual({ + services: [ + { presetId: "google-calendar" }, + { + custom: { + urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"], + slug: "google_custom", + name: "Custom Google APIs", + description: "Custom Google APIs.", + }, + }, + ], + baseUrl: "https://proxy.example", + }); + }); }); diff --git a/packages/plugins/google/src/react/AddGoogleSource.tsx b/packages/plugins/google/src/react/AddGoogleSource.tsx index a410d50df..a9a0d14a7 100644 --- a/packages/plugins/google/src/react/AddGoogleSource.tsx +++ b/packages/plugins/google/src/react/AddGoogleSource.tsx @@ -14,7 +14,6 @@ import { FieldLabel } from "@executor-js/react/components/field"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { Input } from "@executor-js/react/components/input"; import { - addIntegrationErrorMessage, errorMessageFromExit, FormErrorAlert, SlugCollisionAlert, @@ -22,7 +21,7 @@ import { } from "@executor-js/react/lib/integration-add"; import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; -import { addGoogleBundle, addGoogleServices } from "./atoms"; +import { addGoogleServices } from "./atoms"; import { GoogleProductPicker } from "./GoogleProductPicker"; import { GOOGLE_PHOTOS_PRESET_ID, @@ -31,11 +30,8 @@ import { googleServiceSlug, type GoogleOpenApiPreset, } from "../sdk/presets"; -import type { - GoogleAddServicesInput, - GoogleAddServicesResult, - GoogleBundleConfig, -} from "../sdk/plugin"; +import type { GoogleAddServicesInput, GoogleAddServicesResult } from "../sdk/plugin"; +import { GOOGLE_CUSTOM_SERVICE_ID } from "../sdk/plugin"; const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; @@ -54,6 +50,13 @@ export type GoogleServiceIdentityOverride = { readonly name: string; }; +export type GoogleCustomServiceInput = { + readonly urls: readonly string[]; + readonly slug: string; + readonly name: string; + readonly description?: string; +}; + export type AddGoogleServicesMutation = (input: { readonly payload: GoogleAddServicesInput; readonly reactivityKeys: typeof integrationWriteKeys; @@ -62,14 +65,32 @@ export type AddGoogleServicesMutation = (input: { export const googleAddServicesPayload = (input: { readonly presetIds: readonly string[]; readonly identityOverride?: GoogleServiceIdentityOverride; + readonly custom?: GoogleCustomServiceInput; readonly baseUrl?: string; }): GoogleAddServicesInput => { - const identityOverride = input.presetIds.length === 1 ? input.identityOverride : undefined; - const services = input.presetIds.map((presetId: string) => ({ + const identityOverride = + input.presetIds.length === 1 && !input.custom ? input.identityOverride : undefined; + const presetServices = input.presetIds.map((presetId: string) => ({ presetId, ...(identityOverride?.slug.trim() ? { slug: identityOverride.slug.trim() } : {}), ...(identityOverride?.name.trim() ? { name: identityOverride.name.trim() } : {}), })); + const custom = + input.custom && input.custom.urls.length > 0 + ? [ + { + custom: { + urls: [...input.custom.urls], + slug: input.custom.slug, + name: input.custom.name, + ...(input.custom.description?.trim() + ? { description: input.custom.description.trim() } + : {}), + }, + }, + ] + : []; + const services = [...presetServices, ...custom]; const baseUrl = input.baseUrl?.trim() ?? ""; return baseUrl.length > 0 ? { services, baseUrl } : { services }; }; @@ -79,6 +100,7 @@ export const submitGoogleServicesSelection = ( input: { readonly presetIds: readonly string[]; readonly identityOverride?: GoogleServiceIdentityOverride; + readonly custom?: GoogleCustomServiceInput; readonly baseUrl?: string; }, ): Promise> => @@ -149,7 +171,9 @@ export const mergeGoogleAddServicesResult = ( }; const googlePresetName = (presetId: string): string => - googleOpenApiPresetById.get(presetId)?.name ?? presetId; + presetId === GOOGLE_CUSTOM_SERVICE_ID + ? "Custom Discovery URLs" + : (googleOpenApiPresetById.get(presetId)?.name ?? presetId); export function GoogleServiceResultPanel(props: { readonly result: GoogleAddServicesResult; @@ -266,36 +290,6 @@ const BaseUrlSettings = (props: { ); -const CustomGoogleBundleResult = (props: { readonly slug: string }) => ( -
-
-
-

Custom Discovery URLs

-

- Custom URLs were added through the legacy Google bundle path. -

-
- -
-
-); - -const googleBundlePayload = (config: GoogleBundleConfig): GoogleBundleConfig => { - const baseUrl = config.baseUrl?.trim() ?? ""; - const description = config.description?.trim() ?? ""; - return { - urls: config.urls, - ...(config.slug !== undefined ? { slug: config.slug } : {}), - ...(config.name !== undefined ? { name: config.name } : {}), - ...(description.length > 0 ? { description } : {}), - ...(baseUrl.length > 0 ? { baseUrl } : {}), - }; -}; - export default function AddGoogleSource(props: { onComplete: (slug?: string) => void; onCancel: () => void; @@ -313,20 +307,19 @@ export default function AddGoogleSource(props: { const [retryingPresetId, setRetryingPresetId] = useState(null); const [addError, setAddError] = useState(null); const [servicesResult, setServicesResult] = useState(null); - const [customBundleSlug, setCustomBundleSlug] = useState(null); const selectedIds = useMemo(() => [...selectedPresetIds], [selectedPresetIds]); const singleSelectedPreset = selectedIds.length === 1 ? googleOpenApiPresetById.get(selectedIds[0]!) : undefined; - const usesCustomBundleFallback = customDiscoveryUrls.length > 0; + const hasCustomDiscoveryUrls = customDiscoveryUrls.length > 0; const identity = useIntegrationIdentity({ - fallbackName: usesCustomBundleFallback + fallbackName: hasCustomDiscoveryUrls ? "Custom Google APIs" : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google")), fallbackNamespace: props.initialNamespace ?? - (usesCustomBundleFallback + (hasCustomDiscoveryUrls ? "google_custom" : singleSelectedPreset ? googleServiceSlug(singleSelectedPreset.id) @@ -357,85 +350,72 @@ export default function AddGoogleSource(props: { }, []); const doAddServices = useAtomSet(addGoogleServices, { mode: "promiseExit" }); - const doAddBundle = useAtomSet(addGoogleBundle, { mode: "promiseExit" }); const resolvedSourceId = slugifyNamespace(identity.namespace) || "google_custom"; const resolvedDisplayName = identity.name.trim() || - (usesCustomBundleFallback + (hasCustomDiscoveryUrls ? "Custom Google APIs" : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google"))); const resolvedDescription = descriptionDraft ?? - (usesCustomBundleFallback + (hasCustomDiscoveryUrls ? "Custom Google APIs." : isGooglePhotosPreset ? "Google Photos albums, uploads, app-created media, and selected picker media." : "Google APIs"); const customSlugAlreadyExists = useSlugAlreadyExists( - usesCustomBundleFallback ? resolvedSourceId : "", + hasCustomDiscoveryUrls ? resolvedSourceId : "", ); const identityOverride = - selectedIds.length === 1 && !usesCustomBundleFallback + selectedIds.length === 1 && !hasCustomDiscoveryUrls ? { slug: resolvedSourceId, name: resolvedDisplayName } : undefined; + const customService = + customDiscoveryUrls.length > 0 + ? { + urls: [...customDiscoveryUrls], + slug: resolvedSourceId, + name: resolvedDisplayName, + description: resolvedDescription, + } + : undefined; const canAdd = (selectedIds.length > 0 || customDiscoveryUrls.length > 0) && !customSlugAlreadyExists && !adding; - const addCustomDiscoveryBundle = async (): Promise => { - if (customDiscoveryUrls.length === 0) return true; - const exit = await doAddBundle({ - payload: googleBundlePayload({ - urls: [...customDiscoveryUrls], - slug: resolvedSourceId, - name: resolvedDisplayName, - description: resolvedDescription, - baseUrl, - }), - reactivityKeys: integrationWriteKeys, - }); - if (Exit.isFailure(exit)) { - setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Google")); - return false; - } - setCustomBundleSlug(String(exit.value.slug)); - return true; - }; - const handleAdd = async () => { setAdding(true); setAddError(null); setServicesResult(null); - setCustomBundleSlug(null); - if (selectedIds.length > 0) { - const exit = await submitGoogleServicesSelection(doAddServices, { - presetIds: selectedIds, - ...(identityOverride ? { identityOverride } : {}), - baseUrl, - }); - if (Exit.isFailure(exit)) { - setAddError(errorMessageFromExit(exit, "Failed to add Google services")); - setAdding(false); - return; - } - setServicesResult(exit.value); + const exit = await submitGoogleServicesSelection(doAddServices, { + presetIds: selectedIds, + ...(identityOverride ? { identityOverride } : {}), + ...(customService ? { custom: customService } : {}), + baseUrl, + }); + if (Exit.isFailure(exit)) { + setAddError(errorMessageFromExit(exit, "Failed to add Google services")); + setAdding(false); + return; } - await addCustomDiscoveryBundle(); + setServicesResult(exit.value); setAdding(false); }; const handleRetry = async (presetId: string) => { setRetryingPresetId(presetId); setAddError(null); + const retryingCustom = presetId === GOOGLE_CUSTOM_SERVICE_ID; const retryIdentityOverride = - identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId + !retryingCustom && identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId ? identityOverride : undefined; const exit = await submitGoogleServicesSelection(doAddServices, { - presetIds: [presetId], + presetIds: retryingCustom ? [] : [presetId], ...(retryIdentityOverride ? { identityOverride: retryIdentityOverride } : {}), + ...(retryingCustom && customService ? { custom: customService } : {}), baseUrl, }); if (Exit.isFailure(exit)) { @@ -449,22 +429,22 @@ export default function AddGoogleSource(props: { setRetryingPresetId(null); }; - const showIdentityDetails = selectedIds.length === 1 || usesCustomBundleFallback; - const detailTitle = usesCustomBundleFallback + const showIdentityDetails = selectedIds.length === 1 || hasCustomDiscoveryUrls; + const detailTitle = hasCustomDiscoveryUrls ? "Custom Google Discovery URLs" : (singleSelectedPreset?.name ?? "Google"); - const detailSubtitle = usesCustomBundleFallback + const detailSubtitle = hasCustomDiscoveryUrls ? selectedIds.length > 0 ? `${customDiscoveryUrls.length} custom URL${ customDiscoveryUrls.length === 1 ? "" : "s" - } added through the legacy bundle path. Selected products keep preset names.` + } added as its own integration. Selected products keep preset names.` : `${customDiscoveryUrls.length} custom URL${ customDiscoveryUrls.length === 1 ? "" : "s" - } added through the legacy bundle path.` + } added as its own integration.` : "This product is added as its own integration."; const dismiss = () => { - if (servicesResult || customBundleSlug) { + if (servicesResult) { props.onComplete(); return; } @@ -493,7 +473,7 @@ export default function AddGoogleSource(props: { title={detailTitle} subtitle={detailSubtitle} identity={identity} - {...(usesCustomBundleFallback + {...(hasCustomDiscoveryUrls ? { description: resolvedDescription, onDescriptionChange: setDescriptionDraft } : {})} baseUrl={baseUrl} @@ -518,11 +498,9 @@ export default function AddGoogleSource(props: { /> )} - {customBundleSlug && } - - - -); - -type MicrosoftGraphAddPayload = MicrosoftGraphConfig & { - readonly presetIds: readonly string[]; -}; - -// The payload for the legacy addGraph call that carries the custom scopes. -// The checked workloads already fan out as their own integrations through -// addWorkloads in the same submit, so the custom bundle must carry only the -// custom scopes (presetIds stays empty): folding the preset ids in again -// would create a second integration containing the same tools. -export const microsoftCustomGraphPayload = (input: { - readonly customScopes: readonly string[]; - readonly slug: string; - readonly name: string; - readonly description: string; - readonly baseUrl: string; -}): MicrosoftGraphAddPayload => { - const baseUrl = input.baseUrl.trim(); - const description = input.description.trim(); - return { - presetIds: [], - customScopes: [...input.customScopes], - slug: input.slug, - name: input.name, - ...(description.length > 0 ? { description } : {}), - ...(baseUrl.length > 0 ? { baseUrl } : {}), - }; -}; - export default function AddMicrosoftSource(props: { onComplete: (slug?: string) => void; onCancel: () => void; @@ -316,20 +294,19 @@ export default function AddMicrosoftSource(props: { const [retryingPresetId, setRetryingPresetId] = useState(null); const [addError, setAddError] = useState(null); const [workloadsResult, setWorkloadsResult] = useState(null); - const [customGraphSlug, setCustomGraphSlug] = useState(null); const selectedIds = useMemo(() => [...selectedPresetIds], [selectedPresetIds]); const singleSelectedPreset = selectedIds.length === 1 ? microsoftGraphPresetForId(selectedIds[0]!) : undefined; - const usesCustomGraphFallback = customScopes.length > 0; + const hasCustomScopes = customScopes.length > 0; const identity = useIntegrationIdentity({ - fallbackName: usesCustomGraphFallback + fallbackName: hasCustomScopes ? "Custom Microsoft Graph" : (singleSelectedPreset?.name ?? "Microsoft Graph"), fallbackNamespace: props.initialNamespace ?? - (usesCustomGraphFallback + (hasCustomScopes ? "microsoft_graph_custom" : singleSelectedPreset ? microsoftServiceSlug(singleSelectedPreset.id) @@ -358,56 +335,43 @@ export default function AddMicrosoftSource(props: { }, []); const doAddWorkloads = useAtomSet(addMicrosoftWorkloads, { mode: "promiseExit" }); - const doAddGraph = useAtomSet(addMicrosoftGraph, { mode: "promiseExit" }); const resolvedSourceId = slugifyNamespace(identity.namespace) || "microsoft_graph_custom"; const resolvedDisplayName = identity.name.trim() || - (usesCustomGraphFallback + (hasCustomScopes ? "Custom Microsoft Graph" : (singleSelectedPreset?.name ?? "Microsoft Graph")); const resolvedDescription = descriptionDraft ?? - (usesCustomGraphFallback - ? "Custom Microsoft Graph scopes." - : "Selected Microsoft Graph workloads."); + (hasCustomScopes ? "Custom Microsoft Graph scopes." : "Selected Microsoft Graph workloads."); const customGraphSlugAlreadyExists = useSlugAlreadyExists( - usesCustomGraphFallback ? resolvedSourceId : "", + hasCustomScopes ? resolvedSourceId : "", ); const identityOverride = - selectedIds.length === 1 && !usesCustomGraphFallback + selectedIds.length === 1 && !hasCustomScopes ? { slug: resolvedSourceId, name: resolvedDisplayName } : undefined; - const canAdd = selectedIds.length > 0 && !customGraphSlugAlreadyExists && !adding; - - const addCustomGraphScopes = async (): Promise => { - if (customScopes.length === 0) return true; - const exit = await doAddGraph({ - payload: microsoftCustomGraphPayload({ - customScopes, - slug: resolvedSourceId, - name: resolvedDisplayName, - description: resolvedDescription, - baseUrl, - }), - reactivityKeys: integrationWriteKeys, - }); - if (Exit.isFailure(exit)) { - setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Microsoft")); - return false; - } - setCustomGraphSlug(String(exit.value.slug)); - return true; - }; + const customWorkload = + customScopes.length > 0 + ? { + customScopes: [...customScopes], + slug: resolvedSourceId, + name: resolvedDisplayName, + description: resolvedDescription, + } + : undefined; + const canAdd = + (selectedIds.length > 0 || customScopes.length > 0) && !customGraphSlugAlreadyExists && !adding; const handleAdd = async () => { setAdding(true); setAddError(null); setWorkloadsResult(null); - setCustomGraphSlug(null); const exit = await submitMicrosoftWorkloadsSelection(doAddWorkloads, { presetIds: selectedIds, ...(identityOverride ? { identityOverride } : {}), + ...(customWorkload ? { custom: customWorkload } : {}), baseUrl, }); if (Exit.isFailure(exit)) { @@ -416,20 +380,21 @@ export default function AddMicrosoftSource(props: { return; } setWorkloadsResult(exit.value); - await addCustomGraphScopes(); setAdding(false); }; const handleRetry = async (presetId: string) => { setRetryingPresetId(presetId); setAddError(null); + const retryingCustom = presetId === MICROSOFT_CUSTOM_WORKLOAD_ID; const retryIdentityOverride = - identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId + !retryingCustom && identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId ? identityOverride : undefined; const exit = await submitMicrosoftWorkloadsSelection(doAddWorkloads, { - presetIds: [presetId], + presetIds: retryingCustom ? [] : [presetId], ...(retryIdentityOverride ? { identityOverride: retryIdentityOverride } : {}), + ...(retryingCustom && customWorkload ? { custom: customWorkload } : {}), baseUrl, }); if (Exit.isFailure(exit)) { @@ -443,18 +408,18 @@ export default function AddMicrosoftSource(props: { setRetryingPresetId(null); }; - const showIdentityDetails = selectedIds.length === 1 || usesCustomGraphFallback; - const detailTitle = usesCustomGraphFallback + const showIdentityDetails = selectedIds.length === 1 || hasCustomScopes; + const detailTitle = hasCustomScopes ? "Custom Microsoft Graph scopes" : (singleSelectedPreset?.name ?? "Microsoft Graph"); - const detailSubtitle = usesCustomGraphFallback + const detailSubtitle = hasCustomScopes ? `${customScopes.length} custom scope${ customScopes.length === 1 ? "" : "s" - } added through the legacy Graph path.` + } added as its own integration.` : "This workload is added as its own integration."; const dismiss = () => { - if (workloadsResult || customGraphSlug) { + if (workloadsResult) { props.onComplete(); return; } @@ -483,7 +448,7 @@ export default function AddMicrosoftSource(props: { title={detailTitle} subtitle={detailSubtitle} identity={identity} - {...(usesCustomGraphFallback + {...(hasCustomScopes ? { description: resolvedDescription, onDescriptionChange: setDescriptionDraft } : {})} baseUrl={baseUrl} @@ -509,11 +474,9 @@ export default function AddMicrosoftSource(props: { /> )} - {customGraphSlug && } - @@ -302,7 +343,6 @@ export default function AddGoogleSource(props: { ); const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState([]); const [baseUrl, setBaseUrl] = useState(""); - const [descriptionDraft, setDescriptionDraft] = useState(null); const [adding, setAdding] = useState(false); const [retryingPresetId, setRetryingPresetId] = useState(null); const [addError, setAddError] = useState(null); @@ -357,16 +397,6 @@ export default function AddGoogleSource(props: { (hasCustomDiscoveryUrls ? "Custom Google APIs" : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google"))); - const resolvedDescription = - descriptionDraft ?? - (hasCustomDiscoveryUrls - ? "Custom Google APIs." - : isGooglePhotosPreset - ? "Google Photos albums, uploads, app-created media, and selected picker media." - : "Google APIs"); - const customSlugAlreadyExists = useSlugAlreadyExists( - hasCustomDiscoveryUrls ? resolvedSourceId : "", - ); const identityOverride = selectedIds.length === 1 && !hasCustomDiscoveryUrls ? { slug: resolvedSourceId, name: resolvedDisplayName } @@ -375,15 +405,9 @@ export default function AddGoogleSource(props: { customDiscoveryUrls.length > 0 ? { urls: [...customDiscoveryUrls], - slug: resolvedSourceId, - name: resolvedDisplayName, - description: resolvedDescription, } : undefined; - const canAdd = - (selectedIds.length > 0 || customDiscoveryUrls.length > 0) && - !customSlugAlreadyExists && - !adding; + const canAdd = (selectedIds.length > 0 || customDiscoveryUrls.length > 0) && !adding; const handleAdd = async () => { setAdding(true); @@ -407,7 +431,7 @@ export default function AddGoogleSource(props: { const handleRetry = async (presetId: string) => { setRetryingPresetId(presetId); setAddError(null); - const retryingCustom = presetId === GOOGLE_CUSTOM_SERVICE_ID; + const retryingCustom = !googleOpenApiPresetById.has(presetId); const retryIdentityOverride = !retryingCustom && identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId ? identityOverride @@ -429,19 +453,9 @@ export default function AddGoogleSource(props: { setRetryingPresetId(null); }; - const showIdentityDetails = selectedIds.length === 1 || hasCustomDiscoveryUrls; - const detailTitle = hasCustomDiscoveryUrls - ? "Custom Google Discovery URLs" - : (singleSelectedPreset?.name ?? "Google"); - const detailSubtitle = hasCustomDiscoveryUrls - ? selectedIds.length > 0 - ? `${customDiscoveryUrls.length} custom URL${ - customDiscoveryUrls.length === 1 ? "" : "s" - } added as its own integration. Selected products keep preset names.` - : `${customDiscoveryUrls.length} custom URL${ - customDiscoveryUrls.length === 1 ? "" : "s" - } added as its own integration.` - : "This product is added as its own integration."; + const showIdentityDetails = selectedIds.length === 1 && !hasCustomDiscoveryUrls; + const detailTitle = singleSelectedPreset?.name ?? "Google"; + const detailSubtitle = "This product is added as its own integration."; const dismiss = () => { if (servicesResult) { @@ -473,9 +487,6 @@ export default function AddGoogleSource(props: { title={detailTitle} subtitle={detailSubtitle} identity={identity} - {...(hasCustomDiscoveryUrls - ? { description: resolvedDescription, onDescriptionChange: setDescriptionDraft } - : {})} baseUrl={baseUrl} onBaseUrlChange={setBaseUrl} baseUrlLabel="Base URL override (optional)" @@ -486,8 +497,6 @@ export default function AddGoogleSource(props: { )} - {customSlugAlreadyExists && !adding && } - {addError && } {servicesResult && ( diff --git a/packages/plugins/google/src/sdk/discovery.ts b/packages/plugins/google/src/sdk/discovery.ts index 2e4bbec31..b94138914 100644 --- a/packages/plugins/google/src/sdk/discovery.ts +++ b/packages/plugins/google/src/sdk/discovery.ts @@ -241,6 +241,7 @@ const DiscoveryDocument = Schema.Struct({ name: TextOption, version: TextOption, title: TextOption, + description: TextOption, rootUrl: TextOption, servicePath: Schema.optional(Schema.Trim).pipe( Schema.withDecodingDefaultType(Effect.succeed("")), @@ -268,6 +269,12 @@ const DiscoveryDocument = Schema.Struct({ }); type DiscoveryDocument = typeof DiscoveryDocument.Type; +export interface GoogleDiscoveryDocumentIdentity { + readonly name: string; + readonly title?: string; + readonly description?: string; +} + export interface GoogleDiscoveryOpenApiConversion { readonly specText: string; readonly baseUrl: string; @@ -286,6 +293,25 @@ const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod); const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource); const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const parseDiscoveryDocument = (documentText: string) => + parseJson(documentText).pipe( + Effect.mapError( + () => + new OpenApiParseError({ + message: "Failed to parse Google Discovery document", + }), + ), + Effect.flatMap((parsed) => + Effect.try({ + try: () => decodeDiscoveryDocument(parsed), + catch: () => + new OpenApiParseError({ + message: "Failed to decode Google Discovery document", + }), + }), + ), + ); + const DISCOVERY_SERVICE_PATH_RE = /^\/discovery\/v1\/apis\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/rest\/?$/; const DISCOVERY_VERSION_RE = /^[A-Za-z0-9._-]+$/; @@ -389,6 +415,25 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov }, ); +export const googleDiscoveryDocumentIdentity = Effect.fn("OpenApi.googleDiscoveryDocumentIdentity")( + function* (input: { readonly documentText: string }) { + const document = yield* parseDiscoveryDocument(input.documentText); + const name = Option.getOrUndefined(document.name); + if (!name) { + return yield* new OpenApiParseError({ + message: "Google Discovery document is missing name", + }); + } + const title = Option.getOrUndefined(document.title); + const description = Option.getOrUndefined(document.description); + return { + name, + ...(title !== undefined ? { title } : {}), + ...(description !== undefined ? { description } : {}), + }; + }, +); + const schemaRef = (name: string) => `#/components/schemas/${name}`; const identitySchemaName = (name: string): string => name; @@ -946,22 +991,7 @@ const hasOperation = ( export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleDiscovery")( function* (input: { readonly discoveryUrl: string; readonly documentText: string }) { - const parsed = yield* parseJson(input.documentText).pipe( - Effect.mapError( - () => - new OpenApiParseError({ - message: "Failed to parse Google Discovery document", - }), - ), - ); - const document = yield* Effect.try({ - try: () => decodeDiscoveryDocument(parsed), - catch: () => - new OpenApiParseError({ - message: "Failed to decode Google Discovery document", - }), - }); - + const document = yield* parseDiscoveryDocument(input.documentText); const info = yield* discoveryDocumentInfo(document, input.discoveryUrl); const { service, version, rootUrl, baseUrl, title } = info; const paths: Record> = {}; @@ -1095,21 +1125,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( const infos = yield* Effect.forEach(input.documents, ({ discoveryUrl, documentText }) => Effect.gen(function* () { - const parsed = yield* parseJson(documentText).pipe( - Effect.mapError( - () => - new OpenApiParseError({ - message: "Failed to parse Google Discovery document", - }), - ), - ); - const document = yield* Effect.try({ - try: () => decodeDiscoveryDocument(parsed), - catch: () => - new OpenApiParseError({ - message: "Failed to decode Google Discovery document", - }), - }); + const document = yield* parseDiscoveryDocument(documentText); return yield* discoveryDocumentInfo(document, discoveryUrl); }), ); diff --git a/packages/plugins/google/src/sdk/plugin.test.ts b/packages/plugins/google/src/sdk/plugin.test.ts index 0995ad116..de3d79ddc 100644 --- a/packages/plugins/google/src/sdk/plugin.test.ts +++ b/packages/plugins/google/src/sdk/plugin.test.ts @@ -1,14 +1,9 @@ // --------------------------------------------------------------------------- -// Google bundle add flow, "customize your Google connection". +// Google per-service add flow, "customize your Google connection". // -// The product picker emits a URL list. The server fetches each Discovery -// document, merges them into ONE `google` -// integration spec, and stores the unioned `googleOAuth2` auth template. These -// tests exercise that path end-to-end against a stubbed Discovery host: -// - a 3-API bundle (calendar + gmail + drive) produces a single `google` -// integration whose merged tools carry NO name collisions (each method id -// is service-prefixed) even when two APIs share a generic method name; -// - the stored oauth template carries the UNION of every API's scopes. +// The product picker emits preset ids and custom Discovery URLs. The server +// fans them out into separate integrations, deriving custom identity from the +// fetched Discovery document before it compiles the OpenAPI surface. // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; @@ -24,7 +19,6 @@ import { import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; import { defaultGoogleHealthCheck, googlePlugin } from "./plugin"; -import { googleOpenApiPresets, type GoogleOpenApiPreset } from "./presets"; // --- Canned Discovery documents ------------------------------------------- // 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"; const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"; const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; const DOCS_URL = "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest"; +const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"; const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest"; const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1"; const PEOPLE_URL = "https://www.googleapis.com/discovery/v1/apis/people/v1/rest"; @@ -219,6 +214,40 @@ const docsDoc = { }, }; +const tasksDoc = { + name: "tasks", + version: "v1", + title: "Tasks API", + description: "Manage your tasks and task lists.", + rootUrl: "https://tasks.googleapis.com/", + servicePath: "tasks/v1/", + auth: { + oauth2: { + scopes: { + "https://www.googleapis.com/auth/tasks": { description: "Manage tasks" }, + }, + }, + }, + resources: { + tasks: { + methods: { + list: { + id: "tasks.tasks.list", + httpMethod: "GET", + path: "lists/{tasklist}/tasks", + scopes: ["https://www.googleapis.com/auth/tasks"], + parameters: { + tasklist: { location: "path", required: true, type: "string" }, + }, + }, + }, + }, + }, + schemas: { + Task: { id: "Task", type: "object", properties: { id: { type: "string" } } }, + }, +}; + const photosLibraryDoc = { name: "photoslibrary", version: "v1", @@ -425,6 +454,7 @@ const DISCOVERY_BODIES: Readonly> = { [SHEETS_URL]: toJson(sheetsDoc), [DRIVE_URL]: toJson(driveDoc), [DOCS_URL]: toJson(docsDoc), + [TASKS_URL]: toJson(tasksDoc), [PHOTOS_LIBRARY_URL]: toJson(photosLibraryDoc), [PHOTOS_PICKER_URL]: toJson(photosPickerDoc), [PEOPLE_URL]: toJson(peopleDoc), @@ -499,6 +529,240 @@ describe("Google custom service add flow", () => { expect(presetIds).toContain("google-photos"); }); + it.effect("derives custom integration identity from the Discovery document", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); + + const result = yield* executor.google.addServices({ + services: [ + { + custom: { + urls: [TASKS_URL], + }, + }, + ], + }); + + expect(result).toEqual({ + added: [ + { + slug: IntegrationSlug.make("google_tasks"), + presetId: "google_tasks", + toolCount: 4, + }, + ], + skipped: [], + failed: [], + }); + + const integration = (yield* executor.integrations.list()).find( + (item) => String(item.slug) === "google_tasks", + ); + expect(integration?.name).toBe("Google Tasks"); + expect(integration?.description).toBe("Manage your tasks and task lists."); + + const config = yield* executor.google.getConfig("google_tasks"); + expect(config?.googleDiscoveryUrls).toEqual([TASKS_URL, OAUTH2_URL]); + expect(oauthScopesFromConfig(config)).toEqual( + ["email", "https://www.googleapis.com/auth/tasks", "openid", "profile"].sort(), + ); + }), + ), + ); + + it.effect("uses explicit custom identity overrides for a single Discovery URL", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); + + const result = yield* executor.google.addServices({ + services: [ + { + custom: { + urls: [TASKS_URL], + slug: "team_tasks", + name: "Team Tasks", + description: "Internal task workflow.", + }, + }, + ], + }); + + expect(result).toEqual({ + added: [ + { + slug: IntegrationSlug.make("team_tasks"), + presetId: "team_tasks", + toolCount: 4, + }, + ], + skipped: [], + failed: [], + }); + + const integration = (yield* executor.integrations.list()).find( + (item) => String(item.slug) === "team_tasks", + ); + expect(integration?.name).toBe("Team Tasks"); + expect(integration?.description).toBe("Internal task workflow."); + }), + ), + ); + + it.effect("rejects explicit custom identity overrides for multiple Discovery URLs", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); + + const result = yield* executor.google.addServices({ + services: [ + { + custom: { + urls: [TASKS_URL, CALENDAR_URL], + slug: "team_google", + name: "Team Google", + }, + }, + ], + }); + + expect(result).toEqual({ + added: [], + skipped: [], + failed: [ + { + slug: IntegrationSlug.make("team_google"), + presetId: "team_google", + error: "Custom Google service identity overrides require exactly one Discovery URL", + }, + ], + }); + }), + ), + ); + + it.effect("reports fetch failures with the fallback custom slug", () => + Effect.scoped( + Effect.gen(function* () { + const failingLayer = makeDiscoveryHttpClientLayer({ + failedUrls: new Set([TASKS_URL]), + }); + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [googlePlugin({ httpClientLayer: failingLayer }), memoryCredentialsPlugin()], + }), + ); + + const result = yield* executor.google.addServices({ + services: [ + { + custom: { + urls: [TASKS_URL], + }, + }, + ], + }); + + expect(result).toEqual({ + added: [], + skipped: [], + failed: [ + { + slug: IntegrationSlug.make("google_custom"), + presetId: "google_custom", + error: "Failed to fetch Google Discovery document: HTTP 503", + }, + ], + }); + expect(yield* executor.google.getIntegration("google_custom")).toBeNull(); + }), + ), + ); + + it.effect("adds one integration per custom Discovery URL", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); + + const result = yield* executor.google.addServices({ + services: [ + { + custom: { + urls: [TASKS_URL, CALENDAR_URL], + }, + }, + ], + }); + + expect(result).toEqual({ + added: [ + { + slug: IntegrationSlug.make("google_tasks"), + presetId: "google_tasks", + toolCount: 4, + }, + { + slug: IntegrationSlug.make("google_calendar"), + presetId: "google_calendar", + toolCount: 4, + }, + ], + skipped: [], + failed: [], + }); + const integrations = yield* executor.integrations.list(); + expect(integrations.find((item) => String(item.slug) === "google_tasks")?.name).toBe( + "Google Tasks", + ); + expect(integrations.find((item) => String(item.slug) === "google_calendar")?.name).toBe( + "Google Calendar", + ); + }), + ), + ); + + it.effect("skips a duplicate custom Discovery URL in the same request", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); + + const result = yield* executor.google.addServices({ + services: [ + { + custom: { + urls: [TASKS_URL, TASKS_URL], + }, + }, + ], + }); + + expect(result).toEqual({ + added: [ + { + slug: IntegrationSlug.make("google_tasks"), + presetId: "google_tasks", + toolCount: 4, + }, + ], + skipped: [ + { + slug: IntegrationSlug.make("google_tasks"), + presetId: "google_tasks", + reason: "already_exists", + }, + ], + failed: [], + }); + + const integrations = yield* executor.integrations.list(); + expect( + integrations.filter((integration) => String(integration.slug) === "google_tasks"), + ).toHaveLength(1); + }), + ), + ); + it.effect("rejects lookalike Discovery hosts before fetching custom service documents", () => Effect.scoped( Effect.gen(function* () { @@ -541,7 +805,7 @@ describe("Google custom service add flow", () => { failed: [ { slug: IntegrationSlug.make("bad_google"), - presetId: "custom", + presetId: "bad_google", error: "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint", }, @@ -552,239 +816,47 @@ describe("Google custom service add flow", () => { ), ); - it.effect( - "addServices custom entry merges calendar+gmail+drive into one requested integration with no tool-name collisions", - () => - Effect.scoped( - Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); - - const result = yield* executor.google.addServices({ - services: [ - { - custom: { - urls: [CALENDAR_URL, GMAIL_URL, DRIVE_URL], - slug: "google_custom", - name: "Custom Google APIs", - description: "Google", - }, - }, - ], - }); - expect(result).toEqual({ - added: [ - { - slug: IntegrationSlug.make("google_custom"), - presetId: "custom", - toolCount: 6, - }, - ], - skipped: [], - failed: [], - }); - - const integration = yield* executor.google.getIntegration("google_custom"); - expect(integration?.slug).toBe(IntegrationSlug.make("google_custom")); - - // The stored oauth template carries the COMPACTED union of every API's - // scopes plus the hidden OAuth2 identity scopes that `oauth.start` - // requests. - // `calendar.readonly` collapses under `calendar`, and `gmail.readonly` - // collapses under `https://mail.google.com/`, so the requested consent - // is clean rather than the raw per-method union. - const config = yield* executor.google.getConfig("google_custom"); - expect(config?.googleDiscoveryUrls).toEqual([ - CALENDAR_URL, - GMAIL_URL, - DRIVE_URL, - OAUTH2_URL, - ]); - const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); - expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( - [ - "email", - "https://mail.google.com/", - "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/drive", - "openid", - "profile", - ].sort(), - ); - - // A connection stamps the merged tools; assert all three `list`s are - // present under distinct service-prefixed names (no collision). - yield* executor.connections.create({ - owner: "org", - name: ConnectionName.make("main"), - integration: IntegrationSlug.make("google_custom"), - template: AuthTemplateSlug.make("googleOAuth2"), - value: "token-xyz", - }); - - const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); - expect(toolNames).toContain("calendar.events.list"); - expect(toolNames).toContain("gmail.users.messages.list"); - expect(toolNames).toContain("drive.files.list"); - - // No duplicate tool names across the merged surface. - const googleTools = toolNames.filter((name) => name.endsWith(".list")); - expect(new Set(googleTools).size).toBe(googleTools.length); - expect(googleTools.length).toBe(3); - }), - ), - ); - - it.effect( - "addServices custom entry constrains Google Photos to the preset scopes and upload tool", - () => - Effect.scoped( - Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); - - yield* executor.google.addServices({ - services: [ - { - custom: { - urls: [ - PHOTOS_LIBRARY_URL, - "https://photospicker.googleapis.com/$discovery/rest?version=v1", - ], - slug: "google_photos", - name: "Google Photos", - }, - }, - ], - }); - - const config = yield* executor.google.getConfig("google_photos"); - const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); - expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( - [ - "email", - "https://www.googleapis.com/auth/photoslibrary.appendonly", - "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", - "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", - "openid", - "profile", - ].sort(), - ); - - yield* executor.connections.create({ - owner: "org", - name: ConnectionName.make("main"), - integration: IntegrationSlug.make("google_photos"), - template: AuthTemplateSlug.make("googleOAuth2"), - value: "token-xyz", - }); - - const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); - expect(toolNames).toContain("photoslibrary.mediaItems.upload"); - expect(toolNames).toContain("photoslibrary.mediaItems.search"); - expect(toolNames).toContain("photospicker.mediaItems.list"); - expect(toolNames).not.toContain("photoslibrary.albums.list"); - }), - ), - ); - - it.effect( - "addServices custom entry keeps Google Photos scoped when combined with another API", - () => - Effect.scoped( - Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); - - yield* executor.google.addServices({ - services: [ - { - custom: { - urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL, PHOTOS_PICKER_URL], - slug: "google_photos_calendar", - name: "Google Photos and Calendar", - }, - }, - ], - }); - - const config = yield* executor.google.getConfig("google_photos_calendar"); - const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); - expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( - [ - "email", - "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/photoslibrary.appendonly", - "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", - "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", - "openid", - "profile", - ].sort(), - ); - - yield* executor.connections.create({ - owner: "org", - name: ConnectionName.make("main"), - integration: IntegrationSlug.make("google_photos_calendar"), - template: AuthTemplateSlug.make("googleOAuth2"), - value: "token-xyz", - }); - - const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); - expect(toolNames).toContain("calendar.events.list"); - expect(toolNames).toContain("photoslibrary.mediaItems.upload"); - expect(toolNames).toContain("photoslibrary.mediaItems.search"); - expect(toolNames).toContain("photospicker.mediaItems.list"); - expect(toolNames).not.toContain("photoslibrary.albums.list"); - }), - ), - ); - - it.effect( - "addServices custom entry scopes partial Google Photos URLs when mixed with another API", - () => - Effect.scoped( - Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); + it.effect("custom Google Photos Library uses the focused Photos scopes and upload tool", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); - yield* executor.google.addServices({ - services: [ - { - custom: { - urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL], - slug: "google_photos_library_calendar", - name: "Google Photos Library and Calendar", - }, + yield* executor.google.addServices({ + services: [ + { + custom: { + urls: [PHOTOS_LIBRARY_URL], }, - ], - }); + }, + ], + }); - const config = yield* executor.google.getConfig("google_photos_library_calendar"); - const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); - expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( - [ - "email", - "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/photoslibrary.appendonly", - "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", - "openid", - "profile", - ].sort(), - ); + const config = yield* executor.google.getConfig("google_photoslibrary"); + const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); + expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( + [ + "email", + "https://www.googleapis.com/auth/photoslibrary.appendonly", + "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", + "openid", + "profile", + ].sort(), + ); - yield* executor.connections.create({ - owner: "org", - name: ConnectionName.make("main"), - integration: IntegrationSlug.make("google_photos_library_calendar"), - template: AuthTemplateSlug.make("googleOAuth2"), - value: "token-xyz", - }); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("google_photoslibrary"), + template: AuthTemplateSlug.make("googleOAuth2"), + value: "token-xyz", + }); - const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); - expect(toolNames).toContain("calendar.events.list"); - expect(toolNames).toContain("photoslibrary.mediaItems.upload"); - expect(toolNames).toContain("photoslibrary.mediaItems.search"); - expect(toolNames).not.toContain("photospicker.mediaItems.list"); - expect(toolNames).not.toContain("photoslibrary.albums.list"); - }), - ), + const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); + expect(toolNames).toContain("photoslibrary.mediaItems.upload"); + expect(toolNames).toContain("photoslibrary.mediaItems.search"); + expect(toolNames).not.toContain("photoslibrary.albums.list"); + }), + ), ); }); @@ -1021,31 +1093,23 @@ describe("Google per-service add flow", () => { }); describe("Google health-check default", () => { - it.effect("custom service with featured URLs auto-configures OAuth2 userinfo identity", () => + it.effect("custom service auto-configures OAuth2 userinfo identity", () => Effect.scoped( Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); - const defaultUrls = googleOpenApiPresets - .filter((preset: GoogleOpenApiPreset) => preset.featured) - .flatMap((preset: GoogleOpenApiPreset) => (preset.url ? [preset.url] : [])); - - expect(defaultUrls).toEqual([CALENDAR_URL, GMAIL_URL, SHEETS_URL, DRIVE_URL, DOCS_URL]); yield* executor.google.addServices({ services: [ { custom: { - urls: defaultUrls, - slug: "google_default", - name: "Google", - description: "Google", + urls: [TASKS_URL], }, }, ], }); const stored = yield* executor.integrations.healthCheck.get( - IntegrationSlug.make("google_default"), + IntegrationSlug.make("google_tasks"), ); expect(stored?.operation, "the default check targets OAuth2 userinfo").toBe( "oauth2.userinfo.get", @@ -1053,24 +1117,15 @@ describe("Google health-check default", () => { expect(stored?.args, "userinfo needs no args").toBeUndefined(); expect(stored?.identityField, "the default reads the account email").toBe("email"); - const config = yield* executor.google.getConfig("google_default"); - expect(config?.googleDiscoveryUrls).toEqual([...defaultUrls, OAUTH2_URL]); + const config = yield* executor.google.getConfig("google_tasks"); + expect(config?.googleDiscoveryUrls).toEqual([TASKS_URL, OAUTH2_URL]); const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( - [ - "email", - "https://mail.google.com/", - "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/documents", - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/spreadsheets", - "openid", - "profile", - ].sort(), + ["email", "https://www.googleapis.com/auth/tasks", "openid", "profile"].sort(), ); const candidates = yield* executor.integrations.healthCheck.candidates( - IntegrationSlug.make("google_default"), + IntegrationSlug.make("google_tasks"), ); const userinfo = candidates.find((c) => c.operation === "oauth2.userinfo.get"); expect(userinfo, "OAuth2 userinfo is a ranked candidate").toBeDefined(); @@ -1091,10 +1146,7 @@ describe("Google health-check default", () => { services: [ { custom: { - urls: [PEOPLE_URL, CALENDAR_URL], - slug: "google_people", - name: "Google", - description: "Google", + urls: [PEOPLE_URL], }, }, ], diff --git a/packages/plugins/google/src/sdk/plugin.ts b/packages/plugins/google/src/sdk/plugin.ts index 0b8f5444a..03cee439d 100644 --- a/packages/plugins/google/src/sdk/plugin.ts +++ b/packages/plugins/google/src/sdk/plugin.ts @@ -38,7 +38,9 @@ import { import { convertGoogleDiscoveryBundleToOpenApi, fetchGoogleDiscoveryDocument, + googleDiscoveryDocumentIdentity, normalizeGoogleDiscoveryUrl, + type GoogleDiscoveryDocumentIdentity, } from "./discovery"; import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; import { @@ -103,9 +105,15 @@ export interface GooglePresetServiceConfig { export interface GoogleCustomServiceConfig { readonly custom: { + /** + * Each Discovery URL is added as its own integration. `slug`, `name`, and + * `description` are explicit overrides for a single URL; supplying them + * with multiple URLs returns a failed row instead of guessing which service + * the override belongs to. + */ readonly urls: readonly string[]; readonly slug?: string; - readonly name: string; + readonly name?: string; readonly description?: string; }; } @@ -162,6 +170,8 @@ export interface GooglePluginOptions { } const DEFAULT_GOOGLE_SLUG = "google"; +const DEFAULT_GOOGLE_CUSTOM_SLUG = "google_custom"; +const MAX_GOOGLE_CUSTOM_DESCRIPTION_LENGTH = 500; const googleOpenApiPresetById: ReadonlyMap = new Map( googleOpenApiPresets.map((preset) => [preset.id, preset]), @@ -177,7 +187,7 @@ const googleServiceEntryId = (service: GoogleServiceConfig): string => const googleServiceEntrySlug = (service: GoogleServiceConfig): IntegrationSlug => isGooglePresetServiceConfig(service) ? IntegrationSlug.make(service.slug?.trim() || googleServiceSlug(service.presetId)) - : IntegrationSlug.make(service.custom.slug?.trim() || "google_custom"); + : IntegrationSlug.make(service.custom.slug?.trim() || DEFAULT_GOOGLE_CUSTOM_SLUG); type GoogleAddServiceOutcome = { readonly added: readonly GoogleAddServicesAdded[]; @@ -185,16 +195,21 @@ type GoogleAddServiceOutcome = { readonly failed: readonly GoogleAddServicesFailed[]; }; -const googleAddServiceFailure = ( - service: GoogleServiceConfig, - slug: IntegrationSlug, - error: string, -): GoogleAddServiceOutcome => ({ +const googleAddServiceFailure = (input: { + readonly slug: IntegrationSlug; + readonly presetId: string; + readonly error: string; +}): GoogleAddServiceOutcome => ({ added: [], skipped: [], - failed: [{ slug, presetId: googleServiceEntryId(service), error }], + failed: [{ slug: input.slug, presetId: input.presetId, error: input.error }], }); +type GoogleFetchedDiscoveryDocument = { + readonly discoveryUrl: string; + readonly documentText: string; +}; + const googlePhotosBundlePresetIdByUrl = new Map( googlePhotosOpenApiPresets.flatMap((preset) => preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [], @@ -213,20 +228,28 @@ const googlePhotosBundleConsentScopes = ( : undefined; }; -const fetchGoogleBundleConversion = ( +const fetchGoogleDiscoveryDocuments = ( urls: readonly string[], httpClientLayer: Layer.Layer, - consentScopesOverride?: readonly string[], ) => Effect.forEach( urls, (url) => fetchGoogleDiscoveryDocument(url).pipe( Effect.provide(httpClientLayer), - Effect.map((documentText) => ({ discoveryUrl: url, documentText })), + Effect.map( + (documentText): GoogleFetchedDiscoveryDocument => ({ discoveryUrl: url, documentText }), + ), ), { concurrency: 4 }, - ).pipe( + ); + +const fetchGoogleBundleConversion = ( + urls: readonly string[], + httpClientLayer: Layer.Layer, + consentScopesOverride?: readonly string[], +) => + fetchGoogleDiscoveryDocuments(urls, httpClientLayer).pipe( Effect.flatMap((documents) => { const consentScopes = consentScopesOverride ?? googlePhotosBundleConsentScopes(urls); return convertGoogleDiscoveryBundleToOpenApi({ @@ -258,6 +281,132 @@ const googleBundleUrlsWithIdentity = ( return uniqueUrls([...normalized, GOOGLE_OAUTH2_DISCOVERY_URL]); }); +type GoogleServiceAddPlan = + | { + readonly kind: "preset"; + readonly service: GooglePresetServiceConfig; + } + | { + readonly kind: "custom"; + readonly service: GoogleCustomServiceConfig; + readonly url: string; + readonly fallbackSlug: IntegrationSlug; + } + | { + readonly kind: "invalid-custom"; + readonly service: GoogleCustomServiceConfig; + readonly slug: IntegrationSlug; + readonly presetId: string; + readonly error: string; + }; + +const hasCustomGoogleServiceOverride = (service: GoogleCustomServiceConfig): boolean => + Boolean( + service.custom.slug?.trim() || + service.custom.name?.trim() || + service.custom.description?.trim(), + ); + +const googleCustomFallbackSlug = (index: number): IntegrationSlug => + IntegrationSlug.make( + index === 0 ? DEFAULT_GOOGLE_CUSTOM_SLUG : `${DEFAULT_GOOGLE_CUSTOM_SLUG}_${index + 1}`, + ); + +const googleCustomResultId = (slug: IntegrationSlug): string => String(slug); + +const googleDiscoveryNameSlugPart = (name: string): string => + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "_") + .replace(/^_+|_+$/g, ""); + +const googleDiscoverySlug = (name: string): IntegrationSlug => + IntegrationSlug.make( + googleServiceSlug(`google-${googleDiscoveryNameSlugPart(name) || "custom"}`), + ); + +const googleDiscoveryDisplayName = (identity: GoogleDiscoveryDocumentIdentity): string => { + const title = identity.title + ?.trim() + .replace(/\s+API(?:\s+v[0-9][A-Za-z0-9._-]*)?$/i, "") + .trim(); + if (title && title.length > 0) { + return /^google\b/i.test(title) ? title : `Google ${title}`; + } + return identity.name + .trim() + .replace(/[-_.]+/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +}; + +const googleDiscoveryDescription = ( + identity: GoogleDiscoveryDocumentIdentity, + displayName: string, +): string => { + const description = identity.description?.trim(); + if (description && description.length > 0) { + return description.length > MAX_GOOGLE_CUSTOM_DESCRIPTION_LENGTH + ? `${description.slice(0, MAX_GOOGLE_CUSTOM_DESCRIPTION_LENGTH - 3).trimEnd()}...` + : description; + } + return `${displayName}.`; +}; + +const googleServiceAddPlans = ( + services: readonly GoogleServiceConfig[], +): readonly GoogleServiceAddPlan[] => { + const plans: GoogleServiceAddPlan[] = []; + let customIndex = 0; + for (const service of services) { + if (isGooglePresetServiceConfig(service)) { + plans.push({ kind: "preset", service }); + continue; + } + + if (service.custom.urls.length === 0) { + const slug = googleCustomFallbackSlug(customIndex); + customIndex += 1; + plans.push({ + kind: "invalid-custom", + service, + slug, + presetId: googleCustomResultId(slug), + error: "Custom Google service requires at least one Discovery URL", + }); + continue; + } + + if (service.custom.urls.length > 1 && hasCustomGoogleServiceOverride(service)) { + const slug = googleServiceEntrySlug(service); + plans.push({ + kind: "invalid-custom", + service, + slug, + presetId: googleCustomResultId(slug), + error: "Custom Google service identity overrides require exactly one Discovery URL", + }); + continue; + } + + for (const url of service.custom.urls) { + const overrideSlug = service.custom.slug?.trim(); + const fallbackSlug = + overrideSlug && overrideSlug.length > 0 + ? IntegrationSlug.make(overrideSlug) + : googleCustomFallbackSlug(customIndex); + plans.push({ + kind: "custom", + service, + url, + fallbackSlug, + }); + customIndex += 1; + } + } + return plans; +}; + const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { const config = decodeGoogleIntegrationConfig(record.config); if (!config) return []; @@ -292,8 +441,9 @@ const makeGooglePluginExtension = ( ) => { const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; - const addGoogleOpenApiIntegration = (input: { + const addGoogleOpenApiIntegrationFromDocuments = (input: { readonly urls: readonly string[]; + readonly documents: readonly GoogleFetchedDiscoveryDocument[]; readonly slug: IntegrationSlug; readonly name: string; readonly description: string; @@ -301,12 +451,11 @@ const makeGooglePluginExtension = ( readonly consentScopes?: readonly string[]; }) => Effect.gen(function* () { - const urls = yield* googleBundleUrlsWithIdentity(input.urls); - const conversion = yield* fetchGoogleBundleConversion( - urls, - httpClientLayer, - input.consentScopes, - ); + const consentScopes = input.consentScopes ?? googlePhotosBundleConsentScopes(input.urls); + const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({ + documents: input.documents, + ...(consentScopes !== undefined ? { consentScopes } : {}), + }); const compiled = yield* compileOpenApiSpec(conversion.specText); const slug = input.slug; @@ -318,7 +467,7 @@ const makeGooglePluginExtension = ( const specHash = yield* sha256Hex(conversion.specText); const integrationConfig: GoogleIntegrationConfig = { specHash, - googleDiscoveryUrls: urls, + googleDiscoveryUrls: input.urls, ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}), ...(conversion.authenticationTemplate ? { authenticationTemplate: conversion.authenticationTemplate } @@ -349,7 +498,7 @@ const makeGooglePluginExtension = ( // added to every new Google OpenAPI integration. Older integrations // without oauth2/v2 can still fall back to the People API identity // operation. - const defaultHealthCheck = defaultGoogleHealthCheck(urls, compiled.definitions); + const defaultHealthCheck = defaultGoogleHealthCheck(input.urls, compiled.definitions); if (defaultHealthCheck) { yield* ctx.core.integrations.setHealthCheck(slug, defaultHealthCheck); } @@ -357,23 +506,26 @@ const makeGooglePluginExtension = ( return { slug, toolCount: compiled.definitions.length }; }); - const addOneService = (service: GoogleServiceConfig, baseUrl?: string) => + const addGoogleOpenApiIntegration = (input: { + readonly urls: readonly string[]; + readonly slug: IntegrationSlug; + readonly name: string; + readonly description: string; + readonly baseUrl?: string; + readonly consentScopes?: readonly string[]; + }) => Effect.gen(function* () { - if (!isGooglePresetServiceConfig(service)) { - if (service.custom.urls.length === 0) { - return yield* new OpenApiParseError({ - message: "Custom Google service requires at least one Discovery URL", - }); - } - return yield* addGoogleOpenApiIntegration({ - urls: service.custom.urls, - slug: googleServiceEntrySlug(service), - name: service.custom.name.trim() || "Custom Google APIs", - description: service.custom.description ?? "Custom Google APIs.", - baseUrl, - }); - } + const urls = yield* googleBundleUrlsWithIdentity(input.urls); + const documents = yield* fetchGoogleDiscoveryDocuments(urls, httpClientLayer); + return yield* addGoogleOpenApiIntegrationFromDocuments({ + ...input, + urls, + documents, + }); + }); + const addOnePresetService = (service: GooglePresetServiceConfig, baseUrl?: string) => + Effect.gen(function* () { const preset = googleOpenApiPresetById.get(service.presetId); if (!preset?.url) { return yield* new OpenApiParseError({ @@ -391,53 +543,201 @@ const makeGooglePluginExtension = ( }); }); - const addServices = (input: GoogleAddServicesInput) => - Effect.gen(function* () { - const outcomes = yield* Effect.forEach( - input.services, - (service): Effect.Effect => { - const slug = googleServiceEntrySlug(service); - const presetId = googleServiceEntryId(service); - return addOneService(service, input.baseUrl).pipe( - Effect.map( - (result): GoogleAddServiceOutcome => ({ - added: [ - { - slug: result.slug, - presetId, - toolCount: result.toolCount, - }, - ], - skipped: [], - failed: [], + const addCustomServicePlanOutcome = ( + plan: Extract, + baseUrl?: string, + ): Effect.Effect => { + const fallbackSlug = plan.fallbackSlug; + const fallbackPresetId = googleCustomResultId(fallbackSlug); + const fallbackFailure = (error: string): GoogleAddServiceOutcome => + googleAddServiceFailure({ + slug: fallbackSlug, + presetId: fallbackPresetId, + error, + }); + + return Effect.gen(function* () { + const urls = yield* googleBundleUrlsWithIdentity([plan.url]); + const documents = yield* fetchGoogleDiscoveryDocuments(urls, httpClientLayer); + const serviceDocument = documents[0]; + if (!serviceDocument) { + return yield* new OpenApiParseError({ + message: "Custom Google service requires a Discovery document", + }); + } + const identity = yield* googleDiscoveryDocumentIdentity({ + documentText: serviceDocument.documentText, + }); + const overrideSlug = plan.service.custom.slug?.trim(); + const overrideName = plan.service.custom.name?.trim(); + const displayName = + overrideName && overrideName.length > 0 + ? overrideName + : googleDiscoveryDisplayName(identity); + const slug = + overrideSlug && overrideSlug.length > 0 + ? IntegrationSlug.make(overrideSlug) + : googleDiscoverySlug(identity.name); + const presetId = googleCustomResultId(slug); + const description = + plan.service.custom.description?.trim() || + googleDiscoveryDescription(identity, displayName); + + return yield* addGoogleOpenApiIntegrationFromDocuments({ + urls, + documents, + slug, + name: displayName, + description, + baseUrl, + }).pipe( + Effect.map( + (result): GoogleAddServiceOutcome => ({ + added: [ + { + slug: result.slug, + presetId, + toolCount: result.toolCount, + }, + ], + skipped: [], + failed: [], + }), + ), + Effect.catchTag("IntegrationAlreadyExistsError", (error) => { + const skippedSlug = error.slug; + return Effect.succeed({ + added: [], + skipped: [ + { + slug: skippedSlug, + presetId: googleCustomResultId(skippedSlug), + reason: "already_exists" as const, + }, + ], + failed: [], + }); + }), + Effect.catchTags({ + OpenApiParseError: (error: OpenApiParseError) => + Effect.succeed( + googleAddServiceFailure({ + slug, + presetId, + error: error.message, }), ), - Effect.catchTag("IntegrationAlreadyExistsError", () => - Effect.succeed({ - added: [], - skipped: [ - { - slug, - presetId, - reason: "already_exists" as const, - }, - ], - failed: [], + OpenApiExtractionError: (error: OpenApiExtractionError) => + Effect.succeed( + googleAddServiceFailure({ + slug, + presetId, + error: error.message, }), ), - Effect.catchTags({ - OpenApiParseError: (error: OpenApiParseError) => - Effect.succeed(googleAddServiceFailure(service, slug, error.message)), - OpenApiExtractionError: (error: OpenApiExtractionError) => - Effect.succeed(googleAddServiceFailure(service, slug, error.message)), + }), + ); + }).pipe( + Effect.catchTags({ + OpenApiParseError: (error: OpenApiParseError) => + Effect.succeed(fallbackFailure(error.message)), + }), + ); + }; + + const addServicePlanOutcome = ( + plan: GoogleServiceAddPlan, + baseUrl?: string, + ): Effect.Effect => { + if (plan.kind === "invalid-custom") { + return Effect.succeed( + googleAddServiceFailure({ + slug: plan.slug, + presetId: plan.presetId, + error: plan.error, + }), + ); + } + + if (plan.kind === "custom") { + return addCustomServicePlanOutcome(plan, baseUrl); + } + + const fallbackSlug = googleServiceEntrySlug(plan.service); + const fallbackPresetId = googleServiceEntryId(plan.service); + const add = addOnePresetService(plan.service, baseUrl).pipe( + Effect.map((result) => ({ + slug: result.slug, + presetId: googleServiceEntryId(plan.service), + toolCount: result.toolCount, + })), + ); + + return add.pipe( + Effect.map( + (result): GoogleAddServiceOutcome => ({ + added: [ + { + slug: result.slug, + presetId: result.presetId, + toolCount: result.toolCount, + }, + ], + skipped: [], + failed: [], + }), + ), + Effect.catchTag("IntegrationAlreadyExistsError", (error) => { + const skippedSlug = error.slug; + return Effect.succeed({ + added: [], + skipped: [ + { + slug: skippedSlug, + presetId: fallbackPresetId, + reason: "already_exists" as const, + }, + ], + failed: [], + }); + }), + Effect.catchTags({ + OpenApiParseError: (error: OpenApiParseError) => + Effect.succeed( + googleAddServiceFailure({ + slug: fallbackSlug, + presetId: fallbackPresetId, + error: error.message, }), - Effect.catch(() => - Effect.succeed( - googleAddServiceFailure(service, slug, "Failed to add Google service"), - ), - ), - ); - }, + ), + OpenApiExtractionError: (error: OpenApiExtractionError) => + Effect.succeed( + googleAddServiceFailure({ + slug: fallbackSlug, + presetId: fallbackPresetId, + error: error.message, + }), + ), + }), + Effect.catch(() => + Effect.succeed( + googleAddServiceFailure({ + slug: fallbackSlug, + presetId: fallbackPresetId, + error: "Failed to add Google service", + }), + ), + ), + ); + }; + + const addServices = (input: GoogleAddServicesInput) => + Effect.gen(function* () { + const plans = googleServiceAddPlans(input.services); + const outcomes = yield* Effect.forEach( + plans, + (plan): Effect.Effect => + addServicePlanOutcome(plan, input.baseUrl), { concurrency: 1 }, );