diff --git a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx index bca1c86aa..dcf9fb80e 100644 --- a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +++ b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import { usePendingSources } from "@executor-js/react/api/optimistic"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; @@ -97,22 +99,21 @@ type GoogleDiscoveryTemplate = GoogleDiscoveryPreset & { const GOOGLE_G_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate { - try { - const url = new URL(preset.url); - const parts = url.pathname.split("/").filter(Boolean); - const apisIndex = parts.indexOf("apis"); - const service = apisIndex >= 0 ? parts[apisIndex + 1] : undefined; - const version = - apisIndex >= 0 ? parts[apisIndex + 2] : (url.searchParams.get("version") ?? undefined); - return { - ...preset, - discoveryUrl: preset.url, - service: service ?? url.hostname.replace(/\.googleapis\.com$/, ""), - version: version ?? "", - }; - } catch { + if (!URL.canParse(preset.url)) { return { ...preset, discoveryUrl: preset.url, service: preset.id, version: "" }; } + const url = new URL(preset.url); + const parts = url.pathname.split("/").filter(Boolean); + const apisIndex = parts.indexOf("apis"); + const service = apisIndex >= 0 ? parts[apisIndex + 1] : undefined; + const version = + apisIndex >= 0 ? parts[apisIndex + 2] : (url.searchParams.get("version") ?? undefined); + return { + ...preset, + discoveryUrl: preset.url, + service: service ?? url.hostname.replace(/\.googleapis\.com$/, ""), + version: version ?? "", + }; } const GOOGLE_DISCOVERY_TEMPLATES = googleDiscoveryPresets.map(parseGoogleDiscoveryPreset); @@ -202,8 +203,8 @@ export default function AddGoogleDiscoverySource(props: { "google"; const scopeId = useScope(); - const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promise" }); - const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" }); + const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promiseExit" }); + const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promiseExit" }); const { beginAdd } = usePendingSources(); const secretList = useSecretPickerSecrets(); const oauth = useOAuthPopupFlow({ @@ -235,25 +236,26 @@ export default function AddGoogleDiscoverySource(props: { setError(null); setOauthAuth(null); setShowScopes(false); - try { - const result = await doProbe({ - params: { scopeId }, - payload: { discoveryUrl: discoveryUrl.trim() }, - }); - setProbe({ - ...result, - scopes: [...result.scopes], - operations: [...result.operations], - }); - if (result.scopes.length === 0) { - setAuthKind("none"); - } - } catch (e) { + const exit = await doProbe({ + params: { scopeId }, + payload: { discoveryUrl: discoveryUrl.trim() }, + }); + if (Exit.isFailure(exit)) { setProbe(null); - setError(e instanceof Error ? e.message : "Failed to inspect discovery document"); - } finally { + setError("Failed to inspect discovery document"); setLoadingProbe(false); + return; + } + const result = exit.value; + setProbe({ + ...result, + scopes: [...result.scopes], + operations: [...result.operations], + }); + if (result.scopes.length === 0) { + setAuthKind("none"); } + setLoadingProbe(false); }, [discoveryUrl, doProbe, scopeId]); // Keep the latest handleProbe in a ref so the debounced effect can call it @@ -331,33 +333,33 @@ export default function AddGoogleDiscoverySource(props: { name: displayName, kind: "google-discovery", }); - try { - await doAdd({ - params: { scopeId }, - payload: { - name: displayName, - discoveryUrl: discoveryUrl.trim(), - namespace, - auth: - authKind === "oauth2" && oauthAuth - ? { - kind: "oauth2" as const, - connectionId: oauthAuth.connectionId, - clientIdSecretId: oauthAuth.clientIdSecretId, - clientSecretSecretId: oauthAuth.clientSecretSecretId, - scopes: oauthAuth.scopes, - } - : { kind: "none" as const }, - }, - reactivityKeys: [...sourceWriteKeys], - }); - props.onComplete(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to add source"); + const exit = await doAdd({ + params: { scopeId }, + payload: { + name: displayName, + discoveryUrl: discoveryUrl.trim(), + namespace, + auth: + authKind === "oauth2" && oauthAuth + ? { + kind: "oauth2" as const, + connectionId: oauthAuth.connectionId, + clientIdSecretId: oauthAuth.clientIdSecretId, + clientSecretSecretId: oauthAuth.clientSecretSecretId, + scopes: oauthAuth.scopes, + } + : { kind: "none" as const }, + }, + reactivityKeys: [...sourceWriteKeys], + }); + placeholder.done(); + if (Exit.isFailure(exit)) { + const error = Exit.findErrorOption(exit); + setError(Option.isSome(error) ? error.value.message : "Failed to add source"); setAdding(false); - } finally { - placeholder.done(); + return; } + props.onComplete(); }, [ probe, doAdd, diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts index 268757289..bbcc80d8b 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -21,7 +21,6 @@ import { googleDiscoverySchema, makeGoogleDiscoveryStore, type GoogleDiscoveryStore, - type GoogleDiscoveryStoredSource, } from "./binding-store"; import { extractGoogleDiscoveryManifest } from "./document"; import { annotationsForOperation, invokeGoogleDiscoveryTool } from "./invoke"; @@ -86,30 +85,9 @@ export type GoogleDiscoveryExtensionFailure = | GoogleDiscoverySourceError | StorageFailure; -export interface GoogleDiscoveryPluginExtension { - readonly probeDiscovery: ( - input: string | GoogleDiscoveryProbeInput, - ) => Effect.Effect< - GoogleDiscoveryProbeResult, - GoogleDiscoveryParseError | GoogleDiscoverySourceError - >; - readonly addSource: ( - input: GoogleDiscoveryAddSourceInput, - ) => Effect.Effect< - { readonly toolCount: number; readonly namespace: string }, - GoogleDiscoveryParseError | GoogleDiscoverySourceError | StorageFailure - >; - readonly removeSource: (namespace: string, scope: string) => Effect.Effect; - readonly getSource: ( - namespace: string, - scope: string, - ) => Effect.Effect; - readonly updateSource: ( - namespace: string, - scope: string, - input: GoogleDiscoveryUpdateSourceInput, - ) => Effect.Effect; -} +export type GoogleDiscoveryPluginExtension = ReturnType< + typeof makeGoogleDiscoveryPluginExtension +>; // --------------------------------------------------------------------------- // URL normalization + slug helpers (unchanged) @@ -120,12 +98,8 @@ const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; const normalizeDiscoveryUrl = (discoveryUrl: string): string => { const trimmed = discoveryUrl.trim(); if (trimmed.length === 0) return trimmed; - let parsed: URL; - try { - parsed = new URL(trimmed); - } catch { - return trimmed; - } + if (!URL.canParse(trimmed)) return trimmed; + const parsed = new URL(trimmed); if (parsed.pathname !== "/$discovery/rest") return trimmed; const version = parsed.searchParams.get("version")?.trim(); if (!version) return trimmed; @@ -147,7 +121,7 @@ const resolveGoogleDiscoveryCredentials = ( ctx: PluginCtx, ): Effect.Effect< { headers?: Record; queryParams?: Record } | undefined, - GoogleDiscoverySourceError + GoogleDiscoverySourceError | StorageFailure > => Effect.gen(function* () { if (!credentials) return undefined; @@ -158,16 +132,11 @@ const resolveGoogleDiscoveryCredentials = ( new GoogleDiscoverySourceError({ message: `Secret not found for header "${name}"`, }), - onError: (_error, name) => - new GoogleDiscoverySourceError({ - message: `Secret not found for header "${name}"`, - }), }).pipe( - Effect.mapError((err) => - err instanceof GoogleDiscoverySourceError - ? err - : new GoogleDiscoverySourceError({ message: "Secret resolution failed" }), - ), + Effect.catchTags({ + SecretOwnedByConnectionError: () => + Effect.fail(new GoogleDiscoverySourceError({ message: "Secret resolution failed" })), + }), ); const queryParams = yield* resolveSecretBackedMap({ values: credentials.queryParams, @@ -176,16 +145,11 @@ const resolveGoogleDiscoveryCredentials = ( new GoogleDiscoverySourceError({ message: `Secret not found for query parameter "${name}"`, }), - onError: (_error, name) => - new GoogleDiscoverySourceError({ - message: `Secret not found for query parameter "${name}"`, - }), }).pipe( - Effect.mapError((err) => - err instanceof GoogleDiscoverySourceError - ? err - : new GoogleDiscoverySourceError({ message: "Secret resolution failed" }), - ), + Effect.catchTags({ + SecretOwnedByConnectionError: () => + Effect.fail(new GoogleDiscoverySourceError({ message: "Secret resolution failed" })), + }), ); return { ...(headers ? { headers } : {}), @@ -200,29 +164,41 @@ const fetchDiscoveryDocument = ( readonly queryParams?: Record; }, ) => - Effect.tryPromise({ - try: async () => { - const url = new URL(normalizeDiscoveryUrl(discoveryUrl)); - for (const [key, value] of Object.entries(credentials?.queryParams ?? {})) { - url.searchParams.set(key, value); - } - const response = await fetch(url.toString(), { - headers: credentials?.headers, - signal: AbortSignal.timeout(20_000), - }); - if (!response.ok) { - throw new GoogleDiscoverySourceError({ - message: `Google Discovery fetch failed with status ${response.status}`, + Effect.gen(function* () { + const url = yield* Effect.try({ + try: () => new URL(normalizeDiscoveryUrl(discoveryUrl)), + catch: () => + new GoogleDiscoverySourceError({ + message: "Invalid Google Discovery URL", + }), + }); + const response = yield* Effect.tryPromise({ + try: async () => { + for (const [key, value] of Object.entries(credentials?.queryParams ?? {})) { + url.searchParams.set(key, value); + } + return fetch(url.toString(), { + headers: credentials?.headers, + signal: AbortSignal.timeout(20_000), }); - } - return response.text(); - }, - catch: (cause) => - cause instanceof GoogleDiscoverySourceError - ? cause - : new GoogleDiscoverySourceError({ - message: cause instanceof Error ? cause.message : String(cause), - }), + }, + catch: () => + new GoogleDiscoverySourceError({ + message: "Google Discovery fetch failed", + }), + }); + if (!response.ok) { + return yield* new GoogleDiscoverySourceError({ + message: `Google Discovery fetch failed with status ${response.status}`, + }); + } + return yield* Effect.tryPromise({ + try: () => response.text(), + catch: () => + new GoogleDiscoverySourceError({ + message: "Failed to read Google Discovery response", + }), + }); }); const normalizeSlug = (value: string): string => @@ -306,101 +282,102 @@ const registerManifest = ( // Plugin // --------------------------------------------------------------------------- +const makeGoogleDiscoveryPluginExtension = (ctx: PluginCtx) => ({ + probeDiscovery: (input: string | GoogleDiscoveryProbeInput) => + Effect.gen(function* () { + const discoveryUrl = typeof input === "string" ? input : input.discoveryUrl; + const credentials = + typeof input === "string" + ? undefined + : yield* resolveGoogleDiscoveryCredentials(input.credentials, ctx); + const text = yield* fetchDiscoveryDocument(discoveryUrl, credentials); + const manifest = yield* extractGoogleDiscoveryManifest(text); + const scopes = Object.keys( + Option.isSome(manifest.oauthScopes) ? manifest.oauthScopes.value : {}, + ).sort(); + const operations = manifest.methods.map((method) => ({ + toolPath: method.toolPath, + method: method.binding.method, + pathTemplate: method.binding.pathTemplate, + description: Option.isSome(method.description) ? method.description.value : null, + })); + return { + name: Option.isSome(manifest.title) + ? manifest.title.value + : `${manifest.service} ${manifest.version}`, + title: Option.isSome(manifest.title) ? manifest.title.value : null, + service: manifest.service, + version: manifest.version, + toolCount: manifest.methods.length, + scopes, + operations, + }; + }), + + addSource: (input: GoogleDiscoveryAddSourceInput) => + ctx.transaction( + Effect.gen(function* () { + const credentials = yield* resolveGoogleDiscoveryCredentials(input.credentials, ctx); + const text = yield* fetchDiscoveryDocument(input.discoveryUrl, credentials); + const manifest = yield* extractGoogleDiscoveryManifest(text); + const namespace = + input.namespace ?? + deriveNamespace({ + name: input.name, + service: manifest.service, + version: manifest.version, + }); + const sourceData = new GoogleDiscoveryStoredSourceDataSchema({ + name: input.name, + discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl), + credentials: input.credentials, + service: manifest.service, + version: manifest.version, + rootUrl: manifest.rootUrl, + servicePath: manifest.servicePath, + auth: input.auth, + }); + const toolCount = yield* registerManifest( + ctx, + namespace, + input.scope, + manifest, + sourceData, + ); + return { toolCount, namespace }; + }), + ), + + removeSource: (namespace: string, scope: string) => + ctx.transaction( + Effect.gen(function* () { + yield* ctx.storage.removeBindingsBySource(namespace, scope); + yield* ctx.storage.removeSource(namespace, scope); + yield* ctx.core.sources.unregister(namespace).pipe(Effect.ignore); + }), + ), + + // OAuth start/complete live on `ctx.oauth` now — the UI calls + // the shared `/scopes/:scopeId/oauth/*` endpoints directly with a + // Google-specific `authorization-code` strategy and writes the + // resulting connection back via `updateSource`. + + getSource: (namespace: string, scope: string) => ctx.storage.getSource(namespace, scope), + + updateSource: (namespace: string, scope: string, input: GoogleDiscoveryUpdateSourceInput) => + ctx.storage.updateSourceMeta(namespace, scope, { + name: input.name?.trim() || undefined, + auth: input.auth, + }), +}); + export const googleDiscoveryPlugin = definePlugin(() => ({ id: "googleDiscovery" as const, packageName: "@executor-js/plugin-google-discovery", schema: googleDiscoverySchema, storage: (deps) => makeGoogleDiscoveryStore(deps), - extension: (ctx) => - ({ - probeDiscovery: (input) => - Effect.gen(function* () { - const discoveryUrl = typeof input === "string" ? input : input.discoveryUrl; - const credentials = - typeof input === "string" - ? undefined - : yield* resolveGoogleDiscoveryCredentials(input.credentials, ctx); - const text = yield* fetchDiscoveryDocument(discoveryUrl, credentials); - const manifest = yield* extractGoogleDiscoveryManifest(text); - const scopes = Object.keys( - Option.isSome(manifest.oauthScopes) ? manifest.oauthScopes.value : {}, - ).sort(); - const operations = manifest.methods.map((method) => ({ - toolPath: method.toolPath, - method: method.binding.method, - pathTemplate: method.binding.pathTemplate, - description: Option.isSome(method.description) ? method.description.value : null, - })); - return { - name: Option.isSome(manifest.title) - ? manifest.title.value - : `${manifest.service} ${manifest.version}`, - title: Option.isSome(manifest.title) ? manifest.title.value : null, - service: manifest.service, - version: manifest.version, - toolCount: manifest.methods.length, - scopes, - operations, - }; - }), - - addSource: (input) => - ctx.transaction( - Effect.gen(function* () { - const credentials = yield* resolveGoogleDiscoveryCredentials(input.credentials, ctx); - const text = yield* fetchDiscoveryDocument(input.discoveryUrl, credentials); - const manifest = yield* extractGoogleDiscoveryManifest(text); - const namespace = - input.namespace ?? - deriveNamespace({ - name: input.name, - service: manifest.service, - version: manifest.version, - }); - const sourceData = new GoogleDiscoveryStoredSourceDataSchema({ - name: input.name, - discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl), - credentials: input.credentials, - service: manifest.service, - version: manifest.version, - rootUrl: manifest.rootUrl, - servicePath: manifest.servicePath, - auth: input.auth, - }); - const toolCount = yield* registerManifest( - ctx, - namespace, - input.scope, - manifest, - sourceData, - ); - return { toolCount, namespace }; - }), - ), - - removeSource: (namespace, scope) => - ctx.transaction( - Effect.gen(function* () { - yield* ctx.storage.removeBindingsBySource(namespace, scope); - yield* ctx.storage.removeSource(namespace, scope); - yield* ctx.core.sources.unregister(namespace).pipe(Effect.ignore); - }), - ), - - // OAuth start/complete live on `ctx.oauth` now — the UI calls - // the shared `/scopes/:scopeId/oauth/*` endpoints directly with a - // Google-specific `authorization-code` strategy and writes the - // resulting connection back via `updateSource`. - - getSource: (namespace, scope) => ctx.storage.getSource(namespace, scope), - - updateSource: (namespace, scope, input) => - ctx.storage.updateSourceMeta(namespace, scope, { - name: input.name?.trim() || undefined, - auth: input.auth, - }), - }) satisfies GoogleDiscoveryPluginExtension, + extension: makeGoogleDiscoveryPluginExtension, invokeTool: ({ ctx, toolRow, args }) => invokeGoogleDiscoveryTool({ @@ -563,7 +540,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ servicePath: manifest.servicePath, }); yield* registerManifest(typedCtx, sourceId, scope, manifest, next); - }).pipe(Effect.mapError((err) => (err instanceof Error ? err : new Error(String(err))))), + }), // Connection refresh is owned by the canonical `"oauth2"` // ConnectionProvider registered by core — no plugin-specific handler